diff options
| author | Adam Malczewski <[email protected]> | 2026-10-15 14:38:50 +0900 |
|---|---|---|
| committer | Adam Malczewski <[email protected]> | 2026-10-15 14:38:50 +0900 |
| commit | c4c1d76fec972e5d902a669f0cb1953eaa528f16 (patch) | |
| tree | d5d85b8500157dd690e13954216a61a6aac64a8a /src | |
| parent | 3d3c28b10de06aae7e2ad474e8931a3fdd88b41c (diff) | |
| download | dispatch-web-c4c1d76fec972e5d902a669f0cb1953eaa528f16.tar.gz dispatch-web-c4c1d76fec972e5d902a669f0cb1953eaa528f16.zip | |
feat(tabs): 2-column tab grid with nestable subagent groups + tab menu (add/remove from group)
Diffstat (limited to 'src')
| -rw-r--r-- | src/adapters/ws/index.ts | 5 | ||||
| -rw-r--r-- | src/adapters/ws/logic.test.ts | 60 | ||||
| -rw-r--r-- | src/adapters/ws/logic.ts | 21 | ||||
| -rw-r--r-- | src/app/store.svelte.ts | 66 | ||||
| -rw-r--r-- | src/app/views/TabsPane.svelte | 1 | ||||
| -rw-r--r-- | src/core/wire/conformance.ts | 2 | ||||
| -rw-r--r-- | src/features/tabs/index.ts | 11 | ||||
| -rw-r--r-- | src/features/tabs/tab-groups.test.ts | 124 | ||||
| -rw-r--r-- | src/features/tabs/tab-groups.ts | 155 | ||||
| -rw-r--r-- | src/features/tabs/tabs-store.svelte.ts | 6 | ||||
| -rw-r--r-- | src/features/tabs/tabs.test.ts | 29 | ||||
| -rw-r--r-- | src/features/tabs/tabs.ts | 27 | ||||
| -rw-r--r-- | src/features/tabs/ui.test.ts | 180 | ||||
| -rw-r--r-- | src/features/tabs/ui/TabCard.svelte | 232 | ||||
| -rw-r--r-- | src/features/tabs/ui/TabGroupBox.svelte | 53 | ||||
| -rw-r--r-- | src/features/tabs/ui/TabList.svelte | 310 | ||||
| -rw-r--r-- | src/features/tabs/ui/TabMenu.svelte | 145 |
17 files changed, 1242 insertions, 185 deletions
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<typeof buildTabGroups>["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<string, string | null> { + 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<string, string | null>(); + for (const tab of tabs) { + const id = tab.conversationId; + const parent = direct(id); + let cyclic = false; + const seen = new Set<string>([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<string, string[]>(); + 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<string> { + const parents = visibleGroupParents(tabs); + const out = new Set<string>(); + 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<HTMLElement>(`[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<HTMLElement>('[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<HTMLElement>('[data-tab-group="p"]'); + const inner = document.querySelector<HTMLElement>('[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<HTMLElement>("[data-group-candidate]")].map( + (el) => el.dataset.groupCandidate, + ); + expect(candidates).toEqual(["c2", "solo"]); + const solo = document.querySelector<HTMLElement>('[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 @@ +<script lang="ts"> + import type { Tab } from "../tabs"; + + /** + * One tab in the tab grid — a fixed-height card. Every card has IDENTICAL + * dimensions; a group parent (`wide`) simply spans both grid columns. + * Right-click (or touch-and-hold) asks the owner to open the tab menu. + */ + let { + tab, + handle, + active, + status, + wide = false, + subagentCount = 0, + onSelect, + onClose, + onRename, + onMenu, + }: { + tab: Tab; + /** Short-handle (git-style unique prefix) shown in the id badge. */ + handle: string; + active: boolean; + /** Lifecycle status (drives the spinner), when known. */ + status: string | undefined; + /** Group parent: spans both grid columns. */ + wide?: boolean; + /** Number of direct subagents (shown on a group parent). */ + subagentCount?: number; + onSelect: (conversationId: string) => void; + onClose: (conversationId: string) => void; + onRename?: ((conversationId: string, title: string) => void) | undefined; + /** Open the tab menu at viewport coordinates. */ + onMenu: (conversationId: string, x: number, y: number) => void; + } = $props(); + + // Inline rename: double-click the title to edit, Enter/blur to save. + let editing = $state(false); + let editValue = $state(""); + let editEl = $state<HTMLInputElement>(); + + function startRename(): void { + if (onRename === undefined) return; + editing = true; + editValue = tab.title; + queueMicrotask(() => editEl?.focus()); + } + + function commitRename(): void { + if (!editing) return; + const trimmed = editValue.trim(); + if (trimmed.length > 0 && onRename !== undefined) onRename(tab.conversationId, trimmed); + editing = false; + } + + // Click-to-copy the FULL conversation id; the badge text is selected as the + // feedback (and as a manual-copy fallback when the clipboard is unavailable). + async function copyId(el: HTMLElement): Promise<void> { + const range = document.createRange(); + range.selectNodeContents(el); + const selection = window.getSelection(); + selection?.removeAllRanges(); + selection?.addRange(range); + const clipboard = navigator.clipboard; + if (clipboard === undefined) return; + try { + await clipboard.writeText(tab.conversationId); + } catch { + // Selection already lets the user copy manually. + } + } + + // Touch-and-hold → menu (iOS Safari never fires `contextmenu`; Android does, + // and opening twice at the same spot is idempotent). + const LONG_PRESS_MS = 500; + const MOVE_TOLERANCE_PX = 10; + let pressTimer: ReturnType<typeof setTimeout> | null = null; + let pressX = 0; + let pressY = 0; + let suppressClick = false; + + function cancelPress(): void { + if (pressTimer !== null) clearTimeout(pressTimer); + pressTimer = null; + } + + function onPointerDown(e: PointerEvent): void { + if (e.pointerType !== "touch") return; + cancelPress(); + pressX = e.clientX; + pressY = e.clientY; + pressTimer = setTimeout(() => { + pressTimer = null; + suppressClick = true; + onMenu(tab.conversationId, pressX, pressY); + }, LONG_PRESS_MS); + } + + function onPointerMove(e: PointerEvent): void { + if (pressTimer === null) return; + if (Math.hypot(e.clientX - pressX, e.clientY - pressY) > MOVE_TOLERANCE_PX) cancelPress(); + } + + $effect(() => cancelPress); +</script> + +<div + class="tab-card group/card relative flex h-[4.5rem] min-w-0 cursor-pointer flex-col justify-between rounded-md border px-2 py-1.5 text-sm transition-colors {active + ? 'border-primary bg-primary/15' + : 'border-base-content/10 bg-base-300 hover:border-base-content/25'}" + class:col-span-2={wide} + role="tab" + tabindex="0" + aria-selected={active} + title={tab.title} + data-conversation-id={tab.conversationId} + data-wide={wide ? "true" : undefined} + onclick={() => { + if (suppressClick) { + suppressClick = false; + return; + } + onSelect(tab.conversationId); + }} + onkeydown={(e) => { + if (e.target !== e.currentTarget) return; + if (e.key === "Enter") onSelect(tab.conversationId); + else if (e.key === "ContextMenu" || (e.shiftKey && e.key === "F10")) { + e.preventDefault(); + const r = e.currentTarget.getBoundingClientRect(); + onMenu(tab.conversationId, r.left + 8, r.bottom); + } + }} + oncontextmenu={(e) => { + e.preventDefault(); + cancelPress(); + onMenu(tab.conversationId, e.clientX, e.clientY); + }} + onpointerdown={onPointerDown} + onpointermove={onPointerMove} + onpointerup={cancelPress} + onpointercancel={cancelPress} + onpointerleave={cancelPress} +> + <div class="flex min-w-0 items-center gap-1"> + <button + type="button" + class="shrink-0 select-text rounded bg-base-100 px-1 py-0.5 font-mono text-[10px] leading-none text-base-content/60 transition-colors hover:bg-primary hover:text-primary-content" + data-copy-id={tab.conversationId} + title="Click to copy conversation id" + aria-label={`Copy conversation id ${tab.conversationId}`} + onclick={(e) => { + e.stopPropagation(); + void copyId(e.currentTarget); + }} + > + {handle} + </button> + {#if wide && subagentCount > 0} + <span + class="shrink-0 truncate rounded bg-base-100 px-1 py-0.5 text-[10px] leading-none text-base-content/60" + data-subagent-count={subagentCount} + > + {subagentCount} + {subagentCount === 1 ? "subagent" : "subagents"} + </span> + {/if} + <span class="flex-1"></span> + {#if status === "queued"} + <!-- Waiting for a concurrency slot — a ring (vs the dots of `active`). --> + <span + class="loading loading-ring loading-xs shrink-0 text-primary" + aria-label="Queued" + title="Waiting for a concurrency slot" + ></span> + {:else if status === "active"} + <span class="loading loading-dots loading-xs shrink-0 text-primary"></span> + {/if} + <button + type="button" + class="btn btn-ghost btn-xs -mr-1 h-5 min-h-5 w-5 shrink-0 p-0 text-base-content/60" + aria-label="Close tab" + onclick={(e) => { + e.stopPropagation(); + onClose(tab.conversationId); + }} + > + × + </button> + </div> + {#if editing} + <input + bind:this={editEl} + bind:value={editValue} + class="w-full min-w-0 rounded bg-base-100 px-1 py-0.5 text-left text-xs outline outline-1 outline-primary" + onclick={(e) => e.stopPropagation()} + onkeydown={(e) => { + if (e.key === "Enter") { + e.preventDefault(); + commitRename(); + } else if (e.key === "Escape") { + e.preventDefault(); + editing = false; + } + }} + onblur={commitRename} + /> + {:else} + <span + class="line-clamp-2 min-w-0 break-words text-left text-xs leading-tight" + role="button" + tabindex="-1" + title={tab.title} + ondblclick={(e) => { + e.stopPropagation(); + startRename(); + }} + > + {tab.title} + </span> + {/if} +</div> + +<style> + /* Touch-and-hold opens the tab menu — suppress the native callout/selection. */ + .tab-card { + -webkit-touch-callout: none; + -webkit-user-select: none; + user-select: none; + } +</style> 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 @@ +<script lang="ts"> + import type { Snippet } from "svelte"; + import type { TabGroupNode } from "../tab-groups"; + import { groupBleedPx } from "../tab-groups"; + import type { Tab } from "../tabs"; + import TabGroupBox from "./TabGroupBox.svelte"; + + /** + * A subagent group: a darker box spanning both grid columns that wraps the + * group parent's (double-width) card + its subagents' cards. Recursive — + * a subagent with its own subagents renders a nested box. The box BLEEDS + * outward (negative margin + equal padding, `groupBleedPx`) so the cards + * inside stay on the same two columns as top-level cards: every card keeps + * identical dimensions at every depth. The translucent black background + * compounds, so each nesting level reads darker than the one around it. + */ + let { + node, + level, + maxDepth, + card, + }: { + node: TabGroupNode; + /** Nesting level of THIS box (1 = outermost). */ + level: number; + maxDepth: number; + /** Renders one card: (tab, wide, subagentCount). */ + card: Snippet<[Tab, boolean, number]>; + } = $props(); + + const bleed = $derived(groupBleedPx(level, maxDepth)); +</script> + +<div + class="col-span-2 rounded-lg border border-black/50 bg-black/40 py-1.5 shadow-inner" + style:margin-inline="-{bleed}px" + style:padding-inline="{bleed}px" + role="group" + aria-label={`Subagents of ${node.tab.title}`} + data-tab-group={node.tab.conversationId} + data-level={level} +> + <div class="grid grid-flow-row-dense grid-cols-2 gap-1.5"> + {@render card(node.tab, true, node.children.length)} + {#each node.children as child (child.tab.conversationId)} + {#if child.children.length > 0} + <TabGroupBox node={child} level={level + 1} {maxDepth} {card} /> + {:else} + {@render card(child.tab, false, 0)} + {/if} + {/each} + </div> +</div> 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 @@ <script lang="ts"> - import type { Tab } from "../tabs"; - import { shortHandle } from "../tabs"; + import { + buildTabGroups, + directGroupChildren, + groupGutterPx, + groupParentCandidates, + visibleGroupParents, + } from "../tab-groups"; + import type { Tab } from "../tabs"; + import { shortHandle } from "../tabs"; + import TabCard from "./TabCard.svelte"; + import TabGroupBox from "./TabGroupBox.svelte"; + import TabMenu from "./TabMenu.svelte"; - let { - tabs, - activeConversationId, - statusFor, - onSelect, - onClose, - onNewDraft, - onRename, - }: { - tabs: readonly Tab[]; - activeConversationId: string | null; - /** Returns the conversation's lifecycle status, or undefined when unknown. */ - statusFor?: (conversationId: string) => string | undefined; - onSelect: (conversationId: string) => void; - onClose: (conversationId: string) => void; - onNewDraft: () => void; - onRename?: (conversationId: string, title: string) => void; - } = $props(); + let { + tabs, + activeConversationId, + statusFor, + onSelect, + onClose, + onNewDraft, + onRename, + onSetTabGroup, + }: { + tabs: readonly Tab[]; + activeConversationId: string | null; + /** Returns the conversation's lifecycle status, or undefined when unknown. */ + statusFor?: (conversationId: string) => string | undefined; + onSelect: (conversationId: string) => void; + onClose: (conversationId: string) => void; + onNewDraft: () => void; + onRename?: (conversationId: string, title: string) => void; + /** + * Move a tab into a subagent group (parent id) or out of it (null). Purely + * presentational grouping. When absent, the tab menu is disabled. + */ + onSetTabGroup?: (conversationId: string, parentId: string | null) => void; + } = $props(); - // Git-style short handle (shortest unique prefix) per open tab — the visible - // "tab ID". Derived from the set of open conversation ids; pure helper. - const handles = $derived.by(() => { - const ids = tabs.map((t) => t.conversationId); - const map = new Map<string, string>(); - for (const id of ids) map.set(id, shortHandle(id, ids)); - return map; - }); + // Git-style short handle (shortest unique prefix) per open tab — the visible + // "tab ID". Derived from the set of open conversation ids; pure helper. + const handles = $derived.by(() => { + const ids = tabs.map((t) => t.conversationId); + const map = new Map<string, string>(); + for (const id of ids) map.set(id, shortHandle(id, ids)); + return map; + }); + const handleFor = (id: string): string => handles.get(id) ?? id; - // Inline rename: double-click a tab's title to edit, Enter/blur to save. - let editingId = $state<string | null>(null); - let editValue = $state(""); - let editEl = $state<HTMLInputElement>(); + // The subagent-group forest (pure) + the gutter the outermost box bleeds into. + const forest = $derived(buildTabGroups(tabs)); + const gutter = $derived(groupGutterPx(forest.maxDepth)); - function startRename(tab: Tab): void { - if (onRename === undefined) return; - editingId = tab.conversationId; - editValue = tab.title; - // Focus the input after it renders. - queueMicrotask(() => editEl?.focus()); - } + // Tab menu (right-click / touch-and-hold). Local UI state owned here. + let menu = $state<{ conversationId: string; x: number; y: number } | null>(null); + const menuTab = $derived( + menu === null ? null : (tabs.find((t) => t.conversationId === menu?.conversationId) ?? null), + ); + const menuGroupParent = $derived.by(() => { + if (menuTab === null) return null; + const pid = visibleGroupParents(tabs).get(menuTab.conversationId) ?? null; + return pid === null ? null : (tabs.find((t) => t.conversationId === pid) ?? null); + }); + const menuChildren = $derived( + menuTab === null ? [] : directGroupChildren(tabs, menuTab.conversationId), + ); + const menuCandidates = $derived( + menuTab === null ? [] : groupParentCandidates(tabs, menuTab.conversationId), + ); - function commitRename(): void { - const id = editingId; - if (id !== null && onRename !== undefined) { - const trimmed = editValue.trim(); - if (trimmed.length > 0) onRename(id, trimmed); - } - editingId = null; - } - - function cancelRename(): void { - editingId = null; - } - - // Click-to-copy the agent (conversation) id: clicking the ID badge copies the - // FULL conversationId to the clipboard (the stable, useful id — the badge - // only shows a short prefix) and highlights the badge text as feedback. The - // highlight (text selection) is the indicator — no text is swapped, so the - // badge keeps a stable width. If the clipboard write fails, the selection is - // already in place so the user can Ctrl+C the text manually. - async function copyId(conversationId: string, el: HTMLElement): Promise<void> { - selectText(el); - const clipboard = navigator.clipboard; - if (clipboard === undefined) return; - try { - await clipboard.writeText(conversationId); - } catch { - // Selection already lets the user copy manually. - } - } - - function selectText(el: HTMLElement): void { - const range = document.createRange(); - range.selectNodeContents(el); - const selection = window.getSelection(); - selection?.removeAllRanges(); - selection?.addRange(range); - } + function openMenu(conversationId: string, x: number, y: number): void { + if (onSetTabGroup === undefined) return; + menu = { conversationId, x, y }; + } </script> +{#snippet card(tab: Tab, wide: boolean, subagentCount: number)} + <TabCard + {tab} + handle={handleFor(tab.conversationId)} + active={tab.conversationId === activeConversationId} + status={statusFor?.(tab.conversationId)} + {wide} + {subagentCount} + {onSelect} + {onClose} + {onRename} + onMenu={openMenu} + /> +{/snippet} + <div class="flex flex-col gap-2"> - <!-- Single-column vertical tab list. Fixed at 40% of the viewport height so a - long tab set scrolls inside this region instead of growing the whole sidebar. --> - <div class="flex h-[40vh] flex-col gap-1 overflow-y-auto pr-1"> - {#each tabs as tab (tab.conversationId)} - <div - class="flex items-center gap-1.5 rounded px-2 py-1.5 text-sm hover:bg-base-300" - class:bg-base-300={tab.conversationId === activeConversationId} - role="tab" - tabindex="0" - aria-selected={tab.conversationId === activeConversationId} - title={tab.title} - onclick={() => onSelect(tab.conversationId)} - onkeydown={(e) => { - if (e.key === "Enter") onSelect(tab.conversationId); - }} - > - <button - type="button" - class="shrink-0 rounded bg-base-300 px-1 py-0.5 font-mono text-[10px] leading-none text-base-content/60 transition-colors hover:bg-primary hover:text-primary-content" - data-copy-id={tab.conversationId} - title="Click to copy conversation id" - aria-label={`Copy conversation id ${tab.conversationId}`} - onclick={(e) => { - e.stopPropagation(); - void copyId(tab.conversationId, e.currentTarget); - }} - > - {handles.get(tab.conversationId) ?? tab.conversationId} - </button> - {#if editingId === tab.conversationId} - <input - bind:this={editEl} - bind:value={editValue} - class="min-w-0 flex-1 rounded bg-base-100 px-1 py-0.5 text-left text-sm outline outline-1 outline-primary" - onclick={(e) => e.stopPropagation()} - onkeydown={(e) => { - if (e.key === "Enter") { - e.preventDefault(); - commitRename(); - } else if (e.key === "Escape") { - e.preventDefault(); - cancelRename(); - } - }} - onblur={commitRename} - /> - {:else} - <span - class="min-w-0 flex-1 cursor-pointer truncate text-left" - role="button" - tabindex="-1" - title={tab.title} - ondblclick={(e) => { - e.stopPropagation(); - startRename(tab); - }} - > - {tab.title} - </span> - {/if} - {#if statusFor?.(tab.conversationId) === "queued"} - <!-- Waiting for a concurrency slot — a ring (vs the dots of `active`). --> - <span - class="loading loading-ring loading-xs shrink-0 text-primary" - aria-label="Queued" - title="Waiting for a concurrency slot" - ></span> - {:else if statusFor?.(tab.conversationId) === "active"} - <span class="loading loading-dots loading-xs shrink-0 text-primary"></span> - {/if} - <button - class="btn btn-ghost btn-xs shrink-0" - aria-label="Close tab" - onclick={(e) => { - e.stopPropagation(); - onClose(tab.conversationId); - }} - > - × - </button> - </div> - {/each} - </div> + <!-- 2-column grid of same-size tab cards, fixed at 40% of the viewport height + so a long tab set scrolls inside this region instead of growing the + sidebar. Subagent groups span both columns; `dense` lets a later card + fill the half-row a group would otherwise leave empty. --> + <div + class="h-[40vh] overflow-y-auto py-0.5" + style:padding-inline="{gutter}px" + data-tab-grid + onscroll={() => (menu = null)} + > + <div class="grid grid-flow-row-dense grid-cols-2 gap-1.5" role="tablist"> + {#each forest.roots as node (node.tab.conversationId)} + {#if node.children.length > 0} + <TabGroupBox {node} level={1} maxDepth={forest.maxDepth} {card} /> + {:else} + {@render card(node.tab, false, 0)} + {/if} + {/each} + </div> + </div> - <button - type="button" - class="btn btn-ghost btn-sm w-full border border-base-300" - class:btn-primary={activeConversationId === null} - aria-label="New chat" - onclick={() => onNewDraft()} - > - {#if activeConversationId === null} - New Chat - {:else} - + New chat - {/if} - </button> + <button + type="button" + class="btn btn-ghost btn-sm w-full border border-base-300" + class:btn-primary={activeConversationId === null} + aria-label="New chat" + onclick={() => onNewDraft()} + > + {#if activeConversationId === null} + New Chat + {:else} + + New chat + {/if} + </button> </div> + +{#if menu !== null && menuTab !== null && onSetTabGroup !== undefined} + {@const setGroup = onSetTabGroup} + {@const target = menuTab.conversationId} + {@const children = [...menuChildren]} + <TabMenu + tab={menuTab} + handle={handleFor(target)} + x={menu.x} + y={menu.y} + groupParent={menuGroupParent} + childCount={children.length} + candidates={menuCandidates} + {handleFor} + onAddToGroup={(parentId) => 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 @@ +<script lang="ts"> + import type { Tab } from "../tabs"; + + /** + * The tab context menu (right-click / touch-and-hold on a tab card): add the + * tab to a subagent group, remove it from its group, or release its own + * subagents. Purely presentational grouping. Fixed-positioned at the + * pointer, clamped into the viewport; closes on Escape / outside press / + * scroll / resize. + */ + let { + tab, + handle, + x, + y, + groupParent, + childCount, + candidates, + handleFor, + onAddToGroup, + onRemoveFromGroup, + onUngroupChildren, + onDismiss, + }: { + tab: Tab; + handle: string; + /** Requested viewport position (pointer). */ + x: number; + y: number; + /** The tab's current visible group parent, or null when top-level. */ + groupParent: Tab | null; + /** Number of tabs directly grouped under this tab. */ + childCount: number; + /** Tabs this tab may be moved under. */ + candidates: readonly Tab[]; + handleFor: (conversationId: string) => string; + onAddToGroup: (parentId: string) => void; + onRemoveFromGroup: () => void; + onUngroupChildren: () => void; + onDismiss: () => void; + } = $props(); + + let menuEl = $state<HTMLDivElement>(); + let width = $state(0); + let height = $state(0); + const MARGIN = 8; + + const left = $derived( + Math.max(MARGIN, Math.min(x, (globalThis.innerWidth ?? 0) - width - MARGIN)), + ); + const top = $derived( + Math.max(MARGIN, Math.min(y, (globalThis.innerHeight ?? 0) - height - MARGIN)), + ); + + $effect(() => { + menuEl?.focus(); + }); + + function act(fn: () => void): void { + fn(); + onDismiss(); + } +</script> + +<svelte:window + onkeydown={(e) => { + if (e.key === "Escape") onDismiss(); + }} + onpointerdown={(e) => { + if (menuEl !== undefined && !menuEl.contains(e.target as Node)) onDismiss(); + }} + onresize={onDismiss} +/> + +<div + bind:this={menuEl} + bind:clientWidth={width} + bind:clientHeight={height} + class="fixed z-50 flex w-64 max-w-[calc(100vw-1rem)] flex-col rounded-lg border border-base-content/15 bg-base-100 p-1 text-sm shadow-xl outline-none" + style:left="{left}px" + style:top="{top}px" + role="menu" + tabindex="-1" + aria-label={`Tab menu for ${tab.title}`} + oncontextmenu={(e) => e.preventDefault()} +> + <div class="flex min-w-0 items-center gap-1.5 px-2 py-1 text-xs text-base-content/60"> + <span class="shrink-0 rounded bg-base-300 px-1 py-0.5 font-mono text-[10px] leading-none"> + {handle} + </span> + <span class="truncate">{tab.title}</span> + </div> + + {#if groupParent !== null} + <button + type="button" + role="menuitem" + class="rounded px-2 py-1.5 text-left hover:bg-base-300" + onclick={() => act(onRemoveFromGroup)} + > + Remove from group + <span class="block truncate text-xs text-base-content/50"> + Currently under {handleFor(groupParent.conversationId)} · {groupParent.title} + </span> + </button> + {/if} + + {#if childCount > 0} + <button + type="button" + role="menuitem" + class="rounded px-2 py-1.5 text-left hover:bg-base-300" + onclick={() => act(onUngroupChildren)} + > + Ungroup {childCount} + {childCount === 1 ? "subagent" : "subagents"} + </button> + {/if} + + <div class="mt-1 border-t border-base-content/10 px-2 pt-1.5 pb-1 text-xs text-base-content/60"> + Add to group under… + </div> + {#if candidates.length === 0} + <div class="px-2 py-1.5 text-xs text-base-content/40">No other tabs</div> + {:else} + <div class="flex max-h-56 flex-col overflow-y-auto"> + {#each candidates as candidate (candidate.conversationId)} + <button + type="button" + role="menuitem" + class="flex min-w-0 items-center gap-1.5 rounded px-2 py-1.5 text-left hover:bg-base-300" + data-group-candidate={candidate.conversationId} + onclick={() => act(() => onAddToGroup(candidate.conversationId))} + > + <span + class="shrink-0 rounded bg-base-300 px-1 py-0.5 font-mono text-[10px] leading-none text-base-content/60" + > + {handleFor(candidate.conversationId)} + </span> + <span class="truncate">{candidate.title}</span> + </button> + {/each} + </div> + {/if} +</div> |
