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) <noreply@anthropic.com>
This commit is contained in:
Codeman maintainer
2026-08-18 16:20:43 +02:00
parent d30cac4440
commit 76ea090a67
3 changed files with 178 additions and 16 deletions
+3 -1
View File
@@ -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
+26 -15
View File
@@ -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);
+149
View File
@@ -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<string, Function> & {
sessions: Map<string, { parentSessionId: string | null; status: string }>;
sessionOrder: string[];
};
type LineageSandbox = { document: Record<string, Function>; 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<string, unknown> = {
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<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;
}
// 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);
});
});