mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-05 23:19:43 +02:00
chore: version packages
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -493,6 +493,11 @@ export class Session extends EventEmitter {
|
||||
// from req.authUser and round-tripped through recovery like _remote/_docker.
|
||||
private _owner?: string;
|
||||
|
||||
// The session that spawned this one (tab lineage lines). Resolved by the create
|
||||
// route before it reaches here, so this is always either an id that existed at
|
||||
// create time or undefined. Decoration only — see SessionState.parentSessionId.
|
||||
private readonly _parentSessionId?: string;
|
||||
|
||||
// Session color for visual differentiation
|
||||
private _color: import('./types.js').SessionColor = 'default';
|
||||
|
||||
@@ -574,6 +579,8 @@ export class Session extends EventEmitter {
|
||||
docker?: SessionDocker;
|
||||
/** Owning username (multi-user mode); undefined in single-user. */
|
||||
owner?: string;
|
||||
/** Session that spawned this one — tab lineage decoration, resolved by the caller. */
|
||||
parentSessionId?: string;
|
||||
}
|
||||
) {
|
||||
super();
|
||||
@@ -665,6 +672,10 @@ export class Session extends EventEmitter {
|
||||
this._remote = config.remote;
|
||||
this._docker = config.docker;
|
||||
this._owner = config.owner;
|
||||
// Never self-parent: a session pointing at itself would draw a zero-length
|
||||
// lineage arc under its own tab. Only reachable via the recovery path, where
|
||||
// both the id and the saved parent come from disk.
|
||||
this._parentSessionId = config.parentSessionId === this.id ? undefined : config.parentSessionId;
|
||||
if (config.attachmentHistory && config.attachmentHistory.length > 0) {
|
||||
this.restoreAttachmentHistory(config.attachmentHistory);
|
||||
}
|
||||
@@ -781,6 +792,11 @@ export class Session extends EventEmitter {
|
||||
return this._owner;
|
||||
}
|
||||
|
||||
/** The session that spawned this one (tab lineage decoration), else undefined. */
|
||||
get parentSessionId(): string | undefined {
|
||||
return this._parentSessionId;
|
||||
}
|
||||
|
||||
/** Set the owning username (used by recovery to restore ownership). */
|
||||
set owner(username: string | undefined) {
|
||||
this._owner = username;
|
||||
@@ -1176,6 +1192,7 @@ export class Session extends EventEmitter {
|
||||
remote: this._remote,
|
||||
docker: this._docker,
|
||||
owner: this._owner,
|
||||
parentSessionId: this._parentSessionId,
|
||||
currentTaskId: this._currentTaskId,
|
||||
createdAt: this.createdAt,
|
||||
lastActivityAt: this._lastActivityAt,
|
||||
|
||||
@@ -400,6 +400,16 @@ export interface SessionState {
|
||||
docker?: SessionDocker;
|
||||
/** Owning username in multi-user mode; undefined in single-user (ignored when the flag is off) */
|
||||
owner?: string;
|
||||
/**
|
||||
* The Codeman session that spawned this one, supplied by the caller at create time
|
||||
* (`parentSessionId` body field or the `X-Codeman-Parent-Session` header) and resolved
|
||||
* against live sessions before being stored.
|
||||
*
|
||||
* ⚠️ UI DECORATION ONLY — it draws the lineage lines between tabs. It is never an
|
||||
* ownership, permission, or lifecycle signal: a child outlives its parent, and an
|
||||
* unresolvable value is dropped rather than failing the spawn.
|
||||
*/
|
||||
parentSessionId?: string;
|
||||
/** ID of currently assigned task, null if none */
|
||||
currentTaskId: string | null;
|
||||
/** Timestamp when session was created */
|
||||
|
||||
@@ -859,6 +859,8 @@ class CodemanApp {
|
||||
this.applyLocalization();
|
||||
this.applyTabWrapSettings();
|
||||
this.applyMonitorVisibility();
|
||||
this.applyLineageLineSettings?.();
|
||||
this._installLineageStripScrollListener?.();
|
||||
this._setupTabMiddleClickClose();
|
||||
// Must run before the first session:created can arrive: markSessionTabEntering()
|
||||
// ignores ids until this sets up its state, which is what keeps the tabs
|
||||
@@ -924,6 +926,7 @@ class CodemanApp {
|
||||
this.applyLocalization();
|
||||
this.applyTabWrapSettings();
|
||||
this.applyMonitorVisibility();
|
||||
this.applyLineageLineSettings?.();
|
||||
// ultracodeFloatingWindows syncs from the server (non-display key), but on a
|
||||
// FRESH device the getLightState run snapshot can seed workflowRuns BEFORE this
|
||||
// async settings load resolves — so the floating-window gate read false then and
|
||||
@@ -1637,6 +1640,9 @@ class CodemanApp {
|
||||
// The pane is one shared element, so it is only marked here and played when
|
||||
// this session is actually selected (see selectSession).
|
||||
this.markTerminalEntering?.(data.id);
|
||||
// A spawned session's lineage arc draws in with the tab. Keyed the same way
|
||||
// session-lineage.js tags its paths; a no-op unless a line-entrance theme is on.
|
||||
if (data.parentSessionId) this.markConnectionLineEntering?.('lineage:' + data.id);
|
||||
this.renderSessionTabs();
|
||||
this.updateCost();
|
||||
// Start stats polling when first session appears
|
||||
@@ -3743,6 +3749,11 @@ class CodemanApp {
|
||||
this._refreshMobileOverviewIfVisible?.();
|
||||
// Same deal for the desktop home screen's tab column.
|
||||
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 an arc to keep anchored.
|
||||
if (this._lineageEdgeCount > 0) this.updateConnectionLines();
|
||||
}
|
||||
|
||||
// Auto-wrap desktop session tabs to a second row when they overflow one row,
|
||||
|
||||
@@ -197,6 +197,89 @@ 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
|
||||
// spawned (a worker started through the codeman agent skill, which passes its own
|
||||
// id as parentSessionId). Pure: the caller measures and appends, this decides.
|
||||
//
|
||||
// Two shapes, because both endpoints live in ONE horizontal strip and the subagent
|
||||
// shape (tab-bottom → window-top) has nothing to aim at:
|
||||
// - same row: a shallow U-bridge HANGING BELOW the strip, 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.
|
||||
// - different rows (desktop `tabs-two-rows` / `tabs-auto-wrap`): the vertical
|
||||
// bezier the subagent lines already use, parent edge → child edge.
|
||||
//
|
||||
// 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.
|
||||
const LINEAGE_DIP_BASE_PX = 14;
|
||||
const LINEAGE_DIP_PER_PX = 0.06;
|
||||
const LINEAGE_DIP_MIN_PX = 16;
|
||||
const LINEAGE_DIP_MAX_PX = 44;
|
||||
const LINEAGE_SIBLING_STEP_PX = 6;
|
||||
const LINEAGE_STRIP_TOLERANCE_PX = 4;
|
||||
|
||||
function computeLineagePath(input) {
|
||||
const parent = input?.parent;
|
||||
const child = input?.child;
|
||||
if (!parent || !child) return null;
|
||||
|
||||
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;
|
||||
|
||||
const px = Number(parent.left) + pw / 2;
|
||||
const cx = Number(child.left) + cw / 2;
|
||||
if (!Number.isFinite(px) || !Number.isFinite(cx)) return null;
|
||||
|
||||
const strip = input?.strip;
|
||||
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;
|
||||
}
|
||||
|
||||
const depth = Math.max(0, Math.min(6, Number(input?.depth) || 0));
|
||||
const pTop = Number(parent.top);
|
||||
const pBottom = pTop + ph;
|
||||
const cTop = Number(child.top);
|
||||
const cBottom = cTop + ch;
|
||||
const sameRow = Math.abs(pTop + ph / 2 - (cTop + ch / 2)) <= Math.min(ph, ch) / 2;
|
||||
|
||||
let d;
|
||||
let endX;
|
||||
let endY;
|
||||
if (sameRow) {
|
||||
const y0 = Math.max(pBottom, cBottom);
|
||||
const span = Math.abs(cx - px);
|
||||
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 = y0 + dip;
|
||||
d = `M ${r1(px)} ${r1(y0)} C ${r1(px)} ${r1(yc)}, ${r1(cx)} ${r1(yc)}, ${r1(cx)} ${r1(y0)}`;
|
||||
endX = cx;
|
||||
endY = y0;
|
||||
} else {
|
||||
const childBelow = cTop + ch / 2 > pTop + ph / 2;
|
||||
const y1 = childBelow ? pBottom : pTop;
|
||||
const y2 = childBelow ? cTop : cBottom;
|
||||
const mid = (y1 + y2) / 2;
|
||||
d = `M ${r1(px)} ${r1(y1)} C ${r1(px)} ${r1(mid)}, ${r1(cx)} ${r1(mid)}, ${r1(cx)} ${r1(y2)}`;
|
||||
endX = cx;
|
||||
endY = y2;
|
||||
}
|
||||
return { d, endX, endY, sameRow };
|
||||
}
|
||||
|
||||
// One decimal is plenty for a screen-space path and keeps the `d` string short.
|
||||
function r1(n) {
|
||||
return Math.round(n * 10) / 10;
|
||||
}
|
||||
|
||||
// COD-134 — Terminal WebSocket reconnect policy.
|
||||
//
|
||||
// Decide what to do after a terminal WebSocket closes, given the close `code`
|
||||
@@ -308,6 +391,12 @@ if (typeof window !== 'undefined') {
|
||||
window.CodemanWsReconnect = {
|
||||
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,
|
||||
};
|
||||
window.CodemanConnectionLoss = {
|
||||
compute: computeConnectionLossUi,
|
||||
GRACE_MS: CONNECTION_LOSS_GRACE_MS,
|
||||
|
||||
@@ -1767,6 +1767,13 @@
|
||||
</div>
|
||||
<label class="switch switch-sm"><input type="checkbox" id="appSettingsShowTabDetachButton"><span class="slider"></span></label>
|
||||
</div>
|
||||
<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>
|
||||
</div>
|
||||
<label class="switch switch-sm"><input type="checkbox" id="appSettingsLineageLines" checked><span class="slider"></span></label>
|
||||
</div>
|
||||
<div class="set-row" id="appSettingsMobileOverviewItem" data-search="overview home screen phone logo">
|
||||
<div class="set-row-text">
|
||||
<span class="set-row-label">Overview Home Screen <span class="set-tag">phone</span></span>
|
||||
@@ -3201,6 +3208,7 @@
|
||||
<script defer src="api-client.js"></script>
|
||||
<script defer src="subagent-windows.js"></script>
|
||||
<script defer src="ultracode-windows.js"></script>
|
||||
<script defer src="session-lineage.js"></script>
|
||||
<script defer src="image-input.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
/**
|
||||
* @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));
|
||||
dot.setAttribute('r', '3');
|
||||
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 });
|
||||
},
|
||||
});
|
||||
@@ -353,6 +353,12 @@ Object.assign(CodemanApp.prototype, {
|
||||
document.getElementById('appSettingsShowRedrawButton').checked = settings.showRedrawButton ?? defaults.showRedrawButton ?? false;
|
||||
// Phone overview home screen: only meaningful under 430px, so the row is
|
||||
// hidden elsewhere rather than offering a toggle that changes nothing.
|
||||
// Spawn lineage lines: desktop-only (the overlay sits UNDER the fixed mobile
|
||||
// header), so the row is hidden elsewhere rather than offering a toggle that
|
||||
// changes nothing. Default ON — only an explicit false turns it off.
|
||||
document.getElementById('appSettingsLineageLines').checked = settings.sessionLineageLines ?? defaults.sessionLineageLines ?? true;
|
||||
const lineageItem = document.getElementById('appSettingsLineageLinesItem');
|
||||
if (lineageItem) lineageItem.style.display = MobileDetection.getDeviceType() === 'desktop' ? '' : 'none';
|
||||
document.getElementById('appSettingsMobileOverview').checked = settings.mobileOverviewEnabled ?? defaults.mobileOverviewEnabled ?? false;
|
||||
const mobileOverviewItem = document.getElementById('appSettingsMobileOverviewItem');
|
||||
if (mobileOverviewItem) mobileOverviewItem.style.display = MobileDetection.getDeviceType() === 'mobile' ? '' : 'none';
|
||||
@@ -1984,6 +1990,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
showPlanUsageLimits: document.getElementById('appSettingsShowPlanUsageLimits').checked,
|
||||
showRedrawButton: document.getElementById('appSettingsShowRedrawButton').checked,
|
||||
mobileOverviewEnabled: document.getElementById('appSettingsMobileOverview').checked,
|
||||
sessionLineageLines: document.getElementById('appSettingsLineageLines').checked,
|
||||
showSessionButton: document.getElementById('appSettingsShowSessionButton').checked,
|
||||
showAwayDigestButton: document.getElementById('appSettingsShowAwayDigestButton').checked,
|
||||
showCronButton: document.getElementById('appSettingsShowCronButton').checked,
|
||||
@@ -2144,6 +2151,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
this.applySkin();
|
||||
this.applyLocalization();
|
||||
this.applyTabWrapSettings();
|
||||
this.applyLineageLineSettings?.();
|
||||
this._updateTokensImmediate(); // Re-render token display (picks up showCost change)
|
||||
this.applyMonitorVisibility();
|
||||
this.renderApprovals?.(); // Approvals Inbox toggle (hide/show bell + drawer)
|
||||
@@ -2190,6 +2198,10 @@ Object.assign(CodemanApp.prototype, {
|
||||
showTabDetachButton: _tdb,
|
||||
// Phone-only home surface, and absent from SettingsUpdateSchema (.strict()).
|
||||
mobileOverviewEnabled: _mov,
|
||||
// Desktop-only tab decoration, per-device, and likewise absent from the
|
||||
// .strict() schema — syncing it would push a desktop-shaped choice onto
|
||||
// devices that cannot render it at all.
|
||||
sessionLineageLines: _sll,
|
||||
...serverSettings
|
||||
} = settings;
|
||||
try {
|
||||
@@ -2844,6 +2856,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
'showSessionButton', 'showAwayDigestButton', 'showCronButton',
|
||||
'showTabDetachButton',
|
||||
'mobileOverviewEnabled',
|
||||
'sessionLineageLines',
|
||||
]);
|
||||
// The plan-usage chip is a PER-DEVICE display setting (desktop default ON,
|
||||
// handheld default OFF): desktop can show it while mobile stays hidden. It
|
||||
|
||||
@@ -9204,6 +9204,66 @@ kbd {
|
||||
50% { opacity: 1; }
|
||||
}
|
||||
|
||||
/* ===== 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.
|
||||
|
||||
Colour comes from --session-purple, which EVERY skin block already defines and
|
||||
already tunes for its own background, so one rule covers all seven (the four
|
||||
light skins included). 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. */
|
||||
.connection-line.lineage-line {
|
||||
stroke: var(--session-purple, #a98fe0);
|
||||
stroke-width: 2;
|
||||
stroke-dasharray: 4 4;
|
||||
stroke-linecap: round;
|
||||
opacity: 0.55;
|
||||
filter: drop-shadow(0 0 2px rgba(0, 0, 0, 0.55)) drop-shadow(0 0 5px var(--session-purple, #a98fe0));
|
||||
}
|
||||
|
||||
.connection-line.lineage-line:hover {
|
||||
opacity: 0.9;
|
||||
stroke-width: 2.5;
|
||||
}
|
||||
|
||||
.lineage-line-dot {
|
||||
fill: var(--session-purple, #a98fe0);
|
||||
opacity: 0.7;
|
||||
filter: drop-shadow(0 0 4px var(--session-purple, #a98fe0));
|
||||
}
|
||||
|
||||
/* 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. */
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
.connection-line.lineage-line--working {
|
||||
opacity: 0.85;
|
||||
animation: lineage-flow 1.1s linear infinite;
|
||||
}
|
||||
|
||||
.lineage-line-dot--working {
|
||||
opacity: 1;
|
||||
animation: lineage-dot-pulse 1.4s ease-in-out infinite;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes lineage-flow {
|
||||
to {
|
||||
stroke-dashoffset: -16;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes lineage-dot-pulse {
|
||||
0%, 100% {
|
||||
opacity: 0.6;
|
||||
r: 3;
|
||||
}
|
||||
50% {
|
||||
opacity: 1;
|
||||
r: 4;
|
||||
}
|
||||
}
|
||||
|
||||
/* ========== Project Insights Panel (Bash File Viewers) ========== */
|
||||
|
||||
.project-insights-panel {
|
||||
|
||||
@@ -465,6 +465,11 @@ Object.assign(CodemanApp.prototype, {
|
||||
if (typeof this._appendUltracodeAgentConnectionLines === 'function') {
|
||||
this._appendUltracodeAgentConnectionLines(svg, rects);
|
||||
}
|
||||
// Tab → tab it spawned (session-lineage.js). Same shared read/write pass and the
|
||||
// same tab-rect cache; desktop-only and gated on its own setting inside.
|
||||
if (typeof this._appendLineageConnectionLines === 'function') {
|
||||
this._appendLineageConnectionLines(svg, rects);
|
||||
}
|
||||
|
||||
// Every path above was just created from scratch, so any line entrance in
|
||||
// flight has to be re-attached here (resumed via a negative animation-delay).
|
||||
|
||||
@@ -919,7 +919,10 @@ Object.assign(CodemanApp.prototype, {
|
||||
}
|
||||
}
|
||||
}
|
||||
// Update subagent connection lines and local echo at new dimensions
|
||||
// Update subagent connection lines and local echo at new dimensions.
|
||||
// Lineage lines are desktop-only, so a resize across the 1024px boundary
|
||||
// has to re-resolve their gate before the redraw, not just move them.
|
||||
this.applyLineageLineSettings?.();
|
||||
this.updateConnectionLines();
|
||||
if (this._localEchoOverlay?.hasPending) {
|
||||
this._localEchoOverlay.rerender();
|
||||
|
||||
@@ -273,6 +273,54 @@ export function findSessionOrFail(ctx: SessionPort, sessionId: string, req?: Fas
|
||||
return session;
|
||||
}
|
||||
|
||||
/** Shortest prefix accepted for a parent session id (see resolveParentSessionId). */
|
||||
const PARENT_SESSION_ID_MIN_PREFIX = 8;
|
||||
|
||||
/**
|
||||
* Resolve the "who spawned me" hint a create request may carry, for the tab lineage
|
||||
* lines in the web UI. Reads the body field first, then the `X-Codeman-Parent-Session`
|
||||
* header (the agent skill sets that once on its shared curl invocation, so every spawn
|
||||
* recipe carries it without a per-recipe edit).
|
||||
*
|
||||
* ⚠️ Decoration, and resolved rather than trusted:
|
||||
* - Returns `undefined` for anything unresolvable and NEVER throws. A stale or bogus
|
||||
* id must not be able to fail a worker spawn over a cosmetic line.
|
||||
* - The parent must be a live session the caller can already see AND carry the same
|
||||
* owner as the session being created, so a multi-user caller cannot staple their
|
||||
* session under someone else's tab.
|
||||
* - Exact id match first, then a UNIQUE prefix of >= 8 chars, because ids appear
|
||||
* truncated to 8 in mux names and in a Docker export's `$CODEMAN_SESSION_ID`.
|
||||
* An ambiguous prefix resolves to nothing rather than to a guess.
|
||||
*
|
||||
* Returns the parent's FULL id, which is what the frontend matches tabs on.
|
||||
*/
|
||||
export function resolveParentSessionId(
|
||||
ctx: SessionPort,
|
||||
req: FastifyRequest,
|
||||
bodyValue: string | undefined,
|
||||
owner: string | undefined
|
||||
): string | undefined {
|
||||
const header = req.headers['x-codeman-parent-session'];
|
||||
const raw = bodyValue ?? (Array.isArray(header) ? header[0] : header);
|
||||
const candidate = typeof raw === 'string' ? raw.trim() : '';
|
||||
// The body field is schema-capped; the header is not, so cap it here too.
|
||||
if (!candidate || candidate.length > 100) return undefined;
|
||||
|
||||
let parent = ctx.sessions.get(candidate);
|
||||
if (!parent && candidate.length >= PARENT_SESSION_ID_MIN_PREFIX) {
|
||||
for (const session of ctx.sessions.values()) {
|
||||
if (!session.id.startsWith(candidate)) continue;
|
||||
if (parent) return undefined; // ambiguous prefix — resolve to nothing, never a guess
|
||||
parent = session;
|
||||
}
|
||||
}
|
||||
if (!parent) return undefined;
|
||||
|
||||
if (!canAccessOwned(getAuthUser(req), parent.owner)) return undefined;
|
||||
if ((parent.owner ?? undefined) !== (owner ?? undefined)) return undefined;
|
||||
return parent.id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse and validate a request body against a Zod schema, or throw a structured 400 error.
|
||||
* Replaces the repeated pattern: `const r = Schema.safeParse(body); if (!r.success) return createErrorResponse(...)`.
|
||||
|
||||
@@ -67,6 +67,7 @@ import {
|
||||
parseBody,
|
||||
persistAndBroadcastSession,
|
||||
resolveCasesDir,
|
||||
resolveParentSessionId,
|
||||
sessionCapacityMessage,
|
||||
SETTINGS_PATH,
|
||||
validatePathWithinBase,
|
||||
@@ -863,6 +864,7 @@ export function registerSessionRoutes(
|
||||
tmuxHistoryLimit: terminalHistoryConfig.tmuxHistoryLimit,
|
||||
remote,
|
||||
owner,
|
||||
parentSessionId: resolveParentSessionId(ctx, req, body.parentSessionId, owner),
|
||||
});
|
||||
|
||||
ctx.addSession(session);
|
||||
@@ -2570,6 +2572,7 @@ export function registerSessionRoutes(
|
||||
antigravityConfig,
|
||||
envOverrides,
|
||||
effort,
|
||||
parentSessionId,
|
||||
} = parseBody(QuickStartSchema, req.body);
|
||||
|
||||
// Multi-user: shell mode is arbitrary host-account execution, gated by the grant.
|
||||
@@ -2914,6 +2917,7 @@ export function registerSessionRoutes(
|
||||
docker,
|
||||
resumeSessionId: dockerResumeId,
|
||||
tmuxHistoryLimit: qsTerminalHistoryConfig.tmuxHistoryLimit,
|
||||
parentSessionId: resolveParentSessionId(ctx, req, parentSessionId, owner),
|
||||
});
|
||||
|
||||
// Auto-detect completion phrase from CLAUDE.md BEFORE broadcasting
|
||||
|
||||
@@ -269,10 +269,23 @@ const AntigravityConfigSchema = z
|
||||
})
|
||||
.optional();
|
||||
|
||||
/**
|
||||
* The session that spawned the one being created — pure UI decoration, drawn as a
|
||||
* lineage line between the two tabs. Accepted here and, equivalently, as the
|
||||
* `X-Codeman-Parent-Session` header (the agent skill sets that once on its shared
|
||||
* curl invocation so every spawn recipe carries it); the body wins when both are
|
||||
* present. `resolveParentSessionId()` in route-helpers.ts re-checks it against live
|
||||
* sessions and DROPS anything it cannot resolve — a bad value must never fail a
|
||||
* spawn, and this is never an ownership or permission signal.
|
||||
*/
|
||||
const parentSessionIdSchema = z.string().max(100).optional();
|
||||
|
||||
export const CreateSessionSchema = z.object({
|
||||
workingDir: safePathSchema.optional(),
|
||||
mode: z.enum(['claude', 'shell', 'opencode', 'codex', 'gemini', 'antigravity']).optional(),
|
||||
name: z.string().max(100).optional(),
|
||||
/** Session that spawned this one — see parentSessionIdSchema. */
|
||||
parentSessionId: parentSessionIdSchema,
|
||||
envOverrides: safeEnvOverridesSchema,
|
||||
/** Claude CLI effort level (soft default via --settings, switchable in-session via /effort) */
|
||||
effort: effortLevelSchema,
|
||||
@@ -685,6 +698,8 @@ export const QuickStartSchema = z.object({
|
||||
/** Display name for the created session tab (e.g. w1-mycase). Cosmetic; the durable
|
||||
* mux/container names derive from the session id, not this. Defaults server-side. */
|
||||
sessionName: z.string().max(128).optional(),
|
||||
/** Session that spawned this one — see parentSessionIdSchema. */
|
||||
parentSessionId: parentSessionIdSchema,
|
||||
/** Model override written to <case>/.claude/settings.local.json (e.g. "opus[1m]").
|
||||
* Empty string clears. Applied for local AND docker cases (the docker workspace is
|
||||
* a real host dir, so the settings file crosses the bind mount); rejected for
|
||||
|
||||
@@ -2652,6 +2652,10 @@ export class WebServer extends EventEmitter {
|
||||
// rebuilds the `docker exec` launch instead of a broken local command.
|
||||
docker: muxSession.docker ?? savedState?.docker,
|
||||
owner: recoveredOwner,
|
||||
// Tab lineage survives a restart. It is only decoration, so a parent
|
||||
// that did NOT come back is harmless: the frontend draws an edge only
|
||||
// when both tabs are on screen.
|
||||
parentSessionId: savedState?.parentSessionId,
|
||||
});
|
||||
|
||||
// Update session name if it was a "Restored:" placeholder or doesn't match saved name
|
||||
|
||||
Reference in New Issue
Block a user