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

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

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

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Codeman maintainer
2026-10-07 00:03:37 +02:00
parent ac94f339ac
commit 09daf0fe49
14 changed files with 1123 additions and 619 deletions
+16 -7
View File
@@ -5241,6 +5241,9 @@ class CodemanApp {
// strip scrolls horizontally, so a tab selected from the palette, a swipe,
// Alt+N or a push notification could stay parked off-screen.
this._scrollActiveTabIntoView(sessionId);
// Lineage lines draw only the SELECTED tab's family (session-lineage.js), so a
// selection change is a redraw whenever any lineage exists at all.
if (this._lineageTotalEdges > 0) this.updateConnectionLines();
}
/**
@@ -5633,13 +5636,15 @@ class CodemanApp {
this._refreshHomeSessionsIfVisible?.();
// The full-render path already redraws the connection SVG; this incremental
// one does not, and a badge appearing widens a tab and shifts every tab after
// it, sliding the lineage arcs off their anchors. Only pay for it when there
// is something anchored to tab rects: lineage arcs, or — in a VERTICAL list
// (sidebar, where lineage is skipped and the edge count stays 0, or the
// rail, which can show connectors with zero lineage edges too) — the
// subagent/ultracode connectors, whose rows a badge changes the HEIGHT of.
// Same widening as the strip-scroll listener in session-lineage.js.
if (this._lineageEdgeCount > 0 || this._isVerticalTabList()) this.updateConnectionLines();
// it, sliding the lineage lines off their anchors. Only pay for it when there
// is something anchored to tab rects: any lineage at all (not just what is
// drawn now: a parentSessionId or a child's working state can arrive on this
// path and change the selected family's tree), or, in a VERTICAL list (the
// sidebar, where lineage is skipped, or the rail, which can show connectors
// with zero lineage edges too), the subagent/ultracode connectors, whose
// rows a badge changes the HEIGHT of. updateTabOverflowMode() above has just
// refreshed _lineageTotalEdges.
if (this._lineageTotalEdges > 0 || this._isVerticalTabList()) this.updateConnectionLines();
this.applySidebarFilter(this._sidebarFilter);
}
@@ -5651,6 +5656,10 @@ class CodemanApp {
const container = this.$('sessionTabs');
if (!container) return;
// Lineage routing room (session-lineage.js) changes the strip's padding and
// row gap, so it is decided before the wrap is measured below.
this._syncLineageGutter?.();
// The sidebar list is a single vertical column with its own scroller —
// there is no row to overflow, and measuring it would fight the CSS.
if (this.isSessionSidebarActive()) {
+224 -115
View File
@@ -296,139 +296,248 @@ function computeTabScrollLeft(input) {
return Math.min(Math.max(Math.round(target), 0), maxScroll);
}
// Session lineage lines — geometry for the arc drawn between a tab and a tab it
// Session lineage lines: geometry for the lines joining a tab to the tabs it
// spawned (a worker started through the codeman agent skill, which passes its own
// id as parentSessionId). Pure: the caller measures and appends, this decides.
//
// ONE shape, because both endpoints live in the same horizontal strip and the subagent
// shape (tab-bottom → window-top) has nothing to aim at: a U-bridge HANGING BELOW the
// strip, from the parent's bottom edge to the child's bottom edge, so it reads as a
// bracket joining two tabs rather than as a line crossing them. The dip grows with
// horizontal distance and with `depth` (the child's index among its siblings), so
// several children of one parent nest instead of overprinting.
// ONE TREE PER SPAWNING TAB, and only the selected tab's family is drawn (that
// filter lives in session-lineage.js). Every route starts at the PARENT and ends
// at one child, so a parent's routes share their first stretch exactly: overlaid,
// they read as one trunk with a branch per child, and a dashed (working) route
// stays in phase with its siblings along the shared part.
//
// ⚠ A WRAPPED STRIP USED TO GET ITS OWN SHAPE, AND THAT SHAPE WAS THE BUG. When the
// desktop strip wraps (`tabs-two-rows` / `tabs-auto-wrap`) a parent on row 1 and its
// child on row 2 are ~4px apart vertically, so the old parent-bottom → child-TOP bezier
// had a 4px span to work with and drew a flat horizontal line inside the row gap
// (reported as "they connect already, but the lines are straight and not easy visible"),
// and three siblings drew three of them on top of each other. Aiming BOTH ends at the
// tab BOTTOMS and putting the control points below the LOWER row gives the wrapped case
// the same bracket as the flat case: it leaves the parent downward, crosses the lower
// row once, and comes back up under the child. Same formula, no branch.
// ⚠ ROUTES RUN IN THE GAPS, NEVER THROUGH A TAB. This replaced one bezier per
// child hanging below the strip, which in a wrapped strip crossed every lower
// row's labels and the terminal text (owner screenshot 2026-10-06: a parent on
// row 3 with ten children, "too confusing"). A route now moves horizontally only
// inside a row gap, and vertically only along a tab's own stem (its bottom edge to
// the gap right under it) or along the SPINE, a channel left of every row that
// joins the gaps of different rows. styles.css reserves that room
// (`.session-tabs.lineage-tree`: a wider row gap, bottom padding for the last
// row's gap, and the spine channel on the left of a wrapped strip).
//
// Returns null when the edge must not be drawn: a missing/degenerate rect, or an
// endpoint scrolled outside the strip. `.session-tabs` is `overflow-x: auto`, so a
// scrolled-out tab still HAS a rect — one lying over the logo or the header
// buttons. Skipping is honest; clamping would point at a tab that isn't there.
// ⚠ THE DIP IS WHAT MAKES THE ARC AN ARC, and it has now been mis-tuned in BOTH
// directions, so treat these numbers as a corridor rather than a dial to crank:
// - Too shallow (the first ship, 44px cap): a skill worker is appended to the END of
// the strip, so a lead-to-worker span is 800-1500px, and a 44px cap over 1300px is
// a 33px sag, a line that reads as STRAIGHT across the terminal (#285).
// - Too deep (the 104px cap that replaced it): in the wrapped-strip case the cap and
// the FULL row offset stacked, bowing the bracket ~106px into the terminal text
// (owner screenshot 2026-08-15, "die Linien machen einen grossen Bogen nach unten").
// The dip is measured from the STRIP'S BOTTOM EDGE (falling back to the lower tab
// bottom when the strip rect is missing or shorter than its tabs), which buys two
// things at once: the bow needs no per-row offsets stacked on top, and a same-row
// arc between ROW-1 tabs of a wrapped strip clears row 2's labels instead of being
// drawn through them (the retune's own first draft had exactly that regression).
const LINEAGE_DIP_BASE_PX = 14;
const LINEAGE_DIP_PER_PX = 0.06;
const LINEAGE_DIP_MIN_PX = 22;
const LINEAGE_DIP_MAX_PX = 64;
// Siblings nest by this much. Widened with the stroke: at 2.5px plus its glow, arcs 6px
// apart bled into one thick band instead of reading as three separate lines.
const LINEAGE_SIBLING_STEP_PX = 8;
// Rows come from computeLineageRows() over EVERY tab in the strip, not only the
// endpoints: a row's gap sits under its TALLEST tab (the active tab is 2px
// taller), or siblings in one row would hang their bus at different heights.
//
// computeLineageTree() returns null when the parent cannot be drawn (missing or
// degenerate rect, scrolled out of the strip); a child that cannot be drawn is
// left out of `routes`. `.session-tabs` scrolls, so a scrolled-out tab still HAS a
// rect, lying over the logo or the header buttons. Skipping is honest; clamping
// would point at a tab that is not there.
const LINEAGE_CORNER_RADIUS_PX = 10;
// Families drawn together (the selected tab is both a child and a parent) take
// separate lanes: gap lines this far apart, spines LINEAGE_SPINE_STEP_PX apart.
const LINEAGE_LANE_STEP_PX = 3.5;
const LINEAGE_SPINE_INSET_PX = 6;
const LINEAGE_SPINE_STEP_PX = 4;
// The last row has no row below it; with no strip rect to measure, its gap is
// taken to be this deep.
const LINEAGE_LAST_GAP_PX = 12;
const LINEAGE_ROW_TOLERANCE_PX = 6;
const LINEAGE_STRIP_TOLERANCE_PX = 4;
// How far the vertical bracket sits in from the rail's left edge. It has to
// clear the VIEWPORT edge, not just the tabs: the line carries an 11px outer
// glow, so a track at 6px had half of that glow clipped away and the arc read
// as a thin thread pinned to the window frame. The rail reserves the channel
// itself (`--lineage-vertical-gutter` on the rail's .session-tabs), and
// computeLineagePath still clamps the track to stay left of both tabs.
// How far the vertical rail's track sits in from the rail's left edge. It has to
// clear the VIEWPORT edge, not just the tabs, or the line reads as a thread pinned
// to the window frame. The rail reserves the channel itself
// (`--lineage-vertical-gutter` on the rail's .session-tabs), and the track is
// still clamped to stay left of every endpoint.
const LINEAGE_VERTICAL_TRACK_INSET_PX = 10;
const LINEAGE_VERTICAL_SIBLING_STEP_PX = 3;
const LINEAGE_VERTICAL_LANE_STEP_PX = 4;
const LINEAGE_VERTICAL_ANCHOR_CLEARANCE_PX = 4;
// Lineage palette, assigned per SPAWNING TAB in first-seen order and cycled
// (session-lineage.js). Every arc leaving one tab shares its colour however many
// workers it spawns; a child that spawns in turn gets its own for the arcs below it.
// (session-lineage.js). Every line leaving one tab shares its colour however many
// workers it spawns; a child that spawns in turn gets its own for the lines below it.
// The empty FIRST entry means "no override": the CSS then falls back to --session-blue,
// which every skin block tunes for its own background, so a lone arc keeps the
// which every skin block tunes for its own background, so a lone family keeps the
// skin-aware blue that shipped in 1.18.2. The fixed entries are deliberately vivid
// (owner call 2026-08-15: matrix green, pinkish, violet, red, turquoise "and so on");
// they ride the same double glow as the blue, which is what keeps them legible over
// terminal text on every skin.
// (owner call 2026-08-15: matrix green, pinkish, violet, red, turquoise "and so on").
const LINEAGE_COLORS = ['', '#00ff66', '#ff5ea8', '#a78bfa', '#ff5252', '#2dd4bf', '#ffa940'];
function computeLineagePath(input) {
const parent = input?.parent;
const child = input?.child;
if (!parent || !child) return null;
/** A rect normalized to numbers with its edges and center, or null if unusable. */
function lineageRect(rect) {
if (!rect) return null;
const left = Number(rect.left);
const top = Number(rect.top);
const width = Number(rect.width);
const height = Number(rect.height);
if (![left, top, width, height].every(Number.isFinite) || width <= 0 || height <= 0) return null;
return {
left,
top,
width,
height,
right: left + width,
bottom: top + height,
cx: left + width / 2,
cy: top + height / 2,
};
}
const pw = Number(parent.width) || 0;
const ph = Number(parent.height) || 0;
const cw = Number(child.width) || 0;
const ch = Number(child.height) || 0;
if (pw <= 0 || ph <= 0 || cw <= 0 || ch <= 0) return null;
/**
* Group tab rects into the strip's visual rows, top to bottom. A row spans from its
* highest top to its LOWEST bottom, so a taller tab (the active one) sets the row's
* gap for everyone in it.
*/
function computeLineageRows(rects) {
const rows = [];
for (const raw of rects || []) {
const r = lineageRect(raw);
if (!r) continue;
const row = rows.find((candidate) => Math.abs(candidate.top - r.top) <= LINEAGE_ROW_TOLERANCE_PX);
if (row) {
row.top = Math.min(row.top, r.top);
row.bottom = Math.max(row.bottom, r.bottom);
} else {
rows.push({ top: r.top, bottom: r.bottom });
}
}
return rows.sort((a, b) => a.top - b.top);
}
/**
* An orthogonal polyline as an SVG path, each corner rounded by up to `radius`
* (never more than half of either segment, so short stems stay short). Repeated and
* collinear points are dropped first, so a degenerate corner draws nothing odd.
*/
function lineagePolylinePath(points, radius) {
const pts = [];
for (const p of points) {
const prev = pts[pts.length - 1];
if (prev && Math.abs(prev[0] - p[0]) < 0.5 && Math.abs(prev[1] - p[1]) < 0.5) continue;
const before = pts[pts.length - 2];
if (before && prev) {
const cross = (prev[0] - before[0]) * (p[1] - prev[1]) - (prev[1] - before[1]) * (p[0] - prev[0]);
if (Math.abs(cross) < 0.01) pts.pop();
}
pts.push(p);
}
if (pts.length < 2) return null;
let d = `M ${r1(pts[0][0])} ${r1(pts[0][1])}`;
for (let i = 1; i < pts.length - 1; i++) {
const [x0, y0] = pts[i - 1];
const [x1, y1] = pts[i];
const [x2, y2] = pts[i + 1];
const lenIn = Math.hypot(x1 - x0, y1 - y0);
const lenOut = Math.hypot(x2 - x1, y2 - y1);
const r = Math.min(radius, lenIn / 2, lenOut / 2);
if (!(r > 0.5)) {
d += ` L ${r1(x1)} ${r1(y1)}`;
continue;
}
const ax = x1 - ((x1 - x0) / lenIn) * r;
const ay = y1 - ((y1 - y0) / lenIn) * r;
const bx = x1 + ((x2 - x1) / lenOut) * r;
const by = y1 + ((y2 - y1) / lenOut) * r;
d += ` L ${r1(ax)} ${r1(ay)} Q ${r1(x1)} ${r1(y1)} ${r1(bx)} ${r1(by)}`;
}
const last = pts[pts.length - 1];
return d + ` L ${r1(last[0])} ${r1(last[1])}`;
}
/**
* Routes from one parent tab to each of its children.
*
* input: { parent, children: [{ id, rect }], strip?, tabs?, orientation?, lane?,
* laneCount?, radius? }. `tabs` is every tab rect in the strip (rows are
* derived from it); `lane`/`laneCount` separate families drawn together.
* Returns { routes: [{ id, points, d, endX, endY }] } or null.
*/
function computeLineageTree(input) {
const parent = lineageRect(input?.parent);
if (!parent) return null;
const strip = lineageRect(input?.strip);
const orientation = input?.orientation === 'vertical' ? 'vertical' : 'horizontal';
const strip = input?.strip;
const depth = Math.max(0, Math.min(6, Number(input?.depth) || 0));
const pLeft = Number(parent.left);
const cLeft = Number(child.left);
const pTop = Number(parent.top);
const cTop = Number(child.top);
if (![pLeft, cLeft, pTop, cTop].every(Number.isFinite)) return null;
const laneCount = Math.max(1, Math.floor(Number(input?.laneCount)) || 1);
const lane = Math.max(0, Math.min(laneCount - 1, Math.floor(Number(input?.lane)) || 0));
const radius = Number.isFinite(Number(input?.radius)) ? Math.max(0, Number(input.radius)) : LINEAGE_CORNER_RADIUS_PX;
const children = [];
for (const child of input?.children || []) {
const rect = lineageRect(child?.rect);
if (rect) children.push({ id: child.id, rect });
}
const tol = LINEAGE_STRIP_TOLERANCE_PX;
const inStripY = (r) => !strip || (r.cy >= strip.top - tol && r.cy <= strip.bottom + tol);
const inStripX = (r) => !strip || (r.cx >= strip.left - tol && r.cx <= strip.right + tol);
const routes = [];
if (orientation === 'vertical') {
const py = pTop + ph / 2;
const cy = cTop + ch / 2;
if (strip && Number(strip.height) > 0) {
const min = Number(strip.top) - LINEAGE_STRIP_TOLERANCE_PX;
const max = Number(strip.top) + Number(strip.height) + LINEAGE_STRIP_TOLERANCE_PX;
if (py < min || py > max || cy < min || cy > max) return null;
// The rail: one track down the empty left gutter, shared by every sibling.
if (!inStripY(parent)) return null;
const visible = children.filter((c) => inStripY(c.rect));
if (visible.length === 0) return { routes };
const minLeft = Math.min(parent.left, ...visible.map((c) => c.rect.left));
const base = strip ? strip.left : minLeft - LINEAGE_VERTICAL_TRACK_INSET_PX * 2;
const trackX = Math.min(
base + LINEAGE_VERTICAL_TRACK_INSET_PX + lane * LINEAGE_VERTICAL_LANE_STEP_PX,
minLeft - LINEAGE_VERTICAL_ANCHOR_CLEARANCE_PX
);
for (const { id, rect } of visible) {
const points = [
[parent.left, parent.cy],
[trackX, parent.cy],
[trackX, rect.cy],
[rect.left, rect.cy],
];
const d = lineagePolylinePath(points, radius);
if (d) routes.push({ id, points: roundPoints(points), d, endX: r1(rect.left), endY: r1(rect.cy) });
}
const stripLeft =
strip && Number.isFinite(Number(strip.left))
? Number(strip.left)
: Math.min(pLeft, cLeft) - LINEAGE_VERTICAL_TRACK_INSET_PX * 2;
const requestedTrack =
stripLeft + LINEAGE_VERTICAL_TRACK_INSET_PX + depth * LINEAGE_VERTICAL_SIBLING_STEP_PX;
const trackX = Math.min(requestedTrack, Math.min(pLeft, cLeft) - LINEAGE_VERTICAL_ANCHOR_CLEARANCE_PX);
const d = `M ${r1(pLeft)} ${r1(py)} H ${r1(trackX)} V ${r1(cy)} H ${r1(cLeft)}`;
return { d, endX: cLeft, endY: cy, sameRow: false };
return { routes };
}
const px = pLeft + pw / 2;
const cx = cLeft + cw / 2;
if (strip && Number(strip.width) > 0) {
const min = Number(strip.left) - LINEAGE_STRIP_TOLERANCE_PX;
const max = Number(strip.left) + Number(strip.width) + LINEAGE_STRIP_TOLERANCE_PX;
if (px < min || px > max || cx < min || cx > max) return null;
if (!inStripX(parent) || !inStripY(parent)) return null;
const visible = children.filter((c) => inStripX(c.rect) && inStripY(c.rect));
if (visible.length === 0) return { routes };
const rows = computeLineageRows([...(input?.tabs || []), parent, ...visible.map((c) => c.rect)]);
const rowOf = (r) => rows.findIndex((row) => r.cy >= row.top - tol && r.cy <= row.bottom + tol);
const laneOffset = (lane - (laneCount - 1) / 2) * LINEAGE_LANE_STEP_PX;
// Y of the gap under row i, this family's lane. The offset is clamped so a busy
// gap never pushes a lane into the tabs on either side of it.
const gapUnder = (i) => {
const row = rows[i];
const next = rows[i + 1];
let bottom;
if (next) bottom = next.top;
else if (strip && strip.bottom > row.bottom + 1) bottom = strip.bottom;
else bottom = row.bottom + LINEAGE_LAST_GAP_PX;
const half = Math.max(0, (bottom - row.bottom) / 2 - 1);
return (row.bottom + bottom) / 2 + Math.max(-half, Math.min(half, laneOffset));
};
const pRow = rowOf(parent);
const gp = gapUnder(pRow);
const tabLefts = (input?.tabs || []).map(lineageRect).filter(Boolean).map((r) => r.left);
const minLeft = Math.min(parent.left, ...visible.map((c) => c.rect.left), ...tabLefts);
const spineBase = strip ? strip.left : minLeft - LINEAGE_SPINE_INSET_PX * 2;
const spineX = Math.min(spineBase + LINEAGE_SPINE_INSET_PX + lane * LINEAGE_SPINE_STEP_PX, minLeft - 2);
for (const { id, rect } of visible) {
const cRow = rowOf(rect);
const gc = gapUnder(cRow);
const points =
cRow === pRow
? [
[parent.cx, parent.bottom],
[parent.cx, gp],
[rect.cx, gp],
[rect.cx, rect.bottom],
]
: [
[parent.cx, parent.bottom],
[parent.cx, gp],
[spineX, gp],
[spineX, gc],
[rect.cx, gc],
[rect.cx, rect.bottom],
];
const d = lineagePolylinePath(points, radius);
if (d) routes.push({ id, points: roundPoints(points), d, endX: r1(rect.cx), endY: r1(rect.bottom) });
}
return { routes };
}
const pBottom = pTop + ph;
const cBottom = cTop + ch;
const sameRow = Math.abs(pTop + ph / 2 - (cTop + ch / 2)) <= Math.min(ph, ch) / 2;
// Both ends anchor on the tab BOTTOM, and the control points hang below the WHOLE
// strip, so one formula covers a flat strip, a wrapped pair, and a same-row pair
// sitting above further rows (see the corridor note above the constants).
const span = Math.abs(cx - px);
const stripBottom =
strip && Number(strip.height) > 0 && Number.isFinite(Number(strip.top))
? Number(strip.top) + Number(strip.height)
: Number.NEGATIVE_INFINITY;
const baseline = Math.max(pBottom, cBottom, stripBottom);
const dip =
Math.min(LINEAGE_DIP_MAX_PX, Math.max(LINEAGE_DIP_MIN_PX, LINEAGE_DIP_BASE_PX + span * LINEAGE_DIP_PER_PX)) +
depth * LINEAGE_SIBLING_STEP_PX;
const yc = baseline + dip;
const d = `M ${r1(px)} ${r1(pBottom)} C ${r1(px)} ${r1(yc)}, ${r1(cx)} ${r1(yc)}, ${r1(cx)} ${r1(cBottom)}`;
return { d, endX: cx, endY: cBottom, sameRow };
function roundPoints(points) {
return points.map(([x, y]) => [r1(x), r1(y)]);
}
// One decimal is plenty for a screen-space path and keeps the `d` string short.
@@ -961,12 +1070,12 @@ if (typeof window !== 'undefined') {
plan: planWsReconnect,
};
window.CodemanLineage = {
computePath: computeLineagePath,
DIP_MIN_PX: LINEAGE_DIP_MIN_PX,
DIP_MAX_PX: LINEAGE_DIP_MAX_PX,
SIBLING_STEP_PX: LINEAGE_SIBLING_STEP_PX,
computeTree: computeLineageTree,
computeRows: computeLineageRows,
CORNER_RADIUS_PX: LINEAGE_CORNER_RADIUS_PX,
LANE_STEP_PX: LINEAGE_LANE_STEP_PX,
SPINE_INSET_PX: LINEAGE_SPINE_INSET_PX,
VERTICAL_TRACK_INSET_PX: LINEAGE_VERTICAL_TRACK_INSET_PX,
VERTICAL_SIBLING_STEP_PX: LINEAGE_VERTICAL_SIBLING_STEP_PX,
COLORS: LINEAGE_COLORS,
};
window.CodemanConnectionLoss = {
+1 -1
View File
@@ -2153,7 +2153,7 @@
<div class="set-row" id="appSettingsLineageLinesItem" data-search="lineage lines spawned worker parent connection">
<div class="set-row-text">
<span class="set-row-label">Spawn Lineage Lines <span class="set-tag">desktop</span></span>
<span class="set-row-desc">Draw a line under the tab strip from a session to the sessions it spawned.</span>
<span class="set-row-desc">For the selected tab, draw lines to the sessions it spawned and to the session that spawned it.</span>
</div>
<label class="switch switch-sm"><input type="checkbox" id="appSettingsLineageLines" checked><span class="slider"></span></label>
</div>
+199 -87
View File
@@ -1,29 +1,39 @@
/**
* @fileoverview Session lineage lines — the arcs joining a tab to the tabs it spawned.
* @fileoverview Session lineage lines: the tree joining a tab to the tabs it spawned.
*
* A session that starts another session (the `codeman` agent skill spawning a worker,
* which passes its own `$CODEMAN_SESSION_ID`) gets `parentSessionId` stamped on its
* state server-side. This module turns that field into the same kind of glowing
* connection line the subagent windows use, but tab → tab, so the strip shows at a
* glance which tab spawned which.
* state server-side. This module turns that field into a quiet orthogonal tree, one per
* spawning tab, routed through the gaps between tab rows so it never crosses a label or
* the terminal (geometry: `CodemanLineage.computeTree` in constants.js).
*
* ONLY THE SELECTED TAB'S FAMILY IS DRAWN: the family the active tab spawned, and the
* family it belongs to as a child. Drawing every family at once is what the owner
* called "too confusing" (2026-10-06: one parent with ten children across five wrapped
* rows). Selection therefore has to redraw, which `_updateActiveTabImmediate()` does
* whenever any lineage exists (`_lineageTotalEdges`).
*
* It is an ADDITIONAL LAYER on the existing SVG pass, not a second pass: the core
* `_updateConnectionLinesImmediate()` (subagent-windows.js) calls
* `_appendLineageConnectionLines(svg, rects)` at its tail, exactly like ultracode does,
* so every layer shares ONE batched read → write reflow and one tab-rect cache.
*
* Two constraints that are not obvious from the code:
* - DESKTOP ONLY. The overlay is `z-index: 999`; the desktop header is 100 (arcs paint
* Constraints that are not obvious from the code:
* - DESKTOP ONLY. The overlay is `z-index: 999`; the desktop header is 100 (lines paint
* over it, which is what lets them touch tab bottoms), but under 1024px mobile.css
* makes the header `position: fixed; z-index: 1200` and would bury them. The phone
* strip is also a scroller where both endpoints are rarely on screen at once.
* - The routing room is RESERVED in CSS (`.session-tabs.lineage-tree`), toggled by
* `_syncLineageGutter()` from `updateTabOverflowMode()`. It keys on whether ANY
* family exists, never on the selection, so switching tabs never resizes the header
* (and with it the terminal and the PTY).
* - Paths carry `data-agent-id="lineage:<childId>"` because that is the attribute
* `_applyLineEntrances()` queries, so the draw-in animation and its
* negative-`animation-delay` resume across `svg.innerHTML = ''` come for free.
*
* @mixin Extends CodemanApp.prototype via Object.assign
* @dependency subagent-windows.js (_updateConnectionLinesImmediate, #connectionLines)
* @dependency constants.js (window.CodemanLineage.computePath + .COLORS)
* @dependency constants.js (window.CodemanLineage.computeTree + .COLORS)
* @dependency settings-ui.js (loadAppSettingsFromStorage, getDefaultSettings)
* @loadorder 15.6 (after ultracode-windows.js — appended to the same SVG pass)
*/
@@ -62,49 +72,96 @@ Object.assign(CodemanApp.prototype, {
applyLineageLineSettings() {
const prev = this._lineageLinesOn;
const next = this._syncLineageLinesEnabled();
if (prev !== next) this.updateConnectionLines();
if (prev !== next) {
// The reserved routing room follows the setting; updateTabOverflowMode()
// re-syncs it and re-measures the wrap with the new padding.
this.updateTabOverflowMode?.();
this.updateConnectionLines();
}
},
/**
* Every parent → child pair worth drawing, with the child's index among its siblings
* (that index is what nests sibling arcs instead of overprinting them).
* Reserve (or release) the strip's routing room: `.lineage-tree` on #sessionTabs
* widens the row gap, pads the bottom for the last row's gap, and opens the spine
* channel on the left of a wrapped strip (styles.css). Called at the top of
* `updateTabOverflowMode()`, so it runs on every tab render, BEFORE the wrap is
* measured.
*
* Walks `sessionOrder` rather than the sessions Map so sibling depth follows the
* strip's own left-to-right order, which is what the user sees.
* Keyed on whether any family exists at all, never on which one is selected: a
* class that followed the selection would grow and shrink the header on every tab
* switch, and the header's height is the terminal's height.
*
* Only the header strip routes through reserved gaps. The vertical rail keeps its
* own `--lineage-vertical-gutter`, and the sidebar draws no lineage.
*/
_syncLineageGutter() {
const edges = this._lineageLinesEnabled() ? this._collectLineageEdges() : [];
this._lineageTotalEdges = edges.length;
const strip = document.getElementById('sessionTabs');
if (!strip) return;
const want = edges.length > 0 && !this._isVerticalTabList?.();
if (strip.classList.contains('lineage-tree') !== want) strip.classList.toggle('lineage-tree', want);
},
/**
* Every parent → child pair, in strip order. Walks `sessionOrder` rather than the
* sessions Map so sibling order follows the strip's own left-to-right order, which
* is what the user sees.
*/
_collectLineageEdges() {
const edges = [];
if (!this.sessions || this.sessions.size < 2) return edges;
const order = this.sessionOrder && this.sessionOrder.length ? this.sessionOrder : [...this.sessions.keys()];
const seenPerParent = new Map();
for (const id of order) {
const session = this.sessions.get(id);
const parentId = session && session.parentSessionId;
// A parent that is gone (closed, or never came back after a restart) draws
// nothing: the field is decoration, so a dangling one is simply not rendered.
if (!parentId || parentId === id || !this.sessions.has(parentId)) continue;
const depth = seenPerParent.get(parentId) || 0;
seenPerParent.set(parentId, depth + 1);
edges.push({ parentId, childId: id, depth, status: session.status || 'idle' });
edges.push({ parentId, childId: id, status: session.status || 'idle' });
}
return edges;
},
/**
* Colour for one arc, from CodemanLineage.COLORS, keyed on the SPAWNING tab.
* The families worth drawing for the current selection, each as
* `{ parentId, edges }` in strip order: the family the selected tab spawned, and
* the family it was spawned into (its parent plus its siblings). Empty when a web
* tab holds the stage, or the selected tab has no lineage at all.
*/
_focusedLineageFamilies(edges) {
const focus = this.activeWebviewId ? null : this.activeSessionId;
if (!focus) return [];
const parents = new Set();
for (const edge of edges) {
if (edge.parentId === focus || edge.childId === focus) parents.add(edge.parentId);
}
const families = new Map();
for (const edge of edges) {
if (!parents.has(edge.parentId)) continue;
if (!families.has(edge.parentId)) families.set(edge.parentId, []);
families.get(edge.parentId).push(edge);
}
return [...families].map(([parentId, familyEdges]) => ({ parentId, edges: familyEdges }));
},
/**
* Colour for one family, from CodemanLineage.COLORS, keyed on the SPAWNING tab.
*
* ⚠️ Per PARENT, not per child: every arc leaving one tab is the same colour, no
* ⚠ Per PARENT, not per child: every line leaving one tab is the same colour, no
* matter how many workers it spawns, so the strip reads as "these five came from
* w1, those two came from w2". Keying it per child instead gave one tab's own
* children a different colour each, which is the thing the colours exist to tell
* apart. A child that goes on to spawn its own workers is a parent in its turn and
* gets its own colour for the arcs BELOW it, so a chain changes colour at each
* gets its own colour for the lines BELOW it, so a chain changes colour at each
* generation while each generation's fan-out stays uniform.
*
* Assigned in FIRST-SEEN order and remembered per parent id. First-seen rather than
* draw-index keeps a colour stable across re-renders, tab reorders and sibling
* closes (the SVG is wiped and rebuilt constantly, so an index-based colour would
* flicker). An empty string means "no override": the CSS falls back to
* flicker). The draw pass claims a colour for EVERY family in strip order, drawn or
* not, so which family happens to be selected first never decides who gets which
* colour. An empty string means "no override": the CSS falls back to
* --session-blue, so the first spawning tab keeps the skin-aware blue.
*/
_lineageColorFor(parentId) {
@@ -140,19 +197,20 @@ Object.assign(CodemanApp.prototype, {
_appendLineageConnectionLines(svg, rects) {
this._lineageEdgeCount = 0;
if (!svg || !this._lineageLinesEnabled()) return;
// Sidebar layout: computeLineagePath()'s whole geometry — the U-bridge hung
// from the STRIP's bottom edge, the 64px dip corridor — assumes a horizontal
// tab row. Against a vertical list the "strip bottom" is the bottom of the
// sidebar, so every arc would draw a giant loop to the foot of the list.
// Parent/child adjacency reads fine in a vertical list without arcs; a
// sideways lineage shape is a follow-up with its own visual tuning, not a
// by-product of a layout port.
// Sidebar layout: the tree is routed for a horizontal strip or the vertical
// rail. The sidebar is a vertical list with its own scroller and no reserved
// channel; parent/child adjacency reads fine there without lines.
if (this.isSessionSidebarActive?.()) return;
const compute = window.CodemanLineage && window.CodemanLineage.computePath;
if (!compute) return;
const computeTree = window.CodemanLineage && window.CodemanLineage.computeTree;
if (!computeTree) return;
const edges = this._collectLineageEdges();
this._lineageTotalEdges = edges.length;
if (edges.length === 0) return;
// Claim colours in strip order for every family, drawn or not (_lineageColorFor).
for (const edge of edges) this._lineageColorFor(edge.parentId);
const families = this._focusedLineageFamilies(edges);
if (families.length === 0) return;
if (!rects) rects = new Map();
// PHASE 1 — reads.
@@ -162,9 +220,9 @@ Object.assign(CodemanApp.prototype, {
const orientation =
document.documentElement.getAttribute('data-tab-orientation') === 'vertical' ? 'vertical' : 'horizontal';
// A session hidden inside a collapsed group of the grouped rail has no row
// to anchor to, so its end of the arc moves to that group's header (a
// "proxied" endpoint, drawn quieter). Two endpoints proxied to the SAME
// header would be an arc from a row to itself: skipped.
// to anchor to, so its end of the line moves to that group's header (a
// "proxied" endpoint, drawn quieter). A child proxied to the same header as
// its parent would be a line from a row to itself: skipped.
const resolveEndpoint = (id) => {
const tab = strip.querySelector(`.session-tab[data-id="${CSS.escape(id)}"]`);
if (tab) return { key: 'tab:' + id, element: tab, proxied: false };
@@ -173,71 +231,112 @@ Object.assign(CodemanApp.prototype, {
const header = strip.querySelector(`[data-tab-group-header="${CSS.escape(groupId)}"]`);
return { key: 'group:' + groupId, element: header, proxied: !!header };
};
const resolvedEdges = [];
for (const edge of edges) {
const parentEndpoint = resolveEndpoint(edge.parentId);
const childEndpoint = resolveEndpoint(edge.childId);
if (parentEndpoint.key === childEndpoint.key) continue;
resolvedEdges.push({ edge, parentEndpoint, childEndpoint });
for (const endpoint of [parentEndpoint, childEndpoint]) {
if (rects.has(endpoint.key)) continue;
const measure = (endpoint) => {
if (!rects.has(endpoint.key)) {
rects.set(endpoint.key, endpoint.element ? endpoint.element.getBoundingClientRect() : null);
}
return rects.get(endpoint.key);
};
const resolvedFamilies = [];
for (const family of families) {
const parentEndpoint = resolveEndpoint(family.parentId);
const parentRect = measure(parentEndpoint);
if (!parentRect) continue;
const children = [];
for (const edge of family.edges) {
const childEndpoint = resolveEndpoint(edge.childId);
if (childEndpoint.key === parentEndpoint.key) continue;
const rect = measure(childEndpoint);
if (rect) children.push({ edge, endpoint: childEndpoint, rect });
}
if (children.length > 0) resolvedFamilies.push({ family, parentEndpoint, parentRect, children });
}
if (resolvedFamilies.length === 0) return;
// The header strip's rows come from EVERY tab in it (computeTree hangs a row's
// gap under its tallest tab), web tabs included. The rail needs none.
const tabRects = [];
if (orientation === 'horizontal') {
for (const tab of strip.querySelectorAll('.session-tab')) {
const id = tab.getAttribute('data-id');
const key = id ? 'tab:' + id : null;
if (key && rects.has(key)) tabRects.push(rects.get(key));
else {
const rect = tab.getBoundingClientRect();
if (key) rects.set(key, rect);
tabRects.push(rect);
}
}
}
this._lineageEdgeCount = resolvedEdges.length;
// PHASE 2 — writes, from the cache only.
for (const { edge, parentEndpoint, childEndpoint } of resolvedEdges) {
const parentRect = rects.get(parentEndpoint.key);
const childRect = rects.get(childEndpoint.key);
if (!parentRect || !childRect) continue;
const geom = compute({
resolvedFamilies.forEach(({ family, parentEndpoint, parentRect, children }, lane) => {
const geom = computeTree({
parent: parentRect,
child: childRect,
children: children.map((c) => ({ id: c.edge.childId, rect: c.rect })),
strip: stripRect,
depth: edge.depth,
tabs: tabRects,
orientation,
lane,
laneCount: resolvedFamilies.length,
});
if (!geom) continue; // scrolled out of the strip, or a degenerate rect
const line = document.createElementNS('http://www.w3.org/2000/svg', 'path');
line.setAttribute('d', geom.d);
// The working class marches the dashes, so an active worker is visible along
// the line itself. `status` is the CHILD's, which is the interesting end.
const working = edge.status === 'working' ? ' lineage-line--working' : '';
const proxied = parentEndpoint.proxied || childEndpoint.proxied;
line.setAttribute('class', 'connection-line lineage-line' + working + (proxied ? ' lineage-line--proxied' : ''));
// The PARENT's colour rides a CSS custom property so the stylesheet keeps owning
// opacity, glow and dash; an empty colour leaves the --session-blue fallback.
// Every arc out of one tab shares it — see _lineageColorFor().
const color = this._lineageColorFor(edge.parentId);
if (color) line.style.setProperty('--lineage-color', color);
// `data-agent-id` is what _applyLineEntrances() queries — see the file header.
line.setAttribute('data-agent-id', 'lineage:' + edge.childId);
line.setAttribute('data-parent-tab', edge.parentId);
line.setAttribute('data-child-tab', edge.childId);
svg.appendChild(line);
// Direction marker at the CHILD end. A circle rather than an SVG <marker>:
// markers need a <defs> block and fight the dash pattern.
const dot = document.createElementNS('http://www.w3.org/2000/svg', 'circle');
dot.setAttribute('cx', String(geom.endX));
dot.setAttribute('cy', String(geom.endY));
// Resting radius; `lineage-dot-pulse` breathes it 3.5 → 4.5 while the child
// works, so the two have to be changed together.
dot.setAttribute('r', '3.5');
dot.setAttribute('class', 'lineage-line-dot' + working + (proxied ? ' lineage-line-dot--proxied' : ''));
dot.setAttribute('data-child-tab', edge.childId);
if (color) dot.style.setProperty('--lineage-color', color);
svg.appendChild(dot);
}
if (!geom || geom.routes.length === 0) return;
const byId = new Map(children.map((c) => [c.edge.childId, c]));
const color = this._lineageColorFor(family.parentId);
// One group per family: it carries the translucency, so the stretches its
// routes share (the trunk) do not stack into a brighter line than the branches.
const group = document.createElementNS('http://www.w3.org/2000/svg', 'g');
group.setAttribute('class', 'lineage-family');
group.setAttribute('data-parent-tab', family.parentId);
// Working routes go in FIRST, so an idle sibling's solid stroke covers the
// shared trunk and only the working child's own branch shows its dashes.
const routes = geom.routes
.map((route) => ({ route, child: byId.get(route.id) }))
.filter((r) => r.child)
.sort((a, b) => (b.child.edge.status === 'working') - (a.child.edge.status === 'working'));
for (const { route, child } of routes) {
const line = document.createElementNS('http://www.w3.org/2000/svg', 'path');
line.setAttribute('d', route.d);
// `status` is the CHILD's, which is the interesting end: a working child's
// route is dashed (and marches, motion permitting).
const working = child.edge.status === 'working' ? ' lineage-line--working' : '';
const proxied = parentEndpoint.proxied || child.endpoint.proxied;
line.setAttribute(
'class',
'connection-line lineage-line' + working + (proxied ? ' lineage-line--proxied' : '')
);
// The PARENT's colour rides a CSS custom property so the stylesheet keeps
// owning weight and dash; an empty colour leaves the --session-blue fallback.
if (color) line.style.setProperty('--lineage-color', color);
// `data-agent-id` is what _applyLineEntrances() queries — see the file header.
line.setAttribute('data-agent-id', 'lineage:' + child.edge.childId);
line.setAttribute('data-parent-tab', family.parentId);
line.setAttribute('data-child-tab', child.edge.childId);
group.appendChild(line);
}
// Direction marker at each CHILD end, after every path so no stroke covers it.
for (const { route, child } of routes) {
const working = child.edge.status === 'working' ? ' lineage-line--working' : '';
const proxied = parentEndpoint.proxied || child.endpoint.proxied;
const dot = document.createElementNS('http://www.w3.org/2000/svg', 'circle');
dot.setAttribute('cx', String(route.endX));
dot.setAttribute('cy', String(route.endY));
// Resting radius; `lineage-dot-pulse` breathes it 2.5 → 3.3 while the child
// works, so the two have to be changed together.
dot.setAttribute('r', '2.5');
dot.setAttribute('class', 'lineage-line-dot' + working + (proxied ? ' lineage-line-dot--proxied' : ''));
dot.setAttribute('data-child-tab', child.edge.childId);
if (color) dot.style.setProperty('--lineage-color', color);
group.appendChild(dot);
}
svg.appendChild(group);
this._lineageEdgeCount += routes.length;
});
},
/**
* The strip scrolls (desktop `overflow-x: auto` and every wrapped layout), and a
* scroll moves both endpoints without firing any render, so the arcs would slide off
* their tabs. Passive listener, and the redraw is the normal coalesced one.
* scroll moves both endpoints without firing any render, so the lines would slide
* off their tabs. Passive listener, and the redraw is the normal coalesced one.
*
* Installed once; the guard also keeps a re-init from stacking listeners.
*/
@@ -248,11 +347,24 @@ Object.assign(CodemanApp.prototype, {
this._lineageScrollHandler = () => {
// Sidebar layout and the vertical rail scroll the SAME element
// vertically, and there the subagent/ultracode connectors anchor to tab
// rects too (the sidebar skips lineage arcs entirely, and the rail can
// show connectors with zero lineage edges, so _lineageEdgeCount alone
// would never redraw them).
// rects too (the sidebar skips lineage entirely, and the rail can show
// connectors with zero lineage edges, so _lineageEdgeCount alone would
// never redraw them).
if (this._lineageEdgeCount > 0 || this._isVerticalTabList?.()) this.updateConnectionLines();
};
strip.addEventListener('scroll', this._lineageScrollHandler, { passive: true });
// ⚠ A SELECTION RESIZES TABS AFTER THE REDRAW. The active tab reveals its gear
// and close icons by transitioning their padding (styles.css), so it keeps
// widening for ~150ms after `_updateActiveTabImmediate()` has already redrawn,
// and the tab it was selected from shrinks. That can move tabs or re-wrap a row,
// which left a family's trunk hanging under the tab's OLD position. Redraw once
// a size transition inside the strip ends (coalesced, like every other redraw).
this._lineageTransitionHandler = (event) => {
const prop = event.propertyName || '';
if (!(prop === 'width' || prop === 'max-width' || prop.startsWith('padding'))) return;
if (this._lineageTotalEdges > 0) this.updateConnectionLines();
};
strip.addEventListener('transitionend', this._lineageTransitionHandler);
},
});
+68 -62
View File
@@ -596,6 +596,22 @@ body {
max-height: var(--tab-strip-max-height, 40vh);
}
/* Lineage routing room (`_syncLineageGutter()` in session-lineage.js, geometry in
computeLineageTree, constants.js). Present only while some tab has spawned
another, and never toggled by the selection, so switching tabs cannot resize
the header (and with it the terminal). The bottom padding is the last row's
gap; a wrapped strip also widens the row gap the routes run in, and opens the
spine channel on the left that joins one row's gap to another's. */
.session-tabs.lineage-tree {
padding-bottom: 14px;
}
.session-tabs.lineage-tree.tabs-two-rows,
.session-tabs.lineage-tree.tabs-auto-wrap {
row-gap: 12px;
padding-left: 20px;
}
.session-tabs::-webkit-scrollbar {
width: 4px;
height: 0;
@@ -1681,9 +1697,10 @@ html[data-line-anim="packet"] .connection-line.line-enter {
⚠ The 100% frame deliberately omits `opacity`, which makes the browser take
the endpoint from the element's own computed value: a subagent line rests at
0.9, a lineage line at 0.72, and a WORKING lineage line at 0.95. Pinning 0.9
here - as `line-enter-fade` above still does - lands every lineage line on the
wrong opacity and snaps it when the class comes off. */
0.9, a lineage line at 1 (its `.lineage-family` group carries the
translucency), a proxied one at 0.5. Pinning 0.9 here - as `line-enter-fade`
above still does - lands every lineage line on the wrong opacity and snaps it
when the class comes off. */
html[data-line-anim="blur"] .connection-line.line-enter {
animation-name: line-enter-blur;
animation-duration: calc(380ms * var(--anim-enter-scale, 1));
@@ -10665,112 +10682,101 @@ kbd {
}
/* ===== Session lineage lines (tab → tab it spawned, session-lineage.js) =====
Deliberately quieter and thinner than the subagent lines above so the two
layers read as different things in the same SVG.
One quiet orthogonal tree per spawning tab, drawn only for the SELECTED tab's
family and routed through the gaps between tab rows (computeLineageTree in
constants.js). The routing room itself is reserved on the strip by
`.session-tabs.lineage-tree` near the top of this file.
⚠ QUIET ON PURPOSE (owner call 2026-10-06, "B2"): 1.5px, solid, rounded
corners and a 1px dark outline in place of the old double glow. The old lines
were glowing dashed beziers that hung into the terminal, and with ten children
across a wrapped strip they read as a tangle. The lines no longer cross text,
so they no longer need a glow to survive it. Dashes now MEAN something: only a
working child's route is dashed.
Colour: every rule reads --lineage-color, which session-lineage.js sets INLINE
per line from the CodemanLineage.COLORS palette (per child, first-seen order,
owner call 2026-08-15: several connected tabs must get several colours). The
FIRST line gets no override, so it falls through to --session-blue, which EVERY
skin block already defines and tunes for its own background; a lone arc therefore
still renders the skin-aware blue that shipped in 1.18.2. Do not add a per-skin
`.lineage-line` override inside the html:not([data-skin="og"]) block: a bare
class rule in there resolves to (0,2,1) and would outrank this one from a
surprising place.
per path and dot from the CodemanLineage.COLORS palette (per SPAWNING tab,
first-seen order). The first spawning tab gets no override and falls through to
--session-blue, which EVERY skin block already defines and tunes for its own
background. Do not add a per-skin `.lineage-line` override inside the
html:not([data-skin="og"]) block: a bare class rule in there resolves to
(0,2,1) and would outrank this one from a surprising place.
Opacity lives on the family GROUP (`.lineage-family`), not on the paths: a
parent's routes overlap exactly along the trunk, and translucent paths would
stack there into a brighter line than the branches. */
.lineage-family {
opacity: 0.85;
}
⚠ BLUE, NOT THE VIOLET THIS SHIPPED WITH (owner call, 2026-08-14: "make these
lines in blue that they are better visible"). Violet sits close to the terminal's
own dim foreground and lost contrast the moment it crossed text. Hue therefore no
longer separates this layer from the subagent lines, so the separation rests
entirely on SHAPE (this one hangs under the strip and never reaches a window),
weight and dash: keep those differences intact. Per skin the two are not even the
same blue, since --session-blue is tuned per palette while the subagent rule
hardcodes #3b82f6. */
/* ⚠ QUIETER THAN THE SUBAGENT LINES, NOT INVISIBLE. The first cut ran 2px at 0.55
with a single 5px glow, which reads on a design mock and disappears on a real
1080p desktop: a faint thread over terminal text, exactly what it is drawn on
top of. The weight stays UNDER the subagent lines' 3px so the two layers still
separate, and the second, wider glow is what buys the contrast instead: it lifts
the line off the terminal without thickening it. Dashes scale with the stroke
(4 4 on a 2.5px line reads as a dotted smudge), and `lineage-flow` marches by
exactly two dash cycles, so it has to move with them. */
.connection-line.lineage-line {
stroke: var(--lineage-color, var(--session-blue, #2b8fd9));
stroke-width: 2.5;
stroke-dasharray: 5 5;
stroke-width: 1.5;
stroke-dasharray: none;
stroke-linecap: round;
opacity: 0.72;
--line-glow: drop-shadow(0 0 2px rgba(0, 0, 0, 0.7))
drop-shadow(0 0 5px var(--lineage-color, var(--session-blue, #2b8fd9)))
drop-shadow(0 0 11px var(--lineage-color, var(--session-blue, #2b8fd9)));
stroke-linejoin: round;
opacity: 1;
--line-glow: drop-shadow(0 0 1px rgba(0, 0, 0, 0.9));
filter: var(--line-glow);
}
/* ⚠ OUTSIDE the reduced-motion block below on purpose. A working child is the case
the line exists to signal, and pairing the brightness with the marching dashes
left every worker's arc at the resting 0.72 for anyone who turns motion off. */
/* A working child's route is dashed even with motion turned off; the march
below is the extra. Routes start at the PARENT, so dashes on the shared trunk
stay in phase between working siblings. */
.connection-line.lineage-line--working {
opacity: 0.95;
}
.connection-line.lineage-line:hover {
opacity: 1;
stroke-width: 3;
stroke-dasharray: 4 3;
}
.lineage-line-dot {
fill: var(--lineage-color, var(--session-blue, #2b8fd9));
opacity: 0.85;
filter: drop-shadow(0 0 4px var(--lineage-color, var(--session-blue, #2b8fd9)))
drop-shadow(0 0 9px var(--lineage-color, var(--session-blue, #2b8fd9)));
filter: drop-shadow(0 0 1px rgba(0, 0, 0, 0.9));
}
/* An arc re-anchored to a collapsed group's header (its row is hidden): quieter
/* A line re-anchored to a collapsed group's header (its row is hidden): quieter
and dotted, so it reads as "somewhere in there" rather than a real row. */
.connection-line.lineage-line--proxied {
stroke-dasharray: 2 7;
opacity: 0.46;
stroke-dasharray: 1 5;
opacity: 0.5;
}
.connection-line.lineage-line--proxied.lineage-line--working {
opacity: 0.62;
opacity: 0.65;
}
.lineage-line-dot.lineage-line-dot--proxied {
opacity: 0.46;
opacity: 0.5;
}
/* The child end marches while that worker is actually working, so the line
itself carries the signal. Motion is opt-out-able at the OS level. */
/* The working route marches from parent to child, so the line itself carries
the signal. Motion is opt-out-able at the OS level. */
@media (prefers-reduced-motion: no-preference) {
.connection-line.lineage-line--working {
animation: lineage-flow 1.1s linear infinite;
}
.lineage-line-dot--working {
opacity: 1;
animation: lineage-dot-pulse 1.4s ease-in-out infinite;
}
}
/* Two full dash cycles, so the march loops seamlessly. Tied to `stroke-dasharray`
above: at `5 5` the cycle is 10px, so this is -20 rather than the -16 that
matched the old `4 4`. Leaving them out of step makes the dashes jump once per
iteration. */
/* Two full dash cycles, so the march loops seamlessly. Tied to the working
`stroke-dasharray` above: at `4 3` the cycle is 7px, so this is -14. Leaving
them out of step makes the dashes jump once per iteration. */
@keyframes lineage-flow {
to {
stroke-dashoffset: -20;
stroke-dashoffset: -14;
}
}
/* Tied to the resting r="2.5" session-lineage.js sets on the dot. */
@keyframes lineage-dot-pulse {
0%, 100% {
opacity: 0.75;
r: 3.5;
r: 2.5;
}
50% {
opacity: 1;
r: 4.5;
r: 3.3;
}
}