mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-10 01:09:43 +02:00
With tabArrangement 'state' (the default) and lineage lines on (the desktop default), the spine ran straight through the WORKING / WAITING / NEEDS YOU label of every row it passed. #538's `.session-tabs.tabs-triage:is(.tabs-auto-wrap, .tabs-two-rows)` pads the strip by the label gutter and pulls each label to the strip's edge with a negative margin; it outranks #544's `.lineage-tree.tabs-auto-wrap` `padding-left: 20px`, so the spine channel disappeared, and the spine, anchored at the strip's left edge (computeLineageTree), sat in the label column. The channel now opens BETWEEN the label column and the tabs: with lineage, the state strip pads by gutter + --lineage-spine-channel, every label keeps its column at the edge (still under the brand) and hands the channel back after itself, and the lead label still starts right after the brand. computeLineageTree takes the channel's left edge as `spineLeft`, which session-lineage.js reads back from the padding the CSS laid out (content edge minus the channel), so the two cannot drift; without it, or where the channel is the padding itself (classic, ledger, case), the spine stays at the strip's edge exactly as before. The spine's clamp now only considers tabs in rows the spine can run beside (every row after the first), so a first row that starts left of the channel (a quiet idle group right after the brand) no longer drags the spine back over the labels. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
402 lines
20 KiB
JavaScript
402 lines
20 KiB
JavaScript
/**
|
|
* @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 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).
|
|
*
|
|
* 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`).
|
|
*
|
|
* 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.
|
|
*
|
|
* 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.computeTree + .COLORS)
|
|
* @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();
|
|
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();
|
|
}
|
|
},
|
|
|
|
/**
|
|
* 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.
|
|
*
|
|
* 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()];
|
|
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;
|
|
edges.push({ parentId, childId: id, status: session.status || 'idle' });
|
|
}
|
|
return edges;
|
|
},
|
|
|
|
/** Every family as `{ parentId, edges }`, in strip order of first appearance. */
|
|
_lineageFamilies(edges) {
|
|
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 }));
|
|
},
|
|
|
|
/**
|
|
* 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;
|
|
},
|
|
|
|
/**
|
|
* Colour for one family, from CodemanLineage.COLORS, keyed on the SPAWNING tab.
|
|
*
|
|
* ⚠ 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 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). 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
|
|
* --session-blue, so the first spawning tab keeps the skin-aware blue.
|
|
*/
|
|
_lineageColorFor(parentId) {
|
|
const palette = (window.CodemanLineage && window.CodemanLineage.COLORS) || [];
|
|
if (palette.length === 0) return '';
|
|
if (!this._lineageColorByParent) {
|
|
this._lineageColorByParent = new Map();
|
|
this._lineageColorNext = 0;
|
|
}
|
|
let idx = this._lineageColorByParent.get(parentId);
|
|
if (idx === undefined) {
|
|
idx = this._lineageColorNext++ % palette.length;
|
|
this._lineageColorByParent.set(parentId, idx);
|
|
// Bounded: entries for long-gone sessions are pruned once the map is clearly
|
|
// stale, so a day-long dashboard cannot grow it without limit.
|
|
if (this._lineageColorByParent.size > 200 && this.sessions) {
|
|
for (const key of this._lineageColorByParent.keys()) {
|
|
if (!this.sessions.has(key)) this._lineageColorByParent.delete(key);
|
|
}
|
|
}
|
|
}
|
|
return palette[idx] || '';
|
|
},
|
|
|
|
/**
|
|
* 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;
|
|
// 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 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 before drawing (_lineageColorFor).
|
|
for (const edge of edges) this._lineageColorFor(edge.parentId);
|
|
const families = this._lineageFamilies(edges);
|
|
const focusParents = this._lineageFocusParents(edges);
|
|
if (!rects) rects = new Map();
|
|
|
|
// PHASE 1 — reads.
|
|
const strip = document.getElementById('sessionTabs');
|
|
if (!strip) return;
|
|
const stripRect = strip.getBoundingClientRect();
|
|
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 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 };
|
|
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 };
|
|
};
|
|
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 = [];
|
|
let spineLeft;
|
|
if (orientation === 'horizontal') {
|
|
// 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;
|
|
}
|
|
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);
|
|
}
|
|
}
|
|
}
|
|
|
|
// PHASE 2 — writes, from the cache only.
|
|
// 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) {
|
|
const geom = computeTree({
|
|
parent: parentRect,
|
|
children: children.map((c) => ({ id: c.edge.childId, rect: c.rect })),
|
|
strip: stripRect,
|
|
tabs: tabRects,
|
|
spineLeft,
|
|
orientation,
|
|
lane,
|
|
laneCount,
|
|
});
|
|
if (!geom || geom.routes.length === 0) continue;
|
|
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,
|
|
// and the emphasis for the selected tab's families (styles.css).
|
|
const group = document.createElementNS('http://www.w3.org/2000/svg', 'g');
|
|
group.setAttribute('class', 'lineage-family' + (focus ? ' lineage-family--focus' : ''));
|
|
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));
|
|
// Fallback radius only: styles.css sizes the dot through `--lineage-dot-r`
|
|
// (larger in an emphasized family), and `lineage-dot-pulse` breathes from it.
|
|
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 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.
|
|
*/
|
|
_installLineageStripScrollListener() {
|
|
if (this._lineageScrollHandler) return;
|
|
const strip = document.getElementById('sessionTabs');
|
|
if (!strip) return;
|
|
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 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);
|
|
},
|
|
});
|