Skip to content

Commit 9fbaa8d

Browse files
sanchitrkclaude
andcommitted
fix(web): let the frame that set the tab own it, so tabs don't flicker
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 parent 98924be commit 9fbaa8d

5 files changed

Lines changed: 42 additions & 3 deletions

File tree

‎apps/web/src/sdk/widget.tsx‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,11 @@ export interface WidgetDefinition<Props> extends Navigation {
5353
readonly minHeight: number;
5454
/** "one" allows a single instance (the shell hides duplicate). */
5555
readonly cardinality: "one" | "many";
56+
/**
57+
* This widget sets its workspace tab (the frame's `setTab`), possibly once it has loaded. Until it
58+
* does, the tab keeps what it showed rather than flashing this frame's default title.
59+
*/
60+
readonly ownsTab?: boolean;
5661
/** Validates an instance's saved settings before they reach the component. */
5762
readonly props: Schema.Decoder<Props>;
5863
readonly component: ComponentType<WidgetProps<Props>>;
@@ -66,6 +71,7 @@ export interface Widget extends Navigation {
6671
readonly minWidth: number;
6772
readonly minHeight: number;
6873
readonly cardinality: "one" | "many";
74+
readonly ownsTab?: boolean;
6975
render(instance: {
7076
readonly instanceId: string;
7177
readonly props: unknown;

‎apps/web/src/shell/switcher.tsx‎

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
11
import { useAtomValue } from "@effect/atom-react";
22
import { Link } from "@tanstack/react-router";
3-
import { Fragment } from "react";
3+
import { Fragment, useRef } from "react";
44

55
import { isMac } from "../kernel/platform";
6-
import { chromeAtom, chromeOf, focusedAtom } from "../wm/chrome";
6+
import type { Tab } from "../sdk";
7+
import { registry } from "../widgets";
8+
import { chromeAtom, chromeOf, focusedAtom, tabOwnerAtom } from "../wm/chrome";
79
import { deskAtom } from "../wm/instances";
810
import { type Workspace, workspaceRoute, workspaceTag } from "../wm/workspaces";
911
import { iconOf } from "./icons";
@@ -22,10 +24,22 @@ export function Switcher({ workspaces, active }: SwitcherProps) {
2224
const { instances, order } = useAtomValue(deskAtom);
2325
const chrome = useAtomValue(chromeAtom);
2426
const focusedOn = useAtomValue(focusedAtom);
27+
const owners = useAtomValue(tabOwnerAtom);
28+
// What each tab showed last, kept while a frame that will set the tab is still loading.
29+
const shown = useRef(new Map<string, Tab>());
2530
const showing = (workspace: Workspace) => {
31+
const tab = tabOf(workspace);
32+
shown.current.set(workspace.id, tab);
33+
return tab;
34+
};
35+
const tabOf = (workspace: Workspace): Tab => {
2636
const tag = workspaceTag(workspace);
2737
const onWorkspace = (instance: (typeof instances)[number] | undefined) =>
2838
instance !== undefined && (instance.tags & tag) !== 0;
39+
// The frame that claimed the tab keeps it while it is still on this workspace.
40+
const owner = instances.find((instance) => instance.id === owners.get(workspace.id));
41+
const claimed = owner !== undefined && onWorkspace(owner) ? chrome.get(owner.id)?.tab : undefined;
42+
if (claimed !== undefined) return claimed;
2943
// Its own last focus once it has been on screen, else its main frame (first in tile order), so
3044
// focusing a frame shared with another workspace never relabels a tab not yet visited.
3145
const remembered = instances.find((instance) => instance.id === focusedOn.get(workspace.id));
@@ -36,6 +50,8 @@ export function Switcher({ workspaces, active }: SwitcherProps) {
3650
const frame = chrome.get(focused.id);
3751
// The focused frame decides what the tab says; without a say, the workspace's name over its title.
3852
if (frame?.tab !== undefined) return frame.tab;
53+
const kept = shown.current.get(workspace.id);
54+
if (registry.get(focused.kind)?.ownsTab === true && kept !== undefined) return kept;
3955
const title = chromeOf(focused, frame).title;
4056
// A frame named like its workspace adds nothing to the tab.
4157
return title.toLowerCase() === workspace.name.toLowerCase()

‎apps/web/src/widgets/projects/index.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ export const session = defineWidget({
2828
cardinality: "many",
2929
priority: "builtin",
3030
opens: ["codework://session/*"],
31+
ownsTab: true,
3132
replaceable: true,
3233
props: Schema.Struct({}),
3334
component: Session,

‎apps/web/src/wm/chrome.ts‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,13 @@ export const chromeAtom = Atom.make<ReadonlyMap<string, Chrome>>(new Map()).pipe
2525
*/
2626
export const focusedAtom = Atom.make<ReadonlyMap<string, string>>(new Map()).pipe(Atom.keepAlive);
2727

28+
/**
29+
* The frame that owns each workspace's tab (workspace id → instance id): the last one focused there
30+
* that set a tab. Focus alone does not move the tab, so clicking a frame with no tab of its own, or
31+
* opening one that has not set it yet, leaves the tab as it was instead of flickering through both.
32+
*/
33+
export const tabOwnerAtom = Atom.make<ReadonlyMap<string, string>>(new Map()).pipe(Atom.keepAlive);
34+
2835
/** A frame's title and icon: what the widget set, else the instance's, else its definition's. */
2936
export function chromeOf(instance: Instance, chrome: Chrome | undefined) {
3037
const definition = registry.get(instance.kind);

‎apps/web/src/wm/desktop.tsx‎

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ import { useArrangedWidgets, useElementSize } from "webwm/react";
1919
import type { Frame as FrameApi, Shell } from "../sdk";
2020
import { FrameContext, ShellContext } from "../sdk/shell";
2121
import { builtins, registry } from "../widgets";
22-
import { type Chrome, chromeAtom, chromeOf, focusedAtom } from "./chrome";
22+
import { type Chrome, chromeAtom, chromeOf, focusedAtom, tabOwnerAtom } from "./chrome";
2323
import { Divider } from "./divider";
2424
import { Frame } from "./frame";
2525
import { add, deskAtom, type Instance, remove, retarget, focus as raise, update } from "./instances";
@@ -151,6 +151,15 @@ export function Desktop({ workspace }: DesktopProps) {
151151
current.get(workspace.id) === focusedId ? current : new Map(current).set(workspace.id, focusedId),
152152
);
153153
}, [workspace.id, focusedId, setFocused]);
154+
// The focused frame takes the tab once it has one to show, which may be after it loads.
155+
const focusedTab = focusedId === undefined ? undefined : chrome.get(focusedId)?.tab;
156+
const setTabOwner = useAtomSet(tabOwnerAtom);
157+
useEffect(() => {
158+
if (focusedId === undefined || focusedTab === undefined) return;
159+
setTabOwner((current) =>
160+
current.get(workspace.id) === focusedId ? current : new Map(current).set(workspace.id, focusedId),
161+
);
162+
}, [workspace.id, focusedId, focusedTab, setTabOwner]);
154163
const bounds: Bounds = { x: 0, y: 0, w: size.width, h: size.height };
155164
const tiled = order.tileOrder.flatMap((id) =>
156165
widgets.filter((widget) => widget.id === id && isVisible(widget, activeTags)),

0 commit comments

Comments
 (0)