mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-06 07:29:42 +02:00
The lines that join a tab to the workers its codeman skill spawned were drawn with numbers tuned against two tabs sitting side by side, and they degraded in exactly the two situations the feature is actually used in. 1. A spawned worker is appended to the END of the strip, so the real span between a lead and its worker is 800-1500px. With the dip clamped at 44px that is a 33px sag: the arc reads as a straight line drawn across the terminal instead of a bracket hanging under the strip. The dip now grows at 0.085/px and clamps at 104. 2. When the desktop strip wraps (tabs-two-rows / tabs-auto-wrap), a parent on row 1 and its child on row 2 are ~14px apart, and the cross-row branch drew parent-bottom to child-TOP: a flat line hidden inside the row gap, with siblings overprinting each other. Both ends now anchor on the tab BOTTOM with the control points below the LOWER row, so a wrapped pair gets the same bracket a flat strip gets. That deletes the branch: one shape covers both. Visibility, at 1:1 rather than in a zoomed mockup: 2 -> 2.5px stroke, 4 4 -> 5 5 dashes (lineage-flow moves with them, -16 -> -20), opacity .55 -> .72, and a second wider glow so the contrast comes from the halo rather than from more weight, keeping the line under the subagent lines' 3px. A working child is bright (.95) outside the reduced-motion block, so turning motion off no longer also dims every worker's arc. Sibling nesting 6 -> 8px and the direction dot 3 -> 3.5px to match the heavier stroke. Verified at 1:1 in a harness driving the real styles.css and the real computeLineagePath over three layouts (adjacent workers, workers at the far end of a full strip, wrapped two-row strip) on a dark and a light skin. test/session-lineage-lines.test.ts pins both regressions. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
177 lines
8.0 KiB
JavaScript
177 lines
8.0 KiB
JavaScript
/**
|
|
* @fileoverview Session lineage lines — the arcs 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.
|
|
*
|
|
* 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
|
|
* 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.
|
|
* - 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)
|
|
* @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) 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).
|
|
*
|
|
* 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.
|
|
*/
|
|
_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' });
|
|
}
|
|
return edges;
|
|
},
|
|
|
|
/**
|
|
* 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;
|
|
const compute = window.CodemanLineage && window.CodemanLineage.computePath;
|
|
if (!compute) return;
|
|
|
|
const edges = this._collectLineageEdges();
|
|
if (edges.length === 0) return;
|
|
this._lineageEdgeCount = edges.length;
|
|
if (!rects) rects = new Map();
|
|
|
|
// PHASE 1 — reads.
|
|
const strip = document.getElementById('sessionTabs');
|
|
if (!strip) return;
|
|
const stripRect = strip.getBoundingClientRect();
|
|
for (const edge of edges) {
|
|
for (const id of [edge.parentId, edge.childId]) {
|
|
const key = 'tab:' + id;
|
|
if (rects.has(key)) continue;
|
|
const tab = strip.querySelector(`.session-tab[data-id="${CSS.escape(id)}"]`);
|
|
rects.set(key, tab ? tab.getBoundingClientRect() : null);
|
|
}
|
|
}
|
|
|
|
// PHASE 2 — writes, from the cache only.
|
|
for (const edge of edges) {
|
|
const parentRect = rects.get('tab:' + edge.parentId);
|
|
const childRect = rects.get('tab:' + edge.childId);
|
|
if (!parentRect || !childRect) continue;
|
|
|
|
const geom = compute({ parent: parentRect, child: childRect, strip: stripRect, depth: edge.depth });
|
|
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' : '';
|
|
line.setAttribute('class', 'connection-line lineage-line' + working);
|
|
// `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);
|
|
dot.setAttribute('data-child-tab', edge.childId);
|
|
svg.appendChild(dot);
|
|
}
|
|
},
|
|
|
|
/**
|
|
* 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.
|
|
*
|
|
* 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 = () => {
|
|
if (this._lineageEdgeCount > 0) this.updateConnectionLines();
|
|
};
|
|
strip.addEventListener('scroll', this._lineageScrollHandler, { passive: true });
|
|
},
|
|
});
|