From c4c1d76fec972e5d902a669f0cb1953eaa528f16 Mon Sep 17 00:00:00 2001 From: Adam Malczewski Date: Thu, 15 Oct 2026 14:38:50 +0900 Subject: feat(tabs): 2-column tab grid with nestable subagent groups + tab menu (add/remove from group) --- GLOSSARY.md | 1 + backend-handoff.md | 15 ++ src/adapters/ws/index.ts | 5 + src/adapters/ws/logic.test.ts | 60 +++++++ src/adapters/ws/logic.ts | 21 +++ src/app/store.svelte.ts | 66 ++++++- src/app/views/TabsPane.svelte | 1 + src/core/wire/conformance.ts | 2 + src/features/tabs/index.ts | 11 ++ src/features/tabs/tab-groups.test.ts | 124 +++++++++++++ src/features/tabs/tab-groups.ts | 155 ++++++++++++++++ src/features/tabs/tabs-store.svelte.ts | 6 + src/features/tabs/tabs.test.ts | 29 +++ src/features/tabs/tabs.ts | 27 +++ src/features/tabs/ui.test.ts | 180 ++++++++++++++++++- src/features/tabs/ui/TabCard.svelte | 232 ++++++++++++++++++++++++ src/features/tabs/ui/TabGroupBox.svelte | 53 ++++++ src/features/tabs/ui/TabList.svelte | 310 ++++++++++++++------------------ src/features/tabs/ui/TabMenu.svelte | 145 +++++++++++++++ 19 files changed, 1258 insertions(+), 185 deletions(-) create mode 100644 src/features/tabs/tab-groups.test.ts create mode 100644 src/features/tabs/tab-groups.ts create mode 100644 src/features/tabs/ui/TabCard.svelte create mode 100644 src/features/tabs/ui/TabGroupBox.svelte create mode 100644 src/features/tabs/ui/TabMenu.svelte diff --git a/GLOSSARY.md b/GLOSSARY.md index 1c1b68d..70660d9 100644 --- a/GLOSSARY.md +++ b/GLOSSARY.md @@ -43,6 +43,7 @@ | **surface catalog** | The list of available surfaces (metadata) the FE fetches to discover them (`GET /surfaces`). | capability manifest | | **view** | RESERVED — the old-Dispatch sidebar affordance (settings / feature views); a FUTURE FE concept, NOT a surface. | (do not reuse) | | **tab** | A FE workspace slot in the tab strip that *references* one open conversation — holds its `conversationId`, the selected **model name**, and a derived title. Distinct from the conversation itself (the backend thread): closing a tab forgets it LOCALLY (drops the slot + evicts its FE cache); the conversation persists server-side. Open tabs + the active tab are persisted locally. | (do not conflate with **conversation**) | +| **subagent group** / **tab group parent** | The tab grid's purely VISUAL nesting: a tab whose `tabGroupParentId` (backend-owned, EFFECTIVE value — an explicit user override, else the lineage `parentConversationId`) names another open tab renders inside that tab's subagent group (a darker box; the group parent's card is double-width). Users regroup via the tab menu (right-click / touch-and-hold) → `PUT /conversations/:id/tab-group`; synced by `conversation.tabGroupChanged`. Never affects lineage, manageability or agent behaviour. | (do not conflate with lineage **parentConversationId**) | | **feature module** | A self-contained FE feature (chat, history explorer, …); feature-as-a-library, composed at the root. | — | | **composition root** | The single place (`src/app/`) that imports + wires feature modules + the surface host. | — | | **surface interpreter** | The generic renderer: field kind → component. Knows kinds, never surface ids. | — | diff --git a/backend-handoff.md b/backend-handoff.md index 1109f8b..ab79bf2 100644 --- a/backend-handoff.md +++ b/backend-handoff.md @@ -18,6 +18,21 @@ > ?archived=`) was made in the same branch and the FE consumes it via the `file:` deps (dists > rebuilt). Recorded below. +## Milestone: Agent tab grid + subagent groups (`feature/agent-tab-grid`, 2026-10-15) +- FE: the tabs view is now a 2-column grid of identical fixed-height cards (`TabCard`); a tab with + visible subagents is double-width and wrapped with them in a darker, nestable box + (`TabGroupBox`, recursive). Boxes bleed outward (negative margin + equal padding) so every card + stays on the same two columns at any depth. Pure model in `features/tabs/tab-groups.ts` + (forest build, cycle-tolerant, candidates, bleed/gutter). +- Tab menu (`TabMenu`): right-click / touch-and-hold (500 ms, 10 px tolerance) / ContextMenu key → + "Remove from group", "Ungroup N subagents", "Add to group under…" (excludes self, descendants, + current parent). `AppStore.setTabGroup` is optimistic → `PUT /conversations/:id/tab-group` + → reconciles to the response; reverts + reports on failure. +- CONSUMED (backend, same branch — plan `../backend/notes/agent-tab-grid-plan.md`): + `ConversationMeta.tabGroupParentId?` (effective), `ConversationOpenMessage.tabGroupParentId?`, + `ConversationTabGroupChangedMessage` (`conversation.tabGroupChanged`, null = ungrouped), + `SetTabGroupRequest` / `TabGroupResponse`. + ## Milestone: Tool-call cards final visual follow-up (`feature/tool-call-cards`, 2026-10-14) **Slice status:** COMPLETE (frontend-only). diff --git a/src/adapters/ws/index.ts b/src/adapters/ws/index.ts index 4dc0104..76d9e75 100644 --- a/src/adapters/ws/index.ts +++ b/src/adapters/ws/index.ts @@ -4,6 +4,7 @@ import type { ConversationCompactedMessage, ConversationOpenMessage, ConversationStatusChangedMessage, + ConversationTabGroupChangedMessage, ExtensionsChangedMessage, LayoutChangedMessage, TileLayoutChangedMessage, @@ -30,6 +31,8 @@ export interface SurfaceSocketOptions { onConversationStatusChanged?: (msg: ConversationStatusChangedMessage) => void; /** Broadcast when a conversation's history has been compacted (reload needed). */ onConversationCompacted?: (msg: ConversationCompactedMessage) => void; + /** Broadcast when a conversation's (visual) tab group parent changes. */ + onConversationTabGroupChanged?: (msg: ConversationTabGroupChangedMessage) => void; /** * Broadcast after every plugin-manager mutation (plan 05 §11): every * connected frontend refetches its extension list (the server re-pushes the @@ -93,6 +96,8 @@ export function createSurfaceSocket(opts: SurfaceSocketOptions): SurfaceSocketHa opts.onConversationOpen?.(msg as ConversationOpenMessage); } else if (msg.type === "conversation.statusChanged") { opts.onConversationStatusChanged?.(msg as ConversationStatusChangedMessage); + } else if (msg.type === "conversation.tabGroupChanged") { + opts.onConversationTabGroupChanged?.(msg as ConversationTabGroupChangedMessage); } else if (msg.type === "conversation.compacted") { opts.onConversationCompacted?.(msg as ConversationCompactedMessage); } else if (msg.type === "extensions-changed") { diff --git a/src/adapters/ws/logic.test.ts b/src/adapters/ws/logic.test.ts index 8fe2b5f..2179ad0 100644 --- a/src/adapters/ws/logic.test.ts +++ b/src/adapters/ws/logic.test.ts @@ -502,3 +502,63 @@ describe("parseServerMessage — layout (plan 02)", () => { expect(parseServerMessage(JSON.stringify({ type: "layout.unknown" }))).toBeNull(); }); }); + +describe("parseServerMessage — subagent tab groups", () => { + it("forwards the effective tabGroupParentId on conversation.open", () => { + const result = parseServerMessage( + JSON.stringify({ + type: "conversation.open", + conversationId: "child", + workspaceId: "w1", + tabGroupParentId: "parent", + }), + ); + expect(result).toEqual({ + type: "conversation.open", + conversationId: "child", + workspaceId: "w1", + tabGroupParentId: "parent", + }); + }); + + it("omits a non-string/empty tabGroupParentId on conversation.open", () => { + for (const tabGroupParentId of ["", 42, null]) { + const result = parseServerMessage( + JSON.stringify({ + type: "conversation.open", + conversationId: "c1", + workspaceId: "w1", + tabGroupParentId, + }), + ); + expect(result).toEqual({ + type: "conversation.open", + conversationId: "c1", + workspaceId: "w1", + }); + } + }); + + it("parses conversation.tabGroupChanged with a parent id and with null (ungrouped)", () => { + for (const tabGroupParentId of ["parent", null]) { + const msg = { + type: "conversation.tabGroupChanged", + conversationId: "c1", + workspaceId: "w1", + tabGroupParentId, + }; + expect(parseServerMessage(JSON.stringify(msg))).toEqual(msg); + } + }); + + it("rejects a malformed conversation.tabGroupChanged", () => { + const base = { type: "conversation.tabGroupChanged", conversationId: "c1", workspaceId: "w1" }; + expect(parseServerMessage(JSON.stringify(base))).toBeNull(); // missing (undefined) parent + expect(parseServerMessage(JSON.stringify({ ...base, tabGroupParentId: "" }))).toBeNull(); + expect(parseServerMessage(JSON.stringify({ ...base, tabGroupParentId: 1 }))).toBeNull(); + const { workspaceId: _w, ...noWorkspace } = base; + expect( + parseServerMessage(JSON.stringify({ ...noWorkspace, tabGroupParentId: "p" })), + ).toBeNull(); + }); +}); diff --git a/src/adapters/ws/logic.ts b/src/adapters/ws/logic.ts index 4dbd173..cecc66c 100644 --- a/src/adapters/ws/logic.ts +++ b/src/adapters/ws/logic.ts @@ -4,6 +4,7 @@ import type { ConversationCompactedMessage, ConversationOpenMessage, ConversationStatusChangedMessage, + ConversationTabGroupChangedMessage, ExtensionsChangedMessage, LayoutChangedMessage, TileLayoutChangedMessage, @@ -27,6 +28,7 @@ const VALID_SERVER_TYPES = new Set([ "conversation.open", "conversation.statusChanged", "conversation.compacted", + "conversation.tabGroupChanged", "extensions-changed", "layout", "tile-layout", @@ -126,11 +128,30 @@ export function parseServerMessage(data: string): WsServerMessage | null { // it, so a client can stamp the opened tab with the conversation's ACTUAL // model (fix-toolcall-duplicate-keys plan Part 4). const model = parsed.model; + // The EFFECTIVE tab group parent, so a summoned subagent's tab lands in + // its summoner's group immediately. + const tabGroupParentId = parsed.tabGroupParentId; const msg: ConversationOpenMessage = { type: "conversation.open", conversationId: parsed.conversationId, workspaceId: parsed.workspaceId, ...(typeof model === "string" && model.length > 0 ? { model } : {}), + ...(typeof tabGroupParentId === "string" && tabGroupParentId.length > 0 + ? { tabGroupParentId } + : {}), + }; + return msg; + } + case "conversation.tabGroupChanged": { + if (typeof parsed.conversationId !== "string") return null; + if (typeof parsed.workspaceId !== "string") return null; + const parent = parsed.tabGroupParentId; + if (parent !== null && (typeof parent !== "string" || parent.length === 0)) return null; + const msg: ConversationTabGroupChangedMessage = { + type: "conversation.tabGroupChanged", + conversationId: parsed.conversationId, + workspaceId: parsed.workspaceId, + tabGroupParentId: parent, }; return msg; } diff --git a/src/app/store.svelte.ts b/src/app/store.svelte.ts index 0a9cbd1..a3bc226 100644 --- a/src/app/store.svelte.ts +++ b/src/app/store.svelte.ts @@ -12,6 +12,7 @@ import type { ConversationMetricsResponse, ConversationOpenMessage, ConversationStatusChangedMessage, + ConversationTabGroupChangedMessage, CwdResponse, ExtensionsChangedMessage, LayoutChangedMessage, @@ -28,11 +29,13 @@ import type { SetModelRequest, SetReasoningEffortRequest, SetSystemPromptTemplateRequest, + SetTabGroupRequest, SetTitleRequest, SetVisionSettingsRequest, SystemPromptTemplateResponse, SystemPromptVariable, SystemPromptVariablesResponse, + TabGroupResponse, TestComputerResponse, TileLayoutChangedMessage, WarmRequest, @@ -269,6 +272,13 @@ export interface AppStore { selectTab(conversationId: string): void; closeTab(conversationId: string): void; renameTab(conversationId: string, title: string): void; + /** + * Move a tab into a subagent group (the tab group parent's id) or out of + * any group (null). Purely presentational (NOT lineage): applied + * optimistically, persisted via `PUT /conversations/:id/tab-group` (synced + * to other clients by `conversation.tabGroupChanged`), reverted on failure. + */ + setTabGroup(conversationId: string, parentId: string | null): void; invoke(surfaceId: string, actionId: string, payload?: unknown): void; /** * Explicitly (re-)subscribe one surface, scoped to `conversationId` (or the @@ -1351,7 +1361,12 @@ export function createAppStore(opts?: CreateAppStoreOptions): AppStore { * stamped with the conversation's actual `workspaceId`, NOT the viewer's * currently active workspace. */ - function openConversation(conversationId: string, workspaceId: string, model?: string): void { + function openConversation( + conversationId: string, + workspaceId: string, + model?: string, + tabGroupParentId?: string, + ): void { if (chatStores.has(conversationId)) return; // Stamp the tab with the conversation's PERSISTED model when the open // broadcast carries it (falling back to the current global selection) — @@ -1367,6 +1382,7 @@ export function createAppStore(opts?: CreateAppStoreOptions): AppStore { model: tabModel, title: "Conversation", workspaceId, + ...(tabGroupParentId !== undefined ? { tabGroupParentId } : {}), }); } @@ -1465,6 +1481,9 @@ export function createAppStore(opts?: CreateAppStoreOptions): AppStore { model, title: conv.title, workspaceId: conv.workspaceId, + ...(conv.tabGroupParentId !== undefined + ? { tabGroupParentId: conv.tabGroupParentId } + : {}), }); } else { // Already open — reconcile title + workspace from the authoritative @@ -1473,12 +1492,14 @@ export function createAppStore(opts?: CreateAppStoreOptions): AppStore { // authoritative conversation record is the source of truth, so a // differing tab workspace is corrected here. tabsStore.setTitle(conv.id, conv.title); - if ( - tabsStore.tabs.find((t) => t.conversationId === conv.id)?.workspaceId !== - conv.workspaceId - ) { + const tab = tabsStore.tabs.find((t) => t.conversationId === conv.id); + if (tab?.workspaceId !== conv.workspaceId) { tabsStore.setWorkspaceId(conv.id, conv.workspaceId); } + // The backend's EFFECTIVE tab group parent is authoritative too. + if (tab?.tabGroupParentId !== conv.tabGroupParentId) { + tabsStore.setTabGroupParent(conv.id, conv.tabGroupParentId ?? null); + } } } @@ -1533,7 +1554,12 @@ export function createAppStore(opts?: CreateAppStoreOptions): AppStore { onMessage: handleServerMessage, onChat: handleChatMessage, onConversationOpen(msg: ConversationOpenMessage): void { - openConversation(msg.conversationId, msg.workspaceId, msg.model); + openConversation(msg.conversationId, msg.workspaceId, msg.model, msg.tabGroupParentId); + }, + onConversationTabGroupChanged(msg: ConversationTabGroupChangedMessage): void { + // Another client (or this one, echoed) regrouped a tab — apply the + // authoritative EFFECTIVE value. Unknown tabs are a reducer no-op. + tabsStore.setTabGroupParent(msg.conversationId, msg.tabGroupParentId); }, onConversationStatusChanged(msg: ConversationStatusChangedMessage): void { const { conversationId, status, workspaceId } = msg; @@ -2003,6 +2029,34 @@ export function createAppStore(opts?: CreateAppStoreOptions): AppStore { }); }, + setTabGroup(conversationId: string, parentId: string | null): void { + const previous = + tabsStore.tabs.find((t) => t.conversationId === conversationId)?.tabGroupParentId ?? null; + tabsStore.setTabGroupParent(conversationId, parentId); + void (async () => { + try { + const res = await fetchImpl( + `${httpBase}/conversations/${encodeURIComponent(conversationId)}/tab-group`, + { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ parentId } satisfies SetTabGroupRequest), + }, + ); + if (!res.ok) { + const err = (await res.json().catch(() => null)) as { error?: string } | null; + throw new Error(err?.error ?? `HTTP ${res.status}`); + } + const body = (await res.json()) as TabGroupResponse; + tabsStore.setTabGroupParent(conversationId, body.tabGroupParentId); + } catch (err) { + // Revert the optimistic move, then surface the failure. + tabsStore.setTabGroupParent(conversationId, previous); + reportError("Failed to update the tab's subagent group", err); + } + })(); + }, + invoke(surfaceId: string, actionId: string, payload?: unknown): void { invokeSurfaceOp(surfaceId, actionId, payload); }, diff --git a/src/app/views/TabsPane.svelte b/src/app/views/TabsPane.svelte index f87b086..4547e06 100644 --- a/src/app/views/TabsPane.svelte +++ b/src/app/views/TabsPane.svelte @@ -20,5 +20,6 @@ onClose={(id) => store.closeTab(id)} onNewDraft={() => store.newDraft()} onRename={(id, title) => store.renameTab(id, title)} + onSetTabGroup={(id, parentId) => store.setTabGroup(id, parentId)} /> {/key} diff --git a/src/core/wire/conformance.ts b/src/core/wire/conformance.ts index b358641..855f7ea 100644 --- a/src/core/wire/conformance.ts +++ b/src/core/wire/conformance.ts @@ -91,6 +91,8 @@ export function assertWsServerMessageExhaustive(msg: WsServerMessage): string { return "conversation.statusChanged"; case "conversation.compacted": return "conversation.compacted"; + case "conversation.tabGroupChanged": + return "conversation.tabGroupChanged"; case "extensions-changed": return "extensions-changed"; case "layout": diff --git a/src/features/tabs/index.ts b/src/features/tabs/index.ts index 7520215..a0c53f8 100644 --- a/src/features/tabs/index.ts +++ b/src/features/tabs/index.ts @@ -1,3 +1,13 @@ +export type { TabGroupForest, TabGroupNode } from "./tab-groups"; +export { + buildTabGroups, + directGroupChildren, + groupBleedPx, + groupDescendants, + groupGutterPx, + groupParentCandidates, + visibleGroupParents, +} from "./tab-groups"; export type { Tab, TabsState } from "./tabs"; export { activeTab, @@ -9,6 +19,7 @@ export { newDraft, selectTab, setModel, + setTabGroupParent, setTitle, shortHandle, } from "./tabs"; diff --git a/src/features/tabs/tab-groups.test.ts b/src/features/tabs/tab-groups.test.ts new file mode 100644 index 0000000..ca2492c --- /dev/null +++ b/src/features/tabs/tab-groups.test.ts @@ -0,0 +1,124 @@ +import { describe, expect, it } from "vitest"; +import { + buildTabGroups, + directGroupChildren, + groupBleedPx, + groupDescendants, + groupGutterPx, + groupParentCandidates, + visibleGroupParents, +} from "./tab-groups"; +import type { Tab } from "./tabs"; + +function tab(id: string, parent?: string): Tab { + return { + conversationId: id, + model: "m", + title: id.toUpperCase(), + workspaceId: "w", + ...(parent !== undefined ? { tabGroupParentId: parent } : {}), + }; +} + +const ids = (tabs: readonly Tab[]): string[] => tabs.map((t) => t.conversationId); + +describe("visibleGroupParents", () => { + it("keeps a parent that is another open tab", () => { + const parents = visibleGroupParents([tab("a"), tab("b", "a")]); + expect(parents.get("a")).toBeNull(); + expect(parents.get("b")).toBe("a"); + }); + + it("drops a parent that is not open (closed / other workspace) or is the tab itself", () => { + const parents = visibleGroupParents([tab("a", "gone"), tab("b", "b")]); + expect(parents.get("a")).toBeNull(); + expect(parents.get("b")).toBeNull(); + }); + + it("treats every member of a cycle as a root, but keeps hangers-on below them", () => { + const parents = visibleGroupParents([tab("a", "b"), tab("b", "a"), tab("c", "a")]); + expect(parents.get("a")).toBeNull(); + expect(parents.get("b")).toBeNull(); + expect(parents.get("c")).toBe("a"); + }); +}); + +describe("buildTabGroups", () => { + it("returns flat roots and depth 0 when nothing is grouped", () => { + const forest = buildTabGroups([tab("a"), tab("b"), tab("c")]); + expect(forest.maxDepth).toBe(0); + expect(forest.roots.map((n) => n.tab.conversationId)).toEqual(["a", "b", "c"]); + expect(forest.roots.every((n) => n.children.length === 0)).toBe(true); + }); + + it("nests children under their parent in tab order, preserving root order", () => { + // Child listed BEFORE its parent still nests under it. + const forest = buildTabGroups([tab("x", "p"), tab("a"), tab("p"), tab("y", "p")]); + expect(forest.maxDepth).toBe(1); + expect(forest.roots.map((n) => n.tab.conversationId)).toEqual(["a", "p"]); + const p = forest.roots[1]; + expect(p?.children.map((n) => n.tab.conversationId)).toEqual(["x", "y"]); + }); + + it("supports nested groups (subagents with subagents) and reports the depth", () => { + const forest = buildTabGroups([tab("p"), tab("c", "p"), tab("g", "c"), tab("gg", "g")]); + expect(forest.maxDepth).toBe(3); + const c = forest.roots[0]?.children[0]; + expect(c?.tab.conversationId).toBe("c"); + expect(c?.children[0]?.children[0]?.tab.conversationId).toBe("gg"); + }); + + it("renders every tab exactly once", () => { + const tabs = [tab("a", "b"), tab("b", "a"), tab("c", "a"), tab("d", "nope"), tab("e", "c")]; + const seen: string[] = []; + const walk = (nodes: ReturnType["roots"]): void => { + for (const n of nodes) { + seen.push(n.tab.conversationId); + walk(n.children); + } + }; + walk(buildTabGroups(tabs).roots); + expect(seen.sort()).toEqual(["a", "b", "c", "d", "e"]); + }); +}); + +describe("groupDescendants / directGroupChildren / groupParentCandidates", () => { + const tabs = [tab("p"), tab("c1", "p"), tab("c2", "p"), tab("g", "c1"), tab("solo")]; + + it("collects transitive descendants", () => { + expect([...groupDescendants(tabs, "p")].sort()).toEqual(["c1", "c2", "g"]); + expect([...groupDescendants(tabs, "c1")]).toEqual(["g"]); + expect(groupDescendants(tabs, "solo").size).toBe(0); + }); + + it("lists direct children only", () => { + expect(directGroupChildren(tabs, "p")).toEqual(["c1", "c2"]); + }); + + it("offers every tab except self, descendants (cycle) and the current parent", () => { + expect(ids(groupParentCandidates(tabs, "c1"))).toEqual(["c2", "solo"]); + expect(ids(groupParentCandidates(tabs, "p"))).toEqual(["solo"]); + expect(ids(groupParentCandidates(tabs, "solo"))).toEqual(["p", "c1", "c2", "g"]); + }); +}); + +describe("groupBleedPx / groupGutterPx", () => { + it("is zero without groups", () => { + expect(groupGutterPx(0)).toBe(0); + expect(groupBleedPx(1, 0)).toBe(0); + }); + + it("bleeds outer boxes further than inner ones, the outermost filling the gutter", () => { + const max = 3; + const bleeds = [1, 2, 3].map((l) => groupBleedPx(l, max)); + expect(bleeds[0]).toBe(groupGutterPx(max)); + expect(bleeds[0]).toBeGreaterThan(bleeds[1] ?? 0); + expect(bleeds[1]).toBeGreaterThan(bleeds[2] ?? 0); + expect(bleeds[2]).toBeGreaterThan(0); + }); + + it("keeps the gutter bounded for deep nesting", () => { + expect(groupGutterPx(12)).toBeLessThanOrEqual(24); + expect(groupBleedPx(12, 12)).toBeGreaterThan(0); + }); +}); diff --git a/src/features/tabs/tab-groups.ts b/src/features/tabs/tab-groups.ts new file mode 100644 index 0000000..ef16451 --- /dev/null +++ b/src/features/tabs/tab-groups.ts @@ -0,0 +1,155 @@ +import type { Tab } from "./tabs"; + +/** + * Subagent tab groups — the PURE model behind the tab grid. + * + * Each tab may name a tab group parent (`Tab.tabGroupParentId`, the effective + * value from the backend). The tab grid renders the tabs as a forest: a tab + * with ≥1 visible child is a GROUP (double-width card + a darker box wrapping + * it and its children); everything else is a single half-width card. Groups + * nest (subagents can summon subagents). Purely presentational. + */ + +export interface TabGroupNode { + readonly tab: Tab; + /** Visible children, in tab order. Non-empty ⇒ this node renders as a group. */ + readonly children: readonly TabGroupNode[]; +} + +export interface TabGroupForest { + /** Top-level nodes, in tab order. */ + readonly roots: readonly TabGroupNode[]; + /** Deepest group-box nesting level (0 = no groups, 1 = only top-level groups, …). */ + readonly maxDepth: number; +} + +/** + * The VISIBLE tab group parent of every tab in `tabs`: its `tabGroupParentId` + * when that names ANOTHER tab in the same set, else null. A tab whose + * ancestor chain loops back to itself (a cycle — never produced by the + * backend, but tolerated) is treated as a root, so rendering never loops. + */ +export function visibleGroupParents(tabs: readonly Tab[]): ReadonlyMap { + const byId = new Map(tabs.map((t) => [t.conversationId, t])); + const direct = (id: string): string | null => { + const p = byId.get(id)?.tabGroupParentId; + return p !== undefined && p !== id && byId.has(p) ? p : null; + }; + const out = new Map(); + for (const tab of tabs) { + const id = tab.conversationId; + const parent = direct(id); + let cyclic = false; + const seen = new Set([id]); + let cursor = parent; + while (cursor !== null) { + if (cursor === id) { + cyclic = true; + break; + } + if (seen.has(cursor)) break; // a loop above us that doesn't include us + seen.add(cursor); + cursor = direct(cursor); + } + out.set(id, cyclic ? null : parent); + } + return out; +} + +/** Build the group forest for one workspace's tab set (input order preserved). */ +export function buildTabGroups(tabs: readonly Tab[]): TabGroupForest { + const parents = visibleGroupParents(tabs); + const childIds = new Map(); + for (const tab of tabs) { + const p = parents.get(tab.conversationId) ?? null; + if (p === null) continue; + const list = childIds.get(p); + if (list === undefined) childIds.set(p, [tab.conversationId]); + else list.push(tab.conversationId); + } + const byId = new Map(tabs.map((t) => [t.conversationId, t])); + let maxDepth = 0; + const build = (tab: Tab, depth: number): TabGroupNode => { + const ids = childIds.get(tab.conversationId) ?? []; + if (ids.length > 0) maxDepth = Math.max(maxDepth, depth + 1); + const children: TabGroupNode[] = []; + for (const cid of ids) { + const child = byId.get(cid); + if (child !== undefined) children.push(build(child, depth + 1)); + } + return { tab, children }; + }; + const roots = tabs + .filter((t) => (parents.get(t.conversationId) ?? null) === null) + .map((t) => build(t, 0)); + return { roots, maxDepth }; +} + +/** Every (transitive) descendant id of `conversationId` in the VISIBLE grouping. */ +export function groupDescendants(tabs: readonly Tab[], conversationId: string): Set { + const parents = visibleGroupParents(tabs); + const out = new Set(); + let grew = true; + while (grew) { + grew = false; + for (const [id, p] of parents) { + if (out.has(id) || id === conversationId || p === null) continue; + if (p === conversationId || out.has(p)) { + out.add(id); + grew = true; + } + } + } + return out; +} + +/** + * The tabs `conversationId` may be moved under (the "Add to group" menu): + * every other tab EXCEPT itself, its descendants (that would form a cycle), + * and its current visible group parent (already there). Tab order preserved. + */ +export function groupParentCandidates(tabs: readonly Tab[], conversationId: string): Tab[] { + const parents = visibleGroupParents(tabs); + const current = parents.get(conversationId) ?? null; + const descendants = groupDescendants(tabs, conversationId); + return tabs.filter( + (t) => + t.conversationId !== conversationId && + t.conversationId !== current && + !descendants.has(t.conversationId), + ); +} + +/** Ids of the tabs DIRECTLY grouped under `conversationId` (visible grouping). */ +export function directGroupChildren(tabs: readonly Tab[], conversationId: string): string[] { + const parents = visibleGroupParents(tabs); + return tabs + .filter((t) => parents.get(t.conversationId) === conversationId) + .map((t) => t.conversationId); +} + +/** + * Horizontal bleed (px) of a group box at nesting `level` (1 = outermost) + * when the deepest box is `maxDepth`. A box bleeds OUTWARD by this much (a + * negative margin + equal padding), so the cards inside every box sit on + * exactly the same two grid columns as top-level cards — all cards keep + * IDENTICAL dimensions at every depth. Outer boxes bleed more than inner + * ones so nested edges stay visible. The step shrinks for deep nesting so the + * total gutter (`groupGutterPx`) stays bounded. + */ +export function groupBleedPx(level: number, maxDepth: number): number { + if (maxDepth <= 0 || level < 1 || level > maxDepth) return 0; + return (maxDepth - level + 1) * groupStepPx(maxDepth); +} + +/** The horizontal gutter the grid must reserve so the outermost box fits. */ +export function groupGutterPx(maxDepth: number): number { + return maxDepth <= 0 ? 0 : maxDepth * groupStepPx(maxDepth); +} + +const MAX_STEP_PX = 6; +const MAX_GUTTER_PX = 18; + +function groupStepPx(maxDepth: number): number { + return Math.max(2, Math.min(MAX_STEP_PX, Math.floor(MAX_GUTTER_PX / maxDepth))); +} diff --git a/src/features/tabs/tabs-store.svelte.ts b/src/features/tabs/tabs-store.svelte.ts index 5ed08d4..8e63171 100644 --- a/src/features/tabs/tabs-store.svelte.ts +++ b/src/features/tabs/tabs-store.svelte.ts @@ -9,6 +9,7 @@ import { selectTab as reduceSelectTab, setModel as reduceSetModel, setSnapshot as reduceSetSnapshot, + setTabGroupParent as reduceSetTabGroupParent, setTitle as reduceSetTitle, setWorkspaceId as reduceSetWorkspaceId, activeTab as selectActiveTab, @@ -37,6 +38,8 @@ export interface TabsStore { setSnapshot(conversationId: string, snapshot: NamedAgentSelectionSnapshot | null): void; setTitle(conversationId: string, title: string): void; setWorkspaceId(conversationId: string, workspaceId: string): void; + /** Set (string) or clear (null) a tab's tab group parent (visual subagent group). */ + setTabGroupParent(conversationId: string, parentId: string | null): void; } export function createTabsStore(storage: TabsStorage): TabsStore { @@ -84,5 +87,8 @@ export function createTabsStore(storage: TabsStorage): TabsStore { setWorkspaceId(conversationId: string, workspaceId: string): void { apply(reduceSetWorkspaceId(state, conversationId, workspaceId)); }, + setTabGroupParent(conversationId: string, parentId: string | null): void { + apply(reduceSetTabGroupParent(state, conversationId, parentId)); + }, }; } diff --git a/src/features/tabs/tabs.test.ts b/src/features/tabs/tabs.test.ts index ec93076..696e994 100644 --- a/src/features/tabs/tabs.test.ts +++ b/src/features/tabs/tabs.test.ts @@ -10,6 +10,7 @@ import { newDraft, selectTab, setModel, + setTabGroupParent, setTitle, shortHandle, } from "./tabs"; @@ -223,3 +224,31 @@ describe("shortHandle", () => { expect(shortHandle("abcd", ["abcd", "abcd1234"])).toBe("abcd"); }); }); + +describe("setTabGroupParent", () => { + const base: TabsState = { + tabs: [ + { conversationId: "a", model: "m", title: "A", workspaceId: "w" }, + { conversationId: "b", model: "m", title: "B", workspaceId: "w" }, + ], + activeConversationId: "a", + }; + + it("sets the tab group parent on the named tab only", () => { + const next = setTabGroupParent(base, "b", "a"); + expect(next.tabs[1]?.tabGroupParentId).toBe("a"); + expect(next.tabs[0]).toBe(base.tabs[0]); + expect(next.activeConversationId).toBe("a"); + }); + + it("clears the key entirely with null", () => { + const grouped = setTabGroupParent(base, "b", "a"); + const cleared = setTabGroupParent(grouped, "b", null); + expect(cleared.tabs[1]).toEqual(base.tabs[1]); + expect("tabGroupParentId" in (cleared.tabs[1] ?? {})).toBe(false); + }); + + it("is a no-op for an unknown tab", () => { + expect(setTabGroupParent(base, "zz", "a").tabs).toEqual(base.tabs); + }); +}); diff --git a/src/features/tabs/tabs.ts b/src/features/tabs/tabs.ts index ff7bb4f..b2dad7d 100644 --- a/src/features/tabs/tabs.ts +++ b/src/features/tabs/tabs.ts @@ -13,6 +13,13 @@ export interface Tab { * renamed/removed. Absent for a raw model selection. */ readonly snapshot?: NamedAgentSelectionSnapshot; + /** + * The EFFECTIVE tab group parent (backend `tabGroupParentId`): the + * conversation whose subagent group this tab is visually nested under in + * the tab grid. Purely presentational — distinct from the lineage + * `parentConversationId`. Absent = ungrouped (a top-level tab). + */ + readonly tabGroupParentId?: string; } export interface TabsState { @@ -120,6 +127,26 @@ export function setTitle(state: TabsState, conversationId: string, title: string return { tabs, activeConversationId: state.activeConversationId }; } +/** + * Set (string) or clear (null) a tab's tab group parent. Omits the key when + * cleared (`exactOptionalPropertyTypes`). No-op for an unknown tab. + */ +export function setTabGroupParent( + state: TabsState, + conversationId: string, + parentId: string | null, +): TabsState { + const tabs = state.tabs.map((t) => { + if (t.conversationId !== conversationId) return t; + if (parentId !== null) return { ...t, tabGroupParentId: parentId }; + if (!("tabGroupParentId" in t)) return t; + const { tabGroupParentId: _omit, ...rest } = t; + void _omit; + return rest; + }); + return { tabs, activeConversationId: state.activeConversationId }; +} + export function setWorkspaceId( state: TabsState, conversationId: string, diff --git a/src/features/tabs/ui.test.ts b/src/features/tabs/ui.test.ts index ae2a021..7c8b0db 100644 --- a/src/features/tabs/ui.test.ts +++ b/src/features/tabs/ui.test.ts @@ -165,7 +165,7 @@ describe("TabList", () => { expect(screen.getByText("7c2d")).toBeInTheDocument(); }); - it("renders each tab as a single vertical row (flex-col list, not a horizontal strip)", () => { + it("renders the tabs as a 2-column grid of same-size cards", () => { render(TabList, { props: { tabs: sampleTabs, @@ -176,14 +176,17 @@ describe("TabList", () => { }, }); - // The scroll region containing the tab rows is a vertical flex column. const tabs = screen.getAllByRole("tab"); - expect(tabs.length).toBeGreaterThan(0); - const region = tabs[0]?.parentElement; - expect(region).toHaveClass("flex-col"); + const grid = tabs[0]?.parentElement; + expect(grid).toHaveClass("grid", "grid-cols-2"); + // Every card has the same fixed height; none is double-width without a group. + for (const t of tabs) { + expect(t).toHaveClass("h-[4.5rem]"); + expect(t).not.toHaveClass("col-span-2"); + } }); - it("fixes the tab list region at 40vh so a long set scrolls internally", () => { + it("fixes the tab grid region at 40vh so a long set scrolls internally", () => { render(TabList, { props: { tabs: sampleTabs, @@ -195,7 +198,7 @@ describe("TabList", () => { }); const tabs = screen.getAllByRole("tab"); - const region = tabs[0]?.parentElement; + const region = tabs[0]?.parentElement?.parentElement; expect(region).toHaveClass("h-[40vh]"); expect(region).toHaveClass("overflow-y-auto"); }); @@ -295,4 +298,167 @@ describe("TabList", () => { // c3 has no status → no spinner at all. expect(idleTab.querySelector(".loading")).toBeNull(); }); + + describe("subagent groups", () => { + const grouped: readonly Tab[] = [ + { conversationId: "p", model: "m", title: "Parent", workspaceId: "w" }, + { + conversationId: "c1", + model: "m", + title: "Child 1", + workspaceId: "w", + tabGroupParentId: "p", + }, + { + conversationId: "c2", + model: "m", + title: "Child 2", + workspaceId: "w", + tabGroupParentId: "p", + }, + { + conversationId: "g", + model: "m", + title: "Grandchild", + workspaceId: "w", + tabGroupParentId: "c1", + }, + { conversationId: "solo", model: "m", title: "Solo", workspaceId: "w" }, + ]; + + function renderGrouped(onSetTabGroup = vi.fn()) { + render(TabList, { + props: { + tabs: grouped, + activeConversationId: "p", + onSelect: vi.fn(), + onClose: vi.fn(), + onNewDraft: vi.fn(), + onSetTabGroup, + }, + }); + return onSetTabGroup; + } + + const card = (id: string): HTMLElement => { + const el = document.querySelector(`[role="tab"][data-conversation-id="${id}"]`); + if (el === null) throw new Error(`card ${id} not found`); + return el; + }; + + it("renders a group parent double-width inside a darker group box with its subagents", () => { + renderGrouped(); + const box = document.querySelector('[data-tab-group="p"]'); + expect(box).not.toBeNull(); + expect(box).toHaveClass("col-span-2"); + expect(card("p")).toHaveClass("col-span-2"); + expect(card("p")).toHaveTextContent("2 subagents"); + expect(box?.contains(card("c1"))).toBe(true); + expect(box?.contains(card("c2"))).toBe(true); + expect(box?.contains(card("solo"))).toBe(false); + // Subagents are regular half-width cards. + expect(card("c2")).not.toHaveClass("col-span-2"); + }); + + it("nests a subagent that has its own subagents in an inner box", () => { + renderGrouped(); + const outer = document.querySelector('[data-tab-group="p"]'); + const inner = document.querySelector('[data-tab-group="c1"]'); + expect(inner).not.toBeNull(); + expect(outer?.contains(inner)).toBe(true); + expect(inner?.dataset.level).toBe("2"); + expect(card("c1")).toHaveClass("col-span-2"); + expect(inner?.contains(card("g"))).toBe(true); + // Inner boxes bleed less than outer ones, so nested edges stay visible + // while the cards keep identical widths. + const pad = (el: HTMLElement | null) => el?.style.getPropertyValue("padding-inline") ?? ""; + const bleed = (el: HTMLElement | null) => Number.parseFloat(pad(el)); + expect(bleed(inner)).toBeGreaterThan(0); + expect(bleed(outer)).toBeGreaterThan(bleed(inner)); + expect(outer?.style.getPropertyValue("margin-inline")).toBe(`-${pad(outer)}`); + }); + + it("right-click opens the menu; 'Remove from group' ungroups the tab", async () => { + const onSetTabGroup = renderGrouped(); + await fireEvent.contextMenu(card("c2"), { clientX: 10, clientY: 10 }); + const menu = screen.getByRole("menu"); + expect(menu).toBeInTheDocument(); + await fireEvent.click(screen.getByRole("menuitem", { name: /Remove from group/ })); + expect(onSetTabGroup).toHaveBeenCalledWith("c2", null); + expect(screen.queryByRole("menu")).toBeNull(); + }); + + it("'Add to group' lists valid parents (no self/descendants/current) and moves the tab", async () => { + const onSetTabGroup = renderGrouped(); + await fireEvent.contextMenu(card("c1")); + const candidates = [...document.querySelectorAll("[data-group-candidate]")].map( + (el) => el.dataset.groupCandidate, + ); + expect(candidates).toEqual(["c2", "solo"]); + const solo = document.querySelector('[data-group-candidate="solo"]'); + if (solo === null) throw new Error("solo candidate missing"); + await fireEvent.click(solo); + expect(onSetTabGroup).toHaveBeenCalledWith("c1", "solo"); + }); + + it("a group parent's menu can ungroup all of its direct subagents", async () => { + const onSetTabGroup = renderGrouped(); + await fireEvent.contextMenu(card("p")); + expect(screen.queryByRole("menuitem", { name: /Remove from group/ })).toBeNull(); + await fireEvent.click(screen.getByRole("menuitem", { name: /Ungroup 2 subagents/ })); + expect(onSetTabGroup).toHaveBeenCalledTimes(2); + expect(onSetTabGroup).toHaveBeenCalledWith("c1", null); + expect(onSetTabGroup).toHaveBeenCalledWith("c2", null); + }); + + it("Escape closes the menu without changing anything", async () => { + const onSetTabGroup = renderGrouped(); + await fireEvent.contextMenu(card("solo")); + expect(screen.getByRole("menu")).toBeInTheDocument(); + await fireEvent.keyDown(window, { key: "Escape" }); + expect(screen.queryByRole("menu")).toBeNull(); + expect(onSetTabGroup).not.toHaveBeenCalled(); + }); + + it("touch-and-hold opens the menu and swallows the follow-up click", async () => { + vi.useFakeTimers(); + try { + const onSelect = vi.fn(); + render(TabList, { + props: { + tabs: grouped, + activeConversationId: "p", + onSelect, + onClose: vi.fn(), + onNewDraft: vi.fn(), + onSetTabGroup: vi.fn(), + }, + }); + await fireEvent.pointerDown(card("solo"), { pointerType: "touch", clientX: 5, clientY: 5 }); + await vi.advanceTimersByTimeAsync(600); + expect(screen.getByRole("menu")).toBeInTheDocument(); + await fireEvent.click(card("solo")); + expect(onSelect).not.toHaveBeenCalled(); + } finally { + vi.useRealTimers(); + } + }); + + it("a touch that moves away cancels the long-press", async () => { + vi.useFakeTimers(); + try { + renderGrouped(); + await fireEvent.pointerDown(card("solo"), { pointerType: "touch", clientX: 5, clientY: 5 }); + await fireEvent.pointerMove(card("solo"), { + pointerType: "touch", + clientX: 5, + clientY: 60, + }); + await vi.advanceTimersByTimeAsync(600); + expect(screen.queryByRole("menu")).toBeNull(); + } finally { + vi.useRealTimers(); + } + }); + }); }); diff --git a/src/features/tabs/ui/TabCard.svelte b/src/features/tabs/ui/TabCard.svelte new file mode 100644 index 0000000..a95f8e6 --- /dev/null +++ b/src/features/tabs/ui/TabCard.svelte @@ -0,0 +1,232 @@ + + + + + diff --git a/src/features/tabs/ui/TabGroupBox.svelte b/src/features/tabs/ui/TabGroupBox.svelte new file mode 100644 index 0000000..5cbfb27 --- /dev/null +++ b/src/features/tabs/ui/TabGroupBox.svelte @@ -0,0 +1,53 @@ + + +
+
+ {@render card(node.tab, true, node.children.length)} + {#each node.children as child (child.tab.conversationId)} + {#if child.children.length > 0} + + {:else} + {@render card(child.tab, false, 0)} + {/if} + {/each} +
+
diff --git a/src/features/tabs/ui/TabList.svelte b/src/features/tabs/ui/TabList.svelte index 6d85065..06d5aa6 100644 --- a/src/features/tabs/ui/TabList.svelte +++ b/src/features/tabs/ui/TabList.svelte @@ -1,183 +1,149 @@ +{#snippet card(tab: Tab, wide: boolean, subagentCount: number)} + +{/snippet} +
- -
- {#each tabs as tab (tab.conversationId)} - - {/each} -
+ +
(menu = null)} + > +
+ {#each forest.roots as node (node.tab.conversationId)} + {#if node.children.length > 0} + + {:else} + {@render card(node.tab, false, 0)} + {/if} + {/each} +
+
- +
+ +{#if menu !== null && menuTab !== null && onSetTabGroup !== undefined} + {@const setGroup = onSetTabGroup} + {@const target = menuTab.conversationId} + {@const children = [...menuChildren]} + setGroup(target, parentId)} + onRemoveFromGroup={() => setGroup(target, null)} + onUngroupChildren={() => { + for (const id of children) setGroup(id, null); + }} + onDismiss={() => (menu = null)} + /> +{/if} diff --git a/src/features/tabs/ui/TabMenu.svelte b/src/features/tabs/ui/TabMenu.svelte new file mode 100644 index 0000000..513463f --- /dev/null +++ b/src/features/tabs/ui/TabMenu.svelte @@ -0,0 +1,145 @@ + + + { + if (e.key === "Escape") onDismiss(); + }} + onpointerdown={(e) => { + if (menuEl !== undefined && !menuEl.contains(e.target as Node)) onDismiss(); + }} + onresize={onDismiss} +/> + + -- cgit v1.2.3