summaryrefslogtreecommitdiffhomepage
path: root/src/features
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-10-15 14:38:50 +0900
committerAdam Malczewski <[email protected]>2026-10-15 14:38:50 +0900
commitc4c1d76fec972e5d902a669f0cb1953eaa528f16 (patch)
treed5d85b8500157dd690e13954216a61a6aac64a8a /src/features
parent3d3c28b10de06aae7e2ad474e8931a3fdd88b41c (diff)
downloaddispatch-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/features')
-rw-r--r--src/features/tabs/index.ts11
-rw-r--r--src/features/tabs/tab-groups.test.ts124
-rw-r--r--src/features/tabs/tab-groups.ts155
-rw-r--r--src/features/tabs/tabs-store.svelte.ts6
-rw-r--r--src/features/tabs/tabs.test.ts29
-rw-r--r--src/features/tabs/tabs.ts27
-rw-r--r--src/features/tabs/ui.test.ts180
-rw-r--r--src/features/tabs/ui/TabCard.svelte232
-rw-r--r--src/features/tabs/ui/TabGroupBox.svelte53
-rw-r--r--src/features/tabs/ui/TabList.svelte310
-rw-r--r--src/features/tabs/ui/TabMenu.svelte145
11 files changed, 1093 insertions, 179 deletions
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);
+ }}
+ >
+ &times;
+ </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);
- }}
- >
- &times;
- </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>