From 76ea090a671d56e7958902005ab9e99b4a6545da Mon Sep 17 00:00:00 2001 From: Codeman maintainer Date: Tue, 18 Aug 2026 16:20:43 +0200 Subject: [PATCH] fix(ui): bind lineage line colours to the spawning tab Lineage arcs were coloured per child, so one tab's own workers each got a different colour, which is the distinction the colours exist to make. The colour is now keyed on the parent: every arc leaving one tab is the same colour however many workers it spawns, so the strip reads as "these five came from w1, those two came from w2". A child that spawns in turn is a parent in its own right and gets its own colour for the arcs below it, so a chain changes colour at each generation while each generation's fan-out stays uniform. The new tests drive the real _appendLineageConnectionLines() and assert the painted custom property, because testing the colour function alone passes just as happily with the child id passed back in. Co-Authored-By: Claude Opus 5 (1M context) --- src/web/public/constants.js | 4 +- src/web/public/session-lineage.js | 41 +++++--- test/session-lineage-lines.test.ts | 149 +++++++++++++++++++++++++++++ 3 files changed, 178 insertions(+), 16 deletions(-) diff --git a/src/web/public/constants.js b/src/web/public/constants.js index 4b97711e..20df330a 100644 --- a/src/web/public/constants.js +++ b/src/web/public/constants.js @@ -243,7 +243,9 @@ const LINEAGE_DIP_MAX_PX = 64; // apart bled into one thick band instead of reading as three separate lines. const LINEAGE_SIBLING_STEP_PX = 8; const LINEAGE_STRIP_TOLERANCE_PX = 4; -// Lineage palette, assigned per CHILD in first-seen order and cycled (session-lineage.js). +// 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. // 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 // skin-aware blue that shipped in 1.18.2. The fixed entries are deliberately vivid diff --git a/src/web/public/session-lineage.js b/src/web/public/session-lineage.js index d625d08f..d0ba8cfc 100644 --- a/src/web/public/session-lineage.js +++ b/src/web/public/session-lineage.js @@ -91,28 +91,38 @@ Object.assign(CodemanApp.prototype, { }, /** - * Colour for one child's arc, from CodemanLineage.COLORS, assigned in FIRST-SEEN - * order and remembered per child id. First-seen rather than draw-index keeps a - * line's 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 --session-blue. + * Colour for one arc, from CodemanLineage.COLORS, keyed on the SPAWNING tab. + * + * ⚠️ Per PARENT, not per child: every arc 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 + * 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 + * --session-blue, so the first spawning tab keeps the skin-aware blue. */ - _lineageColorFor(childId) { + _lineageColorFor(parentId) { const palette = (window.CodemanLineage && window.CodemanLineage.COLORS) || []; if (palette.length === 0) return ''; - if (!this._lineageColorByChild) { - this._lineageColorByChild = new Map(); + if (!this._lineageColorByParent) { + this._lineageColorByParent = new Map(); this._lineageColorNext = 0; } - let idx = this._lineageColorByChild.get(childId); + let idx = this._lineageColorByParent.get(parentId); if (idx === undefined) { idx = this._lineageColorNext++ % palette.length; - this._lineageColorByChild.set(childId, idx); + this._lineageColorByParent.set(parentId, idx); // Bounded: entries for long-gone sessions are pruned once the map is clearly // stale, so a day-long dashboard cannot grow it without limit. - if (this._lineageColorByChild.size > 200 && this.sessions) { - for (const key of this._lineageColorByChild.keys()) { - if (!this.sessions.has(key)) this._lineageColorByChild.delete(key); + if (this._lineageColorByParent.size > 200 && this.sessions) { + for (const key of this._lineageColorByParent.keys()) { + if (!this.sessions.has(key)) this._lineageColorByParent.delete(key); } } } @@ -174,9 +184,10 @@ Object.assign(CodemanApp.prototype, { // the line itself. `status` is the CHILD's, which is the interesting end. const working = edge.status === 'working' ? ' lineage-line--working' : ''; line.setAttribute('class', 'connection-line lineage-line' + working); - // Per-child colour rides a CSS custom property so the stylesheet keeps owning + // 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. - const color = this._lineageColorFor(edge.childId); + // 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); diff --git a/test/session-lineage-lines.test.ts b/test/session-lineage-lines.test.ts index fb505c86..a3236ffc 100644 --- a/test/session-lineage-lines.test.ts +++ b/test/session-lineage-lines.test.ts @@ -30,6 +30,88 @@ function loadLineageHelper() { ).CodemanLineage; } +/** The vm sandbox session-lineage.js was evaluated in, plus an app instance from it. */ +type LineageApp = Record & { + sessions: Map; + sessionOrder: string[]; +}; +type LineageSandbox = { document: Record; CSS?: { escape: (v: string) => string } }; + +/** + * 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. + */ +function loadLineageApp(): { app: LineageApp; sandbox: LineageSandbox } { + function CodemanApp(this: unknown) {} + const sandbox: Record = { + window: {}, + globalThis: {}, + CodemanApp, + MobileDetection: { getDeviceType: () => 'desktop' }, + document: { 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 }); + } + const app = new (CodemanApp as unknown as new () => LineageApp)(); + app.sessions = new Map(); + app.sessionOrder = []; + return { app, sandbox: sandbox as unknown as LineageSandbox }; +} + +/** + * 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): Record { + 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; style: Record & { 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 & { 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 = {}; + 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; +} + // 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 }); @@ -184,3 +266,70 @@ describe('lineage line geometry', () => { expect(geom!.d).not.toContain('NaN'); }); }); + +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); + }); + + 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('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('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('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 first = app._lineageColorFor('w1'); + app._lineageColorFor('w2'); + app._lineageColorFor('w3'); + expect(app._lineageColorFor('w1')).toBe(first); + }); + + it('cycles the palette once every tab in it has spawned', () => { + 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); + }); +});