feat(tabs): draw every lineage family, emphasize the selected tab's

Drawing only the selected tab's family hid every other connection until you
clicked into one. All families are drawn again (still as routed trees); the
selected tab's families (what it spawned, and the family it was spawned into)
get .lineage-family--focus: 2.5px instead of 1.5px, full opacity instead of
0.75, a larger end dot, and drawn last so nothing covers them.

Lanes follow strip order (cycling through CodemanLineage.MAX_LANES, 3), never
the selection, so a family's lines never move when you click a tab.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Codeman maintainer
2026-10-07 01:35:42 +02:00
parent 09daf0fe49
commit 107e87e457
10 changed files with 154 additions and 85 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) **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()`) 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) **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 EVERY family, emphasizing the selected tab's (what it spawned, and the family it was spawned into: `lineage-family--focus`, thicker, opaque, drawn last; lanes stay in strip order), 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) **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)
+1 -1
View File
@@ -177,7 +177,7 @@ Further detail: `resumeHistorySession()` is what sends `codexConfig.resumeSessio
**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. **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.
**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). **Every family is drawn, and the SELECTED tab's is emphasized** (2026-10-07): the family the active tab spawned, plus the family it was spawned into (its parent and siblings), get `<g class="lineage-family lineage-family--focus">`: 2.5px instead of 1.5px, full opacity instead of 0.75, a larger end dot, and drawn LAST so no other family covers it. A tab that is both a child and a parent emphasizes both; a web tab or a tab without lineage emphasizes nothing. ⚠️ Drawing ONLY the selected family was built first and rejected by the owner the same day ("I wanna see all the connections always but when I select one … make thicker"), so do not reintroduce a filter. ⚠️ Lanes follow STRIP order (`index % CodemanLineage.MAX_LANES`, 3, since a 12px row gap fits three lanes at 3.5px), never the selection: only the draw order changes, so a family's lines never jump when you click a tab; families sharing a lane are told apart by colour. ⚠️ Because the emphasis 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).
**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". **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".
+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. | | 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. | | Tall Tabs | Taller tab strip. |
| Pop-out Button on Tabs | Adds the detach control to tabs, with a per-tab override. | | Pop-out Button on Tabs | Adds the detach control to tabs, with a per-tab override. |
| 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. | | Spawn Lineage Lines | Lines from each tab to the sessions it spawned; the selected tab's family is drawn thicker. 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). | | 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. | | Overview Home Screen | The phone home screen. On by default. |
+5 -4
View File
@@ -86,10 +86,11 @@ scrolled into view. It is not reordered to the front, so the `Alt+N` numbering s
### Lineage lines ### Lineage lines
When one session spawns another (an agent starting a worker through the API), selecting When one session spawns another (an agent starting a worker through the API), Codeman draws
either tab draws its family: lines from the parent to each child, in the parent's colour, lines from the parent to each child, in the parent's colour, routed through the gaps between
routed through the gaps between tab rows so they never cover a tab or the terminal. A dashed tab rows so they never cover a tab or the terminal. Every family is always shown; selecting
branch means that child is working. It is how a fan-out of eight workers stays readable. a tab draws its own family thicker and brighter. A dashed branch means that child is
working. It is how a fan-out of eight workers stays readable.
While any tab has spawned another, the strip keeps a little extra room between rows for the 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. lines, so switching tabs never changes the header height.
+6 -5
View File
@@ -24,13 +24,14 @@ eight workers looks like a stalled terminal for several minutes.
## Session lineage lines ## Session lineage lines
Select a tab and the tab strip draws lines from it to every tab it spawned, and to the tab The tab strip draws lines from every tab to the tabs it spawned. That covers the other
that spawned it (with its siblings). That covers the other direction of fan-out: not direction of fan-out: not subagents inside one session, but whole sessions started by an
subagents inside one session, but whole sessions started by an agent through the API. agent through the API.
The lines form one tree per spawning tab, in that tab's colour, and run only through the 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 gaps between tab rows, so they never cover a tab name or the terminal. Select a tab and its
means that child is working. Tabs with no lineage draw nothing. family (the tabs it spawned, or its parent and siblings) is drawn thicker and brighter. A
dashed branch means that child is working.
Desktop only, on by default, and toggled in **App Settings → Appearance**. Lines are skipped Desktop only, on by default, and toggled in **App Settings → Appearance**. Lines are skipped
for tabs scrolled out of view. for tabs scrolled out of view.
+11 -7
View File
@@ -300,11 +300,11 @@ function computeTabScrollLeft(input) {
// spawned (a worker started through the codeman agent skill, which passes its own // spawned (a worker started through the codeman agent skill, which passes its own
// id as parentSessionId). Pure: the caller measures and appends, this decides. // id as parentSessionId). Pure: the caller measures and appends, this decides.
// //
// ONE TREE PER SPAWNING TAB, and only the selected tab's family is drawn (that // ONE TREE PER SPAWNING TAB. Every family is drawn, the selected tab's emphasized
// filter lives in session-lineage.js). Every route starts at the PARENT and ends // (session-lineage.js). Every route starts at the PARENT and ends at one child, so
// at one child, so a parent's routes share their first stretch exactly: overlaid, // a parent's routes share their first stretch exactly: overlaid, they read as one
// they read as one trunk with a branch per child, and a dashed (working) route // trunk with a branch per child, and a dashed (working) route stays in phase with
// stays in phase with its siblings along the shared part. // its siblings along the shared part.
// //
// ⚠ ROUTES RUN IN THE GAPS, NEVER THROUGH A TAB. This replaced one bezier per // ⚠ 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 // child hanging below the strip, which in a wrapped strip crossed every lower
@@ -326,9 +326,12 @@ function computeTabScrollLeft(input) {
// rect, lying over the logo or the header buttons. Skipping is honest; clamping // rect, lying over the logo or the header buttons. Skipping is honest; clamping
// would point at a tab that is not there. // would point at a tab that is not there.
const LINEAGE_CORNER_RADIUS_PX = 10; const LINEAGE_CORNER_RADIUS_PX = 10;
// Families drawn together (the selected tab is both a child and a parent) take // Families drawn together take separate lanes: gap lines this far apart, spines
// separate lanes: gap lines this far apart, spines LINEAGE_SPINE_STEP_PX apart. // LINEAGE_SPINE_STEP_PX apart.
const LINEAGE_LANE_STEP_PX = 3.5; const LINEAGE_LANE_STEP_PX = 3.5;
// Every family is drawn at once, and a 12px row gap only fits this many lanes at
// LINEAGE_LANE_STEP_PX; session-lineage.js cycles families through them.
const LINEAGE_MAX_LANES = 3;
const LINEAGE_SPINE_INSET_PX = 6; const LINEAGE_SPINE_INSET_PX = 6;
const LINEAGE_SPINE_STEP_PX = 4; const LINEAGE_SPINE_STEP_PX = 4;
// The last row has no row below it; with no strip rect to measure, its gap is // The last row has no row below it; with no strip rect to measure, its gap is
@@ -1074,6 +1077,7 @@ if (typeof window !== 'undefined') {
computeRows: computeLineageRows, computeRows: computeLineageRows,
CORNER_RADIUS_PX: LINEAGE_CORNER_RADIUS_PX, CORNER_RADIUS_PX: LINEAGE_CORNER_RADIUS_PX,
LANE_STEP_PX: LINEAGE_LANE_STEP_PX, LANE_STEP_PX: LINEAGE_LANE_STEP_PX,
MAX_LANES: LINEAGE_MAX_LANES,
SPINE_INSET_PX: LINEAGE_SPINE_INSET_PX, SPINE_INSET_PX: LINEAGE_SPINE_INSET_PX,
VERTICAL_TRACK_INSET_PX: LINEAGE_VERTICAL_TRACK_INSET_PX, VERTICAL_TRACK_INSET_PX: LINEAGE_VERTICAL_TRACK_INSET_PX,
COLORS: LINEAGE_COLORS, COLORS: LINEAGE_COLORS,
+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" id="appSettingsLineageLinesItem" data-search="lineage lines spawned worker parent connection">
<div class="set-row-text"> <div class="set-row-text">
<span class="set-row-label">Spawn Lineage Lines <span class="set-tag">desktop</span></span> <span class="set-row-label">Spawn Lineage Lines <span class="set-tag">desktop</span></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> <span class="set-row-desc">Draw lines from each session to the sessions it spawned. The selected tab's family is drawn thicker.</span>
</div> </div>
<label class="switch switch-sm"><input type="checkbox" id="appSettingsLineageLines" checked><span class="slider"></span></label> <label class="switch switch-sm"><input type="checkbox" id="appSettingsLineageLines" checked><span class="slider"></span></label>
</div> </div>
+51 -33
View File
@@ -7,11 +7,13 @@
* spawning tab, routed through the gaps between tab rows so it never crosses a label or * spawning tab, routed through the gaps between tab rows so it never crosses a label or
* the terminal (geometry: `CodemanLineage.computeTree` in constants.js). * the terminal (geometry: `CodemanLineage.computeTree` in constants.js).
* *
* ONLY THE SELECTED TAB'S FAMILY IS DRAWN: the family the active tab spawned, and the * EVERY FAMILY IS ALWAYS DRAWN, AND THE SELECTED TAB'S IS EMPHASIZED: the family the
* family it belongs to as a child. Drawing every family at once is what the owner * active tab spawned, and the family it belongs to as a child, get the
* called "too confusing" (2026-10-06: one parent with ten children across five wrapped * `lineage-family--focus` group (thicker, full opacity, drawn last so nothing covers
* rows). Selection therefore has to redraw, which `_updateActiveTabImmediate()` does * it). Drawing ONLY the selected family was tried first and rejected by the owner
* whenever any lineage exists (`_lineageTotalEdges`). * (2026-10-07: "I wanna see all the connections always"). Selection still has to
* redraw to move the emphasis, which `_updateActiveTabImmediate()` does whenever any
* lineage exists (`_lineageTotalEdges`).
* *
* It is an ADDITIONAL LAYER on the existing SVG pass, not a second pass: the core * It is an ADDITIONAL LAYER on the existing SVG pass, not a second pass: the core
* `_updateConnectionLinesImmediate()` (subagent-windows.js) calls * `_updateConnectionLinesImmediate()` (subagent-windows.js) calls
@@ -123,28 +125,31 @@ Object.assign(CodemanApp.prototype, {
return edges; return edges;
}, },
/** /** Every family as `{ parentId, edges }`, in strip order of first appearance. */
* The families worth drawing for the current selection, each as _lineageFamilies(edges) {
* `{ 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(); const families = new Map();
for (const edge of edges) { for (const edge of edges) {
if (!parents.has(edge.parentId)) continue;
if (!families.has(edge.parentId)) families.set(edge.parentId, []); if (!families.has(edge.parentId)) families.set(edge.parentId, []);
families.get(edge.parentId).push(edge); families.get(edge.parentId).push(edge);
} }
return [...families].map(([parentId, familyEdges]) => ({ parentId, edges: familyEdges })); return [...families].map(([parentId, familyEdges]) => ({ parentId, edges: familyEdges }));
}, },
/**
* Parent ids of the families the selection emphasizes: 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.
*/
_lineageFocusParents(edges) {
const parents = new Set();
const focus = this.activeWebviewId ? null : this.activeSessionId;
if (!focus) return parents;
for (const edge of edges) {
if (edge.parentId === focus || edge.childId === focus) parents.add(edge.parentId);
}
return parents;
},
/** /**
* Colour for one family, from CodemanLineage.COLORS, keyed on the SPAWNING tab. * Colour for one family, from CodemanLineage.COLORS, keyed on the SPAWNING tab.
* *
@@ -159,9 +164,9 @@ Object.assign(CodemanApp.prototype, {
* Assigned in FIRST-SEEN order and remembered per parent id. First-seen rather than * 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 * 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 * closes (the SVG is wiped and rebuilt constantly, so an index-based colour would
* flicker). The draw pass claims a colour for EVERY family in strip order, drawn or * flicker). The draw pass claims a colour for EVERY family in strip order before it
* not, so which family happens to be selected first never decides who gets which * draws anything, so neither the draw order (the selected family goes last) nor
* colour. An empty string means "no override": the CSS falls back to * which family was selected first ever 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. * --session-blue, so the first spawning tab keeps the skin-aware blue.
*/ */
_lineageColorFor(parentId) { _lineageColorFor(parentId) {
@@ -207,10 +212,10 @@ Object.assign(CodemanApp.prototype, {
const edges = this._collectLineageEdges(); const edges = this._collectLineageEdges();
this._lineageTotalEdges = edges.length; this._lineageTotalEdges = edges.length;
if (edges.length === 0) return; if (edges.length === 0) return;
// Claim colours in strip order for every family, drawn or not (_lineageColorFor). // Claim colours in strip order for every family before drawing (_lineageColorFor).
for (const edge of edges) this._lineageColorFor(edge.parentId); for (const edge of edges) this._lineageColorFor(edge.parentId);
const families = this._focusedLineageFamilies(edges); const families = this._lineageFamilies(edges);
if (families.length === 0) return; const focusParents = this._lineageFocusParents(edges);
if (!rects) rects = new Map(); if (!rects) rects = new Map();
// PHASE 1 — reads. // PHASE 1 — reads.
@@ -269,7 +274,19 @@ Object.assign(CodemanApp.prototype, {
} }
// PHASE 2 — writes, from the cache only. // PHASE 2 — writes, from the cache only.
resolvedFamilies.forEach(({ family, parentEndpoint, parentRect, children }, lane) => { // Lanes follow STRIP order, so a family keeps its lane when the selection moves;
// only the DRAW order changes (the emphasized families last, on top). A row gap
// fits a few lanes, so they cycle: families that share one are told apart by colour.
const laneLimit = Math.max(1, (window.CodemanLineage && window.CodemanLineage.MAX_LANES) || 3);
const laneCount = Math.min(laneLimit, resolvedFamilies.length);
const drawOrder = resolvedFamilies
.map((resolved, index) => ({
...resolved,
lane: index % laneCount,
focus: focusParents.has(resolved.family.parentId),
}))
.sort((a, b) => a.focus - b.focus);
for (const { family, parentEndpoint, parentRect, children, lane, focus } of drawOrder) {
const geom = computeTree({ const geom = computeTree({
parent: parentRect, parent: parentRect,
children: children.map((c) => ({ id: c.edge.childId, rect: c.rect })), children: children.map((c) => ({ id: c.edge.childId, rect: c.rect })),
@@ -277,15 +294,16 @@ Object.assign(CodemanApp.prototype, {
tabs: tabRects, tabs: tabRects,
orientation, orientation,
lane, lane,
laneCount: resolvedFamilies.length, laneCount,
}); });
if (!geom || geom.routes.length === 0) return; if (!geom || geom.routes.length === 0) continue;
const byId = new Map(children.map((c) => [c.edge.childId, c])); const byId = new Map(children.map((c) => [c.edge.childId, c]));
const color = this._lineageColorFor(family.parentId); const color = this._lineageColorFor(family.parentId);
// One group per family: it carries the translucency, so the stretches its // 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. // routes share (the trunk) do not stack into a brighter line than the branches,
// and the emphasis for the selected tab's families (styles.css).
const group = document.createElementNS('http://www.w3.org/2000/svg', 'g'); const group = document.createElementNS('http://www.w3.org/2000/svg', 'g');
group.setAttribute('class', 'lineage-family'); group.setAttribute('class', 'lineage-family' + (focus ? ' lineage-family--focus' : ''));
group.setAttribute('data-parent-tab', family.parentId); group.setAttribute('data-parent-tab', family.parentId);
// Working routes go in FIRST, so an idle sibling's solid stroke covers the // 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. // shared trunk and only the working child's own branch shows its dashes.
@@ -320,8 +338,8 @@ Object.assign(CodemanApp.prototype, {
const dot = document.createElementNS('http://www.w3.org/2000/svg', 'circle'); const dot = document.createElementNS('http://www.w3.org/2000/svg', 'circle');
dot.setAttribute('cx', String(route.endX)); dot.setAttribute('cx', String(route.endX));
dot.setAttribute('cy', String(route.endY)); dot.setAttribute('cy', String(route.endY));
// Resting radius; `lineage-dot-pulse` breathes it 2.5 → 3.3 while the child // Fallback radius only: styles.css sizes the dot through `--lineage-dot-r`
// works, so the two have to be changed together. // (larger in an emphasized family), and `lineage-dot-pulse` breathes from it.
dot.setAttribute('r', '2.5'); dot.setAttribute('r', '2.5');
dot.setAttribute('class', 'lineage-line-dot' + working + (proxied ? ' lineage-line-dot--proxied' : '')); dot.setAttribute('class', 'lineage-line-dot' + working + (proxied ? ' lineage-line-dot--proxied' : ''));
dot.setAttribute('data-child-tab', child.edge.childId); dot.setAttribute('data-child-tab', child.edge.childId);
@@ -330,7 +348,7 @@ Object.assign(CodemanApp.prototype, {
} }
svg.appendChild(group); svg.appendChild(group);
this._lineageEdgeCount += routes.length; this._lineageEdgeCount += routes.length;
}); }
}, },
/** /**
+27 -7
View File
@@ -10682,9 +10682,12 @@ kbd {
} }
/* ===== Session lineage lines (tab → tab it spawned, session-lineage.js) ===== /* ===== Session lineage lines (tab → tab it spawned, session-lineage.js) =====
One quiet orthogonal tree per spawning tab, drawn only for the SELECTED tab's One quiet orthogonal tree per spawning tab, every family always drawn and
family and routed through the gaps between tab rows (computeLineageTree in routed through the gaps between tab rows (computeLineageTree in constants.js).
constants.js). The routing room itself is reserved on the strip by The SELECTED tab's families (what it spawned, and the family it was spawned
into) are emphasized by `.lineage-family--focus`: thicker, full opacity, a
larger end dot, drawn last (owner call 2026-10-07: see every connection always,
thicker when selected). The routing room itself is reserved on the strip by
`.session-tabs.lineage-tree` near the top of this file. `.session-tabs.lineage-tree` near the top of this file.
⚠ QUIET ON PURPOSE (owner call 2026-10-06, "B2"): 1.5px, solid, rounded ⚠ QUIET ON PURPOSE (owner call 2026-10-06, "B2"): 1.5px, solid, rounded
@@ -10706,7 +10709,11 @@ kbd {
parent's routes overlap exactly along the trunk, and translucent paths would parent's routes overlap exactly along the trunk, and translucent paths would
stack there into a brighter line than the branches. */ stack there into a brighter line than the branches. */
.lineage-family { .lineage-family {
opacity: 0.85; opacity: 0.75;
}
.lineage-family--focus {
opacity: 1;
} }
.connection-line.lineage-line { .connection-line.lineage-line {
@@ -10720,6 +10727,11 @@ kbd {
filter: var(--line-glow); filter: var(--line-glow);
} }
/* The selected tab's families: thicker (the group above makes them opaque). */
.lineage-family--focus .connection-line.lineage-line {
stroke-width: 2.5;
}
/* A working child's route is dashed even with motion turned off; the march /* 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 below is the extra. Routes start at the PARENT, so dashes on the shared trunk
stay in phase between working siblings. */ stay in phase between working siblings. */
@@ -10727,11 +10739,19 @@ kbd {
stroke-dasharray: 4 3; stroke-dasharray: 4 3;
} }
/* The radius rides a variable so the pulse below can breathe from whichever size
the dot rests at; the `r` attribute session-lineage.js sets is only a fallback. */
.lineage-line-dot { .lineage-line-dot {
--lineage-dot-r: 2.5px;
r: var(--lineage-dot-r);
fill: var(--lineage-color, var(--session-blue, #2b8fd9)); fill: var(--lineage-color, var(--session-blue, #2b8fd9));
filter: drop-shadow(0 0 1px rgba(0, 0, 0, 0.9)); filter: drop-shadow(0 0 1px rgba(0, 0, 0, 0.9));
} }
.lineage-family--focus .lineage-line-dot {
--lineage-dot-r: 3.5px;
}
/* A line 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. */ and dotted, so it reads as "somewhere in there" rather than a real row. */
.connection-line.lineage-line--proxied { .connection-line.lineage-line--proxied {
@@ -10768,15 +10788,15 @@ kbd {
} }
} }
/* Tied to the resting r="2.5" session-lineage.js sets on the dot. */ /* Breathes from the dot's own resting radius (`--lineage-dot-r`). */
@keyframes lineage-dot-pulse { @keyframes lineage-dot-pulse {
0%, 100% { 0%, 100% {
opacity: 0.75; opacity: 0.75;
r: 2.5; r: var(--lineage-dot-r);
} }
50% { 50% {
opacity: 1; opacity: 1;
r: 3.3; r: calc(var(--lineage-dot-r) + 0.8px);
} }
} }
+50 -25
View File
@@ -497,66 +497,91 @@ function draw(app: LineageApp, active: string | null) {
app._appendLineageConnectionLines(svg, new Map()); app._appendLineageConnectionLines(svg, new Map());
return svg.children.map((group) => ({ return svg.children.map((group) => ({
parent: group.attrs['data-parent-tab'], parent: group.attrs['data-parent-tab'],
focus: group.attrs.class.split(' ').includes('lineage-family--focus'),
paths: group.children.filter((n) => n.tag === 'path'), paths: group.children.filter((n) => n.tag === 'path'),
dots: group.children.filter((n) => n.tag === 'circle'), dots: group.children.filter((n) => n.tag === 'circle'),
})); }));
} }
const family = (families: ReturnType<typeof draw>, parent: string) => families.find((f) => f.parent === parent)!;
const childIds = (families: ReturnType<typeof draw>) => const childIds = (families: ReturnType<typeof draw>) =>
families.flatMap((f) => f.paths.map((p) => p.attrs['data-child-tab'])); families.flatMap((f) => f.paths.map((p) => p.attrs['data-child-tab']));
describe('lineage focus: only the selected tab family is drawn', () => { describe('lineage focus: every family is drawn, the selected one emphasized', () => {
const fleet = { w1: null, a: 'w1', b: 'w1', w2: null, c: 'w2', w3: null }; const fleet = { w1: null, a: 'w1', b: 'w1', w2: null, c: 'w2', w3: null };
const focused = (families: ReturnType<typeof draw>) => families.filter((f) => f.focus).map((f) => f.parent);
it("draws the selected parent's own family and nothing else", () => { it('draws every family whichever tab is selected, the selected one last and emphasized', () => {
const { app } = loadLineageApp(fleet); const { app } = loadLineageApp(fleet);
const families = draw(app, 'w1'); const families = draw(app, 'w1');
expect(families.map((f) => f.parent)).toEqual(['w1']); // Drawn last, so no other family's line covers it.
expect(childIds(families)).toEqual(['a', 'b']); expect(families.map((f) => f.parent)).toEqual(['w2', 'w1']);
expect(app._lineageEdgeCount).toBe(2); expect(focused(families)).toEqual(['w1']);
// Every edge still counts toward "is there lineage at all", which is what expect(childIds(families).sort()).toEqual(['a', 'b', 'c']);
// decides the reserved routing room and the redraw on selection. expect(app._lineageEdgeCount).toBe(3);
expect(app._lineageTotalEdges).toBe(3); expect(app._lineageTotalEdges).toBe(3);
}); });
it('shows a selected child its parent and its siblings', () => { it('emphasizes the parent and siblings of a selected child', () => {
const { app } = loadLineageApp(fleet); const { app } = loadLineageApp(fleet);
expect(childIds(draw(app, 'a'))).toEqual(['a', 'b']); expect(focused(draw(app, 'a'))).toEqual(['w1']);
expect(childIds(draw(app, 'c'))).toEqual(['c']); expect(focused(draw(app, 'c'))).toEqual(['w2']);
}); });
it('shows both families of a tab that is a child AND a parent', () => { it('emphasizes both families of a tab that is a child AND a parent', () => {
const { app } = loadLineageApp({ w1: null, a: 'w1', x: 'a', y: 'a' }); const { app } = loadLineageApp({ w1: null, a: 'w1', x: 'a', y: 'a', w2: null, c: 'w2' });
const families = draw(app, 'a'); const families = draw(app, 'a');
expect(families.map((f) => f.parent)).toEqual(['w1', 'a']); expect(focused(families)).toEqual(['w1', 'a']);
expect(childIds(families)).toEqual(['a', 'x', 'y']); expect(families.map((f) => f.parent)).toEqual(['w2', 'w1', 'a']);
}); });
it('draws nothing for a tab with no lineage, or while a web tab holds the stage', () => { it('still draws everything, emphasizing nothing, for a tab without lineage or a web tab', () => {
const { app } = loadLineageApp(fleet); const { app } = loadLineageApp(fleet);
expect(draw(app, 'w3')).toEqual([]); const plain = draw(app, 'w3');
expect(plain.map((f) => f.parent)).toEqual(['w1', 'w2']);
expect(focused(plain)).toEqual([]);
app.activeWebviewId = 'dash'; app.activeWebviewId = 'dash';
expect(draw(app, 'w1')).toEqual([]); const web = draw(app, 'w1');
expect(app._lineageEdgeCount).toBe(0); expect(web).toHaveLength(2);
expect(focused(web)).toEqual([]);
});
it('keeps every family in its lane when the selection moves', () => {
// Lanes follow strip order, so selecting another tab changes only the emphasis
// and the draw order, never where a family's lines run.
const { app } = loadLineageApp(fleet);
const before = family(draw(app, 'w1'), 'w2').paths.map((p) => p.attrs.d);
const after = family(draw(app, 'w2'), 'w2').paths.map((p) => p.attrs.d);
expect(after).toEqual(before);
}); });
it('keeps the data-agent-id the entrance animation looks for, and a dot per child', () => { it('keeps the data-agent-id the entrance animation looks for, and a dot per child', () => {
const { app } = loadLineageApp(fleet); const { app } = loadLineageApp(fleet);
const [family] = draw(app, 'w1'); const w1 = family(draw(app, 'w1'), 'w1');
expect(family.paths.map((p) => p.attrs['data-agent-id'])).toEqual(['lineage:a', 'lineage:b']); expect(w1.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']); expect(w1.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', () => { it('puts working routes first, so an idle sibling draws the shared trunk solid', () => {
const { app } = loadLineageApp(fleet, { b: 'working' }); const { app } = loadLineageApp(fleet, { b: 'working' });
const [family] = draw(app, 'w1'); const w1 = family(draw(app, 'w1'), 'w1');
expect(family.paths.map((p) => p.attrs['data-child-tab'])).toEqual(['b', 'a']); expect(w1.paths.map((p) => p.attrs['data-child-tab'])).toEqual(['b', 'a']);
expect(family.paths[0].attrs.class).toContain('lineage-line--working'); expect(w1.paths[0].attrs.class).toContain('lineage-line--working');
expect(family.paths[1].attrs.class).not.toContain('lineage-line--working'); expect(w1.paths[1].attrs.class).not.toContain('lineage-line--working');
});
it('makes the emphasized family thicker in CSS, after the base rule', () => {
const base = stylesCss.indexOf('.connection-line.lineage-line {');
const focus = stylesCss.indexOf('.lineage-family--focus .connection-line.lineage-line {');
expect(base).toBeGreaterThan(-1);
expect(focus).toBeGreaterThan(base);
const width = (at: number) => Number(/stroke-width:\s*([\d.]+)/.exec(stylesCss.slice(at))![1]);
expect(width(focus)).toBeGreaterThan(width(base));
}); });
}); });