feat(tabs): draw lineage lines as one tree for the selected tab's family

Lineage lines used to draw one glowing dashed bezier per parent/child pair,
hanging below the tab strip, for every family at once. With a parent on row 3
of a wrapped strip and ten children below it, the curves crossed every lower
row's tab names and ran through the terminal text.

- One rounded orthogonal tree per spawning tab: every route starts at the
  parent, so siblings share a trunk. 1.5px, solid, 1px dark outline; only a
  working child's branch is dashed.
- Routes run only through the gaps between tab rows, joined by a spine left of
  every row (computeLineageTree/computeLineageRows in constants.js), so they
  never cross a tab or reach the terminal. The vertical rail gets the same tree
  on its existing left track.
- Only the selected tab's family is drawn (what it spawned, plus its parent and
  siblings). Selection redraws, and the strip's size transitionend redraws once
  more, since the active tab widens for ~150ms after the selection redraw.
- The routing room is reserved by .session-tabs.lineage-tree, keyed on whether
  any lineage exists, never on the selection, so a tab switch never resizes the
  header or the PTY.
- Colours stay per spawning tab, now claimed in strip order for every family so
  selection order never decides who gets the skin blue.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Codeman maintainer
2026-10-07 00:03:37 +02:00
parent ac94f339ac
commit 09daf0fe49
14 changed files with 1123 additions and 619 deletions
+1 -1
View File
@@ -250,7 +250,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
**Run launch synchronization**: the Run entrypoint holds an in-flight lock and disables `#runBtn` for the whole launch (≥500ms) so a double click cannot create duplicate `w<n>-<case>` sessions; `_ensureCreatedSessionVisible()` runs before `selectSession()` and `_onSessionCreated()` stays an idempotent upsert, so POST-first and SSE-first both render exactly one tab. ⚠️ **Closing has the mirror-image race**: `closeSession()` must read `wasActive` BEFORE its `await` and announce the delete via `_closingSessions`, and `_onSessionDeleted` skips the active-session handoff for ids in that set; never read `activeSessionId` after the fact. The fallback picks the first `sessionOrder` entry still in `sessions`. Tests: `test/session-close-fallback.test.ts`. → [architecture-invariants#run-launch-synchronization](docs/architecture-invariants.md#run-launch-synchronization)
**Session lineage lines** (tab → tab it spawned, `sessionLineageLines`, per-device, desktop default ON): a create request may name its spawner via a `parentSessionId` body field or the `X-Codeman-Parent-Session` header; `resolveParentSessionId()` (route-helpers.ts) resolves it (exact id or unique ≥8-char prefix, live, visible, same owner) and ⚠️ anything unresolvable is DROPPED, never a 400. Rides `toState()`, no new SSE event. ⚠️ Rendering is a LAYER on the existing SVG pass (`_appendLineageConnectionLines` at the tail of `_updateConnectionLinesImmediate()`), geometry pure in `computeLineagePath()`: one U-bridge shape hanging from the strip bottom, colors keyed on the SPAWNING tab and memoized (never by draw index). ⚠️ Desktop only (z-index vs the fixed mobile header). ⚠️ Paths must keep `data-agent-id="lineage:<childId>"` (the entrance animation queries it); skip edges whose endpoint is scrolled out of the strip. → [architecture-invariants#session-lineage-lines-tab--tab-it-spawned](docs/architecture-invariants.md#session-lineage-lines-tab--tab-it-spawned)
**Session lineage lines** (tab → tab it spawned, `sessionLineageLines`, per-device, desktop default ON): a create request may name its spawner via a `parentSessionId` body field or the `X-Codeman-Parent-Session` header; `resolveParentSessionId()` (route-helpers.ts) resolves it (exact id or unique ≥8-char prefix, live, visible, same owner) and ⚠️ anything unresolvable is DROPPED, never a 400. Rides `toState()`, no new SSE event. ⚠️ Rendering is a LAYER on the existing SVG pass (`_appendLineageConnectionLines` at the tail of `_updateConnectionLinesImmediate()`) and draws ONLY the selected tab's family (what it spawned, and the family it was spawned into), so a selection change must redraw (`_updateActiveTabImmediate`, gated on `_lineageTotalEdges`), and again on the strip's size `transitionend` (the active tab widens for ~150ms after the redraw). Geometry is pure in `computeLineageTree()`: one rounded orthogonal tree per spawning tab, every route starting at the PARENT (siblings share the trunk exactly), routed ONLY through the gaps between tab rows plus a spine left of every row, so it never crosses a tab or the terminal. ⚠️ That room is reserved in CSS by `.session-tabs.lineage-tree` (`_syncLineageGutter()`, called before the wrap is measured), keyed on whether ANY family exists, never on the selection, or every tab switch would resize the header and the PTY. Colors are keyed on the SPAWNING tab, claimed in strip order for every family, and memoized (never by draw index or selection order). ⚠️ Desktop only (z-index vs the fixed mobile header). ⚠️ Paths must keep `data-agent-id="lineage:<childId>"` (the entrance animation queries it); skip endpoints scrolled out of the strip. → [architecture-invariants#session-lineage-lines-tab--tab-it-spawned](docs/architecture-invariants.md#session-lineage-lines-tab--tab-it-spawned)
**Auto-named sessions** (`autoNameSessions`, SYNCED, default OFF): a placeholder tab (`w3-myapp`) takes its first real prompt as a title in the `<prefix>: <title>` form, so the case identity and `w<n>` counter survive. Ownership is `SessionState.nameSource` (`placeholder` | `auto` | `manual`; the `name` setter / `PUT /api/sessions/:id/name` makes it `manual`, never touched again). ⚠️ `applyAutoName()` flips to `auto` even if the string is unchanged, so only the FIRST titled prompt names the tab. ⚠️ Only user input counts: `SessionWriteOptions.fromUser` is set by the browser WS path and `POST /api/sessions/:id/input` ONLY; any new user-input path must set it (and the send-key Shift+Enter path must call `trackUserInput()`). ⚠️ The pure tracker (`session-auto-name.ts`) sits on the raw keystroke stream with an explicit rule per key; add a rule for any new key class. ⚠️ `nameSource` also decides `--name`: only a `manual` name is pinned on the claude CLI (`Session.cliPinnedName`), since `--name` is also the `/resume` title; a rename appends a `custom-title` row to a LOCAL, non-docker transcript, and a same-name PUT is a no-op (never flips to `manual`). Tests: `test/session-auto-name.test.ts`. → [architecture-invariants#auto-named-sessions-first-prompt--tab-title](docs/architecture-invariants.md#auto-named-sessions-first-prompt--tab-title)
+10 -6
View File
@@ -175,15 +175,19 @@ Further detail: `resumeHistorySession()` is what sends `codexConfig.resumeSessio
**Resolved, not trusted** (`resolveParentSessionId()`, route-helpers.ts): exact id first, then a UNIQUE prefix of ≥8 chars (ids reach agents truncated — mux names and a Docker export's `$CODEMAN_SESSION_ID` both carry 8), and an ambiguous prefix resolves to NOTHING rather than to a guess. The parent must be a live session the caller can already see (`canAccessOwned`) AND carry the same owner as the session being created, so a multi-user caller cannot staple their session under someone else's tab. ⚠️ **Everything unresolvable is DROPPED, never a 400**: a stale id from a cached skill preamble must cost a decorative line, not a worker. ⚠️ It is decoration at every layer — never an ownership, permission or lifecycle signal; a child outlives its parent, and the Session ctor refuses a self-parent (reachable only via recovery, where both values come off disk). It rides `toState()` into `session_created` / `session_updated`, so there is **no new SSE event**, and `server.ts`'s recovery path restores it so lineage survives a restart.
**Rendering is an additional LAYER, not a second pass** (`session-lineage.js`, loadorder 15.6): `_updateConnectionLinesImmediate()` (subagent-windows.js) calls `_appendLineageConnectionLines(svg, rects)` at its tail, exactly like ultracode's two layers, so all of them share ONE batched read→write reflow and the same `tab:<id>` rect cache. Geometry is pure and unit-tested in `computeLineagePath()` (constants.js): both endpoints live in one horizontal strip, so the subagent shape (tab-bottom → window-top) has nothing to aim at, and every pair gets a U-bridge HANGING BELOW the strip, anchored on both tabs' BOTTOM edges (dip scales with distance, plus a per-sibling step so several children of one parent nest instead of overprinting, plus the row offset when the strip has wrapped). ⚠️ **A wrapped strip used to get its own shape, and that shape was the bug** (fixed 2026-08-14): `tabs-two-rows`/`tabs-auto-wrap` put a parent on row 1 ~14px above its child on row 2, so the old parent-bottom → child-TOP bezier had 14px to bend in and drew a flat line inside the row gap, siblings overprinting. Hanging the control points below the LOWER row gives the wrapped case the same bracket as the flat one and deletes the branch. The same pass raised the dip clamp (44 → 104, 0.06 → 0.085/px) because a skill worker is appended to the END of the strip, where the old cap flattened an 800-1500px span into a straight thread across the terminal, and traded weight for a second, wider glow (2 → 2.5px, `4 4` → `5 5` dashes at `-20`, opacity .55 → .72 / .95 working) because the original styling vanished into terminal text at 1:1.
**Rendering is an additional LAYER, not a second pass** (`session-lineage.js`, loadorder 15.6): `_updateConnectionLinesImmediate()` (subagent-windows.js) calls `_appendLineageConnectionLines(svg, rects)` at its tail, exactly like ultracode's two layers, so all of them share ONE batched read→write reflow and the same `tab:<id>` rect cache.
⚠️ **Desktop only, for a z-index reason**: the overlay is `z-index: 999` and the desktop header is 100, so arcs paint OVER it — which is exactly what lets them touch tab bottoms. Under 1024px mobile.css makes the header `position: fixed; z-index: 1200` and would bury them, and the phone strip is a scroller where both endpoints are rarely on screen at once. Raising the SVG to ~1250 (above the fixed header, below modals at 1300) is the phase-2 option, and needs a real check against the mobile overview and the drawer.
**Only the SELECTED tab's family is drawn** (2026-10-06): the family the active tab spawned, plus the family it was spawned into (its parent and siblings); a tab that is both a child and a parent shows both, in separate lanes. Nothing is drawn while a web tab holds the stage. Drawing every family at once is what the owner called "too confusing": one parent with ten children across a five-row wrapped strip drew ten glowing dashed beziers through every lower row's labels and into the terminal text. ⚠️ Because the drawing now depends on the selection, `_updateActiveTabImmediate()` redraws whenever any lineage exists (`_lineageTotalEdges > 0`), and the incremental tab render redraws on the same condition (a child's `parentSessionId` or working state can arrive there). `_lineageEdgeCount` counts what is DRAWN and still gates the strip-scroll redraw. ⚠️ **A selection keeps resizing tabs AFTER that redraw**: the active tab reveals its gear and close icons by transitioning their padding (~150ms), which can move tabs or re-wrap a row, so the strip's `transitionend` (size properties only: `width`, `max-width`, `padding*`) triggers one more coalesced redraw. Without it the trunk hung under the tab's old position (measured ~10px off, more after a re-wrap).
⚠️ **`data-agent-id="lineage:<childId>"` is load-bearing**, not a label: `_applyLineEntrances()` queries paths by that attribute, so tagging them this way is the whole reason the arcs get the draw-in animation AND its negative-`animation-delay` resume across `svg.innerHTML = ''` with zero new animation code. ⚠️ `.session-tabs` is `overflow-x: auto`, so a tab scrolled out of the strip still HAS a rect — one lying over the logo or the header buttons; edges with an endpoint outside the strip are SKIPPED (clamping would point at a tab that is not there), and a passive `scroll` listener re-anchors the rest, since a scroll moves both endpoints without firing any render. The incremental tab render also redraws when `_lineageEdgeCount > 0`: a badge appearing widens a tab and shifts every tab after it. Setting: `sessionLineageLines`, per-device (in `displayKeys`, absent from the `.strict()` `SettingsUpdateSchema`), desktop default ON. Tests: `test/session-lineage-lines.test.ts` (geometry), `test/routes/session-routes-parent-lineage.test.ts` (resolution + reject paths).
**Geometry is a tree routed through the gaps** (`computeLineageTree()` + `computeLineageRows()`, constants.js, pure and unit-tested): one rounded orthogonal tree per spawning tab. Every route starts at the PARENT's bottom-center and ends at one child's bottom-center, so a parent's routes share their first stretch exactly and overlay into one trunk with a branch per child; that is also what keeps a working child's dashes in phase with its siblings along the shared part. A route moves horizontally only inside a row gap (the gap under each endpoint's row, at the row's TALLEST tab, since the active tab is 2px taller) and vertically only along a tab's own stem or the SPINE, a channel left of every row that joins the gaps of different rows. In the vertical rail the same idea is one shared track down `--lineage-vertical-gutter`. `test/session-lineage-lines.test.ts` pins "never crosses a tab" as a property over a five-row fixture. ⚠️ **The room is reserved, not borrowed**: `.session-tabs.lineage-tree` (styles.css) adds bottom padding for the last row's gap, and on a wrapped strip a 12px row gap plus a 20px left channel for the spine. `_syncLineageGutter()` toggles it from the top of `updateTabOverflowMode()`, BEFORE the wrap is measured, and keys it on whether ANY family exists, never on the selection: the header's height is the terminal's height, so a class that followed the selection would resize the PTY on every tab switch. ⚠️ **Opacity lives on the family `<g class="lineage-family">`, not on the paths**: translucent paths overlapping along the trunk would stack into a brighter line than the branches. Working routes are appended first so an idle sibling's solid stroke covers the shared trunk. Style is deliberately quiet (1.5px, rounded, a 1px dark outline instead of the old double glow): the lines no longer cross text, so they no longer need a glow to survive it, and a dash now means "this child is working".
Further detail (current geometry and colors, superseding the dip numbers above where they differ): ⚠️ **ONE shape, and the second one was the bug**: every pair (flat strip or wrapped) gets a U-bridge hanging below the strip, anchored on both tabs' BOTTOM edges. ⚠️ The dip is a **mis-tuned-in-both-directions corridor** (44px cap = straight thread at strip-wide spans, #285; 104px cap + full row offset = ~106px over-bow into the terminal, 2026-08-15): it now hangs from the **STRIP's bottom edge** (fallback: lower tab bottom), capped at 64px, with NO per-row offsets stacked on top — the strip-bottom baseline is also what keeps a row-1 pair's arc from drawing through row 2's tab labels.
⚠️ **Desktop only, for a z-index reason**: the overlay is `z-index: 999` and the desktop header is 100, so the lines paint OVER it, which is exactly what lets them touch tab bottoms. Under 1024px mobile.css makes the header `position: fixed; z-index: 1200` and would bury them, and the phone strip is a scroller where both endpoints are rarely on screen at once. Raising the SVG to ~1250 (above the fixed header, below modals at 1300) is the phase-2 option, and needs a real check against the mobile overview and the drawer. The sidebar layout draws no lineage.
⚠️ **Colors are keyed on the SPAWNING tab, not per child**: every arc leaving one tab is the same color however many workers it spawns, so the strip reads as "these five came from w1, those two came from w2" — per-child coloring gave one tab's own children a different color each, which is the distinction the colors exist to make. A child that spawns in turn is a parent in its own right and gets its own color for the arcs below it, so a chain changes color at each generation while each generation's fan-out stays uniform. Assignment cycles `CodemanLineage.COLORS` in first-seen order per parent id (first entry empty = the skin-tuned `--session-blue`, so the first spawning tab keeps it; the rest vivid fixed hexes), memoized rather than derived from draw index (the SVG is wiped and rebuilt constantly, so an index-based color would flicker), and set inline as `--lineage-color` so styles.css keeps owning opacity/glow/dash. `test/session-lineage-lines.test.ts` drives the real `_appendLineageConnectionLines()` and asserts the painted property, since testing the color function alone would pass just as happily with the child id passed back in.
⚠️ **`data-agent-id="lineage:<childId>"` is load-bearing**, not a label: `_applyLineEntrances()` queries paths by that attribute (nested inside the family group is fine), so tagging them this way is the whole reason the lines get the draw-in animation AND its negative-`animation-delay` resume across `svg.innerHTML = ''` with zero new animation code. ⚠️ `.session-tabs` scrolls, so a tab scrolled out of the strip still HAS a rect, one lying over the logo or the header buttons; an endpoint outside the strip is SKIPPED (a child is left out, a parent drops its whole family), since clamping would point at a tab that is not there, and a passive `scroll` listener re-anchors the rest, since a scroll moves both endpoints without firing any render. Setting: `sessionLineageLines`, per-device (in `displayKeys`, absent from the `.strict()` `SettingsUpdateSchema`), desktop default ON. Tests: `test/session-lineage-lines.test.ts` (geometry, focus rule, routing room, colours), `test/routes/session-routes-parent-lineage.test.ts` (resolution + reject paths).
History: before the tree, every pair got a U-bridge bezier hanging below the strip, and its dip was mis-tuned in both directions (a 44px cap flattened strip-wide spans into a straight thread, #285; a 104px cap plus the row offset bowed ~106px into the terminal, 2026-08-15). The tree removed the dip entirely.
⚠️ **Colors are keyed on the SPAWNING tab, not per child**: every line leaving one tab is the same color however many workers it spawns, so the strip reads as "these five came from w1, those two came from w2"; per-child coloring gave one tab's own children a different color each, which is the distinction the colors exist to make. A child that spawns in turn is a parent in its own right and gets its own color for the lines below it, so a chain changes color at each generation while each generation's fan-out stays uniform. Assignment cycles `CodemanLineage.COLORS` in first-seen order per parent id (first entry empty = the skin-tuned `--session-blue`, so the first spawning tab keeps it; the rest vivid fixed hexes), memoized rather than derived from draw index (the SVG is wiped and rebuilt constantly, so an index-based color would flicker). ⚠️ The draw pass claims a color for EVERY family in strip order, drawn or not, so which family happens to be selected first never decides who gets the blue. Colors are set inline as `--lineage-color` so styles.css keeps owning weight and dash. `test/session-lineage-lines.test.ts` drives the real `_appendLineageConnectionLines()` and asserts the painted property, since testing the color function alone would pass just as happily with the child id passed back in.
### Auto-named sessions (first prompt → tab title)
@@ -771,7 +775,7 @@ Further detail: in the Settings section order, the version and the updater lead
**The session list can render as the horizontal header strip (default) or as a collapsible left sidebar** — App Settings → Layout → Tabs → **Session List Layout** (`sessionListLayout: 'header' | 'sidebar'`, in the per-device `displayKeys` set, so it never syncs across devices; also in `SettingsUpdateSchema`, which is `.strict()` — without that entry the server 400s the ENTIRE settings PUT and every unrelated setting silently stops persisting). ⚠️ **There is exactly ONE `#sessionTabs` element and `applySessionListLayout()` RE-PARENTS it** between `#sessionTabsHost` (in `<header>`) and `#sessionSidebarList` (in the `<aside>`, a flex sibling of `.terminal-wrap` so the terminal shrinks and `terminal-ui.js`'s `ResizeObserver` refits xterm on its own). It must never be cloned or rebuilt: `app.$(id)` caches elements by id and NEVER invalidates, and `settings-ui.js` / `webview-tabs.js` resolve the same id independently, so a rebuilt container leaves every consumer writing into a detached orphan — silently, with no error. Everything else is CSS keyed off `html[data-session-list]` / `html[data-sidebar]`, both written by a pre-paint script in `<head>` so the loading skeleton already matches. Consequences: the renderers, drag/keyboard handlers, web tabs (`data-webview-id` rows stay in the same list, keeping the shared Alt+N numbering and the single-active-tab invariant) and the generated gesture bundle (`TAB_SELECTOR`/`DOCK_SELECTOR` match on class names that are unchanged) all need **zero** edits.
⚠️ Collapsed means **different things per viewport**: at 1024px and up the sidebar keeps a 44px icon rail so the ambient signal (status dot, task/subagent/ultracode badges) survives — the Alt+N number, the name/folder and the `sh`/`oc`/`cx`/`gm` mode chip do NOT, because 44px minus paddings and borders is ~34px of content box and the chip lives inside `.tab-info`; below 1024px `mobile.css` turns the sidebar into an off-canvas overlay where collapsed == drawer closed (mirrored into an `.open` class plus `inert`/`aria-hidden`, since `translateX(-100%)` alone leaves every row in the Tab order), it defaults to CLOSED when the user has made no choice, and picking a session or web tab dismisses it. ⚠️ **That 1024px breakpoint is the only handheld test the sidebar may use** (`_isSessionSidebarOverlay()`, mirrored in the pre-paint script): `MobileDetection.getDeviceType()` calls everything from 768px up `'desktop'`, so using it gave 768-1023px the overlay CSS with docked-sidebar logic — drawer opening itself on load, immune to selection and Escape. The toggle chord (default Alt+B) also needs its gate in `terminal-ui.js`'s `attachCustomKeyEventHandler`, or `preventDefault()` in the capture handler still lets xterm write ESC b into the live PTY (same trap as COD-153). The sidebar filter only applies while its input is on screen — `applySidebarFilter()` strips the class in the header strip, the collapsed rail and the closed drawer, because a filter with no reachable control hides sessions permanently. Collapse state lives in its OWN `codeman-sidebar-collapsed` key, **not** in the settings blob — `saveAppSettings()` rebuilds that blob from DOM controls, so a key without a control is wiped on every Save. Solo (`/session/:id`) windows never get a sidebar (three guards: `getSessionListLayout()`, the pre-paint script, and `body.solo-mode`), because `#sessionTabs` parked in a `display:none` subtree measures 0/0 for tab overflow and inline rename. The sidebar CSS block sits at the END of `styles.css`, **after** the `html:not([data-skin="og"])` nesting block, and is layout-only — any colour on `.session-tab` there would render correctly on the `og` skin only. Same for the `mobile.css` block: it must stay at the end of the file or the earlier compact-strip rules clip the list to a 36px sliver. Two surfaces DEFER to the sidebar rather than adapt: **lineage arcs are skipped** in sidebar layout (`_appendLineageConnectionLines` early-returns — `computeLineagePath()`'s whole geometry hangs a U-bridge from the horizontal STRIP's bottom edge, so against a vertical list every arc would loop to the foot of the sidebar; a sideways lineage shape needs its own visual tuning, it is not a by-product of re-parenting), and the **desktop home tab rail** (`shouldShowHomeSessions()`) stays hidden while the sidebar is active, because both dock the session list flush left and the rail would render the same list next to it, z-ordered UNDER it. The subagent/ultracode connectors DO adapt (`_tabAnchor()`/`_tabConnectorPath()` in app.js: right-edge anchor, horizontal bezier), and the lineage strip-scroll listener redraws them on the sidebar's vertical scroll. `_scrollActiveTabIntoView()` owns active-row reveal on BOTH axes: sidebar mode branches to `scrollIntoView({block:'nearest'})` because the horizontal `computeTabScrollLeft` math no-ops against a vertical scroller, and `_fullRenderSessionTabs()` restores `scrollTop` alongside the #257 `scrollLeft` restore or ambient rebuilds yank a mid-scroll sidebar back to the top. Tests: `test/session-list-layout.test.ts`.
⚠️ Collapsed means **different things per viewport**: at 1024px and up the sidebar keeps a 44px icon rail so the ambient signal (status dot, task/subagent/ultracode badges) survives — the Alt+N number, the name/folder and the `sh`/`oc`/`cx`/`gm` mode chip do NOT, because 44px minus paddings and borders is ~34px of content box and the chip lives inside `.tab-info`; below 1024px `mobile.css` turns the sidebar into an off-canvas overlay where collapsed == drawer closed (mirrored into an `.open` class plus `inert`/`aria-hidden`, since `translateX(-100%)` alone leaves every row in the Tab order), it defaults to CLOSED when the user has made no choice, and picking a session or web tab dismisses it. ⚠️ **That 1024px breakpoint is the only handheld test the sidebar may use** (`_isSessionSidebarOverlay()`, mirrored in the pre-paint script): `MobileDetection.getDeviceType()` calls everything from 768px up `'desktop'`, so using it gave 768-1023px the overlay CSS with docked-sidebar logic — drawer opening itself on load, immune to selection and Escape. The toggle chord (default Alt+B) also needs its gate in `terminal-ui.js`'s `attachCustomKeyEventHandler`, or `preventDefault()` in the capture handler still lets xterm write ESC b into the live PTY (same trap as COD-153). The sidebar filter only applies while its input is on screen — `applySidebarFilter()` strips the class in the header strip, the collapsed rail and the closed drawer, because a filter with no reachable control hides sessions permanently. Collapse state lives in its OWN `codeman-sidebar-collapsed` key, **not** in the settings blob — `saveAppSettings()` rebuilds that blob from DOM controls, so a key without a control is wiped on every Save. Solo (`/session/:id`) windows never get a sidebar (three guards: `getSessionListLayout()`, the pre-paint script, and `body.solo-mode`), because `#sessionTabs` parked in a `display:none` subtree measures 0/0 for tab overflow and inline rename. The sidebar CSS block sits at the END of `styles.css`, **after** the `html:not([data-skin="og"])` nesting block, and is layout-only — any colour on `.session-tab` there would render correctly on the `og` skin only. Same for the `mobile.css` block: it must stay at the end of the file or the earlier compact-strip rules clip the list to a 36px sliver. Two surfaces DEFER to the sidebar rather than adapt: **lineage lines are skipped** in sidebar layout (`_appendLineageConnectionLines` early-returns: `computeLineageTree()` routes through a horizontal strip's reserved row gaps or the vertical rail's reserved `--lineage-vertical-gutter`, and the sidebar has neither; a sidebar lineage shape needs its own visual tuning, it is not a by-product of re-parenting), and the **desktop home tab rail** (`shouldShowHomeSessions()`) stays hidden while the sidebar is active, because both dock the session list flush left and the rail would render the same list next to it, z-ordered UNDER it. The subagent/ultracode connectors DO adapt (`_tabAnchor()`/`_tabConnectorPath()` in app.js: right-edge anchor, horizontal bezier), and the lineage strip-scroll listener redraws them on the sidebar's vertical scroll. `_scrollActiveTabIntoView()` owns active-row reveal on BOTH axes: sidebar mode branches to `scrollIntoView({block:'nearest'})` because the horizontal `computeTabScrollLeft` math no-ops against a vertical scroller, and `_fullRenderSessionTabs()` restores `scrollTop` alongside the #257 `scrollLeft` restore or ambient rebuilds yank a mid-scroll sidebar back to the top. Tests: `test/session-list-layout.test.ts`.
Further detail: with many sessions the horizontal strip stops being scannable, which is why the list can move into the vertical `<aside>` with a filter box and a live count, collapsible to a 44px rail (`--sidebar-width` 260 / `--sidebar-width-collapsed` 44) via **Alt+B** (`toggleSessionSidebar`; Alt, not Ctrl+B, which must reach tmux/readline in the terminal). The setting lives in App Settings → Appearance → Tabs. There is a THIRD host for `#sessionTabs` besides `#sessionTabsHost` and `#sessionSidebarList`: `#tabRail`, the vertical rail. Exactly TWO functions reparent it and they must run in this order: `applySessionListLayout()` first (sidebar wins), then `applyTabOrientation()` (settings-ui.js), which moves the tabs into `#tabRail` only when the sidebar does not own them. ⚠️ `applySessionListLayout()` sets `data-session-list` / `data-sidebar` on `<html>` and must run BEFORE `applyTabWrapSettings()`, which is the one owner of `tabs-two-rows`/`tabs-show-folder` and reads those attributes.
+1 -1
View File
@@ -189,7 +189,7 @@ followed by a wait races, and reports the previous turn's state.
## Lineage
A create request can name the session that spawned it, through a body field or a header, and
the dashboard then draws a lineage arc from parent to child. The skill sets it automatically.
the dashboard then draws a lineage line from parent to child. The skill sets it automatically.
It is resolved rather than trusted: an unresolvable parent is dropped silently rather than
failing the spawn, because a cosmetic field must never break a worker.
+1 -1
View File
@@ -158,7 +158,7 @@ enough to fix a typo an agent introduced while you are away from your desk.
enforces it.
- The Approvals bell. Phones get the NEEDS YOU strips on the home screen instead.
- The desktop home tab rail, which needs a wide window.
- Lineage arcs, which are a desktop overlay.
- Lineage lines, which are a desktop overlay.
## Gotchas
+1 -1
View File
@@ -89,7 +89,7 @@ every session or only the active tab.
| Vertical Rail Order | *By activity* (default) sorts the rail the way the home screens are sorted; *Manual* keeps your tab order and drag-reordering. |
| Tall Tabs | Taller tab strip. |
| Pop-out Button on Tabs | Adds the detach control to tabs, with a per-tab override. |
| Spawn Lineage Lines | Arcs from a parent tab to sessions it spawned. Desktop only, on by default. |
| Spawn Lineage Lines | Lines from the selected tab to the sessions it spawned, and to the one that spawned it. Desktop only, on by default. |
| Auto-name Sessions | Titles a new tab after its first prompt, keeping the case prefix (`w3-myapp: fix the login redirect`). Synced, off by default. See [The Dashboard](The-Dashboard#automatic-session-names). |
| Overview Home Screen | The phone home screen. On by default. |
+9 -5
View File
@@ -84,13 +84,17 @@ title is derived locally from the prompt's first sentence; no text leaves the ma
On phones the strip scrolls horizontally instead of wrapping, and the active tab is always
scrolled into view. It is not reordered to the front, so the `Alt+N` numbering stays stable.
### Lineage arcs
### Lineage lines
When one session spawns another (an agent starting a worker through the API), Codeman draws
a coloured arc under the strip connecting parent to child, with one colour per child. It is
how a fan-out of eight workers stays readable.
When one session spawns another (an agent starting a worker through the API), selecting
either tab draws its family: lines from the parent to each child, in the parent's colour,
routed through the gaps between tab rows so they never cover a tab or the terminal. A dashed
branch means that child is working. It is how a fan-out of eight workers stays readable.
Desktop only, and on by default. Turn it off in **App Settings → Appearance**. Arcs are
While any tab has spawned another, the strip keeps a little extra room between rows for the
lines, so switching tabs never changes the header height.
Desktop only, and on by default. Turn it off in **App Settings → Appearance**. Lines are
skipped for tabs scrolled out of the strip.
## Header controls
+9 -5
View File
@@ -22,13 +22,17 @@ transcript: what it was asked to do, what it is doing, and what it returned.
This is the feature that makes a fan-out legible. Without it, a lead session that spawned
eight workers looks like a stalled terminal for several minutes.
## Session lineage arcs
## Session lineage lines
The tab strip draws a coloured arc from a parent tab to any tab it spawned, one colour per
child. That covers the other direction of fan-out: not subagents inside one session, but
whole sessions started by an agent through the API.
Select a tab and the tab strip draws lines from it to every tab it spawned, and to the tab
that spawned it (with its siblings). That covers the other direction of fan-out: not
subagents inside one session, but whole sessions started by an agent through the API.
Desktop only, on by default, and toggled in **App Settings → Appearance**. Arcs are skipped
The lines form one tree per spawning tab, in that tab's colour, and run only through the
gaps between tab rows, so they never cover a tab name or the terminal. A dashed branch
means that child is working. Tabs with no lineage draw nothing.
Desktop only, on by default, and toggled in **App Settings → Appearance**. Lines are skipped
for tabs scrolled out of view.
See [Driving Codeman From An Agent](Driving-Codeman-From-An-Agent) for the spawning side.
+16 -7
View File
@@ -5241,6 +5241,9 @@ class CodemanApp {
// strip scrolls horizontally, so a tab selected from the palette, a swipe,
// Alt+N or a push notification could stay parked off-screen.
this._scrollActiveTabIntoView(sessionId);
// Lineage lines draw only the SELECTED tab's family (session-lineage.js), so a
// selection change is a redraw whenever any lineage exists at all.
if (this._lineageTotalEdges > 0) this.updateConnectionLines();
}
/**
@@ -5633,13 +5636,15 @@ class CodemanApp {
this._refreshHomeSessionsIfVisible?.();
// The full-render path already redraws the connection SVG; this incremental
// one does not, and a badge appearing widens a tab and shifts every tab after
// it, sliding the lineage arcs off their anchors. Only pay for it when there
// is something anchored to tab rects: lineage arcs, or — in a VERTICAL list
// (sidebar, where lineage is skipped and the edge count stays 0, or the
// rail, which can show connectors with zero lineage edges too) — the
// subagent/ultracode connectors, whose rows a badge changes the HEIGHT of.
// Same widening as the strip-scroll listener in session-lineage.js.
if (this._lineageEdgeCount > 0 || this._isVerticalTabList()) this.updateConnectionLines();
// it, sliding the lineage lines off their anchors. Only pay for it when there
// is something anchored to tab rects: any lineage at all (not just what is
// drawn now: a parentSessionId or a child's working state can arrive on this
// path and change the selected family's tree), or, in a VERTICAL list (the
// sidebar, where lineage is skipped, or the rail, which can show connectors
// with zero lineage edges too), the subagent/ultracode connectors, whose
// rows a badge changes the HEIGHT of. updateTabOverflowMode() above has just
// refreshed _lineageTotalEdges.
if (this._lineageTotalEdges > 0 || this._isVerticalTabList()) this.updateConnectionLines();
this.applySidebarFilter(this._sidebarFilter);
}
@@ -5651,6 +5656,10 @@ class CodemanApp {
const container = this.$('sessionTabs');
if (!container) return;
// Lineage routing room (session-lineage.js) changes the strip's padding and
// row gap, so it is decided before the wrap is measured below.
this._syncLineageGutter?.();
// The sidebar list is a single vertical column with its own scroller —
// there is no row to overflow, and measuring it would fight the CSS.
if (this.isSessionSidebarActive()) {
+224 -115
View File
@@ -296,139 +296,248 @@ function computeTabScrollLeft(input) {
return Math.min(Math.max(Math.round(target), 0), maxScroll);
}
// Session lineage lines — geometry for the arc drawn between a tab and a tab it
// Session lineage lines: geometry for the lines joining a tab to the tabs it
// spawned (a worker started through the codeman agent skill, which passes its own
// id as parentSessionId). Pure: the caller measures and appends, this decides.
//
// ONE shape, because both endpoints live in the same horizontal strip and the subagent
// shape (tab-bottom → window-top) has nothing to aim at: a U-bridge HANGING BELOW the
// strip, from the parent's bottom edge to the child's bottom edge, so it reads as a
// bracket joining two tabs rather than as a line crossing them. The dip grows with
// horizontal distance and with `depth` (the child's index among its siblings), so
// several children of one parent nest instead of overprinting.
// ONE TREE PER SPAWNING TAB, and only the selected tab's family is drawn (that
// filter lives in session-lineage.js). Every route starts at the PARENT and ends
// at one child, so a parent's routes share their first stretch exactly: overlaid,
// they read as one trunk with a branch per child, and a dashed (working) route
// stays in phase with its siblings along the shared part.
//
// ⚠ A WRAPPED STRIP USED TO GET ITS OWN SHAPE, AND THAT SHAPE WAS THE BUG. When the
// desktop strip wraps (`tabs-two-rows` / `tabs-auto-wrap`) a parent on row 1 and its
// child on row 2 are ~4px apart vertically, so the old parent-bottom → child-TOP bezier
// had a 4px span to work with and drew a flat horizontal line inside the row gap
// (reported as "they connect already, but the lines are straight and not easy visible"),
// and three siblings drew three of them on top of each other. Aiming BOTH ends at the
// tab BOTTOMS and putting the control points below the LOWER row gives the wrapped case
// the same bracket as the flat case: it leaves the parent downward, crosses the lower
// row once, and comes back up under the child. Same formula, no branch.
// ⚠ ROUTES RUN IN THE GAPS, NEVER THROUGH A TAB. This replaced one bezier per
// child hanging below the strip, which in a wrapped strip crossed every lower
// row's labels and the terminal text (owner screenshot 2026-10-06: a parent on
// row 3 with ten children, "too confusing"). A route now moves horizontally only
// inside a row gap, and vertically only along a tab's own stem (its bottom edge to
// the gap right under it) or along the SPINE, a channel left of every row that
// joins the gaps of different rows. styles.css reserves that room
// (`.session-tabs.lineage-tree`: a wider row gap, bottom padding for the last
// row's gap, and the spine channel on the left of a wrapped strip).
//
// Returns null when the edge must not be drawn: a missing/degenerate rect, or an
// endpoint scrolled outside the strip. `.session-tabs` is `overflow-x: auto`, so a
// scrolled-out tab still HAS a rect — one lying over the logo or the header
// buttons. Skipping is honest; clamping would point at a tab that isn't there.
// ⚠ THE DIP IS WHAT MAKES THE ARC AN ARC, and it has now been mis-tuned in BOTH
// directions, so treat these numbers as a corridor rather than a dial to crank:
// - Too shallow (the first ship, 44px cap): a skill worker is appended to the END of
// the strip, so a lead-to-worker span is 800-1500px, and a 44px cap over 1300px is
// a 33px sag, a line that reads as STRAIGHT across the terminal (#285).
// - Too deep (the 104px cap that replaced it): in the wrapped-strip case the cap and
// the FULL row offset stacked, bowing the bracket ~106px into the terminal text
// (owner screenshot 2026-08-15, "die Linien machen einen grossen Bogen nach unten").
// The dip is measured from the STRIP'S BOTTOM EDGE (falling back to the lower tab
// bottom when the strip rect is missing or shorter than its tabs), which buys two
// things at once: the bow needs no per-row offsets stacked on top, and a same-row
// arc between ROW-1 tabs of a wrapped strip clears row 2's labels instead of being
// drawn through them (the retune's own first draft had exactly that regression).
const LINEAGE_DIP_BASE_PX = 14;
const LINEAGE_DIP_PER_PX = 0.06;
const LINEAGE_DIP_MIN_PX = 22;
const LINEAGE_DIP_MAX_PX = 64;
// Siblings nest by this much. Widened with the stroke: at 2.5px plus its glow, arcs 6px
// apart bled into one thick band instead of reading as three separate lines.
const LINEAGE_SIBLING_STEP_PX = 8;
// Rows come from computeLineageRows() over EVERY tab in the strip, not only the
// endpoints: a row's gap sits under its TALLEST tab (the active tab is 2px
// taller), or siblings in one row would hang their bus at different heights.
//
// computeLineageTree() returns null when the parent cannot be drawn (missing or
// degenerate rect, scrolled out of the strip); a child that cannot be drawn is
// left out of `routes`. `.session-tabs` scrolls, so a scrolled-out tab still HAS a
// rect, lying over the logo or the header buttons. Skipping is honest; clamping
// would point at a tab that is not there.
const LINEAGE_CORNER_RADIUS_PX = 10;
// Families drawn together (the selected tab is both a child and a parent) take
// separate lanes: gap lines this far apart, spines LINEAGE_SPINE_STEP_PX apart.
const LINEAGE_LANE_STEP_PX = 3.5;
const LINEAGE_SPINE_INSET_PX = 6;
const LINEAGE_SPINE_STEP_PX = 4;
// The last row has no row below it; with no strip rect to measure, its gap is
// taken to be this deep.
const LINEAGE_LAST_GAP_PX = 12;
const LINEAGE_ROW_TOLERANCE_PX = 6;
const LINEAGE_STRIP_TOLERANCE_PX = 4;
// How far the vertical bracket sits in from the rail's left edge. It has to
// clear the VIEWPORT edge, not just the tabs: the line carries an 11px outer
// glow, so a track at 6px had half of that glow clipped away and the arc read
// as a thin thread pinned to the window frame. The rail reserves the channel
// itself (`--lineage-vertical-gutter` on the rail's .session-tabs), and
// computeLineagePath still clamps the track to stay left of both tabs.
// How far the vertical rail's track sits in from the rail's left edge. It has to
// clear the VIEWPORT edge, not just the tabs, or the line reads as a thread pinned
// to the window frame. The rail reserves the channel itself
// (`--lineage-vertical-gutter` on the rail's .session-tabs), and the track is
// still clamped to stay left of every endpoint.
const LINEAGE_VERTICAL_TRACK_INSET_PX = 10;
const LINEAGE_VERTICAL_SIBLING_STEP_PX = 3;
const LINEAGE_VERTICAL_LANE_STEP_PX = 4;
const LINEAGE_VERTICAL_ANCHOR_CLEARANCE_PX = 4;
// Lineage palette, assigned per SPAWNING TAB in first-seen order and cycled
// (session-lineage.js). Every arc leaving one tab shares its colour however many
// workers it spawns; a child that spawns in turn gets its own for the arcs below it.
// (session-lineage.js). Every line leaving one tab shares its colour however many
// workers it spawns; a child that spawns in turn gets its own for the lines below it.
// The empty FIRST entry means "no override": the CSS then falls back to --session-blue,
// which every skin block tunes for its own background, so a lone arc keeps the
// which every skin block tunes for its own background, so a lone family keeps the
// skin-aware blue that shipped in 1.18.2. The fixed entries are deliberately vivid
// (owner call 2026-08-15: matrix green, pinkish, violet, red, turquoise "and so on");
// they ride the same double glow as the blue, which is what keeps them legible over
// terminal text on every skin.
// (owner call 2026-08-15: matrix green, pinkish, violet, red, turquoise "and so on").
const LINEAGE_COLORS = ['', '#00ff66', '#ff5ea8', '#a78bfa', '#ff5252', '#2dd4bf', '#ffa940'];
function computeLineagePath(input) {
const parent = input?.parent;
const child = input?.child;
if (!parent || !child) return null;
/** A rect normalized to numbers with its edges and center, or null if unusable. */
function lineageRect(rect) {
if (!rect) return null;
const left = Number(rect.left);
const top = Number(rect.top);
const width = Number(rect.width);
const height = Number(rect.height);
if (![left, top, width, height].every(Number.isFinite) || width <= 0 || height <= 0) return null;
return {
left,
top,
width,
height,
right: left + width,
bottom: top + height,
cx: left + width / 2,
cy: top + height / 2,
};
}
const pw = Number(parent.width) || 0;
const ph = Number(parent.height) || 0;
const cw = Number(child.width) || 0;
const ch = Number(child.height) || 0;
if (pw <= 0 || ph <= 0 || cw <= 0 || ch <= 0) return null;
/**
* Group tab rects into the strip's visual rows, top to bottom. A row spans from its
* highest top to its LOWEST bottom, so a taller tab (the active one) sets the row's
* gap for everyone in it.
*/
function computeLineageRows(rects) {
const rows = [];
for (const raw of rects || []) {
const r = lineageRect(raw);
if (!r) continue;
const row = rows.find((candidate) => Math.abs(candidate.top - r.top) <= LINEAGE_ROW_TOLERANCE_PX);
if (row) {
row.top = Math.min(row.top, r.top);
row.bottom = Math.max(row.bottom, r.bottom);
} else {
rows.push({ top: r.top, bottom: r.bottom });
}
}
return rows.sort((a, b) => a.top - b.top);
}
/**
* An orthogonal polyline as an SVG path, each corner rounded by up to `radius`
* (never more than half of either segment, so short stems stay short). Repeated and
* collinear points are dropped first, so a degenerate corner draws nothing odd.
*/
function lineagePolylinePath(points, radius) {
const pts = [];
for (const p of points) {
const prev = pts[pts.length - 1];
if (prev && Math.abs(prev[0] - p[0]) < 0.5 && Math.abs(prev[1] - p[1]) < 0.5) continue;
const before = pts[pts.length - 2];
if (before && prev) {
const cross = (prev[0] - before[0]) * (p[1] - prev[1]) - (prev[1] - before[1]) * (p[0] - prev[0]);
if (Math.abs(cross) < 0.01) pts.pop();
}
pts.push(p);
}
if (pts.length < 2) return null;
let d = `M ${r1(pts[0][0])} ${r1(pts[0][1])}`;
for (let i = 1; i < pts.length - 1; i++) {
const [x0, y0] = pts[i - 1];
const [x1, y1] = pts[i];
const [x2, y2] = pts[i + 1];
const lenIn = Math.hypot(x1 - x0, y1 - y0);
const lenOut = Math.hypot(x2 - x1, y2 - y1);
const r = Math.min(radius, lenIn / 2, lenOut / 2);
if (!(r > 0.5)) {
d += ` L ${r1(x1)} ${r1(y1)}`;
continue;
}
const ax = x1 - ((x1 - x0) / lenIn) * r;
const ay = y1 - ((y1 - y0) / lenIn) * r;
const bx = x1 + ((x2 - x1) / lenOut) * r;
const by = y1 + ((y2 - y1) / lenOut) * r;
d += ` L ${r1(ax)} ${r1(ay)} Q ${r1(x1)} ${r1(y1)} ${r1(bx)} ${r1(by)}`;
}
const last = pts[pts.length - 1];
return d + ` L ${r1(last[0])} ${r1(last[1])}`;
}
/**
* Routes from one parent tab to each of its children.
*
* input: { parent, children: [{ id, rect }], strip?, tabs?, orientation?, lane?,
* laneCount?, radius? }. `tabs` is every tab rect in the strip (rows are
* derived from it); `lane`/`laneCount` separate families drawn together.
* Returns { routes: [{ id, points, d, endX, endY }] } or null.
*/
function computeLineageTree(input) {
const parent = lineageRect(input?.parent);
if (!parent) return null;
const strip = lineageRect(input?.strip);
const orientation = input?.orientation === 'vertical' ? 'vertical' : 'horizontal';
const strip = input?.strip;
const depth = Math.max(0, Math.min(6, Number(input?.depth) || 0));
const pLeft = Number(parent.left);
const cLeft = Number(child.left);
const pTop = Number(parent.top);
const cTop = Number(child.top);
if (![pLeft, cLeft, pTop, cTop].every(Number.isFinite)) return null;
const laneCount = Math.max(1, Math.floor(Number(input?.laneCount)) || 1);
const lane = Math.max(0, Math.min(laneCount - 1, Math.floor(Number(input?.lane)) || 0));
const radius = Number.isFinite(Number(input?.radius)) ? Math.max(0, Number(input.radius)) : LINEAGE_CORNER_RADIUS_PX;
const children = [];
for (const child of input?.children || []) {
const rect = lineageRect(child?.rect);
if (rect) children.push({ id: child.id, rect });
}
const tol = LINEAGE_STRIP_TOLERANCE_PX;
const inStripY = (r) => !strip || (r.cy >= strip.top - tol && r.cy <= strip.bottom + tol);
const inStripX = (r) => !strip || (r.cx >= strip.left - tol && r.cx <= strip.right + tol);
const routes = [];
if (orientation === 'vertical') {
const py = pTop + ph / 2;
const cy = cTop + ch / 2;
if (strip && Number(strip.height) > 0) {
const min = Number(strip.top) - LINEAGE_STRIP_TOLERANCE_PX;
const max = Number(strip.top) + Number(strip.height) + LINEAGE_STRIP_TOLERANCE_PX;
if (py < min || py > max || cy < min || cy > max) return null;
// The rail: one track down the empty left gutter, shared by every sibling.
if (!inStripY(parent)) return null;
const visible = children.filter((c) => inStripY(c.rect));
if (visible.length === 0) return { routes };
const minLeft = Math.min(parent.left, ...visible.map((c) => c.rect.left));
const base = strip ? strip.left : minLeft - LINEAGE_VERTICAL_TRACK_INSET_PX * 2;
const trackX = Math.min(
base + LINEAGE_VERTICAL_TRACK_INSET_PX + lane * LINEAGE_VERTICAL_LANE_STEP_PX,
minLeft - LINEAGE_VERTICAL_ANCHOR_CLEARANCE_PX
);
for (const { id, rect } of visible) {
const points = [
[parent.left, parent.cy],
[trackX, parent.cy],
[trackX, rect.cy],
[rect.left, rect.cy],
];
const d = lineagePolylinePath(points, radius);
if (d) routes.push({ id, points: roundPoints(points), d, endX: r1(rect.left), endY: r1(rect.cy) });
}
const stripLeft =
strip && Number.isFinite(Number(strip.left))
? Number(strip.left)
: Math.min(pLeft, cLeft) - LINEAGE_VERTICAL_TRACK_INSET_PX * 2;
const requestedTrack =
stripLeft + LINEAGE_VERTICAL_TRACK_INSET_PX + depth * LINEAGE_VERTICAL_SIBLING_STEP_PX;
const trackX = Math.min(requestedTrack, Math.min(pLeft, cLeft) - LINEAGE_VERTICAL_ANCHOR_CLEARANCE_PX);
const d = `M ${r1(pLeft)} ${r1(py)} H ${r1(trackX)} V ${r1(cy)} H ${r1(cLeft)}`;
return { d, endX: cLeft, endY: cy, sameRow: false };
return { routes };
}
const px = pLeft + pw / 2;
const cx = cLeft + cw / 2;
if (strip && Number(strip.width) > 0) {
const min = Number(strip.left) - LINEAGE_STRIP_TOLERANCE_PX;
const max = Number(strip.left) + Number(strip.width) + LINEAGE_STRIP_TOLERANCE_PX;
if (px < min || px > max || cx < min || cx > max) return null;
if (!inStripX(parent) || !inStripY(parent)) return null;
const visible = children.filter((c) => inStripX(c.rect) && inStripY(c.rect));
if (visible.length === 0) return { routes };
const rows = computeLineageRows([...(input?.tabs || []), parent, ...visible.map((c) => c.rect)]);
const rowOf = (r) => rows.findIndex((row) => r.cy >= row.top - tol && r.cy <= row.bottom + tol);
const laneOffset = (lane - (laneCount - 1) / 2) * LINEAGE_LANE_STEP_PX;
// Y of the gap under row i, this family's lane. The offset is clamped so a busy
// gap never pushes a lane into the tabs on either side of it.
const gapUnder = (i) => {
const row = rows[i];
const next = rows[i + 1];
let bottom;
if (next) bottom = next.top;
else if (strip && strip.bottom > row.bottom + 1) bottom = strip.bottom;
else bottom = row.bottom + LINEAGE_LAST_GAP_PX;
const half = Math.max(0, (bottom - row.bottom) / 2 - 1);
return (row.bottom + bottom) / 2 + Math.max(-half, Math.min(half, laneOffset));
};
const pRow = rowOf(parent);
const gp = gapUnder(pRow);
const tabLefts = (input?.tabs || []).map(lineageRect).filter(Boolean).map((r) => r.left);
const minLeft = Math.min(parent.left, ...visible.map((c) => c.rect.left), ...tabLefts);
const spineBase = strip ? strip.left : minLeft - LINEAGE_SPINE_INSET_PX * 2;
const spineX = Math.min(spineBase + LINEAGE_SPINE_INSET_PX + lane * LINEAGE_SPINE_STEP_PX, minLeft - 2);
for (const { id, rect } of visible) {
const cRow = rowOf(rect);
const gc = gapUnder(cRow);
const points =
cRow === pRow
? [
[parent.cx, parent.bottom],
[parent.cx, gp],
[rect.cx, gp],
[rect.cx, rect.bottom],
]
: [
[parent.cx, parent.bottom],
[parent.cx, gp],
[spineX, gp],
[spineX, gc],
[rect.cx, gc],
[rect.cx, rect.bottom],
];
const d = lineagePolylinePath(points, radius);
if (d) routes.push({ id, points: roundPoints(points), d, endX: r1(rect.cx), endY: r1(rect.bottom) });
}
return { routes };
}
const pBottom = pTop + ph;
const cBottom = cTop + ch;
const sameRow = Math.abs(pTop + ph / 2 - (cTop + ch / 2)) <= Math.min(ph, ch) / 2;
// Both ends anchor on the tab BOTTOM, and the control points hang below the WHOLE
// strip, so one formula covers a flat strip, a wrapped pair, and a same-row pair
// sitting above further rows (see the corridor note above the constants).
const span = Math.abs(cx - px);
const stripBottom =
strip && Number(strip.height) > 0 && Number.isFinite(Number(strip.top))
? Number(strip.top) + Number(strip.height)
: Number.NEGATIVE_INFINITY;
const baseline = Math.max(pBottom, cBottom, stripBottom);
const dip =
Math.min(LINEAGE_DIP_MAX_PX, Math.max(LINEAGE_DIP_MIN_PX, LINEAGE_DIP_BASE_PX + span * LINEAGE_DIP_PER_PX)) +
depth * LINEAGE_SIBLING_STEP_PX;
const yc = baseline + dip;
const d = `M ${r1(px)} ${r1(pBottom)} C ${r1(px)} ${r1(yc)}, ${r1(cx)} ${r1(yc)}, ${r1(cx)} ${r1(cBottom)}`;
return { d, endX: cx, endY: cBottom, sameRow };
function roundPoints(points) {
return points.map(([x, y]) => [r1(x), r1(y)]);
}
// One decimal is plenty for a screen-space path and keeps the `d` string short.
@@ -961,12 +1070,12 @@ if (typeof window !== 'undefined') {
plan: planWsReconnect,
};
window.CodemanLineage = {
computePath: computeLineagePath,
DIP_MIN_PX: LINEAGE_DIP_MIN_PX,
DIP_MAX_PX: LINEAGE_DIP_MAX_PX,
SIBLING_STEP_PX: LINEAGE_SIBLING_STEP_PX,
computeTree: computeLineageTree,
computeRows: computeLineageRows,
CORNER_RADIUS_PX: LINEAGE_CORNER_RADIUS_PX,
LANE_STEP_PX: LINEAGE_LANE_STEP_PX,
SPINE_INSET_PX: LINEAGE_SPINE_INSET_PX,
VERTICAL_TRACK_INSET_PX: LINEAGE_VERTICAL_TRACK_INSET_PX,
VERTICAL_SIBLING_STEP_PX: LINEAGE_VERTICAL_SIBLING_STEP_PX,
COLORS: LINEAGE_COLORS,
};
window.CodemanConnectionLoss = {
+1 -1
View File
@@ -2153,7 +2153,7 @@
<div class="set-row" id="appSettingsLineageLinesItem" data-search="lineage lines spawned worker parent connection">
<div class="set-row-text">
<span class="set-row-label">Spawn Lineage Lines <span class="set-tag">desktop</span></span>
<span class="set-row-desc">Draw a line under the tab strip from a session to the sessions it spawned.</span>
<span class="set-row-desc">For the selected tab, draw lines to the sessions it spawned and to the session that spawned it.</span>
</div>
<label class="switch switch-sm"><input type="checkbox" id="appSettingsLineageLines" checked><span class="slider"></span></label>
</div>
+199 -87
View File
@@ -1,29 +1,39 @@
/**
* @fileoverview Session lineage lines — the arcs joining a tab to the tabs it spawned.
* @fileoverview Session lineage lines: the tree joining a tab to the tabs it spawned.
*
* A session that starts another session (the `codeman` agent skill spawning a worker,
* which passes its own `$CODEMAN_SESSION_ID`) gets `parentSessionId` stamped on its
* state server-side. This module turns that field into the same kind of glowing
* connection line the subagent windows use, but tab → tab, so the strip shows at a
* glance which tab spawned which.
* state server-side. This module turns that field into a quiet orthogonal tree, one per
* spawning tab, routed through the gaps between tab rows so it never crosses a label or
* the terminal (geometry: `CodemanLineage.computeTree` in constants.js).
*
* ONLY THE SELECTED TAB'S FAMILY IS DRAWN: the family the active tab spawned, and the
* family it belongs to as a child. Drawing every family at once is what the owner
* called "too confusing" (2026-10-06: one parent with ten children across five wrapped
* rows). Selection therefore has to redraw, which `_updateActiveTabImmediate()` does
* whenever any lineage exists (`_lineageTotalEdges`).
*
* It is an ADDITIONAL LAYER on the existing SVG pass, not a second pass: the core
* `_updateConnectionLinesImmediate()` (subagent-windows.js) calls
* `_appendLineageConnectionLines(svg, rects)` at its tail, exactly like ultracode does,
* so every layer shares ONE batched read → write reflow and one tab-rect cache.
*
* Two constraints that are not obvious from the code:
* - DESKTOP ONLY. The overlay is `z-index: 999`; the desktop header is 100 (arcs paint
* Constraints that are not obvious from the code:
* - DESKTOP ONLY. The overlay is `z-index: 999`; the desktop header is 100 (lines paint
* over it, which is what lets them touch tab bottoms), but under 1024px mobile.css
* makes the header `position: fixed; z-index: 1200` and would bury them. The phone
* strip is also a scroller where both endpoints are rarely on screen at once.
* - The routing room is RESERVED in CSS (`.session-tabs.lineage-tree`), toggled by
* `_syncLineageGutter()` from `updateTabOverflowMode()`. It keys on whether ANY
* family exists, never on the selection, so switching tabs never resizes the header
* (and with it the terminal and the PTY).
* - Paths carry `data-agent-id="lineage:<childId>"` because that is the attribute
* `_applyLineEntrances()` queries, so the draw-in animation and its
* negative-`animation-delay` resume across `svg.innerHTML = ''` come for free.
*
* @mixin Extends CodemanApp.prototype via Object.assign
* @dependency subagent-windows.js (_updateConnectionLinesImmediate, #connectionLines)
* @dependency constants.js (window.CodemanLineage.computePath + .COLORS)
* @dependency constants.js (window.CodemanLineage.computeTree + .COLORS)
* @dependency settings-ui.js (loadAppSettingsFromStorage, getDefaultSettings)
* @loadorder 15.6 (after ultracode-windows.js — appended to the same SVG pass)
*/
@@ -62,49 +72,96 @@ Object.assign(CodemanApp.prototype, {
applyLineageLineSettings() {
const prev = this._lineageLinesOn;
const next = this._syncLineageLinesEnabled();
if (prev !== next) this.updateConnectionLines();
if (prev !== next) {
// The reserved routing room follows the setting; updateTabOverflowMode()
// re-syncs it and re-measures the wrap with the new padding.
this.updateTabOverflowMode?.();
this.updateConnectionLines();
}
},
/**
* Every parent → child pair worth drawing, with the child's index among its siblings
* (that index is what nests sibling arcs instead of overprinting them).
* Reserve (or release) the strip's routing room: `.lineage-tree` on #sessionTabs
* widens the row gap, pads the bottom for the last row's gap, and opens the spine
* channel on the left of a wrapped strip (styles.css). Called at the top of
* `updateTabOverflowMode()`, so it runs on every tab render, BEFORE the wrap is
* measured.
*
* Walks `sessionOrder` rather than the sessions Map so sibling depth follows the
* strip's own left-to-right order, which is what the user sees.
* Keyed on whether any family exists at all, never on which one is selected: a
* class that followed the selection would grow and shrink the header on every tab
* switch, and the header's height is the terminal's height.
*
* Only the header strip routes through reserved gaps. The vertical rail keeps its
* own `--lineage-vertical-gutter`, and the sidebar draws no lineage.
*/
_syncLineageGutter() {
const edges = this._lineageLinesEnabled() ? this._collectLineageEdges() : [];
this._lineageTotalEdges = edges.length;
const strip = document.getElementById('sessionTabs');
if (!strip) return;
const want = edges.length > 0 && !this._isVerticalTabList?.();
if (strip.classList.contains('lineage-tree') !== want) strip.classList.toggle('lineage-tree', want);
},
/**
* Every parent → child pair, in strip order. Walks `sessionOrder` rather than the
* sessions Map so sibling order follows the strip's own left-to-right order, which
* is what the user sees.
*/
_collectLineageEdges() {
const edges = [];
if (!this.sessions || this.sessions.size < 2) return edges;
const order = this.sessionOrder && this.sessionOrder.length ? this.sessionOrder : [...this.sessions.keys()];
const seenPerParent = new Map();
for (const id of order) {
const session = this.sessions.get(id);
const parentId = session && session.parentSessionId;
// A parent that is gone (closed, or never came back after a restart) draws
// nothing: the field is decoration, so a dangling one is simply not rendered.
if (!parentId || parentId === id || !this.sessions.has(parentId)) continue;
const depth = seenPerParent.get(parentId) || 0;
seenPerParent.set(parentId, depth + 1);
edges.push({ parentId, childId: id, depth, status: session.status || 'idle' });
edges.push({ parentId, childId: id, status: session.status || 'idle' });
}
return edges;
},
/**
* Colour for one arc, from CodemanLineage.COLORS, keyed on the SPAWNING tab.
* The families worth drawing for the current selection, each as
* `{ parentId, edges }` in strip order: the family the selected tab spawned, and
* the family it was spawned into (its parent plus its siblings). Empty when a web
* tab holds the stage, or the selected tab has no lineage at all.
*/
_focusedLineageFamilies(edges) {
const focus = this.activeWebviewId ? null : this.activeSessionId;
if (!focus) return [];
const parents = new Set();
for (const edge of edges) {
if (edge.parentId === focus || edge.childId === focus) parents.add(edge.parentId);
}
const families = new Map();
for (const edge of edges) {
if (!parents.has(edge.parentId)) continue;
if (!families.has(edge.parentId)) families.set(edge.parentId, []);
families.get(edge.parentId).push(edge);
}
return [...families].map(([parentId, familyEdges]) => ({ parentId, edges: familyEdges }));
},
/**
* Colour for one family, from CodemanLineage.COLORS, keyed on the SPAWNING tab.
*
* ⚠️ Per PARENT, not per child: every arc leaving one tab is the same colour, no
* ⚠ Per PARENT, not per child: every line leaving one tab is the same colour, no
* matter how many workers it spawns, so the strip reads as "these five came from
* w1, those two came from w2". Keying it per child instead gave one tab's own
* children a different colour each, which is the thing the colours exist to tell
* apart. A child that goes on to spawn its own workers is a parent in its turn and
* gets its own colour for the arcs BELOW it, so a chain changes colour at each
* gets its own colour for the lines BELOW it, so a chain changes colour at each
* generation while each generation's fan-out stays uniform.
*
* Assigned in FIRST-SEEN order and remembered per parent id. First-seen rather than
* draw-index keeps a colour stable across re-renders, tab reorders and sibling
* closes (the SVG is wiped and rebuilt constantly, so an index-based colour would
* flicker). An empty string means "no override": the CSS falls back to
* flicker). The draw pass claims a colour for EVERY family in strip order, drawn or
* not, so which family happens to be selected first never decides who gets which
* colour. An empty string means "no override": the CSS falls back to
* --session-blue, so the first spawning tab keeps the skin-aware blue.
*/
_lineageColorFor(parentId) {
@@ -140,19 +197,20 @@ Object.assign(CodemanApp.prototype, {
_appendLineageConnectionLines(svg, rects) {
this._lineageEdgeCount = 0;
if (!svg || !this._lineageLinesEnabled()) return;
// Sidebar layout: computeLineagePath()'s whole geometry — the U-bridge hung
// from the STRIP's bottom edge, the 64px dip corridor — assumes a horizontal
// tab row. Against a vertical list the "strip bottom" is the bottom of the
// sidebar, so every arc would draw a giant loop to the foot of the list.
// Parent/child adjacency reads fine in a vertical list without arcs; a
// sideways lineage shape is a follow-up with its own visual tuning, not a
// by-product of a layout port.
// Sidebar layout: the tree is routed for a horizontal strip or the vertical
// rail. The sidebar is a vertical list with its own scroller and no reserved
// channel; parent/child adjacency reads fine there without lines.
if (this.isSessionSidebarActive?.()) return;
const compute = window.CodemanLineage && window.CodemanLineage.computePath;
if (!compute) return;
const computeTree = window.CodemanLineage && window.CodemanLineage.computeTree;
if (!computeTree) return;
const edges = this._collectLineageEdges();
this._lineageTotalEdges = edges.length;
if (edges.length === 0) return;
// Claim colours in strip order for every family, drawn or not (_lineageColorFor).
for (const edge of edges) this._lineageColorFor(edge.parentId);
const families = this._focusedLineageFamilies(edges);
if (families.length === 0) return;
if (!rects) rects = new Map();
// PHASE 1 — reads.
@@ -162,9 +220,9 @@ Object.assign(CodemanApp.prototype, {
const orientation =
document.documentElement.getAttribute('data-tab-orientation') === 'vertical' ? 'vertical' : 'horizontal';
// A session hidden inside a collapsed group of the grouped rail has no row
// to anchor to, so its end of the arc moves to that group's header (a
// "proxied" endpoint, drawn quieter). Two endpoints proxied to the SAME
// header would be an arc from a row to itself: skipped.
// to anchor to, so its end of the line moves to that group's header (a
// "proxied" endpoint, drawn quieter). A child proxied to the same header as
// its parent would be a line from a row to itself: skipped.
const resolveEndpoint = (id) => {
const tab = strip.querySelector(`.session-tab[data-id="${CSS.escape(id)}"]`);
if (tab) return { key: 'tab:' + id, element: tab, proxied: false };
@@ -173,71 +231,112 @@ Object.assign(CodemanApp.prototype, {
const header = strip.querySelector(`[data-tab-group-header="${CSS.escape(groupId)}"]`);
return { key: 'group:' + groupId, element: header, proxied: !!header };
};
const resolvedEdges = [];
for (const edge of edges) {
const parentEndpoint = resolveEndpoint(edge.parentId);
const childEndpoint = resolveEndpoint(edge.childId);
if (parentEndpoint.key === childEndpoint.key) continue;
resolvedEdges.push({ edge, parentEndpoint, childEndpoint });
for (const endpoint of [parentEndpoint, childEndpoint]) {
if (rects.has(endpoint.key)) continue;
const measure = (endpoint) => {
if (!rects.has(endpoint.key)) {
rects.set(endpoint.key, endpoint.element ? endpoint.element.getBoundingClientRect() : null);
}
return rects.get(endpoint.key);
};
const resolvedFamilies = [];
for (const family of families) {
const parentEndpoint = resolveEndpoint(family.parentId);
const parentRect = measure(parentEndpoint);
if (!parentRect) continue;
const children = [];
for (const edge of family.edges) {
const childEndpoint = resolveEndpoint(edge.childId);
if (childEndpoint.key === parentEndpoint.key) continue;
const rect = measure(childEndpoint);
if (rect) children.push({ edge, endpoint: childEndpoint, rect });
}
if (children.length > 0) resolvedFamilies.push({ family, parentEndpoint, parentRect, children });
}
if (resolvedFamilies.length === 0) return;
// The header strip's rows come from EVERY tab in it (computeTree hangs a row's
// gap under its tallest tab), web tabs included. The rail needs none.
const tabRects = [];
if (orientation === 'horizontal') {
for (const tab of strip.querySelectorAll('.session-tab')) {
const id = tab.getAttribute('data-id');
const key = id ? 'tab:' + id : null;
if (key && rects.has(key)) tabRects.push(rects.get(key));
else {
const rect = tab.getBoundingClientRect();
if (key) rects.set(key, rect);
tabRects.push(rect);
}
}
}
this._lineageEdgeCount = resolvedEdges.length;
// PHASE 2 — writes, from the cache only.
for (const { edge, parentEndpoint, childEndpoint } of resolvedEdges) {
const parentRect = rects.get(parentEndpoint.key);
const childRect = rects.get(childEndpoint.key);
if (!parentRect || !childRect) continue;
const geom = compute({
resolvedFamilies.forEach(({ family, parentEndpoint, parentRect, children }, lane) => {
const geom = computeTree({
parent: parentRect,
child: childRect,
children: children.map((c) => ({ id: c.edge.childId, rect: c.rect })),
strip: stripRect,
depth: edge.depth,
tabs: tabRects,
orientation,
lane,
laneCount: resolvedFamilies.length,
});
if (!geom) continue; // scrolled out of the strip, or a degenerate rect
const line = document.createElementNS('http://www.w3.org/2000/svg', 'path');
line.setAttribute('d', geom.d);
// The working class marches the dashes, so an active worker is visible along
// the line itself. `status` is the CHILD's, which is the interesting end.
const working = edge.status === 'working' ? ' lineage-line--working' : '';
const proxied = parentEndpoint.proxied || childEndpoint.proxied;
line.setAttribute('class', 'connection-line lineage-line' + working + (proxied ? ' lineage-line--proxied' : ''));
// The PARENT's colour rides a CSS custom property so the stylesheet keeps owning
// opacity, glow and dash; an empty colour leaves the --session-blue fallback.
// Every arc out of one tab shares it — see _lineageColorFor().
const color = this._lineageColorFor(edge.parentId);
if (color) line.style.setProperty('--lineage-color', color);
// `data-agent-id` is what _applyLineEntrances() queries — see the file header.
line.setAttribute('data-agent-id', 'lineage:' + edge.childId);
line.setAttribute('data-parent-tab', edge.parentId);
line.setAttribute('data-child-tab', edge.childId);
svg.appendChild(line);
// Direction marker at the CHILD end. A circle rather than an SVG <marker>:
// markers need a <defs> block and fight the dash pattern.
const dot = document.createElementNS('http://www.w3.org/2000/svg', 'circle');
dot.setAttribute('cx', String(geom.endX));
dot.setAttribute('cy', String(geom.endY));
// Resting radius; `lineage-dot-pulse` breathes it 3.5 → 4.5 while the child
// works, so the two have to be changed together.
dot.setAttribute('r', '3.5');
dot.setAttribute('class', 'lineage-line-dot' + working + (proxied ? ' lineage-line-dot--proxied' : ''));
dot.setAttribute('data-child-tab', edge.childId);
if (color) dot.style.setProperty('--lineage-color', color);
svg.appendChild(dot);
}
if (!geom || geom.routes.length === 0) return;
const byId = new Map(children.map((c) => [c.edge.childId, c]));
const color = this._lineageColorFor(family.parentId);
// One group per family: it carries the translucency, so the stretches its
// routes share (the trunk) do not stack into a brighter line than the branches.
const group = document.createElementNS('http://www.w3.org/2000/svg', 'g');
group.setAttribute('class', 'lineage-family');
group.setAttribute('data-parent-tab', family.parentId);
// Working routes go in FIRST, so an idle sibling's solid stroke covers the
// shared trunk and only the working child's own branch shows its dashes.
const routes = geom.routes
.map((route) => ({ route, child: byId.get(route.id) }))
.filter((r) => r.child)
.sort((a, b) => (b.child.edge.status === 'working') - (a.child.edge.status === 'working'));
for (const { route, child } of routes) {
const line = document.createElementNS('http://www.w3.org/2000/svg', 'path');
line.setAttribute('d', route.d);
// `status` is the CHILD's, which is the interesting end: a working child's
// route is dashed (and marches, motion permitting).
const working = child.edge.status === 'working' ? ' lineage-line--working' : '';
const proxied = parentEndpoint.proxied || child.endpoint.proxied;
line.setAttribute(
'class',
'connection-line lineage-line' + working + (proxied ? ' lineage-line--proxied' : '')
);
// The PARENT's colour rides a CSS custom property so the stylesheet keeps
// owning weight and dash; an empty colour leaves the --session-blue fallback.
if (color) line.style.setProperty('--lineage-color', color);
// `data-agent-id` is what _applyLineEntrances() queries — see the file header.
line.setAttribute('data-agent-id', 'lineage:' + child.edge.childId);
line.setAttribute('data-parent-tab', family.parentId);
line.setAttribute('data-child-tab', child.edge.childId);
group.appendChild(line);
}
// Direction marker at each CHILD end, after every path so no stroke covers it.
for (const { route, child } of routes) {
const working = child.edge.status === 'working' ? ' lineage-line--working' : '';
const proxied = parentEndpoint.proxied || child.endpoint.proxied;
const dot = document.createElementNS('http://www.w3.org/2000/svg', 'circle');
dot.setAttribute('cx', String(route.endX));
dot.setAttribute('cy', String(route.endY));
// Resting radius; `lineage-dot-pulse` breathes it 2.5 → 3.3 while the child
// works, so the two have to be changed together.
dot.setAttribute('r', '2.5');
dot.setAttribute('class', 'lineage-line-dot' + working + (proxied ? ' lineage-line-dot--proxied' : ''));
dot.setAttribute('data-child-tab', child.edge.childId);
if (color) dot.style.setProperty('--lineage-color', color);
group.appendChild(dot);
}
svg.appendChild(group);
this._lineageEdgeCount += routes.length;
});
},
/**
* The strip scrolls (desktop `overflow-x: auto` and every wrapped layout), and a
* scroll moves both endpoints without firing any render, so the arcs would slide off
* their tabs. Passive listener, and the redraw is the normal coalesced one.
* scroll moves both endpoints without firing any render, so the lines would slide
* off their tabs. Passive listener, and the redraw is the normal coalesced one.
*
* Installed once; the guard also keeps a re-init from stacking listeners.
*/
@@ -248,11 +347,24 @@ Object.assign(CodemanApp.prototype, {
this._lineageScrollHandler = () => {
// Sidebar layout and the vertical rail scroll the SAME element
// vertically, and there the subagent/ultracode connectors anchor to tab
// rects too (the sidebar skips lineage arcs entirely, and the rail can
// show connectors with zero lineage edges, so _lineageEdgeCount alone
// would never redraw them).
// rects too (the sidebar skips lineage entirely, and the rail can show
// connectors with zero lineage edges, so _lineageEdgeCount alone would
// never redraw them).
if (this._lineageEdgeCount > 0 || this._isVerticalTabList?.()) this.updateConnectionLines();
};
strip.addEventListener('scroll', this._lineageScrollHandler, { passive: true });
// ⚠ A SELECTION RESIZES TABS AFTER THE REDRAW. The active tab reveals its gear
// and close icons by transitioning their padding (styles.css), so it keeps
// widening for ~150ms after `_updateActiveTabImmediate()` has already redrawn,
// and the tab it was selected from shrinks. That can move tabs or re-wrap a row,
// which left a family's trunk hanging under the tab's OLD position. Redraw once
// a size transition inside the strip ends (coalesced, like every other redraw).
this._lineageTransitionHandler = (event) => {
const prop = event.propertyName || '';
if (!(prop === 'width' || prop === 'max-width' || prop.startsWith('padding'))) return;
if (this._lineageTotalEdges > 0) this.updateConnectionLines();
};
strip.addEventListener('transitionend', this._lineageTransitionHandler);
},
});
+68 -62
View File
@@ -596,6 +596,22 @@ body {
max-height: var(--tab-strip-max-height, 40vh);
}
/* Lineage routing room (`_syncLineageGutter()` in session-lineage.js, geometry in
computeLineageTree, constants.js). Present only while some tab has spawned
another, and never toggled by the selection, so switching tabs cannot resize
the header (and with it the terminal). The bottom padding is the last row's
gap; a wrapped strip also widens the row gap the routes run in, and opens the
spine channel on the left that joins one row's gap to another's. */
.session-tabs.lineage-tree {
padding-bottom: 14px;
}
.session-tabs.lineage-tree.tabs-two-rows,
.session-tabs.lineage-tree.tabs-auto-wrap {
row-gap: 12px;
padding-left: 20px;
}
.session-tabs::-webkit-scrollbar {
width: 4px;
height: 0;
@@ -1681,9 +1697,10 @@ html[data-line-anim="packet"] .connection-line.line-enter {
⚠ The 100% frame deliberately omits `opacity`, which makes the browser take
the endpoint from the element's own computed value: a subagent line rests at
0.9, a lineage line at 0.72, and a WORKING lineage line at 0.95. Pinning 0.9
here - as `line-enter-fade` above still does - lands every lineage line on the
wrong opacity and snaps it when the class comes off. */
0.9, a lineage line at 1 (its `.lineage-family` group carries the
translucency), a proxied one at 0.5. Pinning 0.9 here - as `line-enter-fade`
above still does - lands every lineage line on the wrong opacity and snaps it
when the class comes off. */
html[data-line-anim="blur"] .connection-line.line-enter {
animation-name: line-enter-blur;
animation-duration: calc(380ms * var(--anim-enter-scale, 1));
@@ -10665,112 +10682,101 @@ kbd {
}
/* ===== Session lineage lines (tab → tab it spawned, session-lineage.js) =====
Deliberately quieter and thinner than the subagent lines above so the two
layers read as different things in the same SVG.
One quiet orthogonal tree per spawning tab, drawn only for the SELECTED tab's
family and routed through the gaps between tab rows (computeLineageTree in
constants.js). The routing room itself is reserved on the strip by
`.session-tabs.lineage-tree` near the top of this file.
⚠ QUIET ON PURPOSE (owner call 2026-10-06, "B2"): 1.5px, solid, rounded
corners and a 1px dark outline in place of the old double glow. The old lines
were glowing dashed beziers that hung into the terminal, and with ten children
across a wrapped strip they read as a tangle. The lines no longer cross text,
so they no longer need a glow to survive it. Dashes now MEAN something: only a
working child's route is dashed.
Colour: every rule reads --lineage-color, which session-lineage.js sets INLINE
per line from the CodemanLineage.COLORS palette (per child, first-seen order,
owner call 2026-08-15: several connected tabs must get several colours). The
FIRST line gets no override, so it falls through to --session-blue, which EVERY
skin block already defines and tunes for its own background; a lone arc therefore
still renders the skin-aware blue that shipped in 1.18.2. Do not add a per-skin
`.lineage-line` override inside the html:not([data-skin="og"]) block: a bare
class rule in there resolves to (0,2,1) and would outrank this one from a
surprising place.
per path and dot from the CodemanLineage.COLORS palette (per SPAWNING tab,
first-seen order). The first spawning tab gets no override and falls through to
--session-blue, which EVERY skin block already defines and tunes for its own
background. Do not add a per-skin `.lineage-line` override inside the
html:not([data-skin="og"]) block: a bare class rule in there resolves to
(0,2,1) and would outrank this one from a surprising place.
Opacity lives on the family GROUP (`.lineage-family`), not on the paths: a
parent's routes overlap exactly along the trunk, and translucent paths would
stack there into a brighter line than the branches. */
.lineage-family {
opacity: 0.85;
}
⚠ BLUE, NOT THE VIOLET THIS SHIPPED WITH (owner call, 2026-08-14: "make these
lines in blue that they are better visible"). Violet sits close to the terminal's
own dim foreground and lost contrast the moment it crossed text. Hue therefore no
longer separates this layer from the subagent lines, so the separation rests
entirely on SHAPE (this one hangs under the strip and never reaches a window),
weight and dash: keep those differences intact. Per skin the two are not even the
same blue, since --session-blue is tuned per palette while the subagent rule
hardcodes #3b82f6. */
/* ⚠ QUIETER THAN THE SUBAGENT LINES, NOT INVISIBLE. The first cut ran 2px at 0.55
with a single 5px glow, which reads on a design mock and disappears on a real
1080p desktop: a faint thread over terminal text, exactly what it is drawn on
top of. The weight stays UNDER the subagent lines' 3px so the two layers still
separate, and the second, wider glow is what buys the contrast instead: it lifts
the line off the terminal without thickening it. Dashes scale with the stroke
(4 4 on a 2.5px line reads as a dotted smudge), and `lineage-flow` marches by
exactly two dash cycles, so it has to move with them. */
.connection-line.lineage-line {
stroke: var(--lineage-color, var(--session-blue, #2b8fd9));
stroke-width: 2.5;
stroke-dasharray: 5 5;
stroke-width: 1.5;
stroke-dasharray: none;
stroke-linecap: round;
opacity: 0.72;
--line-glow: drop-shadow(0 0 2px rgba(0, 0, 0, 0.7))
drop-shadow(0 0 5px var(--lineage-color, var(--session-blue, #2b8fd9)))
drop-shadow(0 0 11px var(--lineage-color, var(--session-blue, #2b8fd9)));
stroke-linejoin: round;
opacity: 1;
--line-glow: drop-shadow(0 0 1px rgba(0, 0, 0, 0.9));
filter: var(--line-glow);
}
/* ⚠ OUTSIDE the reduced-motion block below on purpose. A working child is the case
the line exists to signal, and pairing the brightness with the marching dashes
left every worker's arc at the resting 0.72 for anyone who turns motion off. */
/* A working child's route is dashed even with motion turned off; the march
below is the extra. Routes start at the PARENT, so dashes on the shared trunk
stay in phase between working siblings. */
.connection-line.lineage-line--working {
opacity: 0.95;
}
.connection-line.lineage-line:hover {
opacity: 1;
stroke-width: 3;
stroke-dasharray: 4 3;
}
.lineage-line-dot {
fill: var(--lineage-color, var(--session-blue, #2b8fd9));
opacity: 0.85;
filter: drop-shadow(0 0 4px var(--lineage-color, var(--session-blue, #2b8fd9)))
drop-shadow(0 0 9px var(--lineage-color, var(--session-blue, #2b8fd9)));
filter: drop-shadow(0 0 1px rgba(0, 0, 0, 0.9));
}
/* An arc re-anchored to a collapsed group's header (its row is hidden): quieter
/* A line re-anchored to a collapsed group's header (its row is hidden): quieter
and dotted, so it reads as "somewhere in there" rather than a real row. */
.connection-line.lineage-line--proxied {
stroke-dasharray: 2 7;
opacity: 0.46;
stroke-dasharray: 1 5;
opacity: 0.5;
}
.connection-line.lineage-line--proxied.lineage-line--working {
opacity: 0.62;
opacity: 0.65;
}
.lineage-line-dot.lineage-line-dot--proxied {
opacity: 0.46;
opacity: 0.5;
}
/* The child end marches while that worker is actually working, so the line
itself carries the signal. Motion is opt-out-able at the OS level. */
/* The working route marches from parent to child, so the line itself carries
the signal. Motion is opt-out-able at the OS level. */
@media (prefers-reduced-motion: no-preference) {
.connection-line.lineage-line--working {
animation: lineage-flow 1.1s linear infinite;
}
.lineage-line-dot--working {
opacity: 1;
animation: lineage-dot-pulse 1.4s ease-in-out infinite;
}
}
/* Two full dash cycles, so the march loops seamlessly. Tied to `stroke-dasharray`
above: at `5 5` the cycle is 10px, so this is -20 rather than the -16 that
matched the old `4 4`. Leaving them out of step makes the dashes jump once per
iteration. */
/* Two full dash cycles, so the march loops seamlessly. Tied to the working
`stroke-dasharray` above: at `4 3` the cycle is 7px, so this is -14. Leaving
them out of step makes the dashes jump once per iteration. */
@keyframes lineage-flow {
to {
stroke-dashoffset: -20;
stroke-dashoffset: -14;
}
}
/* Tied to the resting r="2.5" session-lineage.js sets on the dot. */
@keyframes lineage-dot-pulse {
0%, 100% {
opacity: 0.75;
r: 3.5;
r: 2.5;
}
50% {
opacity: 1;
r: 4.5;
r: 3.3;
}
}
+3 -2
View File
@@ -166,8 +166,9 @@ describe('entrance animation styles', () => {
expect(blur).not.toBeNull();
expect(blur.match(/var\(--line-glow\)/g)?.length).toBe(2);
// The 100% frame deliberately omits opacity so the endpoint comes from the
// element's own resting value: 0.9 on a subagent line, 0.72 on a lineage
// line, 0.95 on a working one. Pinning a number here snaps three of them.
// element's own resting value: 0.9 on a subagent line, 1 on a lineage
// line (its family group is translucent), 0.5 on a proxied one. Pinning a
// number here snaps them.
expect(blur).toMatch(/100%\s*\{\s*filter:[^}]*\}/);
expect(blur).not.toMatch(/100%\s*\{[^}]*opacity/);
});
+580 -325
View File
@@ -1,9 +1,14 @@
/**
* Geometry policy for the session lineage lines (tab → tab it spawned).
* Session lineage lines (tab → tab it spawned).
*
* The renderer in session-lineage.js measures and appends; every decision about
* WHAT to draw (and whether to draw at all) lives in computeLineagePath, so it can
* be pinned here without a browser.
* Geometry: every decision about WHERE a route runs lives in computeLineageTree
* (constants.js), so it is pinned here without a browser. The rules that matter:
* a route never crosses a tab, siblings share one trunk, rows are joined through a
* spine left of every tab, and families drawn together sit in separate lanes.
*
* Rendering: session-lineage.js is loaded for real in a vm sandbox with a tiny fake
* DOM, to pin the focus rule (only the SELECTED tab's family is drawn), the
* per-parent colours, and the reserved routing room.
*/
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
@@ -11,376 +16,648 @@ import vm from 'node:vm';
import { describe, expect, it } from 'vitest';
type Rect = { left: number; top: number; width: number; height: number };
type LineagePath = { d: string; endX: number; endY: number; sameRow: boolean } | null;
type Orientation = 'horizontal' | 'vertical';
const lineageJs = readFileSync(resolve(import.meta.dirname, '../src/web/public/session-lineage.js'), 'utf8');
const stylesCss = readFileSync(resolve(import.meta.dirname, '../src/web/public/styles.css'), 'utf8');
function loadLineageHelper() {
const context = vm.createContext({ window: {}, globalThis: {} });
const source = readFileSync(resolve(import.meta.dirname, '../src/web/public/constants.js'), 'utf8');
vm.runInContext(source, context, { filename: 'constants.js' });
return (
context.window as {
CodemanLineage: {
computePath: (input: {
parent: Rect | null;
child: Rect | null;
strip?: Rect;
depth?: number;
orientation?: Orientation;
}) => LineagePath;
DIP_MIN_PX: number;
DIP_MAX_PX: number;
SIBLING_STEP_PX: number;
COLORS: string[];
};
}
).CodemanLineage;
}
/** The vm sandbox session-lineage.js was evaluated in, plus an app instance from it. */
type LineageApp = Record<string, Function> & {
sessions: Map<string, { parentSessionId: string | null; status: string }>;
sessionOrder: string[];
type Point = [number, number];
type Route = { id: string; points: Point[]; d: string; endX: number; endY: number };
type TreeInput = {
parent: Rect | null;
children: Array<{ id: string; rect: Rect | null }>;
strip?: Rect;
tabs?: Rect[];
orientation?: 'horizontal' | 'vertical';
lane?: number;
laneCount?: number;
radius?: number;
};
type LineageSandbox = { document: Record<string, Function>; CSS?: { escape: (v: string) => string } };
type LineageHelper = {
computeTree: (input: TreeInput) => { routes: Route[] } | null;
computeRows: (rects: Rect[]) => Array<{ top: number; bottom: number }>;
CORNER_RADIUS_PX: number;
LANE_STEP_PX: number;
SPINE_INSET_PX: number;
VERTICAL_TRACK_INSET_PX: number;
COLORS: string[];
};
const PUBLIC = resolve(import.meta.dirname, '../src/web/public');
const read = (file: string) => readFileSync(resolve(PUBLIC, file), 'utf8');
const lineageJs = read('session-lineage.js');
const stylesCss = read('styles.css');
const appJs = read('app.js');
function loadLineageHelper(): LineageHelper {
const context = vm.createContext({ window: {}, globalThis: {} });
vm.runInContext(read('constants.js'), context, { filename: 'constants.js' });
return (context.window as { CodemanLineage: LineageHelper }).CodemanLineage;
}
const tab = (left: number, top = 4, width = 120, height = 30): Rect => ({ left, top, width, height });
/**
* Load session-lineage.js for real, with the globals it declares. The colour memo is
* plain state on the app instance, so nothing here needs a browser.
* A wrapped strip shaped like the owner's 2026-10-06 report: five rows, 12px row gap,
* the 20px spine channel reserved on the left, 14px of bottom padding.
*/
function loadLineageApp(): { app: LineageApp; sandbox: LineageSandbox } {
function CodemanApp(this: unknown) {}
const sandbox: Record<string, unknown> = {
window: {},
globalThis: {},
CodemanApp,
MobileDetection: { getDeviceType: () => 'desktop' },
document: {
documentElement: { getAttribute: () => 'horizontal' },
getElementById: () => null,
createElementNS: () => null,
},
};
const context = vm.createContext(sandbox);
for (const file of ['constants.js', 'session-lineage.js']) {
const source = readFileSync(resolve(import.meta.dirname, `../src/web/public/${file}`), 'utf8');
vm.runInContext(source, context, { filename: file });
function wrappedStrip() {
const strip: Rect = { left: 100, top: 0, width: 1000, height: 0 };
const widths = [
[150, 120, 190, 140, 150, 170],
[140, 140, 140, 140, 140, 90, 90],
[130, 110, 100, 100, 110, 120, 140],
[180, 140, 80, 140, 170, 120, 100],
[150, 120, 110, 90, 110, 100, 120],
];
const rows: Rect[][] = [];
let top = 4;
for (const row of widths) {
let left = strip.left + 20;
const cells: Rect[] = [];
for (const w of row) {
cells.push(tab(left, top, w));
left += w + 2;
}
rows.push(cells);
top += 30 + 12;
}
const app = new (CodemanApp as unknown as new () => LineageApp)();
app.sessions = new Map();
app.sessionOrder = [];
return { app, sandbox: sandbox as unknown as LineageSandbox };
strip.height = top - 12 + 14;
return { strip, rows, all: rows.flat() };
}
/**
* Drive the real `_appendLineageConnectionLines()` and report the inline
* `--lineage-color` each arc ended up with, keyed by child.
*
* The colour function alone cannot prove this: passing the CHILD id there would still
* return a stable colour per child and every direct test would pass, which is exactly
* the regression these tests exist to catch.
*/
function renderLineageColors(sessions: Record<string, string | null>): Record<string, string> {
const { app, sandbox } = loadLineageApp();
app.sessions = new Map(
Object.entries(sessions).map(([id, parentSessionId]) => [id, { parentSessionId, status: 'idle' }])
);
app.sessionOrder = Object.keys(sessions);
app._lineageLinesEnabled = () => true;
app.isSessionSidebarActive = () => false;
type Node = { attrs: Record<string, string>; style: Record<string, string> & { setProperty: Function } };
const made: Node[] = [];
const ids = Object.keys(sessions);
const rect = (left: number) => ({ left, top: 4, width: 120, height: 30, right: left + 120, bottom: 34 });
const strip = {
getBoundingClientRect: () => ({ left: 0, top: 0, width: 2000, height: 40, right: 2000, bottom: 40 }),
querySelector: (sel: string) => {
const id = /data-id="([^"]+)"/.exec(sel)?.[1];
const i = id ? ids.indexOf(id) : -1;
return i < 0 ? null : { getBoundingClientRect: () => rect(i * 140) };
},
};
sandbox.document.getElementById = (id: string) => (id === 'sessionTabs' ? strip : null);
sandbox.document.createElementNS = () => {
const style = {} as Record<string, string> & { setProperty: Function };
style.setProperty = (k: string, v: string) => void (style[k] = v);
const node: Node = { attrs: {}, style };
(node as unknown as { setAttribute: Function }).setAttribute = (k: string, v: string) => void (node.attrs[k] = v);
made.push(node);
return node;
};
sandbox.CSS = { escape: (v: string) => v };
app._appendLineageConnectionLines({ appendChild: () => {} }, new Map());
const out: Record<string, string> = {};
for (const node of made) {
// Both the path and its end dot carry the child id; they must agree on the colour.
const child = node.attrs['data-child-tab'];
if (child) out[child] = node.style['--lineage-color'] ?? '';
}
return out;
/** Does an axis-aligned segment pass through the INSIDE of a rect (touching an edge is fine)? */
function crossesRect([x1, y1]: Point, [x2, y2]: Point, r: Rect): boolean {
const eps = 0.5;
const left = r.left + eps;
const right = r.left + r.width - eps;
const top = r.top + eps;
const bottom = r.top + r.height - eps;
if (x1 === x2) return x1 > left && x1 < right && Math.max(y1, y2) > top && Math.min(y1, y2) < bottom;
return y1 > top && y1 < bottom && Math.max(x1, x2) > left && Math.min(x1, x2) < right;
}
// A strip wide enough that nothing is clipped unless a test says so.
const STRIP: Rect = { left: 0, top: 0, width: 1200, height: 40 };
const tab = (left: number, top = 4): Rect => ({ left, top, width: 120, height: 30 });
const center = (r: Rect) => r.left + r.width / 2;
const bottom = (r: Rect) => r.top + r.height;
/** Pull the control-point Y values out of `M x y C x y, x y, x y`. */
function controlYs(d: string): number[] {
const nums = d.match(/-?\d+(\.\d+)?/g)?.map(Number) ?? [];
// M x0 y0 C x1 y1, x2 y2, x3 y3 → indices 3 and 5 are the control Ys
return [nums[3], nums[5]];
}
describe('lineage line geometry', () => {
it('bridges two same-row tabs with an arc that hangs BELOW the strip', () => {
describe('lineage tree geometry: header strip', () => {
it('routes each child from the parent bottom-center to the child bottom-center', () => {
const helper = loadLineageHelper();
const geom = helper.computePath({ parent: tab(0), child: tab(400), strip: STRIP });
const strip: Rect = { left: 0, top: 0, width: 1200, height: 50 };
const parent = tab(0);
const geom = helper.computeTree({
parent,
children: [
{ id: 'a', rect: tab(400) },
{ id: 'b', rect: tab(800) },
],
strip,
})!;
expect(geom).not.toBeNull();
expect(geom!.sameRow).toBe(true);
// Starts at the parent's bottom-center, ends at the child's bottom-center.
expect(geom!.d.startsWith('M 60 34')).toBe(true);
expect(geom!.endX).toBe(460);
expect(geom!.endY).toBe(34);
// Both control points dip below the tab bottoms — that is what makes it a
// bracket under the strip rather than a line drawn across the tabs.
for (const y of controlYs(geom!.d)) expect(y).toBeGreaterThan(34);
expect(geom.routes.map((r) => r.id)).toEqual(['a', 'b']);
for (const route of geom.routes) {
expect(route.points[0]).toEqual([60, 34]);
expect(route.d.startsWith('M 60 34')).toBe(true);
expect(route.d).not.toContain('NaN');
}
expect([geom.routes[0].endX, geom.routes[0].endY]).toEqual([460, 34]);
expect([geom.routes[1].endX, geom.routes[1].endY]).toEqual([860, 34]);
});
it('deepens the dip with distance, but keeps it inside the clamp', () => {
it('keeps a one-row family in the gap under the row, never over the terminal', () => {
const helper = loadLineageHelper();
const near = helper.computePath({ parent: tab(0), child: tab(140), strip: STRIP })!;
const far = helper.computePath({ parent: tab(0), child: tab(1000), strip: STRIP })!;
// Tabs end at 34, the strip (with its reserved bottom padding) at 50.
const strip: Rect = { left: 0, top: 0, width: 1200, height: 50 };
const geom = helper.computeTree({ parent: tab(0), children: [{ id: 'a', rect: tab(400) }], strip })!;
const [route] = geom.routes;
// The dip hangs from the STRIP's bottom edge (40), not the tab bottoms.
const nearDip = controlYs(near.d)[0] - 40;
const farDip = controlYs(far.d)[0] - 40;
expect(farDip).toBeGreaterThan(nearDip);
expect(nearDip).toBeGreaterThanOrEqual(helper.DIP_MIN_PX);
expect(farDip).toBeLessThanOrEqual(helper.DIP_MAX_PX);
});
it('nests siblings by depth so two children of one parent do not overprint', () => {
const helper = loadLineageHelper();
const first = helper.computePath({ parent: tab(0), child: tab(400), strip: STRIP, depth: 0 })!;
const second = helper.computePath({ parent: tab(0), child: tab(400), strip: STRIP, depth: 1 })!;
expect(controlYs(second.d)[0] - controlYs(first.d)[0]).toBe(helper.SIBLING_STEP_PX);
expect(first.d).not.toBe(second.d);
});
it('keeps bending at strip-wide spans instead of flattening into a straight line', () => {
const helper = loadLineageHelper();
// A worker the agent skill starts is appended to the END of the strip, so this
// is the span the feature is actually used at. The corridor has failed in BOTH
// directions: the first 44px clamp read as a flat thread here (#285), and the
// 104px clamp that replaced it bowed deep into the terminal (2026-08-15), so this
// pins the cap exactly rather than just a floor.
const wide = helper.computePath({ parent: tab(0), child: tab(1300), strip: { ...STRIP, width: 1500 } })!;
const near = helper.computePath({ parent: tab(0), child: tab(140), strip: STRIP })!;
const wideDip = controlYs(wide.d)[0] - 40; // from the strip's bottom edge
const nearDip = controlYs(near.d)[0] - 40;
expect(wideDip).toBeGreaterThan(nearDip * 2);
expect(wideDip).toBe(helper.DIP_MAX_PX);
expect(helper.DIP_MAX_PX).toBe(64);
});
it('hangs the dip from the STRIP bottom, so no per-row offset ever stacks on it', () => {
const helper = loadLineageHelper();
const twoRowStrip = { left: 0, top: 0, width: 1200, height: 84 }; // rows at y 4-34 and 48-78
// A wrapped pair (row 1 → row 2) and a same-row pair on ROW 1 of the same strip.
const wrapped = helper.computePath({ parent: tab(0), child: tab(400, 48), strip: twoRowStrip })!;
const row1Pair = helper.computePath({ parent: tab(0), child: tab(400), strip: twoRowStrip })!;
// Both brackets clear the ENTIRE strip: the wrapped one does not add the row
// offset on top (the 2026-08-15 over-bow), and the row-1 pair does not draw
// through row 2's tab labels (the retune's own first-draft regression).
for (const geom of [wrapped, row1Pair]) {
for (const y of controlYs(geom.d)) {
expect(y).toBeGreaterThanOrEqual(84 + helper.DIP_MIN_PX);
expect(y).toBeLessThanOrEqual(84 + helper.DIP_MAX_PX + helper.SIBLING_STEP_PX);
}
expect(route.points).toEqual([
[60, 34],
[60, 42],
[460, 42],
[460, 34],
]);
for (const [, y] of route.points) {
expect(y).toBeGreaterThanOrEqual(34);
expect(y).toBeLessThanOrEqual(50);
}
});
it('exposes a colour palette whose first entry defers to the skin blue', () => {
it('never crosses a tab in a wrapped strip, wherever parent and children sit', () => {
// The reported bug: a parent on row 3 with children on rows 3-5 drew curves
// straight through every lower row's labels and into the terminal.
const helper = loadLineageHelper();
const colors = helper.COLORS;
expect(Array.isArray(colors)).toBe(true);
// '' = no override: session-lineage.js sets no inline --lineage-color and the
// CSS falls back to the skin-tuned --session-blue, so a lone arc stays blue.
expect(colors[0]).toBe('');
expect(colors.length).toBeGreaterThanOrEqual(6);
expect(new Set(colors).size).toBe(colors.length);
for (const c of colors.slice(1)) expect(c).toMatch(/^#[0-9a-f]{6}$/i);
const { strip, rows, all } = wrappedStrip();
const parent = rows[2][1];
const children = [rows[0][3], rows[2][2], rows[2][5], rows[3][0], rows[3][6], rows[4][1], rows[4][6]].map(
(rect, i) => ({ id: `c${i}`, rect })
);
const geom = helper.computeTree({ parent, children, strip, tabs: all })!;
expect(geom.routes).toHaveLength(children.length);
for (const route of geom.routes) {
for (let i = 1; i < route.points.length; i++) {
for (const r of all) {
expect(crossesRect(route.points[i - 1], route.points[i], r), `${route.id} segment ${i} vs tab`).toBe(false);
}
}
// Nothing hangs below the strip into the terminal.
for (const [, y] of route.points) expect(y).toBeLessThanOrEqual(strip.top + strip.height);
}
});
it('brackets a wrapped pair BELOW the lower row rather than inside the row gap', () => {
it('joins rows through one spine, left of every tab and inside the strip', () => {
const helper = loadLineageHelper();
// The reported bug: with the desktop strip wrapped, a parent on row 1 (bottom 34)
// and its child on row 2 (top 48) are 14px apart, and a parent-bottom → child-TOP
// bezier had 14px to bend in, so it drew a flat line hidden in the gap, three
// siblings overprinting each other. Both ends now anchor on the tab BOTTOM and the
// curve hangs below the LOWER row, the same bracket the flat strip gets.
const strip: Rect = { left: 0, top: 0, width: 1200, height: 90 };
const geom = helper.computePath({ parent: tab(0, 4), child: tab(200, 48), strip })!;
const { strip, rows, all } = wrappedStrip();
const geom = helper.computeTree({
parent: rows[2][1],
children: [
{ id: 'below', rect: rows[4][3] },
{ id: 'above', rect: rows[0][2] },
],
strip,
tabs: all,
})!;
const minTabLeft = Math.min(...all.map((r) => r.left));
expect(geom.sameRow).toBe(false);
expect(geom.d.startsWith('M 60 34')).toBe(true); // parent BOTTOM
expect(geom.endY).toBe(78); // child BOTTOM, not its top
// Every control point clears the lower row by at least the minimum dip.
for (const y of controlYs(geom.d)) expect(y).toBeGreaterThanOrEqual(78 + helper.DIP_MIN_PX);
const spines = geom.routes.map((r) => r.points[2][0]);
expect(new Set(spines).size).toBe(1);
expect(spines[0]).toBeGreaterThan(strip.left);
expect(spines[0]).toBeLessThan(minTabLeft);
expect(spines[0]).toBe(strip.left + helper.SPINE_INSET_PX);
});
it('draws the same bracket when the child sits on the row ABOVE its parent', () => {
it('shares one trunk: sibling routes start identically and branch at their own row', () => {
const helper = loadLineageHelper();
const strip: Rect = { left: 0, top: 0, width: 1200, height: 90 };
const geom = helper.computePath({ parent: tab(0, 48), child: tab(200, 4), strip })!;
const { strip, rows, all } = wrappedStrip();
const geom = helper.computeTree({
parent: rows[1][2],
children: [
{ id: 'r3a', rect: rows[3][1] },
{ id: 'r3b', rect: rows[3][4] },
{ id: 'r4', rect: rows[4][2] },
],
strip,
tabs: all,
})!;
const [a, b, c] = geom.routes;
expect(geom.sameRow).toBe(false);
expect(geom.d.startsWith('M 60 78')).toBe(true); // parent BOTTOM
expect(geom.endY).toBe(34); // child BOTTOM
// The parent's row is the lower one here, so that is what the curve clears.
for (const y of controlYs(geom.d)) expect(y).toBeGreaterThanOrEqual(78 + helper.DIP_MIN_PX);
// Parent stem, the run along the parent's gap, and the spine are common to all.
expect(a.points.slice(0, 3)).toEqual(b.points.slice(0, 3));
expect(a.points.slice(0, 3)).toEqual(c.points.slice(0, 3));
// The two row-3 children also share that row's gap line until they branch.
expect(a.points[3]).toEqual(b.points[3]);
expect(a.points[4][1]).toBe(b.points[4][1]);
expect(c.points[3][1]).toBeGreaterThan(a.points[3][1]);
});
it('skips an edge whose tab is scrolled out of the strip', () => {
it("hangs a row's gap under its TALLEST tab, so siblings in one row share a bus", () => {
const helper = loadLineageHelper();
// `.session-tabs` is overflow-x:auto, so a scrolled-out tab still HAS a rect —
// one lying over the logo or the header buttons. It must not be drawn to.
const strip: Rect = { left: 200, top: 0, width: 600, height: 40 };
const strip: Rect = { left: 0, top: 0, width: 1200, height: 52 };
// The active tab is 2px taller than its neighbours.
const tabs = [tab(0), tab(140, 4, 120, 32), tab(280), tab(420)];
const rows = helper.computeRows(tabs);
expect(rows).toEqual([{ top: 4, bottom: 36 }]);
expect(helper.computePath({ parent: tab(-300), child: tab(400), strip })).toBeNull();
expect(helper.computePath({ parent: tab(400), child: tab(1400), strip })).toBeNull();
expect(helper.computePath({ parent: tab(300), child: tab(600), strip })).not.toBeNull();
const geom = helper.computeTree({
parent: tabs[0],
children: [
{ id: 'tall', rect: tabs[1] },
{ id: 'short', rect: tabs[3] },
],
strip,
tabs,
})!;
expect(geom.routes[0].points[2][1]).toBe(geom.routes[1].points[2][1]);
expect(geom.routes[0].points[2][1]).toBe(44);
});
it('returns null for a missing or degenerate rect instead of emitting NaN', () => {
it('puts two families drawn together in separate lanes', () => {
const helper = loadLineageHelper();
const { strip, rows, all } = wrappedStrip();
const input = { parent: rows[2][1], children: [{ id: 'x', rect: rows[3][2] }], strip, tabs: all, laneCount: 2 };
const first = helper.computeTree({ ...input, lane: 0 })!.routes[0];
const second = helper.computeTree({ ...input, lane: 1 })!.routes[0];
expect(helper.computePath({ parent: null, child: tab(0), strip: STRIP })).toBeNull();
expect(helper.computePath({ parent: tab(0), child: null, strip: STRIP })).toBeNull();
expect(
helper.computePath({ parent: { left: 0, top: 0, width: 0, height: 0 }, child: tab(0), strip: STRIP })
).toBeNull();
});
it('routes vertical tabs through the empty left gutter instead of their shared centerline', () => {
const helper = loadLineageHelper();
const strip: Rect = { left: 100, top: 20, width: 320, height: 320 };
const parent: Rect = { left: 132, top: 40, width: 260, height: 40 };
const child: Rect = { left: 132, top: 200, width: 260, height: 40 };
const geom = helper.computePath({ parent, child, strip, orientation: 'vertical' })!;
const nums = geom.d.match(/-?\d+(\.\d+)?/g)?.map(Number) ?? [];
expect(geom).not.toBeNull();
expect(nums).toHaveLength(5);
expect(nums[0]).toBe(parent.left);
expect(nums[1]).toBe(parent.top + parent.height / 2);
expect(nums[2]).toBeGreaterThan(strip.left);
expect(nums[2]).toBeLessThan(parent.left);
expect(nums[3]).toBe(child.top + child.height / 2);
expect(nums[4]).toBe(child.left);
expect(geom.endX).toBe(child.left);
expect(geom.endY).toBe(child.top + child.height / 2);
});
it('offsets vertical sibling tracks without moving either tab endpoint', () => {
const helper = loadLineageHelper();
const strip: Rect = { left: 100, top: 20, width: 320, height: 320 };
const parent: Rect = { left: 132, top: 40, width: 260, height: 40 };
const child: Rect = { left: 132, top: 200, width: 260, height: 40 };
const first = helper.computePath({ parent, child, strip, orientation: 'vertical', depth: 0 })!;
const second = helper.computePath({ parent, child, strip, orientation: 'vertical', depth: 1 })!;
const numbers = (d: string) => d.match(/-?\d+(\.\d+)?/g)?.map(Number) ?? [];
expect(numbers(second.d)[2]).toBeGreaterThan(numbers(first.d)[2]);
expect(second.points[1][1] - first.points[1][1]).toBeCloseTo(helper.LANE_STEP_PX, 1);
expect(second.points[2][0]).toBeGreaterThan(first.points[2][0]);
expect([second.endX, second.endY]).toEqual([first.endX, first.endY]);
});
it('keeps the same gutter shape when the child sits above its parent', () => {
it('rounds corners, and draws them square with radius 0', () => {
const helper = loadLineageHelper();
const strip: Rect = { left: 100, top: 20, width: 320, height: 320 };
const parent: Rect = { left: 132, top: 220, width: 260, height: 40 };
const child: Rect = { left: 132, top: 60, width: 260, height: 40 };
const geom = helper.computePath({ parent, child, strip, orientation: 'vertical' })!;
const nums = geom.d.match(/-?\d+(\.\d+)?/g)?.map(Number) ?? [];
const { strip, rows, all } = wrappedStrip();
const input = { parent: rows[2][1], children: [{ id: 'x', rect: rows[4][4] }], strip, tabs: all };
expect(nums).toEqual([parent.left, 240, expect.any(Number), 80, child.left]);
expect(nums[2]).toBeGreaterThan(strip.left);
expect(nums[2]).toBeLessThan(parent.left);
expect([geom.endX, geom.endY]).toEqual([child.left, 80]);
expect(helper.computeTree(input)!.routes[0].d).toContain(' Q ');
expect(helper.computeTree({ ...input, radius: 0 })!.routes[0].d).not.toContain(' Q ');
expect(helper.CORNER_RADIUS_PX).toBeGreaterThan(0);
});
it('clips vertical lineage by the visible Y range after rail scrolling', () => {
it('leaves out a child scrolled out of the strip, and draws nothing for a scrolled-out parent', () => {
const helper = loadLineageHelper();
const strip: Rect = { left: 100, top: 100, width: 320, height: 300 };
// `.session-tabs` scrolls, so a scrolled-out tab still HAS a rect, one lying
// over the logo or the header buttons. It must not be drawn to.
const strip: Rect = { left: 200, top: 0, width: 600, height: 50 };
const partial = helper.computeTree({
parent: tab(300),
children: [
{ id: 'in', rect: tab(600) },
{ id: 'out', rect: tab(1400) },
],
strip,
})!;
expect(partial.routes.map((r) => r.id)).toEqual(['in']);
expect(helper.computeTree({ parent: tab(-300), children: [{ id: 'a', rect: tab(400) }], strip })).toBeNull();
});
it('returns null for a missing or degenerate parent, and drops a degenerate child', () => {
const helper = loadLineageHelper();
const strip: Rect = { left: 0, top: 0, width: 1200, height: 50 };
expect(helper.computeTree({ parent: null, children: [{ id: 'a', rect: tab(0) }], strip })).toBeNull();
expect(helper.computeTree({ parent: { left: 0, top: 0, width: 0, height: 0 }, children: [], strip })).toBeNull();
const geom = helper.computeTree({
parent: tab(0),
children: [
{ id: 'zero', rect: { left: 400, top: 4, width: 0, height: 30 } },
{ id: 'none', rect: null },
{ id: 'ok', rect: tab(400) },
],
strip,
})!;
expect(geom.routes.map((r) => r.id)).toEqual(['ok']);
});
it('still draws when no strip rect is supplied (clipping is opt-in)', () => {
const helper = loadLineageHelper();
const geom = helper.computeTree({ parent: tab(0), children: [{ id: 'far', rect: tab(9000, 46) }] })!;
expect(geom.routes).toHaveLength(1);
expect(geom.routes[0].d).not.toContain('NaN');
});
});
describe('lineage tree geometry: vertical rail', () => {
const strip: Rect = { left: 100, top: 20, width: 320, height: 320 };
const parent: Rect = { left: 132, top: 40, width: 260, height: 40 };
it('routes every sibling down ONE track in the empty left gutter', () => {
const helper = loadLineageHelper();
const geom = helper.computeTree({
parent,
children: [
{ id: 'a', rect: { ...parent, top: 120 } },
{ id: 'b', rect: { ...parent, top: 200 } },
],
strip,
orientation: 'vertical',
})!;
const tracks = geom.routes.map((r) => r.points[1][0]);
expect(new Set(tracks).size).toBe(1);
expect(tracks[0]).toBe(strip.left + helper.VERTICAL_TRACK_INSET_PX);
expect(tracks[0]).toBeLessThan(parent.left);
for (const route of geom.routes) {
expect(route.points[0]).toEqual([parent.left, 60]);
expect(route.endX).toBe(parent.left);
}
expect(geom.routes.map((r) => r.endY)).toEqual([140, 220]);
});
it('keeps the same shape when the child sits above its parent', () => {
const helper = loadLineageHelper();
const low = { ...parent, top: 220 };
const geom = helper.computeTree({
parent: low,
children: [{ id: 'up', rect: { ...parent, top: 60 } }],
strip,
orientation: 'vertical',
})!;
const [route] = geom.routes;
expect(route.points[0]).toEqual([low.left, 240]);
expect([route.endX, route.endY]).toEqual([low.left, 80]);
});
it('clips by the visible Y range after the rail scrolls', () => {
const helper = loadLineageHelper();
const scrolled: Rect = { left: 100, top: 100, width: 320, height: 300 };
const visible: Rect = { left: 132, top: 160, width: 260, height: 40 };
const above: Rect = { left: 132, top: 20, width: 260, height: 40 };
const below: Rect = { left: 132, top: 460, width: 260, height: 40 };
expect(helper.computePath({ parent: above, child: visible, strip, orientation: 'vertical' })).toBeNull();
expect(helper.computePath({ parent: visible, child: below, strip, orientation: 'vertical' })).toBeNull();
expect(
helper.computePath({ parent: visible, child: { ...visible, top: 300 }, strip, orientation: 'vertical' })
).not.toBeNull();
helper.computeTree({
parent: above,
children: [{ id: 'v', rect: visible }],
strip: scrolled,
orientation: 'vertical',
})
).toBeNull();
expect(
helper.computeTree({
parent: visible,
children: [{ id: 'b', rect: below }],
strip: scrolled,
orientation: 'vertical',
})!.routes
).toEqual([]);
});
});
describe('lineage wiring', () => {
it('passes the resolved DOM orientation into geometry and reserves a vertical gutter', () => {
expect(lineageJs).toContain("getAttribute('data-tab-orientation')");
expect(lineageJs).toMatch(/compute\(\{[\s\S]{0,180}orientation/);
expect(lineageJs).toMatch(/computeTree\(\{[\s\S]{0,260}orientation/);
const selector = "html[data-tab-orientation='vertical'] .tab-rail .session-tabs {";
const verticalRailBlock = stylesCss.slice(stylesCss.indexOf(selector), stylesCss.indexOf(selector) + 600);
expect(verticalRailBlock).toContain('--lineage-vertical-gutter');
expect(verticalRailBlock).toContain('padding-left');
});
it('still draws when no strip rect is supplied (clipping is opt-in)', () => {
const helper = loadLineageHelper();
const geom = helper.computePath({ parent: tab(0), child: tab(9000) });
it('reserves the header strip routing room in CSS', () => {
const block = (selector: string) => {
const start = stylesCss.indexOf(selector);
expect(start, `${selector} not found`).toBeGreaterThan(-1);
return stylesCss.slice(start, stylesCss.indexOf('}', start));
};
expect(block('.session-tabs.lineage-tree {')).toContain('padding-bottom');
const wrapped = block('.session-tabs.lineage-tree.tabs-auto-wrap {');
expect(wrapped).toContain('row-gap');
expect(wrapped).toContain('padding-left');
});
expect(geom).not.toBeNull();
expect(geom!.d).not.toContain('NaN');
it('syncs the routing room before the wrap is measured, and redraws on selection', () => {
const overflow = appJs.slice(appJs.indexOf(' updateTabOverflowMode() {'));
expect(overflow.indexOf('_syncLineageGutter')).toBeGreaterThan(-1);
expect(overflow.indexOf('_syncLineageGutter')).toBeLessThan(overflow.indexOf('shouldAutoWrapTabs'));
const activeTab = appJs.slice(
appJs.indexOf(' _updateActiveTabImmediate(sessionId) {'),
appJs.indexOf(' _scrollActiveTabIntoView(sessionId')
);
expect(activeTab).toMatch(/_lineageTotalEdges > 0\) this\.updateConnectionLines\(\)/);
});
it('exposes a colour palette whose first entry defers to the skin blue', () => {
const colors = loadLineageHelper().COLORS;
// '' = no override: session-lineage.js sets no inline --lineage-color and the
// CSS falls back to the skin-tuned --session-blue, so a lone family stays blue.
expect(colors[0]).toBe('');
expect(colors.length).toBeGreaterThanOrEqual(6);
expect(new Set(colors).size).toBe(colors.length);
for (const c of colors.slice(1)) expect(c).toMatch(/^#[0-9a-f]{6}$/i);
});
});
describe('lineage line colours', () => {
it('gives every arc out of one tab the same colour, however many it spawns', () => {
const { app } = loadLineageApp();
// The renderer calls this with the edge's PARENT id, so ten children of w1 all
// resolve through the same key.
const forW1 = Array.from({ length: 10 }, () => app._lineageColorFor('w1'));
expect(new Set(forW1).size).toBe(1);
// ─── Rendering through the real session-lineage.js ────────────────────────────
type FakeNode = {
tag: string;
attrs: Record<string, string>;
style: Record<string, string> & { setProperty: (k: string, v: string) => void };
children: FakeNode[];
setAttribute: (k: string, v: string) => void;
appendChild: (n: FakeNode) => void;
};
type LineageApp = Record<string, Function> & {
sessions: Map<string, { parentSessionId: string | null; status: string }>;
sessionOrder: string[];
activeSessionId: string | null;
activeWebviewId: string | null;
_lineageEdgeCount: number;
_lineageTotalEdges: number;
};
function fakeNode(tag: string): FakeNode {
const style = {} as FakeNode['style'];
style.setProperty = (k, v) => void (style[k] = v);
const node: FakeNode = {
tag,
attrs: {},
style,
children: [],
setAttribute: (k, v) => void (node.attrs[k] = v),
appendChild: (n) => void node.children.push(n),
};
return node;
}
/**
* Load session-lineage.js for real, on a one-row strip where every session is a
* 120px tab, 140px apart. The colour memo is plain state on the app instance.
*/
function loadLineageApp(sessions: Record<string, string | null>, status: Record<string, string> = {}) {
function CodemanApp(this: unknown) {}
const ids = Object.keys(sessions);
const rect = (i: number) => ({ left: i * 140, top: 4, width: 120, height: 30, right: i * 140 + 120, bottom: 34 });
const stripClasses = new Set<string>();
const listeners: Record<string, (e: { propertyName: string }) => void> = {};
const strip = {
addEventListener: (type: string, fn: (e: { propertyName: string }) => void) => void (listeners[type] = fn),
getBoundingClientRect: () => ({ left: 0, top: 0, width: 4000, height: 50, right: 4000, bottom: 50 }),
querySelector: (sel: string) => {
const id = /data-id="([^"]+)"/.exec(sel)?.[1];
const i = id ? ids.indexOf(id) : -1;
return i < 0 ? null : { getBoundingClientRect: () => rect(i) };
},
querySelectorAll: () => ids.map((id, i) => ({ getAttribute: () => id, getBoundingClientRect: () => rect(i) })),
classList: {
contains: (c: string) => stripClasses.has(c),
toggle: (c: string, on: boolean) => void (on ? stripClasses.add(c) : stripClasses.delete(c)),
},
};
const sandbox: Record<string, unknown> = {
window: {},
globalThis: {},
CodemanApp,
MobileDetection: { getDeviceType: () => 'desktop' },
CSS: { escape: (v: string) => v },
document: {
documentElement: { getAttribute: () => 'horizontal' },
getElementById: (id: string) => (id === 'sessionTabs' ? strip : null),
createElementNS: (_ns: string, tag: string) => fakeNode(tag),
},
};
const context = vm.createContext(sandbox);
for (const file of ['constants.js', 'session-lineage.js']) {
vm.runInContext(read(file), context, { filename: file });
}
const app = new (CodemanApp as unknown as new () => LineageApp)();
app.sessions = new Map(ids.map((id) => [id, { parentSessionId: sessions[id], status: status[id] ?? 'idle' }]));
app.sessionOrder = ids;
app.activeSessionId = null;
app.activeWebviewId = null;
app._lineageLinesEnabled = () => true;
app.isSessionSidebarActive = () => false;
app._isVerticalTabList = () => false;
return { app, stripClasses, listeners };
}
/** Draw with `active` selected; report each family group and its routes. */
function draw(app: LineageApp, active: string | null) {
app.activeSessionId = active;
const svg = fakeNode('svg');
app._appendLineageConnectionLines(svg, new Map());
return svg.children.map((group) => ({
parent: group.attrs['data-parent-tab'],
paths: group.children.filter((n) => n.tag === 'path'),
dots: group.children.filter((n) => n.tag === 'circle'),
}));
}
const childIds = (families: ReturnType<typeof draw>) =>
families.flatMap((f) => f.paths.map((p) => p.attrs['data-child-tab']));
describe('lineage focus: only the selected tab family is drawn', () => {
const fleet = { w1: null, a: 'w1', b: 'w1', w2: null, c: 'w2', w3: null };
it("draws the selected parent's own family and nothing else", () => {
const { app } = loadLineageApp(fleet);
const families = draw(app, 'w1');
expect(families.map((f) => f.parent)).toEqual(['w1']);
expect(childIds(families)).toEqual(['a', 'b']);
expect(app._lineageEdgeCount).toBe(2);
// Every edge still counts toward "is there lineage at all", which is what
// decides the reserved routing room and the redraw on selection.
expect(app._lineageTotalEdges).toBe(3);
});
it('gives a different spawning tab a different colour', () => {
const { app } = loadLineageApp();
expect(app._lineageColorFor('w1')).not.toBe(app._lineageColorFor('w2'));
expect(app._lineageColorFor('w2')).not.toBe(app._lineageColorFor('w3'));
it('shows a selected child its parent and its siblings', () => {
const { app } = loadLineageApp(fleet);
expect(childIds(draw(app, 'a'))).toEqual(['a', 'b']);
expect(childIds(draw(app, 'c'))).toEqual(['c']);
});
it('lets the first spawning tab keep the skin-aware blue', () => {
// '' = no inline override, so styles.css falls back to --session-blue.
expect(loadLineageApp().app._lineageColorFor('w1')).toBe('');
it('shows both families of a tab that is a child AND a parent', () => {
const { app } = loadLineageApp({ w1: null, a: 'w1', x: 'a', y: 'a' });
const families = draw(app, 'a');
expect(families.map((f) => f.parent)).toEqual(['w1', 'a']);
expect(childIds(families)).toEqual(['a', 'x', 'y']);
});
it('changes colour down a chain, since each generation spawns in its own right', () => {
// w1 -> w2 -> w3: the arc w1->w2 is w1's colour, the arc w2->w3 is w2's.
const { app } = loadLineageApp();
expect(app._lineageColorFor('w1')).not.toBe(app._lineageColorFor('w2'));
it('draws nothing for a tab with no lineage, or while a web tab holds the stage', () => {
const { app } = loadLineageApp(fleet);
expect(draw(app, 'w3')).toEqual([]);
app.activeWebviewId = 'dash';
expect(draw(app, 'w1')).toEqual([]);
expect(app._lineageEdgeCount).toBe(0);
});
it('keeps the data-agent-id the entrance animation looks for, and a dot per child', () => {
const { app } = loadLineageApp(fleet);
const [family] = draw(app, 'w1');
expect(family.paths.map((p) => p.attrs['data-agent-id'])).toEqual(['lineage:a', 'lineage:b']);
expect(family.dots.map((d) => d.attrs['data-child-tab'])).toEqual(['a', 'b']);
});
it('puts working routes first, so an idle sibling draws the shared trunk solid', () => {
const { app } = loadLineageApp(fleet, { b: 'working' });
const [family] = draw(app, 'w1');
expect(family.paths.map((p) => p.attrs['data-child-tab'])).toEqual(['b', 'a']);
expect(family.paths[0].attrs.class).toContain('lineage-line--working');
expect(family.paths[1].attrs.class).not.toContain('lineage-line--working');
});
});
describe('lineage routing room', () => {
it('is reserved while any family exists, whatever is selected', () => {
const { app, stripClasses } = loadLineageApp({ w1: null, a: 'w1', w2: null });
app.activeSessionId = 'w2';
app._syncLineageGutter();
expect(stripClasses.has('lineage-tree')).toBe(true);
expect(app._lineageTotalEdges).toBe(1);
});
it('is released with no lineage, with the setting off, and in a vertical list', () => {
const none = loadLineageApp({ w1: null, w2: null });
none.app._syncLineageGutter();
expect(none.stripClasses.has('lineage-tree')).toBe(false);
const off = loadLineageApp({ w1: null, a: 'w1' });
off.app._syncLineageGutter();
expect(off.stripClasses.has('lineage-tree')).toBe(true);
off.app._lineageLinesEnabled = () => false;
off.app._syncLineageGutter();
expect(off.stripClasses.has('lineage-tree')).toBe(false);
expect(off.app._lineageTotalEdges).toBe(0);
const rail = loadLineageApp({ w1: null, a: 'w1' });
rail.app._isVerticalTabList = () => true;
rail.app._syncLineageGutter();
expect(rail.stripClasses.has('lineage-tree')).toBe(false);
});
});
describe('lineage redraw after a selection resizes tabs', () => {
it('redraws when a size transition in the strip ends, and only for size properties', () => {
// The active tab reveals its icons by transitioning padding, so it keeps
// widening after the selection redraw; without this the trunk hung under the
// tab's old position.
const { app, listeners } = loadLineageApp({ w1: null, a: 'w1' });
let redraws = 0;
app.updateConnectionLines = () => void redraws++;
app._lineageTotalEdges = 1;
app._installLineageStripScrollListener();
listeners.transitionend({ propertyName: 'padding-left' });
listeners.transitionend({ propertyName: 'width' });
expect(redraws).toBe(2);
listeners.transitionend({ propertyName: 'opacity' });
listeners.transitionend({ propertyName: 'transform' });
expect(redraws).toBe(2);
app._lineageTotalEdges = 0;
listeners.transitionend({ propertyName: 'padding-left' });
expect(redraws).toBe(2);
});
});
describe('lineage colours', () => {
/** Colour of each drawn child's path, after selecting each tab in `selections` in turn. */
function colorsAfter(sessions: Record<string, string | null>, selections: string[]) {
const { app } = loadLineageApp(sessions);
const out: Record<string, string> = {};
for (const active of selections) {
for (const family of draw(app, active)) {
for (const path of family.paths) {
// The path and its end dot carry the child id; they must agree on the colour.
const child = path.attrs['data-child-tab'];
const dot = family.dots.find((d) => d.attrs['data-child-tab'] === child)!;
expect(dot.style['--lineage-color'] ?? '').toBe(path.style['--lineage-color'] ?? '');
out[child] = path.style['--lineage-color'] ?? '';
}
}
}
return { app, colors: out };
}
it('paints every line out of one tab the same colour', () => {
const { colors } = colorsAfter({ w1: null, a: 'w1', b: 'w1', c: 'w1' }, ['w1']);
expect(Object.keys(colors).sort()).toEqual(['a', 'b', 'c']);
expect(new Set(Object.values(colors)).size).toBe(1);
});
it('paints two spawning tabs in different colours', () => {
const { colors } = colorsAfter({ w1: null, w2: null, a: 'w1', b: 'w1', c: 'w2', d: 'w2' }, ['w1', 'w2']);
expect(colors.a).toBe(colors.b);
expect(colors.c).toBe(colors.d);
expect(colors.a).not.toBe(colors.c);
});
it('assigns colours in strip order, not in the order families get selected', () => {
// Selecting w2's family first must not hand w2 the first (blue) colour.
const { colors } = colorsAfter({ w1: null, a: 'w1', w2: null, c: 'w2' }, ['c', 'a']);
expect(colors.a).toBe('');
expect(colors.c).not.toBe('');
});
it('changes colour at each generation of a chain', () => {
const { colors } = colorsAfter({ w1: null, w2: 'w1', w3: 'w2' }, ['w2']);
expect(colors.w2).not.toBe(colors.w3);
});
it('keeps a tab on its colour across re-renders and interleaved siblings', () => {
// The SVG is wiped and rebuilt constantly, so the colour must come from a memo
// rather than draw order.
const { app } = loadLineageApp();
const { app } = loadLineageApp({});
const first = app._lineageColorFor('w1');
app._lineageColorFor('w2');
app._lineageColorFor('w3');
@@ -388,32 +665,10 @@ describe('lineage line colours', () => {
});
it('cycles the palette once every tab in it has spawned', () => {
const { app } = loadLineageApp();
const { app } = loadLineageApp({});
const palette = loadLineageHelper().COLORS;
const seen = Array.from({ length: palette.length }, (_, i) => app._lineageColorFor(`p${i}`));
expect(new Set(seen).size).toBe(palette.length);
expect(app._lineageColorFor(`p${palette.length}`)).toBe(seen[0]);
});
});
describe('lineage colours, as actually rendered', () => {
it('paints every arc out of one tab the same colour', () => {
// w1 spawns three workers; all three arcs must match.
const colors = renderLineageColors({ w1: null, a: 'w1', b: 'w1', c: 'w1' });
expect(Object.keys(colors).sort()).toEqual(['a', 'b', 'c']);
expect(new Set(Object.values(colors)).size).toBe(1);
});
it('paints two spawning tabs in different colours', () => {
const colors = renderLineageColors({ w1: null, w2: null, a: 'w1', b: 'w1', c: 'w2', d: 'w2' });
expect(colors.a).toBe(colors.b);
expect(colors.c).toBe(colors.d);
expect(colors.a).not.toBe(colors.c);
});
it('changes colour at each generation of a chain', () => {
// w1 -> w2 -> w3. Each arc takes the colour of the tab it leaves.
const colors = renderLineageColors({ w1: null, w2: 'w1', w3: 'w2' });
expect(colors.w2).not.toBe(colors.w3);
});
});