From c4c1d76fec972e5d902a669f0cb1953eaa528f16 Mon Sep 17 00:00:00 2001 From: Adam Malczewski Date: Thu, 15 Oct 2026 14:38:50 +0900 Subject: feat(tabs): 2-column tab grid with nestable subagent groups + tab menu (add/remove from group) --- src/features/tabs/tab-groups.test.ts | 124 +++++++++++++++++++++++++++++++++++ 1 file changed, 124 insertions(+) create mode 100644 src/features/tabs/tab-groups.test.ts (limited to 'src/features/tabs/tab-groups.test.ts') diff --git a/src/features/tabs/tab-groups.test.ts b/src/features/tabs/tab-groups.test.ts new file mode 100644 index 0000000..ca2492c --- /dev/null +++ b/src/features/tabs/tab-groups.test.ts @@ -0,0 +1,124 @@ +import { describe, expect, it } from "vitest"; +import { + buildTabGroups, + directGroupChildren, + groupBleedPx, + groupDescendants, + groupGutterPx, + groupParentCandidates, + visibleGroupParents, +} from "./tab-groups"; +import type { Tab } from "./tabs"; + +function tab(id: string, parent?: string): Tab { + return { + conversationId: id, + model: "m", + title: id.toUpperCase(), + workspaceId: "w", + ...(parent !== undefined ? { tabGroupParentId: parent } : {}), + }; +} + +const ids = (tabs: readonly Tab[]): string[] => tabs.map((t) => t.conversationId); + +describe("visibleGroupParents", () => { + it("keeps a parent that is another open tab", () => { + const parents = visibleGroupParents([tab("a"), tab("b", "a")]); + expect(parents.get("a")).toBeNull(); + expect(parents.get("b")).toBe("a"); + }); + + it("drops a parent that is not open (closed / other workspace) or is the tab itself", () => { + const parents = visibleGroupParents([tab("a", "gone"), tab("b", "b")]); + expect(parents.get("a")).toBeNull(); + expect(parents.get("b")).toBeNull(); + }); + + it("treats every member of a cycle as a root, but keeps hangers-on below them", () => { + const parents = visibleGroupParents([tab("a", "b"), tab("b", "a"), tab("c", "a")]); + expect(parents.get("a")).toBeNull(); + expect(parents.get("b")).toBeNull(); + expect(parents.get("c")).toBe("a"); + }); +}); + +describe("buildTabGroups", () => { + it("returns flat roots and depth 0 when nothing is grouped", () => { + const forest = buildTabGroups([tab("a"), tab("b"), tab("c")]); + expect(forest.maxDepth).toBe(0); + expect(forest.roots.map((n) => n.tab.conversationId)).toEqual(["a", "b", "c"]); + expect(forest.roots.every((n) => n.children.length === 0)).toBe(true); + }); + + it("nests children under their parent in tab order, preserving root order", () => { + // Child listed BEFORE its parent still nests under it. + const forest = buildTabGroups([tab("x", "p"), tab("a"), tab("p"), tab("y", "p")]); + expect(forest.maxDepth).toBe(1); + expect(forest.roots.map((n) => n.tab.conversationId)).toEqual(["a", "p"]); + const p = forest.roots[1]; + expect(p?.children.map((n) => n.tab.conversationId)).toEqual(["x", "y"]); + }); + + it("supports nested groups (subagents with subagents) and reports the depth", () => { + const forest = buildTabGroups([tab("p"), tab("c", "p"), tab("g", "c"), tab("gg", "g")]); + expect(forest.maxDepth).toBe(3); + const c = forest.roots[0]?.children[0]; + expect(c?.tab.conversationId).toBe("c"); + expect(c?.children[0]?.children[0]?.tab.conversationId).toBe("gg"); + }); + + it("renders every tab exactly once", () => { + const tabs = [tab("a", "b"), tab("b", "a"), tab("c", "a"), tab("d", "nope"), tab("e", "c")]; + const seen: string[] = []; + const walk = (nodes: ReturnType["roots"]): void => { + for (const n of nodes) { + seen.push(n.tab.conversationId); + walk(n.children); + } + }; + walk(buildTabGroups(tabs).roots); + expect(seen.sort()).toEqual(["a", "b", "c", "d", "e"]); + }); +}); + +describe("groupDescendants / directGroupChildren / groupParentCandidates", () => { + const tabs = [tab("p"), tab("c1", "p"), tab("c2", "p"), tab("g", "c1"), tab("solo")]; + + it("collects transitive descendants", () => { + expect([...groupDescendants(tabs, "p")].sort()).toEqual(["c1", "c2", "g"]); + expect([...groupDescendants(tabs, "c1")]).toEqual(["g"]); + expect(groupDescendants(tabs, "solo").size).toBe(0); + }); + + it("lists direct children only", () => { + expect(directGroupChildren(tabs, "p")).toEqual(["c1", "c2"]); + }); + + it("offers every tab except self, descendants (cycle) and the current parent", () => { + expect(ids(groupParentCandidates(tabs, "c1"))).toEqual(["c2", "solo"]); + expect(ids(groupParentCandidates(tabs, "p"))).toEqual(["solo"]); + expect(ids(groupParentCandidates(tabs, "solo"))).toEqual(["p", "c1", "c2", "g"]); + }); +}); + +describe("groupBleedPx / groupGutterPx", () => { + it("is zero without groups", () => { + expect(groupGutterPx(0)).toBe(0); + expect(groupBleedPx(1, 0)).toBe(0); + }); + + it("bleeds outer boxes further than inner ones, the outermost filling the gutter", () => { + const max = 3; + const bleeds = [1, 2, 3].map((l) => groupBleedPx(l, max)); + expect(bleeds[0]).toBe(groupGutterPx(max)); + expect(bleeds[0]).toBeGreaterThan(bleeds[1] ?? 0); + expect(bleeds[1]).toBeGreaterThan(bleeds[2] ?? 0); + expect(bleeds[2]).toBeGreaterThan(0); + }); + + it("keeps the gutter bounded for deep nesting", () => { + expect(groupGutterPx(12)).toBeLessThanOrEqual(24); + expect(groupBleedPx(12, 12)).toBeGreaterThan(0); + }); +}); -- cgit v1.2.3