mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-09 08:59:40 +02:00
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
107e87e457 | ||
|
|
09daf0fe49 |
@@ -250,7 +250,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
|
|||||||
|
|
||||||
**Run launch synchronization**: the Run entrypoint holds an in-flight lock and disables `#runBtn` for the whole launch (≥500ms) so a double click cannot create duplicate `w<n>-<case>` sessions; `_ensureCreatedSessionVisible()` runs before `selectSession()` and `_onSessionCreated()` stays an idempotent upsert, so POST-first and SSE-first both render exactly one tab. ⚠️ **Closing has the mirror-image race**: `closeSession()` must read `wasActive` BEFORE its `await` and announce the delete via `_closingSessions`, and `_onSessionDeleted` skips the active-session handoff for ids in that set; never read `activeSessionId` after the fact. The fallback picks the first `sessionOrder` entry still in `sessions`. Tests: `test/session-close-fallback.test.ts`. → [architecture-invariants#run-launch-synchronization](docs/architecture-invariants.md#run-launch-synchronization)
|
**Run launch synchronization**: the Run entrypoint holds an in-flight lock and disables `#runBtn` for the whole launch (≥500ms) so a double click cannot create duplicate `w<n>-<case>` sessions; `_ensureCreatedSessionVisible()` runs before `selectSession()` and `_onSessionCreated()` stays an idempotent upsert, so POST-first and SSE-first both render exactly one tab. ⚠️ **Closing has the mirror-image race**: `closeSession()` must read `wasActive` BEFORE its `await` and announce the delete via `_closingSessions`, and `_onSessionDeleted` skips the active-session handoff for ids in that set; never read `activeSessionId` after the fact. The fallback picks the first `sessionOrder` entry still in `sessions`. Tests: `test/session-close-fallback.test.ts`. → [architecture-invariants#run-launch-synchronization](docs/architecture-invariants.md#run-launch-synchronization)
|
||||||
|
|
||||||
**Session lineage lines** (tab → tab it spawned, `sessionLineageLines`, per-device, desktop default ON): a create request may name its spawner via a `parentSessionId` body field or the `X-Codeman-Parent-Session` header; `resolveParentSessionId()` (route-helpers.ts) resolves it (exact id or unique ≥8-char prefix, live, visible, same owner) and ⚠️ anything unresolvable is DROPPED, never a 400. Rides `toState()`, no new SSE event. ⚠️ Rendering is a LAYER on the existing SVG pass (`_appendLineageConnectionLines` at the tail of `_updateConnectionLinesImmediate()`), geometry pure in `computeLineagePath()`: one U-bridge shape hanging from the strip bottom, colors keyed on the SPAWNING tab and memoized (never by draw index). ⚠️ Desktop only (z-index vs the fixed mobile header). ⚠️ Paths must keep `data-agent-id="lineage:<childId>"` (the entrance animation queries it); skip edges whose endpoint is scrolled out of the strip. → [architecture-invariants#session-lineage-lines-tab--tab-it-spawned](docs/architecture-invariants.md#session-lineage-lines-tab--tab-it-spawned)
|
**Session lineage lines** (tab → tab it spawned, `sessionLineageLines`, per-device, desktop default ON): a create request may name its spawner via a `parentSessionId` body field or the `X-Codeman-Parent-Session` header; `resolveParentSessionId()` (route-helpers.ts) resolves it (exact id or unique ≥8-char prefix, live, visible, same owner) and ⚠️ anything unresolvable is DROPPED, never a 400. Rides `toState()`, no new SSE event. ⚠️ Rendering is a LAYER on the existing SVG pass (`_appendLineageConnectionLines` at the tail of `_updateConnectionLinesImmediate()`) and draws EVERY family, emphasizing the selected tab's (what it spawned, and the family it was spawned into: `lineage-family--focus`, thicker, opaque, drawn last; lanes stay in strip order), so a selection change must redraw (`_updateActiveTabImmediate`, gated on `_lineageTotalEdges`), and again on the strip's size `transitionend` (the active tab widens for ~150ms after the redraw). Geometry is pure in `computeLineageTree()`: one rounded orthogonal tree per spawning tab, every route starting at the PARENT (siblings share the trunk exactly), routed ONLY through the gaps between tab rows plus a spine left of every row, so it never crosses a tab or the terminal. ⚠️ That room is reserved in CSS by `.session-tabs.lineage-tree` (`_syncLineageGutter()`, called before the wrap is measured), keyed on whether ANY family exists, never on the selection, or every tab switch would resize the header and the PTY. Colors are keyed on the SPAWNING tab, claimed in strip order for every family, and memoized (never by draw index or selection order). ⚠️ Desktop only (z-index vs the fixed mobile header). ⚠️ Paths must keep `data-agent-id="lineage:<childId>"` (the entrance animation queries it); skip endpoints scrolled out of the strip. → [architecture-invariants#session-lineage-lines-tab--tab-it-spawned](docs/architecture-invariants.md#session-lineage-lines-tab--tab-it-spawned)
|
||||||
|
|
||||||
**Auto-named sessions** (`autoNameSessions`, SYNCED, default OFF): a placeholder tab (`w3-myapp`) takes its first real prompt as a title in the `<prefix>: <title>` form, so the case identity and `w<n>` counter survive. Ownership is `SessionState.nameSource` (`placeholder` | `auto` | `manual`; the `name` setter / `PUT /api/sessions/:id/name` makes it `manual`, never touched again). ⚠️ `applyAutoName()` flips to `auto` even if the string is unchanged, so only the FIRST titled prompt names the tab. ⚠️ Only user input counts: `SessionWriteOptions.fromUser` is set by the browser WS path and `POST /api/sessions/:id/input` ONLY; any new user-input path must set it (and the send-key Shift+Enter path must call `trackUserInput()`). ⚠️ The pure tracker (`session-auto-name.ts`) sits on the raw keystroke stream with an explicit rule per key; add a rule for any new key class. ⚠️ `nameSource` also decides `--name`: only a `manual` name is pinned on the claude CLI (`Session.cliPinnedName`), since `--name` is also the `/resume` title; a rename appends a `custom-title` row to a LOCAL, non-docker transcript, and a same-name PUT is a no-op (never flips to `manual`). Tests: `test/session-auto-name.test.ts`. → [architecture-invariants#auto-named-sessions-first-prompt--tab-title](docs/architecture-invariants.md#auto-named-sessions-first-prompt--tab-title)
|
**Auto-named sessions** (`autoNameSessions`, SYNCED, default OFF): a placeholder tab (`w3-myapp`) takes its first real prompt as a title in the `<prefix>: <title>` form, so the case identity and `w<n>` counter survive. Ownership is `SessionState.nameSource` (`placeholder` | `auto` | `manual`; the `name` setter / `PUT /api/sessions/:id/name` makes it `manual`, never touched again). ⚠️ `applyAutoName()` flips to `auto` even if the string is unchanged, so only the FIRST titled prompt names the tab. ⚠️ Only user input counts: `SessionWriteOptions.fromUser` is set by the browser WS path and `POST /api/sessions/:id/input` ONLY; any new user-input path must set it (and the send-key Shift+Enter path must call `trackUserInput()`). ⚠️ The pure tracker (`session-auto-name.ts`) sits on the raw keystroke stream with an explicit rule per key; add a rule for any new key class. ⚠️ `nameSource` also decides `--name`: only a `manual` name is pinned on the claude CLI (`Session.cliPinnedName`), since `--name` is also the `/resume` title; a rename appends a `custom-title` row to a LOCAL, non-docker transcript, and a same-name PUT is a no-op (never flips to `manual`). Tests: `test/session-auto-name.test.ts`. → [architecture-invariants#auto-named-sessions-first-prompt--tab-title](docs/architecture-invariants.md#auto-named-sessions-first-prompt--tab-title)
|
||||||
|
|
||||||
|
|||||||
@@ -175,15 +175,19 @@ Further detail: `resumeHistorySession()` is what sends `codexConfig.resumeSessio
|
|||||||
|
|
||||||
**Resolved, not trusted** (`resolveParentSessionId()`, route-helpers.ts): exact id first, then a UNIQUE prefix of ≥8 chars (ids reach agents truncated — mux names and a Docker export's `$CODEMAN_SESSION_ID` both carry 8), and an ambiguous prefix resolves to NOTHING rather than to a guess. The parent must be a live session the caller can already see (`canAccessOwned`) AND carry the same owner as the session being created, so a multi-user caller cannot staple their session under someone else's tab. ⚠️ **Everything unresolvable is DROPPED, never a 400**: a stale id from a cached skill preamble must cost a decorative line, not a worker. ⚠️ It is decoration at every layer — never an ownership, permission or lifecycle signal; a child outlives its parent, and the Session ctor refuses a self-parent (reachable only via recovery, where both values come off disk). It rides `toState()` into `session_created` / `session_updated`, so there is **no new SSE event**, and `server.ts`'s recovery path restores it so lineage survives a restart.
|
**Resolved, not trusted** (`resolveParentSessionId()`, route-helpers.ts): exact id first, then a UNIQUE prefix of ≥8 chars (ids reach agents truncated — mux names and a Docker export's `$CODEMAN_SESSION_ID` both carry 8), and an ambiguous prefix resolves to NOTHING rather than to a guess. The parent must be a live session the caller can already see (`canAccessOwned`) AND carry the same owner as the session being created, so a multi-user caller cannot staple their session under someone else's tab. ⚠️ **Everything unresolvable is DROPPED, never a 400**: a stale id from a cached skill preamble must cost a decorative line, not a worker. ⚠️ It is decoration at every layer — never an ownership, permission or lifecycle signal; a child outlives its parent, and the Session ctor refuses a self-parent (reachable only via recovery, where both values come off disk). It rides `toState()` into `session_created` / `session_updated`, so there is **no new SSE event**, and `server.ts`'s recovery path restores it so lineage survives a restart.
|
||||||
|
|
||||||
**Rendering is an additional LAYER, not a second pass** (`session-lineage.js`, loadorder 15.6): `_updateConnectionLinesImmediate()` (subagent-windows.js) calls `_appendLineageConnectionLines(svg, rects)` at its tail, exactly like ultracode's two layers, so all of them share ONE batched read→write reflow and the same `tab:<id>` rect cache. Geometry is pure and unit-tested in `computeLineagePath()` (constants.js): both endpoints live in one horizontal strip, so the subagent shape (tab-bottom → window-top) has nothing to aim at, and every pair gets a U-bridge HANGING BELOW the strip, anchored on both tabs' BOTTOM edges (dip scales with distance, plus a per-sibling step so several children of one parent nest instead of overprinting, plus the row offset when the strip has wrapped). ⚠️ **A wrapped strip used to get its own shape, and that shape was the bug** (fixed 2026-08-14): `tabs-two-rows`/`tabs-auto-wrap` put a parent on row 1 ~14px above its child on row 2, so the old parent-bottom → child-TOP bezier had 14px to bend in and drew a flat line inside the row gap, siblings overprinting. Hanging the control points below the LOWER row gives the wrapped case the same bracket as the flat one and deletes the branch. The same pass raised the dip clamp (44 → 104, 0.06 → 0.085/px) because a skill worker is appended to the END of the strip, where the old cap flattened an 800-1500px span into a straight thread across the terminal, and traded weight for a second, wider glow (2 → 2.5px, `4 4` → `5 5` dashes at `-20`, opacity .55 → .72 / .95 working) because the original styling vanished into terminal text at 1:1.
|
**Rendering is an additional LAYER, not a second pass** (`session-lineage.js`, loadorder 15.6): `_updateConnectionLinesImmediate()` (subagent-windows.js) calls `_appendLineageConnectionLines(svg, rects)` at its tail, exactly like ultracode's two layers, so all of them share ONE batched read→write reflow and the same `tab:<id>` rect cache.
|
||||||
|
|
||||||
⚠️ **Desktop only, for a z-index reason**: the overlay is `z-index: 999` and the desktop header is 100, so arcs paint OVER it — which is exactly what lets them touch tab bottoms. Under 1024px mobile.css makes the header `position: fixed; z-index: 1200` and would bury them, and the phone strip is a scroller where both endpoints are rarely on screen at once. Raising the SVG to ~1250 (above the fixed header, below modals at 1300) is the phase-2 option, and needs a real check against the mobile overview and the drawer.
|
**Every family is drawn, and the SELECTED tab's is emphasized** (2026-10-07): the family the active tab spawned, plus the family it was spawned into (its parent and siblings), get `<g class="lineage-family lineage-family--focus">`: 2.5px instead of 1.5px, full opacity instead of 0.75, a larger end dot, and drawn LAST so no other family covers it. A tab that is both a child and a parent emphasizes both; a web tab or a tab without lineage emphasizes nothing. ⚠️ Drawing ONLY the selected family was built first and rejected by the owner the same day ("I wanna see all the connections always but when I select one … make thicker"), so do not reintroduce a filter. ⚠️ Lanes follow STRIP order (`index % CodemanLineage.MAX_LANES`, 3, since a 12px row gap fits three lanes at 3.5px), never the selection: only the draw order changes, so a family's lines never jump when you click a tab; families sharing a lane are told apart by colour. ⚠️ Because the emphasis depends on the selection, `_updateActiveTabImmediate()` redraws whenever any lineage exists (`_lineageTotalEdges > 0`), and the incremental tab render redraws on the same condition (a child's `parentSessionId` or working state can arrive there). `_lineageEdgeCount` counts what is DRAWN and still gates the strip-scroll redraw. ⚠️ **A selection keeps resizing tabs AFTER that redraw**: the active tab reveals its gear and close icons by transitioning their padding (~150ms), which can move tabs or re-wrap a row, so the strip's `transitionend` (size properties only: `width`, `max-width`, `padding*`) triggers one more coalesced redraw. Without it the trunk hung under the tab's old position (measured ~10px off, more after a re-wrap).
|
||||||
|
|
||||||
⚠️ **`data-agent-id="lineage:<childId>"` is load-bearing**, not a label: `_applyLineEntrances()` queries paths by that attribute, so tagging them this way is the whole reason the arcs get the draw-in animation AND its negative-`animation-delay` resume across `svg.innerHTML = ''` with zero new animation code. ⚠️ `.session-tabs` is `overflow-x: auto`, so a tab scrolled out of the strip still HAS a rect — one lying over the logo or the header buttons; edges with an endpoint outside the strip are SKIPPED (clamping would point at a tab that is not there), and a passive `scroll` listener re-anchors the rest, since a scroll moves both endpoints without firing any render. The incremental tab render also redraws when `_lineageEdgeCount > 0`: a badge appearing widens a tab and shifts every tab after it. Setting: `sessionLineageLines`, per-device (in `displayKeys`, absent from the `.strict()` `SettingsUpdateSchema`), desktop default ON. Tests: `test/session-lineage-lines.test.ts` (geometry), `test/routes/session-routes-parent-lineage.test.ts` (resolution + reject paths).
|
**Geometry is a tree routed through the gaps** (`computeLineageTree()` + `computeLineageRows()`, constants.js, pure and unit-tested): one rounded orthogonal tree per spawning tab. Every route starts at the PARENT's bottom-center and ends at one child's bottom-center, so a parent's routes share their first stretch exactly and overlay into one trunk with a branch per child; that is also what keeps a working child's dashes in phase with its siblings along the shared part. A route moves horizontally only inside a row gap (the gap under each endpoint's row, at the row's TALLEST tab, since the active tab is 2px taller) and vertically only along a tab's own stem or the SPINE, a channel left of every row that joins the gaps of different rows. In the vertical rail the same idea is one shared track down `--lineage-vertical-gutter`. `test/session-lineage-lines.test.ts` pins "never crosses a tab" as a property over a five-row fixture. ⚠️ **The room is reserved, not borrowed**: `.session-tabs.lineage-tree` (styles.css) adds bottom padding for the last row's gap, and on a wrapped strip a 12px row gap plus a 20px left channel for the spine. `_syncLineageGutter()` toggles it from the top of `updateTabOverflowMode()`, BEFORE the wrap is measured, and keys it on whether ANY family exists, never on the selection: the header's height is the terminal's height, so a class that followed the selection would resize the PTY on every tab switch. ⚠️ **Opacity lives on the family `<g class="lineage-family">`, not on the paths**: translucent paths overlapping along the trunk would stack into a brighter line than the branches. Working routes are appended first so an idle sibling's solid stroke covers the shared trunk. Style is deliberately quiet (1.5px, rounded, a 1px dark outline instead of the old double glow): the lines no longer cross text, so they no longer need a glow to survive it, and a dash now means "this child is working".
|
||||||
|
|
||||||
Further detail (current geometry and colors, superseding the dip numbers above where they differ): ⚠️ **ONE shape, and the second one was the bug**: every pair (flat strip or wrapped) gets a U-bridge hanging below the strip, anchored on both tabs' BOTTOM edges. ⚠️ The dip is a **mis-tuned-in-both-directions corridor** (44px cap = straight thread at strip-wide spans, #285; 104px cap + full row offset = ~106px over-bow into the terminal, 2026-08-15): it now hangs from the **STRIP's bottom edge** (fallback: lower tab bottom), capped at 64px, with NO per-row offsets stacked on top — the strip-bottom baseline is also what keeps a row-1 pair's arc from drawing through row 2's tab labels.
|
⚠️ **Desktop only, for a z-index reason**: the overlay is `z-index: 999` and the desktop header is 100, so the lines paint OVER it, which is exactly what lets them touch tab bottoms. Under 1024px mobile.css makes the header `position: fixed; z-index: 1200` and would bury them, and the phone strip is a scroller where both endpoints are rarely on screen at once. Raising the SVG to ~1250 (above the fixed header, below modals at 1300) is the phase-2 option, and needs a real check against the mobile overview and the drawer. The sidebar layout draws no lineage.
|
||||||
|
|
||||||
⚠️ **Colors are keyed on the SPAWNING tab, not per child**: every arc leaving one tab is the same color however many workers it spawns, so the strip reads as "these five came from w1, those two came from w2" — per-child coloring gave one tab's own children a different color each, which is the distinction the colors exist to make. A child that spawns in turn is a parent in its own right and gets its own color for the arcs below it, so a chain changes color at each generation while each generation's fan-out stays uniform. Assignment cycles `CodemanLineage.COLORS` in first-seen order per parent id (first entry empty = the skin-tuned `--session-blue`, so the first spawning tab keeps it; the rest vivid fixed hexes), memoized rather than derived from draw index (the SVG is wiped and rebuilt constantly, so an index-based color would flicker), and set inline as `--lineage-color` so styles.css keeps owning opacity/glow/dash. `test/session-lineage-lines.test.ts` drives the real `_appendLineageConnectionLines()` and asserts the painted property, since testing the color function alone would pass just as happily with the child id passed back in.
|
⚠️ **`data-agent-id="lineage:<childId>"` is load-bearing**, not a label: `_applyLineEntrances()` queries paths by that attribute (nested inside the family group is fine), so tagging them this way is the whole reason the lines get the draw-in animation AND its negative-`animation-delay` resume across `svg.innerHTML = ''` with zero new animation code. ⚠️ `.session-tabs` scrolls, so a tab scrolled out of the strip still HAS a rect, one lying over the logo or the header buttons; an endpoint outside the strip is SKIPPED (a child is left out, a parent drops its whole family), since clamping would point at a tab that is not there, and a passive `scroll` listener re-anchors the rest, since a scroll moves both endpoints without firing any render. Setting: `sessionLineageLines`, per-device (in `displayKeys`, absent from the `.strict()` `SettingsUpdateSchema`), desktop default ON. Tests: `test/session-lineage-lines.test.ts` (geometry, focus rule, routing room, colours), `test/routes/session-routes-parent-lineage.test.ts` (resolution + reject paths).
|
||||||
|
|
||||||
|
History: before the tree, every pair got a U-bridge bezier hanging below the strip, and its dip was mis-tuned in both directions (a 44px cap flattened strip-wide spans into a straight thread, #285; a 104px cap plus the row offset bowed ~106px into the terminal, 2026-08-15). The tree removed the dip entirely.
|
||||||
|
|
||||||
|
⚠️ **Colors are keyed on the SPAWNING tab, not per child**: every line leaving one tab is the same color however many workers it spawns, so the strip reads as "these five came from w1, those two came from w2"; per-child coloring gave one tab's own children a different color each, which is the distinction the colors exist to make. A child that spawns in turn is a parent in its own right and gets its own color for the lines below it, so a chain changes color at each generation while each generation's fan-out stays uniform. Assignment cycles `CodemanLineage.COLORS` in first-seen order per parent id (first entry empty = the skin-tuned `--session-blue`, so the first spawning tab keeps it; the rest vivid fixed hexes), memoized rather than derived from draw index (the SVG is wiped and rebuilt constantly, so an index-based color would flicker). ⚠️ The draw pass claims a color for EVERY family in strip order, drawn or not, so which family happens to be selected first never decides who gets the blue. Colors are set inline as `--lineage-color` so styles.css keeps owning weight and dash. `test/session-lineage-lines.test.ts` drives the real `_appendLineageConnectionLines()` and asserts the painted property, since testing the color function alone would pass just as happily with the child id passed back in.
|
||||||
|
|
||||||
### Auto-named sessions (first prompt → tab title)
|
### Auto-named sessions (first prompt → tab title)
|
||||||
|
|
||||||
@@ -771,7 +775,7 @@ Further detail: in the Settings section order, the version and the updater lead
|
|||||||
|
|
||||||
**The session list can render as the horizontal header strip (default) or as a collapsible left sidebar** — App Settings → Layout → Tabs → **Session List Layout** (`sessionListLayout: 'header' | 'sidebar'`, in the per-device `displayKeys` set, so it never syncs across devices; also in `SettingsUpdateSchema`, which is `.strict()` — without that entry the server 400s the ENTIRE settings PUT and every unrelated setting silently stops persisting). ⚠️ **There is exactly ONE `#sessionTabs` element and `applySessionListLayout()` RE-PARENTS it** between `#sessionTabsHost` (in `<header>`) and `#sessionSidebarList` (in the `<aside>`, a flex sibling of `.terminal-wrap` so the terminal shrinks and `terminal-ui.js`'s `ResizeObserver` refits xterm on its own). It must never be cloned or rebuilt: `app.$(id)` caches elements by id and NEVER invalidates, and `settings-ui.js` / `webview-tabs.js` resolve the same id independently, so a rebuilt container leaves every consumer writing into a detached orphan — silently, with no error. Everything else is CSS keyed off `html[data-session-list]` / `html[data-sidebar]`, both written by a pre-paint script in `<head>` so the loading skeleton already matches. Consequences: the renderers, drag/keyboard handlers, web tabs (`data-webview-id` rows stay in the same list, keeping the shared Alt+N numbering and the single-active-tab invariant) and the generated gesture bundle (`TAB_SELECTOR`/`DOCK_SELECTOR` match on class names that are unchanged) all need **zero** edits.
|
**The session list can render as the horizontal header strip (default) or as a collapsible left sidebar** — App Settings → Layout → Tabs → **Session List Layout** (`sessionListLayout: 'header' | 'sidebar'`, in the per-device `displayKeys` set, so it never syncs across devices; also in `SettingsUpdateSchema`, which is `.strict()` — without that entry the server 400s the ENTIRE settings PUT and every unrelated setting silently stops persisting). ⚠️ **There is exactly ONE `#sessionTabs` element and `applySessionListLayout()` RE-PARENTS it** between `#sessionTabsHost` (in `<header>`) and `#sessionSidebarList` (in the `<aside>`, a flex sibling of `.terminal-wrap` so the terminal shrinks and `terminal-ui.js`'s `ResizeObserver` refits xterm on its own). It must never be cloned or rebuilt: `app.$(id)` caches elements by id and NEVER invalidates, and `settings-ui.js` / `webview-tabs.js` resolve the same id independently, so a rebuilt container leaves every consumer writing into a detached orphan — silently, with no error. Everything else is CSS keyed off `html[data-session-list]` / `html[data-sidebar]`, both written by a pre-paint script in `<head>` so the loading skeleton already matches. Consequences: the renderers, drag/keyboard handlers, web tabs (`data-webview-id` rows stay in the same list, keeping the shared Alt+N numbering and the single-active-tab invariant) and the generated gesture bundle (`TAB_SELECTOR`/`DOCK_SELECTOR` match on class names that are unchanged) all need **zero** edits.
|
||||||
|
|
||||||
⚠️ Collapsed means **different things per viewport**: at 1024px and up the sidebar keeps a 44px icon rail so the ambient signal (status dot, task/subagent/ultracode badges) survives — the Alt+N number, the name/folder and the `sh`/`oc`/`cx`/`gm` mode chip do NOT, because 44px minus paddings and borders is ~34px of content box and the chip lives inside `.tab-info`; below 1024px `mobile.css` turns the sidebar into an off-canvas overlay where collapsed == drawer closed (mirrored into an `.open` class plus `inert`/`aria-hidden`, since `translateX(-100%)` alone leaves every row in the Tab order), it defaults to CLOSED when the user has made no choice, and picking a session or web tab dismisses it. ⚠️ **That 1024px breakpoint is the only handheld test the sidebar may use** (`_isSessionSidebarOverlay()`, mirrored in the pre-paint script): `MobileDetection.getDeviceType()` calls everything from 768px up `'desktop'`, so using it gave 768-1023px the overlay CSS with docked-sidebar logic — drawer opening itself on load, immune to selection and Escape. The toggle chord (default Alt+B) also needs its gate in `terminal-ui.js`'s `attachCustomKeyEventHandler`, or `preventDefault()` in the capture handler still lets xterm write ESC b into the live PTY (same trap as COD-153). The sidebar filter only applies while its input is on screen — `applySidebarFilter()` strips the class in the header strip, the collapsed rail and the closed drawer, because a filter with no reachable control hides sessions permanently. Collapse state lives in its OWN `codeman-sidebar-collapsed` key, **not** in the settings blob — `saveAppSettings()` rebuilds that blob from DOM controls, so a key without a control is wiped on every Save. Solo (`/session/:id`) windows never get a sidebar (three guards: `getSessionListLayout()`, the pre-paint script, and `body.solo-mode`), because `#sessionTabs` parked in a `display:none` subtree measures 0/0 for tab overflow and inline rename. The sidebar CSS block sits at the END of `styles.css`, **after** the `html:not([data-skin="og"])` nesting block, and is layout-only — any colour on `.session-tab` there would render correctly on the `og` skin only. Same for the `mobile.css` block: it must stay at the end of the file or the earlier compact-strip rules clip the list to a 36px sliver. Two surfaces DEFER to the sidebar rather than adapt: **lineage arcs are skipped** in sidebar layout (`_appendLineageConnectionLines` early-returns — `computeLineagePath()`'s whole geometry hangs a U-bridge from the horizontal STRIP's bottom edge, so against a vertical list every arc would loop to the foot of the sidebar; a sideways lineage shape needs its own visual tuning, it is not a by-product of re-parenting), and the **desktop home tab rail** (`shouldShowHomeSessions()`) stays hidden while the sidebar is active, because both dock the session list flush left and the rail would render the same list next to it, z-ordered UNDER it. The subagent/ultracode connectors DO adapt (`_tabAnchor()`/`_tabConnectorPath()` in app.js: right-edge anchor, horizontal bezier), and the lineage strip-scroll listener redraws them on the sidebar's vertical scroll. `_scrollActiveTabIntoView()` owns active-row reveal on BOTH axes: sidebar mode branches to `scrollIntoView({block:'nearest'})` because the horizontal `computeTabScrollLeft` math no-ops against a vertical scroller, and `_fullRenderSessionTabs()` restores `scrollTop` alongside the #257 `scrollLeft` restore or ambient rebuilds yank a mid-scroll sidebar back to the top. Tests: `test/session-list-layout.test.ts`.
|
⚠️ Collapsed means **different things per viewport**: at 1024px and up the sidebar keeps a 44px icon rail so the ambient signal (status dot, task/subagent/ultracode badges) survives — the Alt+N number, the name/folder and the `sh`/`oc`/`cx`/`gm` mode chip do NOT, because 44px minus paddings and borders is ~34px of content box and the chip lives inside `.tab-info`; below 1024px `mobile.css` turns the sidebar into an off-canvas overlay where collapsed == drawer closed (mirrored into an `.open` class plus `inert`/`aria-hidden`, since `translateX(-100%)` alone leaves every row in the Tab order), it defaults to CLOSED when the user has made no choice, and picking a session or web tab dismisses it. ⚠️ **That 1024px breakpoint is the only handheld test the sidebar may use** (`_isSessionSidebarOverlay()`, mirrored in the pre-paint script): `MobileDetection.getDeviceType()` calls everything from 768px up `'desktop'`, so using it gave 768-1023px the overlay CSS with docked-sidebar logic — drawer opening itself on load, immune to selection and Escape. The toggle chord (default Alt+B) also needs its gate in `terminal-ui.js`'s `attachCustomKeyEventHandler`, or `preventDefault()` in the capture handler still lets xterm write ESC b into the live PTY (same trap as COD-153). The sidebar filter only applies while its input is on screen — `applySidebarFilter()` strips the class in the header strip, the collapsed rail and the closed drawer, because a filter with no reachable control hides sessions permanently. Collapse state lives in its OWN `codeman-sidebar-collapsed` key, **not** in the settings blob — `saveAppSettings()` rebuilds that blob from DOM controls, so a key without a control is wiped on every Save. Solo (`/session/:id`) windows never get a sidebar (three guards: `getSessionListLayout()`, the pre-paint script, and `body.solo-mode`), because `#sessionTabs` parked in a `display:none` subtree measures 0/0 for tab overflow and inline rename. The sidebar CSS block sits at the END of `styles.css`, **after** the `html:not([data-skin="og"])` nesting block, and is layout-only — any colour on `.session-tab` there would render correctly on the `og` skin only. Same for the `mobile.css` block: it must stay at the end of the file or the earlier compact-strip rules clip the list to a 36px sliver. Two surfaces DEFER to the sidebar rather than adapt: **lineage lines are skipped** in sidebar layout (`_appendLineageConnectionLines` early-returns: `computeLineageTree()` routes through a horizontal strip's reserved row gaps or the vertical rail's reserved `--lineage-vertical-gutter`, and the sidebar has neither; a sidebar lineage shape needs its own visual tuning, it is not a by-product of re-parenting), and the **desktop home tab rail** (`shouldShowHomeSessions()`) stays hidden while the sidebar is active, because both dock the session list flush left and the rail would render the same list next to it, z-ordered UNDER it. The subagent/ultracode connectors DO adapt (`_tabAnchor()`/`_tabConnectorPath()` in app.js: right-edge anchor, horizontal bezier), and the lineage strip-scroll listener redraws them on the sidebar's vertical scroll. `_scrollActiveTabIntoView()` owns active-row reveal on BOTH axes: sidebar mode branches to `scrollIntoView({block:'nearest'})` because the horizontal `computeTabScrollLeft` math no-ops against a vertical scroller, and `_fullRenderSessionTabs()` restores `scrollTop` alongside the #257 `scrollLeft` restore or ambient rebuilds yank a mid-scroll sidebar back to the top. Tests: `test/session-list-layout.test.ts`.
|
||||||
|
|
||||||
Further detail: with many sessions the horizontal strip stops being scannable, which is why the list can move into the vertical `<aside>` with a filter box and a live count, collapsible to a 44px rail (`--sidebar-width` 260 / `--sidebar-width-collapsed` 44) via **Alt+B** (`toggleSessionSidebar`; Alt, not Ctrl+B, which must reach tmux/readline in the terminal). The setting lives in App Settings → Appearance → Tabs. There is a THIRD host for `#sessionTabs` besides `#sessionTabsHost` and `#sessionSidebarList`: `#tabRail`, the vertical rail. Exactly TWO functions reparent it and they must run in this order: `applySessionListLayout()` first (sidebar wins), then `applyTabOrientation()` (settings-ui.js), which moves the tabs into `#tabRail` only when the sidebar does not own them. ⚠️ `applySessionListLayout()` sets `data-session-list` / `data-sidebar` on `<html>` and must run BEFORE `applyTabWrapSettings()`, which is the one owner of `tabs-two-rows`/`tabs-show-folder` and reads those attributes.
|
Further detail: with many sessions the horizontal strip stops being scannable, which is why the list can move into the vertical `<aside>` with a filter box and a live count, collapsible to a 44px rail (`--sidebar-width` 260 / `--sidebar-width-collapsed` 44) via **Alt+B** (`toggleSessionSidebar`; Alt, not Ctrl+B, which must reach tmux/readline in the terminal). The setting lives in App Settings → Appearance → Tabs. There is a THIRD host for `#sessionTabs` besides `#sessionTabsHost` and `#sessionSidebarList`: `#tabRail`, the vertical rail. Exactly TWO functions reparent it and they must run in this order: `applySessionListLayout()` first (sidebar wins), then `applyTabOrientation()` (settings-ui.js), which moves the tabs into `#tabRail` only when the sidebar does not own them. ⚠️ `applySessionListLayout()` sets `data-session-list` / `data-sidebar` on `<html>` and must run BEFORE `applyTabWrapSettings()`, which is the one owner of `tabs-two-rows`/`tabs-show-folder` and reads those attributes.
|
||||||
|
|
||||||
|
|||||||
@@ -189,7 +189,7 @@ followed by a wait races, and reports the previous turn's state.
|
|||||||
## Lineage
|
## Lineage
|
||||||
|
|
||||||
A create request can name the session that spawned it, through a body field or a header, and
|
A create request can name the session that spawned it, through a body field or a header, and
|
||||||
the dashboard then draws a lineage arc from parent to child. The skill sets it automatically.
|
the dashboard then draws a lineage line from parent to child. The skill sets it automatically.
|
||||||
|
|
||||||
It is resolved rather than trusted: an unresolvable parent is dropped silently rather than
|
It is resolved rather than trusted: an unresolvable parent is dropped silently rather than
|
||||||
failing the spawn, because a cosmetic field must never break a worker.
|
failing the spawn, because a cosmetic field must never break a worker.
|
||||||
|
|||||||
@@ -158,7 +158,7 @@ enough to fix a typo an agent introduced while you are away from your desk.
|
|||||||
enforces it.
|
enforces it.
|
||||||
- The Approvals bell. Phones get the NEEDS YOU strips on the home screen instead.
|
- The Approvals bell. Phones get the NEEDS YOU strips on the home screen instead.
|
||||||
- The desktop home tab rail, which needs a wide window.
|
- The desktop home tab rail, which needs a wide window.
|
||||||
- Lineage arcs, which are a desktop overlay.
|
- Lineage lines, which are a desktop overlay.
|
||||||
|
|
||||||
## Gotchas
|
## Gotchas
|
||||||
|
|
||||||
|
|||||||
@@ -89,7 +89,7 @@ every session or only the active tab.
|
|||||||
| Vertical Rail Order | *By activity* (default) sorts the rail the way the home screens are sorted; *Manual* keeps your tab order and drag-reordering. |
|
| Vertical Rail Order | *By activity* (default) sorts the rail the way the home screens are sorted; *Manual* keeps your tab order and drag-reordering. |
|
||||||
| Tall Tabs | Taller tab strip. |
|
| Tall Tabs | Taller tab strip. |
|
||||||
| Pop-out Button on Tabs | Adds the detach control to tabs, with a per-tab override. |
|
| Pop-out Button on Tabs | Adds the detach control to tabs, with a per-tab override. |
|
||||||
| Spawn Lineage Lines | Arcs from a parent tab to sessions it spawned. Desktop only, on by default. |
|
| Spawn Lineage Lines | Lines from each tab to the sessions it spawned; the selected tab's family is drawn thicker. Desktop only, on by default. |
|
||||||
| Auto-name Sessions | Titles a new tab after its first prompt, keeping the case prefix (`w3-myapp: fix the login redirect`). Synced, off by default. See [The Dashboard](The-Dashboard#automatic-session-names). |
|
| Auto-name Sessions | Titles a new tab after its first prompt, keeping the case prefix (`w3-myapp: fix the login redirect`). Synced, off by default. See [The Dashboard](The-Dashboard#automatic-session-names). |
|
||||||
| Overview Home Screen | The phone home screen. On by default. |
|
| Overview Home Screen | The phone home screen. On by default. |
|
||||||
|
|
||||||
|
|||||||
@@ -84,13 +84,18 @@ title is derived locally from the prompt's first sentence; no text leaves the ma
|
|||||||
On phones the strip scrolls horizontally instead of wrapping, and the active tab is always
|
On phones the strip scrolls horizontally instead of wrapping, and the active tab is always
|
||||||
scrolled into view. It is not reordered to the front, so the `Alt+N` numbering stays stable.
|
scrolled into view. It is not reordered to the front, so the `Alt+N` numbering stays stable.
|
||||||
|
|
||||||
### Lineage arcs
|
### Lineage lines
|
||||||
|
|
||||||
When one session spawns another (an agent starting a worker through the API), Codeman draws
|
When one session spawns another (an agent starting a worker through the API), Codeman draws
|
||||||
a coloured arc under the strip connecting parent to child, with one colour per child. It is
|
lines from the parent to each child, in the parent's colour, routed through the gaps between
|
||||||
how a fan-out of eight workers stays readable.
|
tab rows so they never cover a tab or the terminal. Every family is always shown; selecting
|
||||||
|
a tab draws its own family thicker and brighter. A dashed branch means that child is
|
||||||
|
working. It is how a fan-out of eight workers stays readable.
|
||||||
|
|
||||||
Desktop only, and on by default. Turn it off in **App Settings → Appearance**. Arcs are
|
While any tab has spawned another, the strip keeps a little extra room between rows for the
|
||||||
|
lines, so switching tabs never changes the header height.
|
||||||
|
|
||||||
|
Desktop only, and on by default. Turn it off in **App Settings → Appearance**. Lines are
|
||||||
skipped for tabs scrolled out of the strip.
|
skipped for tabs scrolled out of the strip.
|
||||||
|
|
||||||
## Header controls
|
## Header controls
|
||||||
|
|||||||
@@ -22,13 +22,18 @@ transcript: what it was asked to do, what it is doing, and what it returned.
|
|||||||
This is the feature that makes a fan-out legible. Without it, a lead session that spawned
|
This is the feature that makes a fan-out legible. Without it, a lead session that spawned
|
||||||
eight workers looks like a stalled terminal for several minutes.
|
eight workers looks like a stalled terminal for several minutes.
|
||||||
|
|
||||||
## Session lineage arcs
|
## Session lineage lines
|
||||||
|
|
||||||
The tab strip draws a coloured arc from a parent tab to any tab it spawned, one colour per
|
The tab strip draws lines from every tab to the tabs it spawned. That covers the other
|
||||||
child. That covers the other direction of fan-out: not subagents inside one session, but
|
direction of fan-out: not subagents inside one session, but whole sessions started by an
|
||||||
whole sessions started by an agent through the API.
|
agent through the API.
|
||||||
|
|
||||||
Desktop only, on by default, and toggled in **App Settings → Appearance**. Arcs are skipped
|
The lines form one tree per spawning tab, in that tab's colour, and run only through the
|
||||||
|
gaps between tab rows, so they never cover a tab name or the terminal. Select a tab and its
|
||||||
|
family (the tabs it spawned, or its parent and siblings) is drawn thicker and brighter. A
|
||||||
|
dashed branch means that child is working.
|
||||||
|
|
||||||
|
Desktop only, on by default, and toggled in **App Settings → Appearance**. Lines are skipped
|
||||||
for tabs scrolled out of view.
|
for tabs scrolled out of view.
|
||||||
|
|
||||||
See [Driving Codeman From An Agent](Driving-Codeman-From-An-Agent) for the spawning side.
|
See [Driving Codeman From An Agent](Driving-Codeman-From-An-Agent) for the spawning side.
|
||||||
|
|||||||
+16
-7
@@ -5241,6 +5241,9 @@ class CodemanApp {
|
|||||||
// strip scrolls horizontally, so a tab selected from the palette, a swipe,
|
// strip scrolls horizontally, so a tab selected from the palette, a swipe,
|
||||||
// Alt+N or a push notification could stay parked off-screen.
|
// Alt+N or a push notification could stay parked off-screen.
|
||||||
this._scrollActiveTabIntoView(sessionId);
|
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?.();
|
this._refreshHomeSessionsIfVisible?.();
|
||||||
// The full-render path already redraws the connection SVG; this incremental
|
// 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
|
// 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
|
// it, sliding the lineage lines off their anchors. Only pay for it when there
|
||||||
// is something anchored to tab rects: lineage arcs, or — in a VERTICAL list
|
// is something anchored to tab rects: any lineage at all (not just what is
|
||||||
// (sidebar, where lineage is skipped and the edge count stays 0, or the
|
// drawn now: a parentSessionId or a child's working state can arrive on this
|
||||||
// rail, which can show connectors with zero lineage edges too) — the
|
// path and change the selected family's tree), or, in a VERTICAL list (the
|
||||||
// subagent/ultracode connectors, whose rows a badge changes the HEIGHT of.
|
// sidebar, where lineage is skipped, or the rail, which can show connectors
|
||||||
// Same widening as the strip-scroll listener in session-lineage.js.
|
// with zero lineage edges too), the subagent/ultracode connectors, whose
|
||||||
if (this._lineageEdgeCount > 0 || this._isVerticalTabList()) this.updateConnectionLines();
|
// 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);
|
this.applySidebarFilter(this._sidebarFilter);
|
||||||
}
|
}
|
||||||
@@ -5651,6 +5656,10 @@ class CodemanApp {
|
|||||||
const container = this.$('sessionTabs');
|
const container = this.$('sessionTabs');
|
||||||
if (!container) return;
|
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 —
|
// 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.
|
// there is no row to overflow, and measuring it would fight the CSS.
|
||||||
if (this.isSessionSidebarActive()) {
|
if (this.isSessionSidebarActive()) {
|
||||||
|
|||||||
+228
-115
@@ -296,139 +296,251 @@ function computeTabScrollLeft(input) {
|
|||||||
return Math.min(Math.max(Math.round(target), 0), maxScroll);
|
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
|
// spawned (a worker started through the codeman agent skill, which passes its own
|
||||||
// id as parentSessionId). Pure: the caller measures and appends, this decides.
|
// 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
|
// ONE TREE PER SPAWNING TAB. Every family is drawn, the selected tab's emphasized
|
||||||
// shape (tab-bottom → window-top) has nothing to aim at: a U-bridge HANGING BELOW the
|
// (session-lineage.js). Every route starts at the PARENT and ends at one child, so
|
||||||
// strip, from the parent's bottom edge to the child's bottom edge, so it reads as a
|
// a parent's routes share their first stretch exactly: overlaid, they read as one
|
||||||
// bracket joining two tabs rather than as a line crossing them. The dip grows with
|
// trunk with a branch per child, and a dashed (working) route stays in phase with
|
||||||
// horizontal distance and with `depth` (the child's index among its siblings), so
|
// its siblings along the shared part.
|
||||||
// several children of one parent nest instead of overprinting.
|
|
||||||
//
|
//
|
||||||
// ⚠ A WRAPPED STRIP USED TO GET ITS OWN SHAPE, AND THAT SHAPE WAS THE BUG. When the
|
// ⚠ ROUTES RUN IN THE GAPS, NEVER THROUGH A TAB. This replaced one bezier per
|
||||||
// desktop strip wraps (`tabs-two-rows` / `tabs-auto-wrap`) a parent on row 1 and its
|
// child hanging below the strip, which in a wrapped strip crossed every lower
|
||||||
// child on row 2 are ~4px apart vertically, so the old parent-bottom → child-TOP bezier
|
// row's labels and the terminal text (owner screenshot 2026-10-06: a parent on
|
||||||
// had a 4px span to work with and drew a flat horizontal line inside the row gap
|
// row 3 with ten children, "too confusing"). A route now moves horizontally only
|
||||||
// (reported as "they connect already, but the lines are straight and not easy visible"),
|
// inside a row gap, and vertically only along a tab's own stem (its bottom edge to
|
||||||
// and three siblings drew three of them on top of each other. Aiming BOTH ends at the
|
// the gap right under it) or along the SPINE, a channel left of every row that
|
||||||
// tab BOTTOMS and putting the control points below the LOWER row gives the wrapped case
|
// joins the gaps of different rows. styles.css reserves that room
|
||||||
// the same bracket as the flat case: it leaves the parent downward, crosses the lower
|
// (`.session-tabs.lineage-tree`: a wider row gap, bottom padding for the last
|
||||||
// row once, and comes back up under the child. Same formula, no branch.
|
// 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
|
// Rows come from computeLineageRows() over EVERY tab in the strip, not only the
|
||||||
// endpoint scrolled outside the strip. `.session-tabs` is `overflow-x: auto`, so a
|
// endpoints: a row's gap sits under its TALLEST tab (the active tab is 2px
|
||||||
// scrolled-out tab still HAS a rect — one lying over the logo or the header
|
// taller), or siblings in one row would hang their bus at different heights.
|
||||||
// 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
|
// computeLineageTree() returns null when the parent cannot be drawn (missing or
|
||||||
// directions, so treat these numbers as a corridor rather than a dial to crank:
|
// degenerate rect, scrolled out of the strip); a child that cannot be drawn is
|
||||||
// - Too shallow (the first ship, 44px cap): a skill worker is appended to the END of
|
// left out of `routes`. `.session-tabs` scrolls, so a scrolled-out tab still HAS a
|
||||||
// the strip, so a lead-to-worker span is 800-1500px, and a 44px cap over 1300px is
|
// rect, lying over the logo or the header buttons. Skipping is honest; clamping
|
||||||
// a 33px sag, a line that reads as STRAIGHT across the terminal (#285).
|
// would point at a tab that is not there.
|
||||||
// - Too deep (the 104px cap that replaced it): in the wrapped-strip case the cap and
|
const LINEAGE_CORNER_RADIUS_PX = 10;
|
||||||
// the FULL row offset stacked, bowing the bracket ~106px into the terminal text
|
// Families drawn together take separate lanes: gap lines this far apart, spines
|
||||||
// (owner screenshot 2026-08-15, "die Linien machen einen grossen Bogen nach unten").
|
// LINEAGE_SPINE_STEP_PX apart.
|
||||||
// The dip is measured from the STRIP'S BOTTOM EDGE (falling back to the lower tab
|
const LINEAGE_LANE_STEP_PX = 3.5;
|
||||||
// bottom when the strip rect is missing or shorter than its tabs), which buys two
|
// Every family is drawn at once, and a 12px row gap only fits this many lanes at
|
||||||
// things at once: the bow needs no per-row offsets stacked on top, and a same-row
|
// LINEAGE_LANE_STEP_PX; session-lineage.js cycles families through them.
|
||||||
// arc between ROW-1 tabs of a wrapped strip clears row 2's labels instead of being
|
const LINEAGE_MAX_LANES = 3;
|
||||||
// drawn through them (the retune's own first draft had exactly that regression).
|
const LINEAGE_SPINE_INSET_PX = 6;
|
||||||
const LINEAGE_DIP_BASE_PX = 14;
|
const LINEAGE_SPINE_STEP_PX = 4;
|
||||||
const LINEAGE_DIP_PER_PX = 0.06;
|
// The last row has no row below it; with no strip rect to measure, its gap is
|
||||||
const LINEAGE_DIP_MIN_PX = 22;
|
// taken to be this deep.
|
||||||
const LINEAGE_DIP_MAX_PX = 64;
|
const LINEAGE_LAST_GAP_PX = 12;
|
||||||
// Siblings nest by this much. Widened with the stroke: at 2.5px plus its glow, arcs 6px
|
const LINEAGE_ROW_TOLERANCE_PX = 6;
|
||||||
// apart bled into one thick band instead of reading as three separate lines.
|
|
||||||
const LINEAGE_SIBLING_STEP_PX = 8;
|
|
||||||
const LINEAGE_STRIP_TOLERANCE_PX = 4;
|
const LINEAGE_STRIP_TOLERANCE_PX = 4;
|
||||||
// How far the vertical bracket sits in from the rail's left edge. It has to
|
// 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: the line carries an 11px outer
|
// clear the VIEWPORT edge, not just the tabs, or the line reads as a thread pinned
|
||||||
// glow, so a track at 6px had half of that glow clipped away and the arc read
|
// to the window frame. The rail reserves the channel itself
|
||||||
// as a thin thread pinned to the window frame. The rail reserves the channel
|
// (`--lineage-vertical-gutter` on the rail's .session-tabs), and the track is
|
||||||
// itself (`--lineage-vertical-gutter` on the rail's .session-tabs), and
|
// still clamped to stay left of every endpoint.
|
||||||
// computeLineagePath still clamps the track to stay left of both tabs.
|
|
||||||
const LINEAGE_VERTICAL_TRACK_INSET_PX = 10;
|
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;
|
const LINEAGE_VERTICAL_ANCHOR_CLEARANCE_PX = 4;
|
||||||
// Lineage palette, assigned per SPAWNING TAB in first-seen order and cycled
|
// 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
|
// (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 arcs below it.
|
// 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,
|
// 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
|
// 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");
|
// (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.
|
|
||||||
const LINEAGE_COLORS = ['', '#00ff66', '#ff5ea8', '#a78bfa', '#ff5252', '#2dd4bf', '#ffa940'];
|
const LINEAGE_COLORS = ['', '#00ff66', '#ff5ea8', '#a78bfa', '#ff5252', '#2dd4bf', '#ffa940'];
|
||||||
|
|
||||||
function computeLineagePath(input) {
|
/** A rect normalized to numbers with its edges and center, or null if unusable. */
|
||||||
const parent = input?.parent;
|
function lineageRect(rect) {
|
||||||
const child = input?.child;
|
if (!rect) return null;
|
||||||
if (!parent || !child) 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;
|
* Group tab rects into the strip's visual rows, top to bottom. A row spans from its
|
||||||
const cw = Number(child.width) || 0;
|
* highest top to its LOWEST bottom, so a taller tab (the active one) sets the row's
|
||||||
const ch = Number(child.height) || 0;
|
* gap for everyone in it.
|
||||||
if (pw <= 0 || ph <= 0 || cw <= 0 || ch <= 0) return null;
|
*/
|
||||||
|
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 orientation = input?.orientation === 'vertical' ? 'vertical' : 'horizontal';
|
||||||
const strip = input?.strip;
|
const laneCount = Math.max(1, Math.floor(Number(input?.laneCount)) || 1);
|
||||||
const depth = Math.max(0, Math.min(6, Number(input?.depth) || 0));
|
const lane = Math.max(0, Math.min(laneCount - 1, Math.floor(Number(input?.lane)) || 0));
|
||||||
const pLeft = Number(parent.left);
|
const radius = Number.isFinite(Number(input?.radius)) ? Math.max(0, Number(input.radius)) : LINEAGE_CORNER_RADIUS_PX;
|
||||||
const cLeft = Number(child.left);
|
const children = [];
|
||||||
const pTop = Number(parent.top);
|
for (const child of input?.children || []) {
|
||||||
const cTop = Number(child.top);
|
const rect = lineageRect(child?.rect);
|
||||||
if (![pLeft, cLeft, pTop, cTop].every(Number.isFinite)) return null;
|
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') {
|
if (orientation === 'vertical') {
|
||||||
const py = pTop + ph / 2;
|
// The rail: one track down the empty left gutter, shared by every sibling.
|
||||||
const cy = cTop + ch / 2;
|
if (!inStripY(parent)) return null;
|
||||||
if (strip && Number(strip.height) > 0) {
|
const visible = children.filter((c) => inStripY(c.rect));
|
||||||
const min = Number(strip.top) - LINEAGE_STRIP_TOLERANCE_PX;
|
if (visible.length === 0) return { routes };
|
||||||
const max = Number(strip.top) + Number(strip.height) + LINEAGE_STRIP_TOLERANCE_PX;
|
const minLeft = Math.min(parent.left, ...visible.map((c) => c.rect.left));
|
||||||
if (py < min || py > max || cy < min || cy > max) return null;
|
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) });
|
||||||
}
|
}
|
||||||
|
return { routes };
|
||||||
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 };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const px = pLeft + pw / 2;
|
if (!inStripX(parent) || !inStripY(parent)) return null;
|
||||||
const cx = cLeft + cw / 2;
|
const visible = children.filter((c) => inStripX(c.rect) && inStripY(c.rect));
|
||||||
if (strip && Number(strip.width) > 0) {
|
if (visible.length === 0) return { routes };
|
||||||
const min = Number(strip.left) - LINEAGE_STRIP_TOLERANCE_PX;
|
|
||||||
const max = Number(strip.left) + Number(strip.width) + LINEAGE_STRIP_TOLERANCE_PX;
|
const rows = computeLineageRows([...(input?.tabs || []), parent, ...visible.map((c) => c.rect)]);
|
||||||
if (px < min || px > max || cx < min || cx > max) return null;
|
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;
|
function roundPoints(points) {
|
||||||
const cBottom = cTop + ch;
|
return points.map(([x, y]) => [r1(x), r1(y)]);
|
||||||
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 };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// One decimal is plenty for a screen-space path and keeps the `d` string short.
|
// One decimal is plenty for a screen-space path and keeps the `d` string short.
|
||||||
@@ -961,12 +1073,13 @@ if (typeof window !== 'undefined') {
|
|||||||
plan: planWsReconnect,
|
plan: planWsReconnect,
|
||||||
};
|
};
|
||||||
window.CodemanLineage = {
|
window.CodemanLineage = {
|
||||||
computePath: computeLineagePath,
|
computeTree: computeLineageTree,
|
||||||
DIP_MIN_PX: LINEAGE_DIP_MIN_PX,
|
computeRows: computeLineageRows,
|
||||||
DIP_MAX_PX: LINEAGE_DIP_MAX_PX,
|
CORNER_RADIUS_PX: LINEAGE_CORNER_RADIUS_PX,
|
||||||
SIBLING_STEP_PX: LINEAGE_SIBLING_STEP_PX,
|
LANE_STEP_PX: LINEAGE_LANE_STEP_PX,
|
||||||
|
MAX_LANES: LINEAGE_MAX_LANES,
|
||||||
|
SPINE_INSET_PX: LINEAGE_SPINE_INSET_PX,
|
||||||
VERTICAL_TRACK_INSET_PX: LINEAGE_VERTICAL_TRACK_INSET_PX,
|
VERTICAL_TRACK_INSET_PX: LINEAGE_VERTICAL_TRACK_INSET_PX,
|
||||||
VERTICAL_SIBLING_STEP_PX: LINEAGE_VERTICAL_SIBLING_STEP_PX,
|
|
||||||
COLORS: LINEAGE_COLORS,
|
COLORS: LINEAGE_COLORS,
|
||||||
};
|
};
|
||||||
window.CodemanConnectionLoss = {
|
window.CodemanConnectionLoss = {
|
||||||
|
|||||||
@@ -2153,7 +2153,7 @@
|
|||||||
<div class="set-row" id="appSettingsLineageLinesItem" data-search="lineage lines spawned worker parent connection">
|
<div class="set-row" id="appSettingsLineageLinesItem" data-search="lineage lines spawned worker parent connection">
|
||||||
<div class="set-row-text">
|
<div class="set-row-text">
|
||||||
<span class="set-row-label">Spawn Lineage Lines <span class="set-tag">desktop</span></span>
|
<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">Draw lines from each session to the sessions it spawned. The selected tab's family is drawn thicker.</span>
|
||||||
</div>
|
</div>
|
||||||
<label class="switch switch-sm"><input type="checkbox" id="appSettingsLineageLines" checked><span class="slider"></span></label>
|
<label class="switch switch-sm"><input type="checkbox" id="appSettingsLineageLines" checked><span class="slider"></span></label>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,29 +1,41 @@
|
|||||||
/**
|
/**
|
||||||
* @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,
|
* 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
|
* 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
|
* state server-side. This module turns that field into a quiet orthogonal tree, one per
|
||||||
* connection line the subagent windows use, but tab → tab, so the strip shows at a
|
* spawning tab, routed through the gaps between tab rows so it never crosses a label or
|
||||||
* glance which tab spawned which.
|
* 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
|
* It is an ADDITIONAL LAYER on the existing SVG pass, not a second pass: the core
|
||||||
* `_updateConnectionLinesImmediate()` (subagent-windows.js) calls
|
* `_updateConnectionLinesImmediate()` (subagent-windows.js) calls
|
||||||
* `_appendLineageConnectionLines(svg, rects)` at its tail, exactly like ultracode does,
|
* `_appendLineageConnectionLines(svg, rects)` at its tail, exactly like ultracode does,
|
||||||
* so every layer shares ONE batched read → write reflow and one tab-rect cache.
|
* so every layer shares ONE batched read → write reflow and one tab-rect cache.
|
||||||
*
|
*
|
||||||
* Two constraints that are not obvious from the code:
|
* Constraints that are not obvious from the code:
|
||||||
* - DESKTOP ONLY. The overlay is `z-index: 999`; the desktop header is 100 (arcs paint
|
* - 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
|
* 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
|
* 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.
|
* 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
|
* - Paths carry `data-agent-id="lineage:<childId>"` because that is the attribute
|
||||||
* `_applyLineEntrances()` queries, so the draw-in animation and its
|
* `_applyLineEntrances()` queries, so the draw-in animation and its
|
||||||
* negative-`animation-delay` resume across `svg.innerHTML = ''` come for free.
|
* negative-`animation-delay` resume across `svg.innerHTML = ''` come for free.
|
||||||
*
|
*
|
||||||
* @mixin Extends CodemanApp.prototype via Object.assign
|
* @mixin Extends CodemanApp.prototype via Object.assign
|
||||||
* @dependency subagent-windows.js (_updateConnectionLinesImmediate, #connectionLines)
|
* @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)
|
* @dependency settings-ui.js (loadAppSettingsFromStorage, getDefaultSettings)
|
||||||
* @loadorder 15.6 (after ultracode-windows.js — appended to the same SVG pass)
|
* @loadorder 15.6 (after ultracode-windows.js — appended to the same SVG pass)
|
||||||
*/
|
*/
|
||||||
@@ -62,49 +74,99 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
applyLineageLineSettings() {
|
applyLineageLineSettings() {
|
||||||
const prev = this._lineageLinesOn;
|
const prev = this._lineageLinesOn;
|
||||||
const next = this._syncLineageLinesEnabled();
|
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
|
* Reserve (or release) the strip's routing room: `.lineage-tree` on #sessionTabs
|
||||||
* (that index is what nests sibling arcs instead of overprinting them).
|
* 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
|
* Keyed on whether any family exists at all, never on which one is selected: a
|
||||||
* strip's own left-to-right order, which is what the user sees.
|
* 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() {
|
_collectLineageEdges() {
|
||||||
const edges = [];
|
const edges = [];
|
||||||
if (!this.sessions || this.sessions.size < 2) return edges;
|
if (!this.sessions || this.sessions.size < 2) return edges;
|
||||||
const order = this.sessionOrder && this.sessionOrder.length ? this.sessionOrder : [...this.sessions.keys()];
|
const order = this.sessionOrder && this.sessionOrder.length ? this.sessionOrder : [...this.sessions.keys()];
|
||||||
const seenPerParent = new Map();
|
|
||||||
for (const id of order) {
|
for (const id of order) {
|
||||||
const session = this.sessions.get(id);
|
const session = this.sessions.get(id);
|
||||||
const parentId = session && session.parentSessionId;
|
const parentId = session && session.parentSessionId;
|
||||||
// A parent that is gone (closed, or never came back after a restart) draws
|
// 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.
|
// nothing: the field is decoration, so a dangling one is simply not rendered.
|
||||||
if (!parentId || parentId === id || !this.sessions.has(parentId)) continue;
|
if (!parentId || parentId === id || !this.sessions.has(parentId)) continue;
|
||||||
const depth = seenPerParent.get(parentId) || 0;
|
edges.push({ parentId, childId: id, status: session.status || 'idle' });
|
||||||
seenPerParent.set(parentId, depth + 1);
|
|
||||||
edges.push({ parentId, childId: id, depth, status: session.status || 'idle' });
|
|
||||||
}
|
}
|
||||||
return edges;
|
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 }));
|
||||||
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Colour for one arc, from CodemanLineage.COLORS, keyed on the SPAWNING tab.
|
* 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 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
|
* 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
|
* 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
|
* 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
|
* 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.
|
* generation while each generation's fan-out stays uniform.
|
||||||
*
|
*
|
||||||
* Assigned in FIRST-SEEN order and remembered per parent id. First-seen rather than
|
* 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
|
* 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
|
* 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 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.
|
* --session-blue, so the first spawning tab keeps the skin-aware blue.
|
||||||
*/
|
*/
|
||||||
_lineageColorFor(parentId) {
|
_lineageColorFor(parentId) {
|
||||||
@@ -140,19 +202,20 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
_appendLineageConnectionLines(svg, rects) {
|
_appendLineageConnectionLines(svg, rects) {
|
||||||
this._lineageEdgeCount = 0;
|
this._lineageEdgeCount = 0;
|
||||||
if (!svg || !this._lineageLinesEnabled()) return;
|
if (!svg || !this._lineageLinesEnabled()) return;
|
||||||
// Sidebar layout: computeLineagePath()'s whole geometry — the U-bridge hung
|
// Sidebar layout: the tree is routed for a horizontal strip or the vertical
|
||||||
// from the STRIP's bottom edge, the 64px dip corridor — assumes a horizontal
|
// rail. The sidebar is a vertical list with its own scroller and no reserved
|
||||||
// tab row. Against a vertical list the "strip bottom" is the bottom of the
|
// channel; parent/child adjacency reads fine there without lines.
|
||||||
// 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.
|
|
||||||
if (this.isSessionSidebarActive?.()) return;
|
if (this.isSessionSidebarActive?.()) return;
|
||||||
const compute = window.CodemanLineage && window.CodemanLineage.computePath;
|
const computeTree = window.CodemanLineage && window.CodemanLineage.computeTree;
|
||||||
if (!compute) return;
|
if (!computeTree) return;
|
||||||
|
|
||||||
const edges = this._collectLineageEdges();
|
const edges = this._collectLineageEdges();
|
||||||
|
this._lineageTotalEdges = edges.length;
|
||||||
if (edges.length === 0) return;
|
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();
|
if (!rects) rects = new Map();
|
||||||
|
|
||||||
// PHASE 1 — reads.
|
// PHASE 1 — reads.
|
||||||
@@ -162,9 +225,9 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
const orientation =
|
const orientation =
|
||||||
document.documentElement.getAttribute('data-tab-orientation') === 'vertical' ? 'vertical' : 'horizontal';
|
document.documentElement.getAttribute('data-tab-orientation') === 'vertical' ? 'vertical' : 'horizontal';
|
||||||
// A session hidden inside a collapsed group of the grouped rail has no row
|
// 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
|
// to anchor to, so its end of the line moves to that group's header (a
|
||||||
// "proxied" endpoint, drawn quieter). Two endpoints proxied to the SAME
|
// "proxied" endpoint, drawn quieter). A child proxied to the same header as
|
||||||
// header would be an arc from a row to itself: skipped.
|
// its parent would be a line from a row to itself: skipped.
|
||||||
const resolveEndpoint = (id) => {
|
const resolveEndpoint = (id) => {
|
||||||
const tab = strip.querySelector(`.session-tab[data-id="${CSS.escape(id)}"]`);
|
const tab = strip.querySelector(`.session-tab[data-id="${CSS.escape(id)}"]`);
|
||||||
if (tab) return { key: 'tab:' + id, element: tab, proxied: false };
|
if (tab) return { key: 'tab:' + id, element: tab, proxied: false };
|
||||||
@@ -173,71 +236,125 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
const header = strip.querySelector(`[data-tab-group-header="${CSS.escape(groupId)}"]`);
|
const header = strip.querySelector(`[data-tab-group-header="${CSS.escape(groupId)}"]`);
|
||||||
return { key: 'group:' + groupId, element: header, proxied: !!header };
|
return { key: 'group:' + groupId, element: header, proxied: !!header };
|
||||||
};
|
};
|
||||||
const resolvedEdges = [];
|
const measure = (endpoint) => {
|
||||||
for (const edge of edges) {
|
if (!rects.has(endpoint.key)) {
|
||||||
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;
|
|
||||||
rects.set(endpoint.key, endpoint.element ? endpoint.element.getBoundingClientRect() : null);
|
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.
|
// PHASE 2 — writes, from the cache only.
|
||||||
for (const { edge, parentEndpoint, childEndpoint } of resolvedEdges) {
|
// Lanes follow STRIP order, so a family keeps its lane when the selection moves;
|
||||||
const parentRect = rects.get(parentEndpoint.key);
|
// only the DRAW order changes (the emphasized families last, on top). A row gap
|
||||||
const childRect = rects.get(childEndpoint.key);
|
// fits a few lanes, so they cycle: families that share one are told apart by colour.
|
||||||
if (!parentRect || !childRect) continue;
|
const laneLimit = Math.max(1, (window.CodemanLineage && window.CodemanLineage.MAX_LANES) || 3);
|
||||||
|
const laneCount = Math.min(laneLimit, resolvedFamilies.length);
|
||||||
const geom = compute({
|
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,
|
parent: parentRect,
|
||||||
child: childRect,
|
children: children.map((c) => ({ id: c.edge.childId, rect: c.rect })),
|
||||||
strip: stripRect,
|
strip: stripRect,
|
||||||
depth: edge.depth,
|
tabs: tabRects,
|
||||||
orientation,
|
orientation,
|
||||||
|
lane,
|
||||||
|
laneCount,
|
||||||
});
|
});
|
||||||
if (!geom) continue; // scrolled out of the strip, or a degenerate rect
|
if (!geom || geom.routes.length === 0) continue;
|
||||||
|
const byId = new Map(children.map((c) => [c.edge.childId, c]));
|
||||||
const line = document.createElementNS('http://www.w3.org/2000/svg', 'path');
|
const color = this._lineageColorFor(family.parentId);
|
||||||
line.setAttribute('d', geom.d);
|
// One group per family: it carries the translucency, so the stretches its
|
||||||
// The working class marches the dashes, so an active worker is visible along
|
// routes share (the trunk) do not stack into a brighter line than the branches,
|
||||||
// the line itself. `status` is the CHILD's, which is the interesting end.
|
// and the emphasis for the selected tab's families (styles.css).
|
||||||
const working = edge.status === 'working' ? ' lineage-line--working' : '';
|
const group = document.createElementNS('http://www.w3.org/2000/svg', 'g');
|
||||||
const proxied = parentEndpoint.proxied || childEndpoint.proxied;
|
group.setAttribute('class', 'lineage-family' + (focus ? ' lineage-family--focus' : ''));
|
||||||
line.setAttribute('class', 'connection-line lineage-line' + working + (proxied ? ' lineage-line--proxied' : ''));
|
group.setAttribute('data-parent-tab', family.parentId);
|
||||||
// The PARENT's colour rides a CSS custom property so the stylesheet keeps owning
|
// Working routes go in FIRST, so an idle sibling's solid stroke covers the
|
||||||
// opacity, glow and dash; an empty colour leaves the --session-blue fallback.
|
// shared trunk and only the working child's own branch shows its dashes.
|
||||||
// Every arc out of one tab shares it — see _lineageColorFor().
|
const routes = geom.routes
|
||||||
const color = this._lineageColorFor(edge.parentId);
|
.map((route) => ({ route, child: byId.get(route.id) }))
|
||||||
if (color) line.style.setProperty('--lineage-color', color);
|
.filter((r) => r.child)
|
||||||
// `data-agent-id` is what _applyLineEntrances() queries — see the file header.
|
.sort((a, b) => (b.child.edge.status === 'working') - (a.child.edge.status === 'working'));
|
||||||
line.setAttribute('data-agent-id', 'lineage:' + edge.childId);
|
for (const { route, child } of routes) {
|
||||||
line.setAttribute('data-parent-tab', edge.parentId);
|
const line = document.createElementNS('http://www.w3.org/2000/svg', 'path');
|
||||||
line.setAttribute('data-child-tab', edge.childId);
|
line.setAttribute('d', route.d);
|
||||||
svg.appendChild(line);
|
// `status` is the CHILD's, which is the interesting end: a working child's
|
||||||
|
// route is dashed (and marches, motion permitting).
|
||||||
// Direction marker at the CHILD end. A circle rather than an SVG <marker>:
|
const working = child.edge.status === 'working' ? ' lineage-line--working' : '';
|
||||||
// markers need a <defs> block and fight the dash pattern.
|
const proxied = parentEndpoint.proxied || child.endpoint.proxied;
|
||||||
const dot = document.createElementNS('http://www.w3.org/2000/svg', 'circle');
|
line.setAttribute(
|
||||||
dot.setAttribute('cx', String(geom.endX));
|
'class',
|
||||||
dot.setAttribute('cy', String(geom.endY));
|
'connection-line lineage-line' + working + (proxied ? ' lineage-line--proxied' : '')
|
||||||
// Resting radius; `lineage-dot-pulse` breathes it 3.5 → 4.5 while the child
|
);
|
||||||
// works, so the two have to be changed together.
|
// The PARENT's colour rides a CSS custom property so the stylesheet keeps
|
||||||
dot.setAttribute('r', '3.5');
|
// owning weight and dash; an empty colour leaves the --session-blue fallback.
|
||||||
dot.setAttribute('class', 'lineage-line-dot' + working + (proxied ? ' lineage-line-dot--proxied' : ''));
|
if (color) line.style.setProperty('--lineage-color', color);
|
||||||
dot.setAttribute('data-child-tab', edge.childId);
|
// `data-agent-id` is what _applyLineEntrances() queries — see the file header.
|
||||||
if (color) dot.style.setProperty('--lineage-color', color);
|
line.setAttribute('data-agent-id', 'lineage:' + child.edge.childId);
|
||||||
svg.appendChild(dot);
|
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
|
* 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
|
* scroll moves both endpoints without firing any render, so the lines would slide
|
||||||
* their tabs. Passive listener, and the redraw is the normal coalesced one.
|
* 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.
|
* Installed once; the guard also keeps a re-init from stacking listeners.
|
||||||
*/
|
*/
|
||||||
@@ -248,11 +365,24 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
this._lineageScrollHandler = () => {
|
this._lineageScrollHandler = () => {
|
||||||
// Sidebar layout and the vertical rail scroll the SAME element
|
// Sidebar layout and the vertical rail scroll the SAME element
|
||||||
// vertically, and there the subagent/ultracode connectors anchor to tab
|
// vertically, and there the subagent/ultracode connectors anchor to tab
|
||||||
// rects too (the sidebar skips lineage arcs entirely, and the rail can
|
// rects too (the sidebar skips lineage entirely, and the rail can show
|
||||||
// show connectors with zero lineage edges, so _lineageEdgeCount alone
|
// connectors with zero lineage edges, so _lineageEdgeCount alone would
|
||||||
// would never redraw them).
|
// never redraw them).
|
||||||
if (this._lineageEdgeCount > 0 || this._isVerticalTabList?.()) this.updateConnectionLines();
|
if (this._lineageEdgeCount > 0 || this._isVerticalTabList?.()) this.updateConnectionLines();
|
||||||
};
|
};
|
||||||
strip.addEventListener('scroll', this._lineageScrollHandler, { passive: true });
|
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);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
+88
-62
@@ -596,6 +596,22 @@ body {
|
|||||||
max-height: var(--tab-strip-max-height, 40vh);
|
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 {
|
.session-tabs::-webkit-scrollbar {
|
||||||
width: 4px;
|
width: 4px;
|
||||||
height: 0;
|
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 100% frame deliberately omits `opacity`, which makes the browser take
|
||||||
the endpoint from the element's own computed value: a subagent line rests at
|
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
|
0.9, a lineage line at 1 (its `.lineage-family` group carries the
|
||||||
here - as `line-enter-fade` above still does - lands every lineage line on the
|
translucency), a proxied one at 0.5. Pinning 0.9 here - as `line-enter-fade`
|
||||||
wrong opacity and snaps it when the class comes off. */
|
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 {
|
html[data-line-anim="blur"] .connection-line.line-enter {
|
||||||
animation-name: line-enter-blur;
|
animation-name: line-enter-blur;
|
||||||
animation-duration: calc(380ms * var(--anim-enter-scale, 1));
|
animation-duration: calc(380ms * var(--anim-enter-scale, 1));
|
||||||
@@ -10665,112 +10682,121 @@ kbd {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* ===== Session lineage lines (tab → tab it spawned, session-lineage.js) =====
|
/* ===== Session lineage lines (tab → tab it spawned, session-lineage.js) =====
|
||||||
Deliberately quieter and thinner than the subagent lines above so the two
|
One quiet orthogonal tree per spawning tab, every family always drawn and
|
||||||
layers read as different things in the same SVG.
|
routed through the gaps between tab rows (computeLineageTree in constants.js).
|
||||||
|
The SELECTED tab's families (what it spawned, and the family it was spawned
|
||||||
|
into) are emphasized by `.lineage-family--focus`: thicker, full opacity, a
|
||||||
|
larger end dot, drawn last (owner call 2026-10-07: see every connection always,
|
||||||
|
thicker when selected). 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
|
Colour: every rule reads --lineage-color, which session-lineage.js sets INLINE
|
||||||
per line from the CodemanLineage.COLORS palette (per child, first-seen order,
|
per path and dot from the CodemanLineage.COLORS palette (per SPAWNING tab,
|
||||||
owner call 2026-08-15: several connected tabs must get several colours). The
|
first-seen order). The first spawning tab gets no override and falls through to
|
||||||
FIRST line gets no override, so it falls through to --session-blue, which EVERY
|
--session-blue, which EVERY skin block already defines and tunes for its own
|
||||||
skin block already defines and tunes for its own background; a lone arc therefore
|
background. Do not add a per-skin `.lineage-line` override inside the
|
||||||
still renders the skin-aware blue that shipped in 1.18.2. Do not add a per-skin
|
html:not([data-skin="og"]) block: a bare class rule in there resolves to
|
||||||
`.lineage-line` override inside the html:not([data-skin="og"]) block: a bare
|
(0,2,1) and would outrank this one from a surprising place.
|
||||||
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.75;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lineage-family--focus {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
⚠ 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 {
|
.connection-line.lineage-line {
|
||||||
stroke: var(--lineage-color, var(--session-blue, #2b8fd9));
|
stroke: var(--lineage-color, var(--session-blue, #2b8fd9));
|
||||||
stroke-width: 2.5;
|
stroke-width: 1.5;
|
||||||
stroke-dasharray: 5 5;
|
stroke-dasharray: none;
|
||||||
stroke-linecap: round;
|
stroke-linecap: round;
|
||||||
opacity: 0.72;
|
stroke-linejoin: round;
|
||||||
--line-glow: drop-shadow(0 0 2px rgba(0, 0, 0, 0.7))
|
opacity: 1;
|
||||||
drop-shadow(0 0 5px var(--lineage-color, var(--session-blue, #2b8fd9)))
|
--line-glow: drop-shadow(0 0 1px rgba(0, 0, 0, 0.9));
|
||||||
drop-shadow(0 0 11px var(--lineage-color, var(--session-blue, #2b8fd9)));
|
|
||||||
filter: var(--line-glow);
|
filter: var(--line-glow);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ⚠ OUTSIDE the reduced-motion block below on purpose. A working child is the case
|
/* The selected tab's families: thicker (the group above makes them opaque). */
|
||||||
the line exists to signal, and pairing the brightness with the marching dashes
|
.lineage-family--focus .connection-line.lineage-line {
|
||||||
left every worker's arc at the resting 0.72 for anyone who turns motion off. */
|
stroke-width: 2.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 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 {
|
.connection-line.lineage-line--working {
|
||||||
opacity: 0.95;
|
stroke-dasharray: 4 3;
|
||||||
}
|
|
||||||
|
|
||||||
.connection-line.lineage-line:hover {
|
|
||||||
opacity: 1;
|
|
||||||
stroke-width: 3;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* The radius rides a variable so the pulse below can breathe from whichever size
|
||||||
|
the dot rests at; the `r` attribute session-lineage.js sets is only a fallback. */
|
||||||
.lineage-line-dot {
|
.lineage-line-dot {
|
||||||
|
--lineage-dot-r: 2.5px;
|
||||||
|
r: var(--lineage-dot-r);
|
||||||
fill: var(--lineage-color, var(--session-blue, #2b8fd9));
|
fill: var(--lineage-color, var(--session-blue, #2b8fd9));
|
||||||
opacity: 0.85;
|
filter: drop-shadow(0 0 1px rgba(0, 0, 0, 0.9));
|
||||||
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)));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* An arc re-anchored to a collapsed group's header (its row is hidden): quieter
|
.lineage-family--focus .lineage-line-dot {
|
||||||
|
--lineage-dot-r: 3.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 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. */
|
and dotted, so it reads as "somewhere in there" rather than a real row. */
|
||||||
.connection-line.lineage-line--proxied {
|
.connection-line.lineage-line--proxied {
|
||||||
stroke-dasharray: 2 7;
|
stroke-dasharray: 1 5;
|
||||||
opacity: 0.46;
|
opacity: 0.5;
|
||||||
}
|
}
|
||||||
|
|
||||||
.connection-line.lineage-line--proxied.lineage-line--working {
|
.connection-line.lineage-line--proxied.lineage-line--working {
|
||||||
opacity: 0.62;
|
opacity: 0.65;
|
||||||
}
|
}
|
||||||
|
|
||||||
.lineage-line-dot.lineage-line-dot--proxied {
|
.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
|
/* The working route marches from parent to child, so the line itself carries
|
||||||
itself carries the signal. Motion is opt-out-able at the OS level. */
|
the signal. Motion is opt-out-able at the OS level. */
|
||||||
@media (prefers-reduced-motion: no-preference) {
|
@media (prefers-reduced-motion: no-preference) {
|
||||||
.connection-line.lineage-line--working {
|
.connection-line.lineage-line--working {
|
||||||
animation: lineage-flow 1.1s linear infinite;
|
animation: lineage-flow 1.1s linear infinite;
|
||||||
}
|
}
|
||||||
|
|
||||||
.lineage-line-dot--working {
|
.lineage-line-dot--working {
|
||||||
opacity: 1;
|
|
||||||
animation: lineage-dot-pulse 1.4s ease-in-out infinite;
|
animation: lineage-dot-pulse 1.4s ease-in-out infinite;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Two full dash cycles, so the march loops seamlessly. Tied to `stroke-dasharray`
|
/* Two full dash cycles, so the march loops seamlessly. Tied to the working
|
||||||
above: at `5 5` the cycle is 10px, so this is -20 rather than the -16 that
|
`stroke-dasharray` above: at `4 3` the cycle is 7px, so this is -14. Leaving
|
||||||
matched the old `4 4`. Leaving them out of step makes the dashes jump once per
|
them out of step makes the dashes jump once per iteration. */
|
||||||
iteration. */
|
|
||||||
@keyframes lineage-flow {
|
@keyframes lineage-flow {
|
||||||
to {
|
to {
|
||||||
stroke-dashoffset: -20;
|
stroke-dashoffset: -14;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Breathes from the dot's own resting radius (`--lineage-dot-r`). */
|
||||||
@keyframes lineage-dot-pulse {
|
@keyframes lineage-dot-pulse {
|
||||||
0%, 100% {
|
0%, 100% {
|
||||||
opacity: 0.75;
|
opacity: 0.75;
|
||||||
r: 3.5;
|
r: var(--lineage-dot-r);
|
||||||
}
|
}
|
||||||
50% {
|
50% {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
r: 4.5;
|
r: calc(var(--lineage-dot-r) + 0.8px);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -166,8 +166,9 @@ describe('entrance animation styles', () => {
|
|||||||
expect(blur).not.toBeNull();
|
expect(blur).not.toBeNull();
|
||||||
expect(blur.match(/var\(--line-glow\)/g)?.length).toBe(2);
|
expect(blur.match(/var\(--line-glow\)/g)?.length).toBe(2);
|
||||||
// The 100% frame deliberately omits opacity so the endpoint comes from the
|
// The 100% frame deliberately omits opacity so the endpoint comes from the
|
||||||
// element's own resting value: 0.9 on a subagent line, 0.72 on a lineage
|
// element's own resting value: 0.9 on a subagent line, 1 on a lineage
|
||||||
// line, 0.95 on a working one. Pinning a number here snaps three of them.
|
// line (its family group is translucent), 0.5 on a proxied one. Pinning a
|
||||||
|
// number here snaps them.
|
||||||
expect(blur).toMatch(/100%\s*\{\s*filter:[^}]*\}/);
|
expect(blur).toMatch(/100%\s*\{\s*filter:[^}]*\}/);
|
||||||
expect(blur).not.toMatch(/100%\s*\{[^}]*opacity/);
|
expect(blur).not.toMatch(/100%\s*\{[^}]*opacity/);
|
||||||
});
|
});
|
||||||
|
|||||||
+605
-325
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user