Desktop Compound Tabs Implementation Plan#
For agentic workers: REQUIRED SUB-SKILL: use
superpowers:subagent-driven-developmentorsuperpowers:executing-planstask by task. Use TDD and commit after each task.
Goal: Replace the desktop split marker with a two-or-three-member compound tab, preserve the existing close animation and split behavior, and support accessible same-window reorder, grouping, ungrouping, and member reordering.
Architecture: CenterTab[] remains the canonical strip order. CenterTabGroup is defined only in center-tab-groups.ts; group membership, visible member ids, focused member, and pane resolution are pure functions there. The store persists tabs, groups, active id, split compatibility state, and split ratio in one versioned per-window payload. AppShell renders the pane descriptors returned by the resolver, so two session members share one mounted chat pane and never create an empty second pane. One native-drag coordinator owns the payload, refs, geometry, and lifecycle used here and later by cross-window transfer.
Tech stack: TypeScript, React 18, Zustand 5, CSS Modules, native HTML Drag and Drop, Node assert behavior scripts. No new dependency.
Non-negotiable invariants#
CenterTabGrouphas one definition:web/lib/state/center-tab-groups.ts. Other modules import it.- A group has two or three unique, live, contiguous members. One-member groups dissolve. A fourth member is rejected without mutation.
visibleIdscontains one or two group members and always containsfocusedId.- Group membership may contain any tab kinds. Pane count is derived by
resolveCenterTabPanes, not byvisibleIds.length. - All visible session members share one
{ kind: "session" }pane. ItsactiveTabIdis the focused visible session, or the first visible session. Focusing it updates the router anduseSessionStorethrough the existing session activation path. showDividerispanes.length === 2.visibleIds.length === 2must never create one rendered pane plus an unused divider.- A group renders at most two panes. A hidden third member replaces the focused visible member; it is not destroyed.
- The singleton
<PageShell page="chat" />remains mounted exactly once. - Closing every normal or compound member uses the existing
closingIds/enteringIds/onExitedlifecycle. Store removal occurs only throughfinishCloseafter exit animation. - Transfer removal is not implemented here and must not be routed through close. The multi-window plan adds a separate reversible path.
- Compound state is part of the same versioned
centerTabs:<windowId>payload as tabs and split state. Never introduceopenprogram.centerTabGroupsor any other independent group key. - Same-window and cross-window drag use the same
tab-drag-coordinator.ts, payload type, prepared ref, and drop geometry.dragstartis synchronous and never writesDataTransferafter anawait. - A whole-group drag means all members. A segment drag means one member. Keyboard move on a segment changes its member order inside the group before moving the whole strip entry.
- Delete
splitPinned,data-split-pinned, and the static accent line. Accent is transient focus/drop feedback only.
Files#
- Create
web/lib/state/center-tab-groups.ts. - Create
web/lib/tab-drag-coordinator.ts. - Create
web/scripts/check-compound-tabs.mjs. - Modify
web/lib/state/center-tabs-store.ts. - Modify
web/components/app-shell.tsx. - Modify
web/components/center-tabs/center-tab-strip.tsx. - Modify
web/components/center-tabs/center-tabs.module.css. - Modify
web/app/styles/base.css. - Modify
web/scripts/check-center-tabs.mjs. - Modify
web/scripts/check-web-split.mjs. - Modify
web/scripts/check-chat-ui.mjs. - Modify
web/package.json.
Task 1: Add the pure group model and pane resolver#
Files:
- Create
web/lib/state/center-tab-groups.ts - Create
web/scripts/check-compound-tabs.mjs
Public API:
export const MAX_CENTER_TAB_GROUP_MEMBERS = 3;
export const MAX_CENTER_TAB_PANES = 2;
export interface CenterTabGroup {
id: string;
memberIds: string[];
visibleIds: string[];
focusedId: string;
}
export type CenterTabPane =
| { key: "session"; kind: "session"; activeTabId: string; memberIds: string[] }
| { key: string; kind: "tab"; tabId: string };
export interface CenterTabLayout {
tabIds: string[];
groups: CenterTabGroup[];
}
export function normalizeCenterTabLayout(layout: CenterTabLayout): CenterTabLayout;
export function findCenterTabGroup(groups: readonly CenterTabGroup[], tabId: string): CenterTabGroup | undefined;
export function focusCenterTabGroupMember(layout: CenterTabLayout, groupId: string, memberId: string): CenterTabLayout;
export function groupCenterTabs(layout: CenterTabLayout, sourceId: string, targetId: string, memberIndex: number, newGroupId: string): { layout: CenterTabLayout; accepted: boolean };
export function ungroupCenterTab(layout: CenterTabLayout, tabId: string, beforeId?: string | null): CenterTabLayout;
export function moveCenterTab(layout: CenterTabLayout, tabId: string, beforeId: string | null): CenterTabLayout;
export function moveCenterTabGroup(layout: CenterTabLayout, groupId: string, beforeId: string | null): CenterTabLayout;
export function moveCenterTabGroupMember(layout: CenterTabLayout, groupId: string, memberId: string, toIndex: number): CenterTabLayout;
export function centerTabStripEntries(layout: CenterTabLayout): CenterTabStripEntry[];
export function resolveCenterTabPanes(group: CenterTabGroup | undefined, tabs: readonly CenterTab[], activeId: string | null): CenterTabPane[];
- Write
check-compound-tabs.mjsfirst. Import the TypeScript module withnode --experimental-strip-typesand assert these behaviors:
const tabs = [
{ id: "s:a", kind: "session", title: "A", sessionId: "a" },
{ id: "s:b", kind: "session", title: "B", sessionId: "b" },
{ id: "w:one", kind: "web", title: "One", url: "https://one.test/" },
{ id: "w:two", kind: "web", title: "Two", url: "https://two.test/" },
];
const normalized = normalizeCenterTabLayout({
tabIds: tabs.map((tab) => tab.id),
groups: [{
id: "g:one",
memberIds: ["s:a", "w:one", "w:two", "missing"],
visibleIds: ["s:a", "w:one"],
focusedId: "missing",
}],
});
assert.deepEqual(normalized.groups[0].memberIds, ["s:a", "w:one", "w:two"]);
assert.equal(normalized.groups[0].visibleIds.includes(normalized.groups[0].focusedId), true);
const hiddenFocused = focusCenterTabGroupMember(normalized, "g:one", "w:two");
assert.deepEqual(hiddenFocused.groups[0].visibleIds, ["w:two", "w:one"]);
assert.equal(hiddenFocused.groups[0].focusedId, "w:two");
const sessionsOnly = {
id: "g:sessions",
memberIds: ["s:a", "s:b"],
visibleIds: ["s:a", "s:b"],
focusedId: "s:b",
};
assert.deepEqual(resolveCenterTabPanes(sessionsOnly, tabs, "s:b"), [{
key: "session",
kind: "session",
activeTabId: "s:b",
memberIds: ["s:a", "s:b"],
}]);
const sessionAndWeb = { ...sessionsOnly, memberIds: ["s:a", "w:one"], visibleIds: ["s:a", "w:one"], focusedId: "w:one" };
assert.deepEqual(resolveCenterTabPanes(sessionAndWeb, tabs, "w:one").map((pane) => pane.kind), ["session", "tab"]);
const movedMember = moveCenterTabGroupMember({
tabIds: ["s:a", "w:one", "w:two"],
groups: [{ id: "g:one", memberIds: ["s:a", "w:one", "w:two"], visibleIds: ["s:a", "w:one"], focusedId: "w:one" }],
}, "g:one", "w:two", 0);
assert.deepEqual(movedMember.groups[0].memberIds, ["w:two", "s:a", "w:one"]);
assert.deepEqual(movedMember.tabIds, ["w:two", "s:a", "w:one"]);
assert.equal(movedMember.groups[0].visibleIds.includes(movedMember.groups[0].focusedId), true);
Also assert one-member dissolution, member contiguity, duplicate removal, fourth-member rejection without mutation, whole-group movement, and ungroup placement.
- Run
cd web && node --no-warnings --experimental-strip-types scripts/check-compound-tabs.mjs. It must fail withERR_MODULE_NOT_FOUND. - Implement the module immutably. Every mutating helper returns
normalizeCenterTabLayout(...). Normalization must preserve member order, clamp to three, remove missing/claimed ids, make members contiguous, clamp visible ids to two, and choose afocusedIdfromvisibleIds. - Implement
resolveCenterTabPanesby iteratingvisibleIdsin order, adding the session pane once and normal panes by tab id. For multiple visible sessions, set the one session pane'sactiveTabIdtofocusedIdwhen it is a session, otherwise the first visible session. Slice the result to two panes. - Rerun the script; expect
compound-tabs pure checks passed. - Commit:
feat(desktop): add compound tab model.
Task 2: Move tabs, groups, and split state into one per-window payload#
Files:
- Modify
web/lib/state/center-tabs-store.ts - Modify
web/scripts/check-compound-tabs.mjs - Modify
web/scripts/check-web-split.mjs - Modify
web/package.json
Persistence contract:
export interface CenterTabsPersistedPayload {
version: 2;
tabs: CenterTab[];
activeId: string | null;
groups: CenterTabGroup[];
splitWebTabId: string | null;
splitRatio: number;
}
function desktopWindowId(): string | null;
function centerTabsStorageKey(windowId = desktopWindowId()): string {
return windowId ? `centerTabs:${windowId}` : "centerTabs";
}
function readCenterTabsPayload(): CenterTabsPersistedPayload;
function persistCenterTabsPayload(state: Pick<CenterTabsState, "tabs" | "activeId" | "groups" | "splitWebTabId" | "splitRatio">): void;
The helper may read window.openprogramDesktop?.windowId; until the multi-window preload supplies it, desktop defaults to "main". Browser-only use remains on centerTabs. On first read, migrate the existing centerTabs plus openprogram.webSplit values into the unified payload, then stop writing the split key. The multi-window plan extends this helper for secondary windows; it does not add another group type or storage payload.
Store additions: groups, moveTab, moveGroup, moveGroupMember, groupTab, ungroupTab, and focusGroupMember.
- Extend
check-compound-tabs.mjswith a fakelocalStorageandwindow.openprogramDesktop = { isDesktop: true, windowId: "main" }. Seed the two legacy keys, import a query-isolated store, and assert onecenterTabs:mainJSON object containsversion,tabs,activeId,groups,splitWebTabId, andsplitRatio. Assert neither a write nor a read referencesopenprogram.centerTabGroups. - Exercise
setSplitWebTab("w:one"): it must group the active session and web tab, set both visible, preserve the active session, and persist one payload. - Add a third tab, focus it, and assert it replaces the prior
focusedId, keeps membership, and persistsfocusedIdinsidevisibleIds. - Exercise
moveGroupMember; it must reorder bothmemberIdsand the corresponding contiguous portion oftabs. - Close a grouped member through the existing store close action after the check has simulated animation completion. Assert the remaining one-member group dissolves and split compatibility id clears when applicable.
- Run
npm --prefix web run check:compound-tabsandnpm --prefix web run check:web-split; expect failures for missing state/actions. - Replace
readPersisted,persist,readPersistedSplit, andpersistSplitwith the unified helpers. Dirty flags still clear on restore. Invalid group members normalize through Task 1. - Route every existing tab mutation through one
persistCenterTabsPayloadcall. Do not leave a mutation that persists tabs while omitting groups or split state. - Update
setActiveto focus a grouped member first, ensuringfocusedIdremains visible. UpdateopenWebTabInSplitand non-nullsetSplitWebTabto create/reuse the same session+web group. Null split ungrouping keeps other valid groups. - Add
"check:compound-tabs": "node --no-warnings --experimental-strip-types scripts/check-compound-tabs.mjs"toweb/package.jsonand include it innpm run check. - Run
npm --prefix web run check:compound-tabs,npm --prefix web run check:web-split, andnpm --prefix web run check:multi-draft; all must pass. - Commit:
feat(desktop): persist compound workspace state.
Task 3: Resolve panes before rendering#
Files:
-
Modify
web/components/app-shell.tsx -
Modify
web/app/styles/base.css -
Modify
web/scripts/check-web-split.mjs -
Add a behavior section to
check-web-split.mjsthat callsresolveCenterTabPanesfor session+session, session+web, web+web, and a hidden-third replacement. Assert pane lengths1,2,2, and at most2; assert the session+session descriptor selects the focused session. -
Add structural assertions that
AppShellcallsresolveCenterTabPanes, derivesshowDividerfrompanes.length === 2, and contains exactly one<PageShell page="chat" />. RejectvisibleTabs.length === 2as a divider condition. -
In
AppShell, selecttabs,groups, andactiveId; find the active group and call the resolver. Below the existing minimum split width, render only the focused/active pane without mutating the group or ratio. -
Render
{ kind: "session" }with the existing singleton chat wrapper. Do not map session members to multiplePageShellinstances. Render{ kind: "tab" }through the existing file/web/NTP components. -
Keep the existing 360/480 minimum, 6 DIP divider, preferred/effective ratio separation, measurement scheduler, pointer resize, and keyboard resize. Only render the divider between two actual pane descriptors.
-
When a session member becomes focused, invoke the existing
activateSessionroute path; the pathname effect must continue callinguseSessionStore.setCurrentConvorsetCurrentDraft. Move that activation into oneactiveIdeffect inCenterTabStripso imported and pointer-activated sessions use the same path; remove duplicate direct calls. -
For web+web groups, mount two
WebTabPaneinstances. The multi-window plan changes native visibility from one id to a set so both receive bounds and remain visible. -
Run
npm --prefix web run check:web-split && npm --prefix web run build; expect pass and exactly one chat shell. -
Commit:
feat(desktop): resolve compound panes.
Task 4: Render compound segments without breaking close animation#
Files:
- Modify
web/components/center-tabs/center-tab-strip.tsx - Modify
web/components/center-tabs/center-tabs.module.css - Modify
web/scripts/check-center-tabs.mjs - Modify
web/scripts/check-chat-ui.mjs
Required component contract:
interface CompoundTabItemProps {
group: CenterTabGroup;
tabs: CenterTab[];
activeId: string | null;
enteringIds: ReadonlySet<string>;
closingIds: ReadonlySet<string>;
onActivate(tab: CenterTab): void;
onClose(event: React.SyntheticEvent, tab: CenterTab): void;
onExited(tab: CenterTab): void;
}
- Add source-structure checks before editing:
assert.match(strip, /function CompoundTabItem/);
assert.match(strip, /enteringIds\.has\(tab\.id\)/);
assert.match(strip, /closingIds\.has\(tab\.id\)/);
assert.match(strip, /onAnimationEnd/);
assert.match(strip, /onExited\(tab\)/);
assert.match(strip, /onExited=\{finishClose\}/);
assert.doesNotMatch(strip, /splitPinned|data-split-pinned/);
assert.doesNotMatch(css, /data-split-pinned|inset 0 2px 0 var\(--accent-blue\)/);
Also assert each group.memberIds.map renders a role="tab" target and a sibling close button. This is a required structural check because exit animation is DOM lifecycle behavior.
- Run
npm --prefix web run check:center-tabs && npm --prefix web run check:chat-ui; expect failure. - Derive strip entries with
centerTabStripEntries. Render ordinary entries with the currentTabItem; render grouped entries withCompoundTabItem. - Pass the real
enteringIds,closingIds, andfinishCloseintoCompoundTabItem. Each segment applies the same enter/exit CSS class asTabItem. Its animation-end handler callsonExited(tab)only for that segment's exit animation. Do not remove a group member when the close button is clicked;onTabClosestarts animation andfinishCloseperforms the eventual close/cleanup. - Reuse existing icon/title/dirty/close markup and accessible labels. Keep
aria-selected, rovingtabIndex, and independent close focus for every segment. - Implement neutral widths: two members target 360 DIP; three target 440 DIP; internal divider is 1 DIP. Delete the pinned-line state and CSS entirely.
- Run both checks and
npm --prefix web run build; all pass. - Commit:
feat(desktop): render animated compound tabs.
Task 5: Add one prepared drag coordinator and same-window geometry#
Files:
- Create
web/lib/tab-drag-coordinator.ts - Modify
web/components/center-tabs/center-tab-strip.tsx - Modify
web/components/center-tabs/center-tabs.module.css - Modify
web/scripts/check-compound-tabs.mjs - Modify
web/scripts/check-center-tabs.mjs
Shared types used by both plans:
export type TabDragSubject =
| { kind: "tab"; tabIds: [string] }
| { kind: "segment"; tabIds: [string]; sourceGroup: CenterTabGroup; memberIndex: number }
| { kind: "group"; tabIds: string[]; sourceGroup: CenterTabGroup };
export type TabDropIntent =
| { mode: "before"; targetTabId: string }
| { mode: "merge"; targetTabId: string; groupId?: string; memberIndex?: number }
| { mode: "after"; targetTabId: string };
export interface PreparedTabDrag {
subject: TabDragSubject;
transferToken?: string;
started: boolean;
cancelled: boolean;
committed: boolean;
}
export function createTabDragCoordinator(): {
prepare(prepared: PreparedTabDrag): void;
current(): PreparedTabDrag | null;
start(): PreparedTabDrag | null;
cancel(): PreparedTabDrag | null;
commit(): PreparedTabDrag | null;
clear(): void;
};
export function resolveTabDropIntent(rect: Pick<DOMRect, "left" | "width">, clientX: number, target: { tabId: string; groupId?: string; memberIndex?: number }): TabDropIntent;
resolveTabDropIntent uses the first 25% for before, middle 50% for merge, and final 25% for after. Both same-window and cross-window drops call this function.
- Add behavior assertions to
check-compound-tabs.mjs: prepare returns the same payload synchronously;start()changesstartedexactly once; pointer release beforestart()cancels and clears the prepared entry; cancel and commit are single-use; clear removes it; exact x positions resolve to before/merge/after; a segment payload retainsmemberIndex,visibleIds, andfocusedId; a group payload retains every member id. - Add structural checks that
onPointerDownoronMouseDownprepares the coordinator beforeonDragStart; theonDragStartbody contains noawait; and allDataTransfer.setData(...)calls usedragCoordinator.current()synchronously. Rejectasync function ...DragStart. Assert the close button stops pointer/mouse-down propagation and is not a drag-preparation target. - On pointer/mouse down of a normal tab target, compound segment target, or neutral group handle, snapshot the subject into the shared coordinator and register a one-shot window
pointerup/mouseupcancellation. On nativedragstart, callstart(), remove that release listener, and synchronously seteffectAllowedplus MIME/text payload. If pointerup/click occurs whilestarted === false, cancel immediately; a click without drag must not leave a token for its 15-second expiry. If no prepared subject exists, callpreventDefault()and announce cancellation. Close buttons usedraggable={false}and stop pointer/mouse-down propagation, so clicking close never prepares a transfer. - Use
resolveTabDropIntentfor all targets. Edge drops reorder a normal tab or whole group. Segment edge drops first ungroup and then place the tab. Center drops create/insert a group. A full group rejects without mutation. - When a segment is moved left/right while still inside its group, call
moveGroupMember; only moving past the group boundary ungroups it. A whole-group keyboard action callsmoveGroup. - Do not create another drag ref or payload in the multi-window work. That plan adds a synchronously prepared main-process token to this same
PreparedTabDrag. - Use reduced opacity on the source and transient insert/merge markers. Clear them on drop,
dragend, and Escape. No persistent accent. - Run
npm --prefix web run check:compound-tabs,npm --prefix web run check:center-tabs, andnpm --prefix web run build; all pass. - Commit:
feat(desktop): add unified tab drag coordinator.
Task 6: Complete keyboard and announcement behavior#
Files:
-
Modify
web/components/center-tabs/center-tab-strip.tsx -
Modify
web/components/center-tabs/center-tabs.module.css -
Modify
web/scripts/check-chat-ui.mjs -
Modify
web/scripts/check-center-tabs.mjs -
Add checks for ArrowLeft/ArrowRight/Home/End,
Shift+F10,Escape,role="menu", native menu buttons,role="status" aria-live="polite", and focus return to the invoking segment. -
Keep normal roving focus across strip segments. Arrow movement changes focus only; menu Move left/right mutates order. For a segment, Move left/right first reorders within
memberIds, and only crosses the group boundary by ungrouping. -
Menu actions are Move left, Move right, Add to split, Remove from group, and Move to new window. The last action is disabled until the multi-window bridge is present, then uses the same coordinator.
-
Announce successful reorder/group/ungroup, fourth-member rejection, cancelled drag, and completed transfer. Escape cancels the prepared coordinator and clears target/menu state.
-
Run
npm --prefix web run check:chat-ui,npm --prefix web run check:center-tabs,npm --prefix web run check, andnpm --prefix web run build. -
Commit:
feat(desktop): add accessible compound controls.
Final verification#
- Run:
npm --prefix web run check
npm --prefix web run build
npm --prefix desktop run check:webtabs
- Probe both development services before Electron. If either probe fails, start only the established
devworker, then repeat both probes; do not alter stable18100/18109:
if ! curl -fsS http://127.0.0.1:18209/healthz >/dev/null || \
! curl -fsS http://127.0.0.1:18200/chat >/dev/null; then
OPENPROGRAM_PROFILE=dev \
OPENPROGRAM_BACKEND_PORT=18209 \
OPENPROGRAM_WEB_PORT=18200 \
openprogram worker start
fi
curl --fail --retry 30 --retry-delay 1 http://127.0.0.1:18209/healthz >/dev/null
curl --fail --retry 30 --retry-delay 1 http://127.0.0.1:18200/chat >/dev/null
OPENPROGRAM_WEB_PORT=18200 npm --prefix desktop run dev
Expected: backend 18209/healthz and frontend 18200/chat both respond before Electron starts.
- Verify session+web, web+web, and session+session groups. Session+session must show one chat pane and no divider; focusing either session must update the URL and
useSessionStorestate. - Add and focus a hidden third member; confirm it replaces the focused pane and a fourth is rejected.
- Close every compound segment and confirm its exit animation completes before removal and all existing dirty/draft cleanup remains intact.
- Drag a normal tab, segment, and whole group through before/merge/after targets. Confirm segment keyboard movement reorders members and whole-group movement preserves all members.
- Resize below and above the split minimum; confirm group membership, focused member, and preferred ratio persist in one
centerTabs:mainpayload. - Confirm there is no
openprogram.centerTabGroups,splitPinned, static top accent, duplicate chat shell, or empty divider.