2026-08-12 02:30:08 +02:00
|
|
|
/**
|
2026-10-07 00:03:37 +02:00
|
|
|
* @fileoverview Session lineage lines: the tree joining a tab to the tabs it spawned.
|
2026-08-12 02:30:08 +02:00
|
|
|
*
|
|
|
|
|
* 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
|
2026-10-07 00:03:37 +02:00
|
|
|
* 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).
|
|
|
|
|
*
|
2026-10-07 01:35:42 +02:00
|
|
|
* EVERY FAMILY IS ALWAYS DRAWN, AND THE SELECTED TAB'S IS EMPHASIZED: the family the
|
|
|
|
|
* active tab spawned, and the family it belongs to as a child, get the
|
|
|
|
|
* `lineage-family--focus` group (thicker, full opacity, drawn last so nothing covers
|
|
|
|
|
* it). Drawing ONLY the selected family was tried first and rejected by the owner
|
|
|
|
|
* (2026-10-07: "I wanna see all the connections always"). Selection still has to
|
|
|
|
|
* redraw to move the emphasis, which `_updateActiveTabImmediate()` does whenever any
|
|
|
|
|
* lineage exists (`_lineageTotalEdges`).
|
2026-08-12 02:30:08 +02:00
|
|
|
*
|
|
|
|
|
* 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.
|
|
|
|
|
*
|
2026-10-07 00:03:37 +02:00
|
|
|
* Constraints that are not obvious from the code:
|
|
|
|
|
* - DESKTOP ONLY. The overlay is `z-index: 999`; the desktop header is 100 (lines paint
|
2026-08-12 02:30:08 +02:00
|
|
|
* 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.
|
2026-10-07 00:03:37 +02:00
|
|
|
* - 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).
|
2026-08-12 02:30:08 +02:00
|
|
|
* - 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)
|
2026-10-07 00:03:37 +02:00
|
|
|
* @dependency constants.js (window.CodemanLineage.computeTree + .COLORS)
|
2026-08-12 02:30:08 +02:00
|
|
|
* @dependency settings-ui.js (loadAppSettingsFromStorage, getDefaultSettings)
|
|
|
|
|
* @loadorder 15.6 (after ultracode-windows.js — appended to the same SVG pass)
|
|
|
|
|
*/
|
|
|
|
|
/* global CodemanApp, MobileDetection */
|
|
|
|
|
|
|
|
|
|
Object.assign(CodemanApp.prototype, {
|
|
|
|
|
/**
|
|
|
|
|
* Per-device opt-out (App Settings → Appearance), cached because the draw path runs
|
|
|
|
|
* on every tab render, scroll and resize. `applyLineageLineSettings()` refreshes it.
|
|
|
|
|
*
|
|
|
|
|
* Desktop-only for the z-index reason in the file header, and gated on device type
|
|
|
|
|
* rather than on the settings namespace: this is a layout decision, like the phone
|
|
|
|
|
* overview's `shouldUseMobileOverview()`.
|
|
|
|
|
*/
|
|
|
|
|
_lineageLinesEnabled() {
|
|
|
|
|
if (this._lineageLinesOn === undefined) this._syncLineageLinesEnabled();
|
|
|
|
|
return this._lineageLinesOn;
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
_syncLineageLinesEnabled() {
|
|
|
|
|
let on = false;
|
|
|
|
|
try {
|
|
|
|
|
if (MobileDetection.getDeviceType() === 'desktop') {
|
|
|
|
|
const settings = this.loadAppSettingsFromStorage ? this.loadAppSettingsFromStorage() : {};
|
|
|
|
|
const defaults = this.getDefaultSettings ? this.getDefaultSettings() : {};
|
|
|
|
|
on = settings.sessionLineageLines ?? defaults.sessionLineageLines ?? true;
|
|
|
|
|
}
|
|
|
|
|
} catch (_e) {
|
|
|
|
|
on = false;
|
|
|
|
|
}
|
|
|
|
|
this._lineageLinesOn = !!on;
|
|
|
|
|
return this._lineageLinesOn;
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
/** Re-read the setting and redraw. Called from the settings apply pass and on resize. */
|
|
|
|
|
applyLineageLineSettings() {
|
|
|
|
|
const prev = this._lineageLinesOn;
|
|
|
|
|
const next = this._syncLineageLinesEnabled();
|
2026-10-07 00:03:37 +02:00
|
|
|
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();
|
|
|
|
|
}
|
2026-08-12 02:30:08 +02:00
|
|
|
},
|
|
|
|
|
|
|
|
|
|
/**
|
2026-10-07 00:03:37 +02:00
|
|
|
* 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.
|
2026-08-12 02:30:08 +02:00
|
|
|
*
|
2026-10-07 00:03:37 +02:00
|
|
|
* 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.
|
2026-08-12 02:30:08 +02:00
|
|
|
*/
|
|
|
|
|
_collectLineageEdges() {
|
|
|
|
|
const edges = [];
|
|
|
|
|
if (!this.sessions || this.sessions.size < 2) return edges;
|
|
|
|
|
const order = this.sessionOrder && this.sessionOrder.length ? this.sessionOrder : [...this.sessions.keys()];
|
|
|
|
|
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;
|
2026-10-07 00:03:37 +02:00
|
|
|
edges.push({ parentId, childId: id, status: session.status || 'idle' });
|
2026-08-12 02:30:08 +02:00
|
|
|
}
|
|
|
|
|
return edges;
|
|
|
|
|
},
|
|
|
|
|
|
2026-10-07 01:35:42 +02:00
|
|
|
/** Every family as `{ parentId, edges }`, in strip order of first appearance. */
|
|
|
|
|
_lineageFamilies(edges) {
|
2026-10-07 00:03:37 +02:00
|
|
|
const families = new Map();
|
|
|
|
|
for (const edge of edges) {
|
|
|
|
|
if (!families.has(edge.parentId)) families.set(edge.parentId, []);
|
|
|
|
|
families.get(edge.parentId).push(edge);
|
|
|
|
|
}
|
|
|
|
|
return [...families].map(([parentId, familyEdges]) => ({ parentId, edges: familyEdges }));
|
|
|
|
|
},
|
|
|
|
|
|
2026-10-07 01:35:42 +02:00
|
|
|
/**
|
|
|
|
|
* Parent ids of the families the selection emphasizes: the family the selected tab
|
|
|
|
|
* spawned, and the family it was spawned into (its parent plus its siblings). Empty
|
|
|
|
|
* when a web tab holds the stage, or the selected tab has no lineage at all.
|
|
|
|
|
*/
|
|
|
|
|
_lineageFocusParents(edges) {
|
|
|
|
|
const parents = new Set();
|
|
|
|
|
const focus = this.activeWebviewId ? null : this.activeSessionId;
|
|
|
|
|
if (!focus) return parents;
|
|
|
|
|
for (const edge of edges) {
|
|
|
|
|
if (edge.parentId === focus || edge.childId === focus) parents.add(edge.parentId);
|
|
|
|
|
}
|
|
|
|
|
return parents;
|
|
|
|
|
},
|
|
|
|
|
|
2026-10-07 00:03:37 +02:00
|
|
|
/**
|
|
|
|
|
* Colour for one family, from CodemanLineage.COLORS, keyed on the SPAWNING tab.
|
2026-08-18 16:20:43 +02:00
|
|
|
*
|
2026-10-07 00:03:37 +02:00
|
|
|
* ⚠ Per PARENT, not per child: every line leaving one tab is the same colour, no
|
2026-08-18 16:20:43 +02:00
|
|
|
* 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
|
2026-10-07 00:03:37 +02:00
|
|
|
* gets its own colour for the lines BELOW it, so a chain changes colour at each
|
2026-08-18 16:20:43 +02:00
|
|
|
* 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
|
2026-10-07 01:35:42 +02:00
|
|
|
* flicker). The draw pass claims a colour for EVERY family in strip order before it
|
|
|
|
|
* draws anything, so neither the draw order (the selected family goes last) nor
|
|
|
|
|
* which family was selected first ever decides who gets which colour. An empty string means "no override": the CSS falls back to
|
2026-08-18 16:20:43 +02:00
|
|
|
* --session-blue, so the first spawning tab keeps the skin-aware blue.
|
2026-08-15 06:38:26 +02:00
|
|
|
*/
|
2026-08-18 16:20:43 +02:00
|
|
|
_lineageColorFor(parentId) {
|
2026-08-15 06:38:26 +02:00
|
|
|
const palette = (window.CodemanLineage && window.CodemanLineage.COLORS) || [];
|
|
|
|
|
if (palette.length === 0) return '';
|
2026-08-18 16:20:43 +02:00
|
|
|
if (!this._lineageColorByParent) {
|
|
|
|
|
this._lineageColorByParent = new Map();
|
2026-08-15 06:38:26 +02:00
|
|
|
this._lineageColorNext = 0;
|
|
|
|
|
}
|
2026-08-18 16:20:43 +02:00
|
|
|
let idx = this._lineageColorByParent.get(parentId);
|
2026-08-15 06:38:26 +02:00
|
|
|
if (idx === undefined) {
|
|
|
|
|
idx = this._lineageColorNext++ % palette.length;
|
2026-08-18 16:20:43 +02:00
|
|
|
this._lineageColorByParent.set(parentId, idx);
|
2026-08-15 06:38:26 +02:00
|
|
|
// Bounded: entries for long-gone sessions are pruned once the map is clearly
|
|
|
|
|
// stale, so a day-long dashboard cannot grow it without limit.
|
2026-08-18 16:20:43 +02:00
|
|
|
if (this._lineageColorByParent.size > 200 && this.sessions) {
|
|
|
|
|
for (const key of this._lineageColorByParent.keys()) {
|
|
|
|
|
if (!this.sessions.has(key)) this._lineageColorByParent.delete(key);
|
2026-08-15 06:38:26 +02:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return palette[idx] || '';
|
|
|
|
|
},
|
|
|
|
|
|
2026-08-12 02:30:08 +02:00
|
|
|
/**
|
|
|
|
|
* Append the lineage layer to the shared SVG pass.
|
|
|
|
|
*
|
|
|
|
|
* Contract with the caller: `rects` is the batched read cache keyed `tab:<id>`, and
|
|
|
|
|
* everything read here goes through it so a tab another layer already measured is
|
|
|
|
|
* never measured twice. All reads happen before any append, keeping the caller's
|
|
|
|
|
* read → write split intact.
|
|
|
|
|
*/
|
|
|
|
|
_appendLineageConnectionLines(svg, rects) {
|
|
|
|
|
this._lineageEdgeCount = 0;
|
|
|
|
|
if (!svg || !this._lineageLinesEnabled()) return;
|
2026-10-07 00:03:37 +02:00
|
|
|
// 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.
|
2026-08-16 18:28:15 +02:00
|
|
|
if (this.isSessionSidebarActive?.()) return;
|
2026-10-07 00:03:37 +02:00
|
|
|
const computeTree = window.CodemanLineage && window.CodemanLineage.computeTree;
|
|
|
|
|
if (!computeTree) return;
|
2026-08-12 02:30:08 +02:00
|
|
|
|
|
|
|
|
const edges = this._collectLineageEdges();
|
2026-10-07 00:03:37 +02:00
|
|
|
this._lineageTotalEdges = edges.length;
|
2026-08-12 02:30:08 +02:00
|
|
|
if (edges.length === 0) return;
|
2026-10-07 01:35:42 +02:00
|
|
|
// Claim colours in strip order for every family before drawing (_lineageColorFor).
|
2026-10-07 00:03:37 +02:00
|
|
|
for (const edge of edges) this._lineageColorFor(edge.parentId);
|
2026-10-07 01:35:42 +02:00
|
|
|
const families = this._lineageFamilies(edges);
|
|
|
|
|
const focusParents = this._lineageFocusParents(edges);
|
2026-08-12 02:30:08 +02:00
|
|
|
if (!rects) rects = new Map();
|
|
|
|
|
|
|
|
|
|
// PHASE 1 — reads.
|
|
|
|
|
const strip = document.getElementById('sessionTabs');
|
|
|
|
|
if (!strip) return;
|
|
|
|
|
const stripRect = strip.getBoundingClientRect();
|
2026-08-23 12:21:02 -04:00
|
|
|
const orientation =
|
|
|
|
|
document.documentElement.getAttribute('data-tab-orientation') === 'vertical' ? 'vertical' : 'horizontal';
|
2026-10-01 14:14:30 -04:00
|
|
|
// A session hidden inside a collapsed group of the grouped rail has no row
|
2026-10-07 00:03:37 +02:00
|
|
|
// 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.
|
2026-10-01 14:14:30 -04:00
|
|
|
const resolveEndpoint = (id) => {
|
|
|
|
|
const tab = strip.querySelector(`.session-tab[data-id="${CSS.escape(id)}"]`);
|
|
|
|
|
if (tab) return { key: 'tab:' + id, element: tab, proxied: false };
|
|
|
|
|
const groupId = this._hiddenTabGroupByRef?.get('session:' + id);
|
|
|
|
|
if (!groupId) return { key: 'tab:' + id, element: null, proxied: false };
|
|
|
|
|
const header = strip.querySelector(`[data-tab-group-header="${CSS.escape(groupId)}"]`);
|
|
|
|
|
return { key: 'group:' + groupId, element: header, proxied: !!header };
|
|
|
|
|
};
|
2026-10-07 00:03:37 +02:00
|
|
|
const measure = (endpoint) => {
|
|
|
|
|
if (!rects.has(endpoint.key)) {
|
2026-10-01 14:14:30 -04:00
|
|
|
rects.set(endpoint.key, endpoint.element ? endpoint.element.getBoundingClientRect() : null);
|
2026-08-12 02:30:08 +02:00
|
|
|
}
|
2026-10-07 00:03:37 +02:00
|
|
|
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 = [];
|
2026-10-08 20:51:27 +02:00
|
|
|
let spineLeft;
|
2026-10-07 00:03:37 +02:00
|
|
|
if (orientation === 'horizontal') {
|
2026-10-08 20:51:27 +02:00
|
|
|
// Where the spine channel is: the reserved --lineage-spine-channel just left
|
|
|
|
|
// of the strip's content edge. Usually that is the strip's own left edge,
|
|
|
|
|
// but grouped by state the label column comes first (styles.css), and a
|
|
|
|
|
// spine at the edge ran through every label. Read back from the padding
|
|
|
|
|
// the CSS laid out, so geometry and stylesheet cannot disagree.
|
|
|
|
|
const style = typeof getComputedStyle === 'function' ? getComputedStyle(strip) : null;
|
|
|
|
|
const channel = style ? parseFloat(style.getPropertyValue('--lineage-spine-channel')) : NaN;
|
|
|
|
|
if (Number.isFinite(channel)) {
|
|
|
|
|
const inset = (parseFloat(style.borderLeftWidth) || 0) + (parseFloat(style.paddingLeft) || 0);
|
|
|
|
|
spineLeft = stripRect.left + inset - channel;
|
|
|
|
|
}
|
2026-10-07 00:03:37 +02:00
|
|
|
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);
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-08-12 02:30:08 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// PHASE 2 — writes, from the cache only.
|
2026-10-07 01:35:42 +02:00
|
|
|
// Lanes follow STRIP order, so a family keeps its lane when the selection moves;
|
|
|
|
|
// only the DRAW order changes (the emphasized families last, on top). A row gap
|
|
|
|
|
// fits a few lanes, so they cycle: families that share one are told apart by colour.
|
|
|
|
|
const laneLimit = Math.max(1, (window.CodemanLineage && window.CodemanLineage.MAX_LANES) || 3);
|
|
|
|
|
const laneCount = Math.min(laneLimit, resolvedFamilies.length);
|
|
|
|
|
const drawOrder = resolvedFamilies
|
|
|
|
|
.map((resolved, index) => ({
|
|
|
|
|
...resolved,
|
|
|
|
|
lane: index % laneCount,
|
|
|
|
|
focus: focusParents.has(resolved.family.parentId),
|
|
|
|
|
}))
|
|
|
|
|
.sort((a, b) => a.focus - b.focus);
|
|
|
|
|
for (const { family, parentEndpoint, parentRect, children, lane, focus } of drawOrder) {
|
2026-10-07 00:03:37 +02:00
|
|
|
const geom = computeTree({
|
2026-08-23 12:21:02 -04:00
|
|
|
parent: parentRect,
|
2026-10-07 00:03:37 +02:00
|
|
|
children: children.map((c) => ({ id: c.edge.childId, rect: c.rect })),
|
2026-08-23 12:21:02 -04:00
|
|
|
strip: stripRect,
|
2026-10-07 00:03:37 +02:00
|
|
|
tabs: tabRects,
|
2026-10-08 20:51:27 +02:00
|
|
|
spineLeft,
|
2026-08-23 12:21:02 -04:00
|
|
|
orientation,
|
2026-10-07 00:03:37 +02:00
|
|
|
lane,
|
2026-10-07 01:35:42 +02:00
|
|
|
laneCount,
|
2026-08-23 12:21:02 -04:00
|
|
|
});
|
2026-10-07 01:35:42 +02:00
|
|
|
if (!geom || geom.routes.length === 0) continue;
|
2026-10-07 00:03:37 +02:00
|
|
|
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
|
2026-10-07 01:35:42 +02:00
|
|
|
// routes share (the trunk) do not stack into a brighter line than the branches,
|
|
|
|
|
// and the emphasis for the selected tab's families (styles.css).
|
2026-10-07 00:03:37 +02:00
|
|
|
const group = document.createElementNS('http://www.w3.org/2000/svg', 'g');
|
2026-10-07 01:35:42 +02:00
|
|
|
group.setAttribute('class', 'lineage-family' + (focus ? ' lineage-family--focus' : ''));
|
2026-10-07 00:03:37 +02:00
|
|
|
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));
|
2026-10-07 01:35:42 +02:00
|
|
|
// Fallback radius only: styles.css sizes the dot through `--lineage-dot-r`
|
|
|
|
|
// (larger in an emphasized family), and `lineage-dot-pulse` breathes from it.
|
2026-10-07 00:03:37 +02:00
|
|
|
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;
|
2026-10-07 01:35:42 +02:00
|
|
|
}
|
2026-08-12 02:30:08 +02:00
|
|
|
},
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* The strip scrolls (desktop `overflow-x: auto` and every wrapped layout), and a
|
2026-10-07 00:03:37 +02:00
|
|
|
* 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.
|
2026-08-12 02:30:08 +02:00
|
|
|
*
|
|
|
|
|
* Installed once; the guard also keeps a re-init from stacking listeners.
|
|
|
|
|
*/
|
|
|
|
|
_installLineageStripScrollListener() {
|
|
|
|
|
if (this._lineageScrollHandler) return;
|
|
|
|
|
const strip = document.getElementById('sessionTabs');
|
|
|
|
|
if (!strip) return;
|
|
|
|
|
this._lineageScrollHandler = () => {
|
2026-08-24 00:59:48 +02:00
|
|
|
// Sidebar layout and the vertical rail scroll the SAME element
|
|
|
|
|
// vertically, and there the subagent/ultracode connectors anchor to tab
|
2026-10-07 00:03:37 +02:00
|
|
|
// rects too (the sidebar skips lineage entirely, and the rail can show
|
|
|
|
|
// connectors with zero lineage edges, so _lineageEdgeCount alone would
|
|
|
|
|
// never redraw them).
|
2026-08-24 00:59:48 +02:00
|
|
|
if (this._lineageEdgeCount > 0 || this._isVerticalTabList?.()) this.updateConnectionLines();
|
2026-08-12 02:30:08 +02:00
|
|
|
};
|
|
|
|
|
strip.addEventListener('scroll', this._lineageScrollHandler, { passive: true });
|
2026-10-07 00:03:37 +02:00
|
|
|
|
|
|
|
|
// ⚠ 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);
|
2026-08-12 02:30:08 +02:00
|
|
|
},
|
|
|
|
|
});
|