mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-08 08:29:42 +02:00
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>
675 lines
27 KiB
TypeScript
675 lines
27 KiB
TypeScript
/**
|
|
* Session lineage lines (tab → tab it spawned).
|
|
*
|
|
* 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';
|
|
import vm from 'node:vm';
|
|
import { describe, expect, it } from 'vitest';
|
|
|
|
type Rect = { left: number; top: number; width: number; height: number };
|
|
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 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 });
|
|
|
|
/**
|
|
* 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 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;
|
|
}
|
|
strip.height = top - 12 + 14;
|
|
return { strip, rows, all: rows.flat() };
|
|
}
|
|
|
|
/** 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;
|
|
}
|
|
|
|
const center = (r: Rect) => r.left + r.width / 2;
|
|
const bottom = (r: Rect) => r.top + r.height;
|
|
|
|
describe('lineage tree geometry: header strip', () => {
|
|
it('routes each child from the parent bottom-center to the child bottom-center', () => {
|
|
const helper = loadLineageHelper();
|
|
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.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('keeps a one-row family in the gap under the row, never over the terminal', () => {
|
|
const helper = loadLineageHelper();
|
|
// 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;
|
|
|
|
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('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 { 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('joins rows through one spine, left of every tab and inside the strip', () => {
|
|
const helper = loadLineageHelper();
|
|
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));
|
|
|
|
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('shares one trunk: sibling routes start identically and branch at their own row', () => {
|
|
const helper = loadLineageHelper();
|
|
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;
|
|
|
|
// 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("hangs a row's gap under its TALLEST tab, so siblings in one row share a bus", () => {
|
|
const helper = loadLineageHelper();
|
|
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 }]);
|
|
|
|
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('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(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('rounds corners, and draws them square with radius 0', () => {
|
|
const helper = loadLineageHelper();
|
|
const { strip, rows, all } = wrappedStrip();
|
|
const input = { parent: rows[2][1], children: [{ id: 'x', rect: rows[4][4] }], strip, tabs: all };
|
|
|
|
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('leaves out a child scrolled out of the strip, and draws nothing for a scrolled-out parent', () => {
|
|
const helper = loadLineageHelper();
|
|
// `.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.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(/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('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');
|
|
});
|
|
|
|
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);
|
|
});
|
|
});
|
|
|
|
// ─── 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('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('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('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', () => {
|
|
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]);
|
|
});
|
|
});
|