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/features/tabs/tab-groups.ts | |
| 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/features/tabs/tab-groups.ts')
| -rw-r--r-- | src/features/tabs/tab-groups.ts | 155 |
1 files changed, 155 insertions, 0 deletions
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))); +} |
