mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-09 00:49:41 +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)
|
||||
|
||||
**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)
|
||||
|
||||
@@ -333,10 +333,6 @@ Frontend JS modules have `@fileoverview` with `@dependency`/`@loadorder` tags. L
|
||||
|
||||
**Session list layout: header strip or left sidebar** (`sessionListLayout`, default `header`; per-device via `displayKeys`, also in `SettingsUpdateSchema`): the list can move into a collapsible `<aside>` (Alt+B, `toggleSessionSidebar`) or, via `tabOrientation`, a resizable vertical `#tabRail` (desktop/tablet only). ⚠️ There is ONE `#sessionTabs`, MOVED between hosts, never a second list: `applySessionListLayout()` runs first, then `applyTabOrientation()`, both BEFORE `applyTabWrapSettings()`, and both arm/disarm `_startSidebarRichClock()`. ⚠️ Axis decisions use `_isVerticalTabList()`, never `isSessionSidebarActive()` alone. ⚠️ Rich rows share one gate, `isRichTabRows()`; rich CSS pairs sidebar+rail with comma-grouped selectors, never `:is()`, and card rules stay rail-scoped. ⚠️ Rail sort (`tabRailSort`, default `activity`) is the flex `order` property only, never a DOM reorder; the arrow-key walk alone follows computed `order`. ⚠️ Leaving sidebar mode clears `_sidebarFilter`; the handheld overlay drawer is `inert` when closed, the docked rail never. → [architecture-invariants#session-list-layout-header-strip-vs-left-sidebar](docs/architecture-invariants.md#session-list-layout-header-strip-vs-left-sidebar)
|
||||
|
||||
**Tab layouts** (`tabArrangement`, per-device, default `state`; Discussion #426): `state` (option C) splits the list into needs you / waiting / working / idle (idle is `quiet`: its heading stays as the row's anchor but draws no label; `tabStateOrder: 'urgent-last'` flips the order, needs you at the bottom): rows in the desktop header strip with left-aligned labels in a measured column (the brand sits over the strip's corner, so the rows after the first start under "Codeman"), sections in the flat rail and the sidebar, inline dividers on the tablet strip, group order with no headings on phones. `case` (option A) wraps each case's tabs in one `.tab-cluster` box and hides the `-<case>` of a generated name (`.tab-name-case`). `ledger` (option B) is a CSS-only grid on the desktop header strip. `classic` is the old strip. Pure cores: `CodemanTabTriage` and `CodemanTabClusters` (constants.js). ⚠️ `state` is the flex `order` property plus `aria-hidden` heading/break elements reconciled in place by `_syncTabTriageChrome()` after BOTH render paths, never a DOM reorder (a state change is an incremental pass). ⚠️ `case` boxes ARE a DOM grouping, so the incremental path rebuilds whenever `_tabClusterLayout().key` changes; membership follows `_mobileOverviewCaseFor()` like the home screens. ⚠️ Drag only reorders within a group or box (`_isTabDropAcrossGroups()`). ⚠️ Named groups in the vertical rail win over both groupings. ⚠️ `classic` must leave no trace (no headings, no inline order, no boxes, no name split), and the ledger must not change the markup. Tests: `test/tab-triage.test.ts`, `test/tab-clusters.test.ts`. → [architecture-invariants#tab-layouts-by-state-by-case-ledger](docs/architecture-invariants.md#tab-layouts-by-state-by-case-ledger)
|
||||
|
||||
**Header stats styles** (`headerStatsStyle`, per-device, desktop only, default `tiles`; Discussion #426 option G): `tiles` / `compact` (two pills, a ring beside every value) / `classic` (as before), keyed on `data-header-stats` (pre-paint + `applyHeaderStatsStyle()`), forced to `classic` below 768px and in a solo window. ⚠️ The clustered styles MOVE `#connectionIndicator` into `#headerSystemStats` and `#planUsageChip` right after it; `classic` moves them back to comment anchors. ⚠️ WS joins the pill only while System Stats is shown. ⚠️ The extra parts (stat rings, tile words, plan rings, meters) are always rendered and hidden by default in CSS, which is what keeps `classic` unchanged; tile words are derived beside the connection descriptor, never added to it (`test/connection-indicator.test.ts` pins its shape). ⚠️ A tile is a three-row grid (label, value, bar) with pixel line-heights and the bundled JetBrains Mono; never lay the bar over a fixed-height tile (a taller system mono put the value on top of it). The WS tile's grid lives on the inner `.connection-tile`, because JS writes the indicator's `display` inline. Tests: `test/header-stats-style.test.ts`. → [architecture-invariants#header-stats-styles](docs/architecture-invariants.md#header-stats-styles)
|
||||
|
||||
**Phone overview home screen** (`mobile-overview.js`, per-device `mobileOverviewEnabled`, default ON): under 600px the "C" logo shows NEEDS YOU / CURRENT / PAST SESSIONS instead of the welcome overlay, branched in `showWelcome()`/`hideWelcome()` via width-driven `shouldUseMobileOverview()`. ⚠️ The container ships `hidden` and only this module removes it: never give `.mobile-overview` a bare `display` rule (desktop does not load `mobile.css`). ⚠️ The split Run button must carry the toolbar's own classes (`btn-toolbar btn-run mode-<backend>` / `btn-run-gear`) and mobile.css must set no `background`/`color` on it; row status must mirror the session-tab alert language. PAST rows resume through the shared `resumeHistorySession()`. Status pills carry `data-i18n-skip`. → [architecture-invariants#phone-overview-home-screen](docs/architecture-invariants.md#phone-overview-home-screen)
|
||||
|
||||
**Desktop home tab rail** (`home-sessions.js`, desktop only): the welcome overlay's left gutter carries the open tabs as a rail docked flush left, full height, in overview order, each row showing `created … · <state> <duration>` from `_mobileOverviewSince()`; state classification is reused from mobile-overview.js (so it loads after it). ⚠️ The number badge is the Alt+1..9 tab-strip index, never renumber it to row position. ⚠️ The width gate lives in two places that must stay equal: `HOME_SESSIONS_MIN_WIDTH` (1180) and a `max-width: 1179px` media query. ⚠️ `.home-sessions[hidden]` must re-assert `display: none`. ⚠️ Size all children in `em` off the one `clamp()` knob, never `rem`/px. Age stamps tick in place (`_tickHomeSessionsTimes()`), never by re-render. Test: `test/home-sessions.test.ts`. → [architecture-invariants#desktop-home-tab-rail](docs/architecture-invariants.md#desktop-home-tab-rail)
|
||||
|
||||
@@ -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.
|
||||
|
||||
**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)
|
||||
|
||||
@@ -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.
|
||||
|
||||
⚠️ 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.
|
||||
|
||||
@@ -867,30 +871,6 @@ Tests: `test/terminal-touch-tap.test.ts`.
|
||||
|
||||
⚠️ Mobile no longer hoists the active session to the front of the strip: that reordering ran on full renders only, so tab order flipped depending on which render path fired, and it renumbered the Alt+N badges. Scroll-into-view replaces it; do not reintroduce it.
|
||||
|
||||
### Tab layouts (by state, by case, ledger)
|
||||
|
||||
**Tab layouts** (`tabArrangement`, per-device, default `state`; Discussion #426): App Settings → Appearance → Tabs → **Tab Layout**. Four values: `state` (option C), `case` (option A), `ledger` (option B) and `classic`, the strip as before. `<html data-tab-arrangement>` is stamped pre-paint and by `applyTabOrientation()`; the render paths read it through `isTabTriage()`, `isTabClusters()` and `isTabLedger()`. Named groups in the vertical rail (owner tab layouts, `_projectTabGroups()` non-null) win over both groupings, and the grouped tree renders as before.
|
||||
|
||||
**By state.** The list is split into four groups: **needs you** (a permission or question dialog; a failed session joins it), **waiting** (idle prompt pending), **working**, **idle** (also ended sessions, an agent that exited inside a live pane, and web tabs). Idle is `quiet` (`TAB_TRIAGE_GROUPS`): its heading element still exists, because it anchors the row's `order` band and, as the lead, holds the row's place beside the brand, but it draws no label or count (`.tab-triage-head--quiet`). `tabStateOrder` (`urgent-first` default, `urgent-last`) decides which end they start from; only the group order flips, never the rows inside a group. The desktop header strip draws a row per group with its label left-aligned in a column that `_sizeTabTriageGutter()` measures to the widest label on screen (re-measured only when the label text changes, and once on `document.fonts.ready`), so no row carries a fixed gutter's worth of empty space. The brand leaves the flow and sits over the strip's top-left corner (`.header:has(...)`), so the first row's heading (`.tab-triage-head--lead`, natural width) is pushed past it by `--tab-triage-brand` (a ResizeObserver keeps that width, so no render pass reads layout for it) and every later row starts at the edge, under "Codeman"; the flat rail and the sidebar draw a section per group; the tablet strip (600-767px) keeps its scrolling row with the headings as inline dividers; phones keep their chip row in group order with the headings hidden (mobile.css). Classification is `_mobileOverviewState()` + `_mobileOverviewExit()`, the home screens' own; the fold into four groups and the order bands are pure in `CodemanTabTriage` (constants.js, `computeTabTriageLayout()`).
|
||||
|
||||
⚠️ **By state is the flex `order` property, never a DOM reorder**, exactly like the sorted rail: `#sessionTabs` stays in `sessionOrder`, so the Alt+N badges, drag, the keyboard walk (which sorts by COMPUTED order) and the sidebar filter keep reading the list they always read. Each group owns a band of `TAB_TRIAGE_STRIDE` order values: its heading, its rows, its web tabs, then the row break that ends the header line. The headings and breaks are `aria-hidden` direct children of `#sessionTabs`, reconciled in place by `_syncTabTriageChrome()` after BOTH render paths, because a state change is an incremental pass and can still empty a group or fill a new one. Inside a header row tabs keep TAB order; a sorted rail ranks each section the way it ranks the flat rail. The header strip only wraps into rows on desktop, where `updateTabOverflowMode()` forces `tabs-auto-wrap`; the breaks only display in a wrapping strip, since a `flex-basis: 100%` break in a nowrap scroller would steal width.
|
||||
|
||||
**By case.** Each case's tabs sit in one `.tab-cluster` box (`_renderTabClusters()`), in the order the case first appears in the tab order; membership is `_mobileOverviewCaseFor()` (longest case path that prefixes the working directory), else the directory, else the session alone. A box with two or more tabs is labelled (swatch, name, count) and its generated `w<n>-<case>` names drop the `-<case>`, which stays in the DOM in a `.tab-name-case` span only `.tabs-clusters` hides; a one-tab case gets only the swatch in the header strip; a web tab gets a box of its own. The colour is `--session-<colour>` by a stable hash of the case key (`CodemanTabClusters.colorFor()`), so nothing is stored. The rail and the sidebar draw every case as a labelled section; phones dissolve the boxes (`display: contents`) into the one chip row.
|
||||
|
||||
⚠️ **The boxes ARE a DOM grouping**, unlike by state, which is safe only because a tab changes case far less often than it changes state: `_tabClusterLayout().key` is the whole structure, and the incremental path rebuilds whenever it differs from the last full render's. The keyboard walk keeps boxes together (box index first, then computed order, since a sorted rail orders rows inside each box). Drag only reorders inside one box, as it only reorders inside one state group (`_isTabDropAcrossGroups()`).
|
||||
|
||||
**Ledger.** CSS only, on `.tabs-ledger` inside `@media (min-width: 768px)` and the header host: the flat list on an auto-fill column grid with equal cells, mono type and a 3px status bar (`--ledger-bar`: yellow for a waiting alert, red for an action alert or an error, muted for stopped, exited and web tabs) instead of the dot. ⚠️ It must not change the markup (pinned: the ledger's innerHTML equals classic's); the rail, the sidebar and narrower strips show the plain list.
|
||||
|
||||
With `classic` nothing is left behind: no headings, no breaks, no inline order, no boxes, no name split, no arrangement class. A stale cached mobile-overview.js degrades every grouping to the flat strip. Tests: `test/tab-triage.test.ts`, `test/tab-clusters.test.ts`.
|
||||
|
||||
### Header stats styles
|
||||
|
||||
**Header stats styles** (`headerStatsStyle`, per-device, desktop only, default `tiles`; Discussion #426 option G): how the WS readout, CPU, MEM and the plan-usage windows are drawn. `tiles` gives each its own label-over-value tile with a bar along the bottom edge; `compact` is two pills, WS / CPU / MEM and the plan windows, with a ring beside every value (stat rings accent, red past 80%; plan rings green, yellow or red); `classic` is the bars and `5H · 7D` chip as before. `data-header-stats` on `<html>` (pre-paint, then `applyHeaderStatsStyle()` from `applyHeaderVisibilitySettings()`) drives every rule; narrower than 768px and in a solo window it resolves to `classic`, because the stats are hidden there anyway and the moves below would otherwise reach the phone header.
|
||||
|
||||
⚠️ The clustered styles need the three elements contiguous, and the template keeps the classic order, so `applyHeaderStatsStyle()` MOVES `#connectionIndicator` into `#headerSystemStats` (first child) and `#planUsageChip` right after it, and `classic` moves them back to comment anchors it left at the template positions. Ids are unchanged, so every writer still finds them. ⚠️ The indicator joins the pill only while System Stats is shown: the pill is hidden with `display: none`, and the WS readout must not disappear with it.
|
||||
|
||||
⚠️ The extra parts (`.stat-ring`, `.connection-tile`, `.pu-ring`, `.pu-meter`) are always rendered and hidden by default in styles.css, which is what keeps `classic` looking exactly as before. ⚠️ A tile is a three-row grid (9px label, 14px value, 2px bar) with pixel line-heights, 36px tall like the header, in the bundled JetBrains Mono. The first version laid the bar over the bottom of a fixed 28px tile, and a taller system mono (SF Mono) pushed the value into it; the bar is now a real row, so the height comes from the layout, not the font. The WS tile keeps its grid on the inner `.connection-tile` span, because `_updateConnectionIndicator()` writes the indicator's own `display` inline. The tile words come from `_connectionTileWords()`, DERIVED from the connection descriptor rather than added to it, because `test/connection-indicator.test.ts` pins the descriptor's exact shape. Plan rings and meters clamp their fill to 0-100 while the label keeps the real number. Tests: `test/header-stats-style.test.ts`.
|
||||
|
||||
### Phone overview home screen
|
||||
|
||||
**Phone overview home screen** (`mobile-overview.js`, phones only, per-device `mobileOverviewEnabled`, default ON): under 600px the "C" logo shows a session overview (NEEDS YOU / CURRENT SESSIONS / PAST SESSIONS) instead of the welcome overlay; tablet and desktop are unchanged. The branch lives in `showWelcome()`/`hideWelcome()` (terminal-ui.js) behind `shouldUseMobileOverview()`, which is **width-driven** (`getDeviceType() === 'mobile'`) because this is a layout decision, unlike the settings namespace which stays handheld-based.
|
||||
|
||||
@@ -189,7 +189,7 @@ followed by a wait races, and reports the previous turn's state.
|
||||
## Lineage
|
||||
|
||||
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
|
||||
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.
|
||||
- The Approvals bell. Phones get the NEEDS YOU strips on the home screen instead.
|
||||
- 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
|
||||
|
||||
|
||||
@@ -69,8 +69,6 @@ by folder** (per device, on by default) shows changed files under collapsed fold
|
||||
window; off lists every file by its full path.
|
||||
|
||||
Most default to off. The stock desktop header is system stats, File Viewer, and the gear.
|
||||
**Header Stats Style** picks how the system stats and plan usage are drawn: *Tiles*
|
||||
(default; label over value with a bar underneath), *Compact* (two pills with a ring beside every value) or *As before* (the bars and the `5H · 7D` chip). Desktop only, per device.
|
||||
New header controls never appear on phones. Split is desktop-only regardless of this
|
||||
setting — the button and the feature both stay off below a ~1180px viewport, where two
|
||||
resizable panes plus their divider have nowhere to go.
|
||||
@@ -88,12 +86,10 @@ every session or only the active tab.
|
||||
| Interface Language | English or Simplified Chinese. Per device. |
|
||||
| Session List Layout | Header tab strip (default), a collapsible left sidebar, or the sidebar with detailed rows. See [The Dashboard](The-Dashboard#session-list-layout). |
|
||||
| Tab Orientation | Keeps the header list but turns the strip vertical beside the terminal, resizable, with detailed rows by default. Desktop and tablet only. |
|
||||
| Tab Layout | *By state* (default): a row each for needs you, waiting, working and idle, sections in the rail and sidebar. *By case*: one box per case. *Ledger*: an aligned column grid. *Classic*: the single list as before. See [The Dashboard](The-Dashboard#tab-layouts). |
|
||||
| State Order | For *By state*: needs you on top (default) or at the bottom, right above the terminal. |
|
||||
| Vertical Rail Order | *By activity* (default) sorts the rail the way the home screens are sorted; *Manual* keeps your tab order and drag-reordering. With *By state* or *By case* it orders the rows inside each section. |
|
||||
| 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. |
|
||||
| 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). |
|
||||
| Overview Home Screen | The phone home screen. On by default. |
|
||||
|
||||
|
||||
+11
-53
@@ -27,7 +27,7 @@ Session List Layout** can move it into a vertical sidebar on the left instead, a
|
||||
|
||||
| Layout | Behaviour |
|
||||
| -------------------- | --------------------------------------------------------------------------------- |
|
||||
| **Header tab strip** | The default. On desktop it is one row per state by default (see [Tab layouts](#tab-layouts)); it scrolls sideways on a phone. |
|
||||
| **Header tab strip** | The default. Wraps to a second row on desktop, scrolls sideways on a phone. |
|
||||
| **Left sidebar** | A vertical list with a filter box and a live session count. `Alt+B` collapses it to a narrow rail that keeps the status dots and task badges visible. On a phone it is an off-canvas drawer rather than a docked rail. A detailed variant adds the home screen's per-session line (`created 3d ago · working 12m`) and a status pill. |
|
||||
| **Vertical rail** | The strip turned vertical beside the terminal, resizable, with detailed rows by default. **Vertical Rail Order** sorts it by activity (blocked on you first, then longest running, then most recently quiet), the same order as the home screens; pick *Manual* to get your own order and drag-reordering back. **Tab groups:** pick *Move to new group* from a row's ⋯ menu (or Shift+F10 on it) to make the first one; a group header's menu (right-click, Shift+F10 or its ⋯ glyph) renames it (also F2), reorders or deletes it, rows move between groups from their own menu or by dragging with a mouse or pen, and a collapsed group stays collapsed on that device. Desktop and tablet only. |
|
||||
|
||||
@@ -35,40 +35,6 @@ It is the same list either way, just re-hosted: tab order, drag-to-reorder, the
|
||||
to `Alt+9` numbers and every status colour below behave identically in both. The setting is
|
||||
per device, so a sidebar on your desktop does not force one onto your phone.
|
||||
|
||||
## Tab layouts
|
||||
|
||||
**App Settings → Appearance → Tabs → Tab Layout** picks how the tabs are arranged. Per device.
|
||||
|
||||
| Layout | What it does |
|
||||
| ---------------------- | ------------------------------------------------------------------------------------ |
|
||||
| **By state** (default) | Groups the tabs by what each session needs from you (below). |
|
||||
| **By case** | One box per case, labelled with the case and its tab count. Inside a box, `w75-api-gateway` reads just `w75`. A case with one tab gets a box with a colour swatch. |
|
||||
| **Ledger** | The same list on an aligned column grid: equal cells, monospace names, a coloured bar on the left of each cell instead of the dot (yellow waiting, red needs you). Desktop header only. |
|
||||
| **Classic** | The single list in tab order, as before. |
|
||||
|
||||
**By state** groups the tabs like this, most urgent on top:
|
||||
|
||||
| Group | Who is in it |
|
||||
| ------------- | ----------------------------------------------------------------------------------- |
|
||||
| **Needs you** | Red: a question or permission prompt is blocking the agent. A failed session too. |
|
||||
| **Waiting** | Yellow: the agent finished its turn and is waiting for your next prompt. |
|
||||
| **Working** | A turn is running. |
|
||||
| **Idle** | Everything quiet, including ended sessions, agents that exited inside their pane, and web tabs. |
|
||||
|
||||
In the header each group is a row with its name and count on the left (Idle, the quiet
|
||||
default, carries no label); a group with more tabs than fit on one line continues on the
|
||||
next line. **State Order → Needs you at the
|
||||
bottom** turns the rows the other way up, so the needs-you row sits right above the
|
||||
terminal. Empty groups are not shown. These are the same states the phone overview and the
|
||||
desktop home rail use, and tabs move between groups on their own as their state changes.
|
||||
|
||||
Both groupings also apply to the vertical rail and the left sidebar, as labelled sections.
|
||||
Inside a group or a box tabs keep your tab order (on a rail sorted *By activity*, the
|
||||
activity order), and the `Alt+1` to `Alt+9` numbers never change. Dragging reorders tabs
|
||||
within a group or box. On a phone the strip stays a single scrolling row in group order,
|
||||
without labels or boxes. If you have named tab groups in the vertical rail, those take
|
||||
precedence there.
|
||||
|
||||
## Session tabs
|
||||
|
||||
One tab per session, in your order, and that order syncs across your devices.
|
||||
@@ -118,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
|
||||
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
|
||||
a coloured arc under the strip connecting parent to child, with one colour per child. It is
|
||||
how a fan-out of eight workers stays readable.
|
||||
lines from the parent to each child, in the parent's colour, routed through the gaps between
|
||||
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.
|
||||
|
||||
## Header controls
|
||||
@@ -136,7 +107,7 @@ The right side of the header. Almost all of these are off until you enable them
|
||||
| ---------------------- | ------------------ | ------------------------------------------------------------------------------- |
|
||||
| Connection dot | Always on | SSE connection health. Green is connected. |
|
||||
| Font size `-` / `+` | Always on | `Ctrl +` / `Ctrl -` do the same. |
|
||||
| CPU / MEM | On | Server resource use. Drawn as tiles by default; see Header Stats Style below. |
|
||||
| CPU / MEM bars | On | Server resource use. |
|
||||
| File Viewer | On | Toggles the file browser panel. |
|
||||
| Settings gear | Always on | App Settings. |
|
||||
| Plan usage chip | On, desktop only | Live Claude subscription usage. Claude-only, and needs its telemetry exporter, which the same setting installs. |
|
||||
@@ -155,19 +126,6 @@ The right side of the header. Almost all of these are off until you enable them
|
||||
| Tunnel indicator | When a tunnel runs | Cloudflare tunnel status. |
|
||||
| Admin panel | Multi-user only | User administration. |
|
||||
|
||||
### Header Stats Style
|
||||
|
||||
The connection readout, CPU, MEM and the plan usage windows can be drawn three ways
|
||||
(**App Settings → Header & Panels → Header Stats Style**, per device, desktop only):
|
||||
|
||||
| Style | Look |
|
||||
| -------------- | -------------------------------------------------------------------------------------- |
|
||||
| **Tiles** | The default. One small tile each (`WS live`, `CPU 22%`, `MEM 14.4G`, `5H 28%`, `7D 35%`): label over value, a thin bar underneath, no icons. |
|
||||
| **Compact** | Two slim pills, `WS · CPU · MEM` and the plan windows, with a small ring beside every value. Hands the tabs back the most room. |
|
||||
| **As before** | The bars and the `5H · 7D` chip, exactly as they were. |
|
||||
|
||||
Hiding System Stats or Plan Usage still hides them in every style.
|
||||
|
||||
New header controls never appear on phones. Phone layout is deliberately minimal and is
|
||||
covered in [Mobile Guide](Mobile-Guide).
|
||||
|
||||
|
||||
@@ -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
|
||||
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
|
||||
child. That covers the other direction of fan-out: not subagents inside one session, but
|
||||
whole sessions started by an agent through the API.
|
||||
The tab strip draws lines from every tab to the tabs it spawned. That covers the other
|
||||
direction of fan-out: not subagents inside one session, but whole sessions started by an
|
||||
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.
|
||||
|
||||
See [Driving Codeman From An Agent](Driving-Codeman-From-An-Agent) for the spawning side.
|
||||
|
||||
+30
-448
@@ -3229,20 +3229,14 @@ class CodemanApp {
|
||||
// broken feature rather than as an idle window (reported 2026-09-01). A
|
||||
// missing CODEX bucket means the opposite — that plan has no such limit —
|
||||
// so those stay omitted rather than showing a dash forever.
|
||||
// Every window also carries a ring (the Compact header style) and a meter
|
||||
// (Tiles). styles.css hides both in the classic style, so the chip there
|
||||
// reads exactly as before. `fill` is clamped for the two graphics only; the
|
||||
// label keeps the real number.
|
||||
const seg = (label, p, idle) => {
|
||||
if (p === null) {
|
||||
if (!idle) return '';
|
||||
return `<span class="pu-win pu-win-idle"><span class="pu-ring" style="--pu:0"></span><span class="pu-label">${label}</span><span class="pu-val">—</span><span class="pu-meter"><i style="width:0%"></i></span></span>`;
|
||||
return `<span class="pu-win pu-win-idle"><span class="pu-label">${label}</span><span class="pu-val">—</span></span>`;
|
||||
}
|
||||
const n = Math.round(Number(p));
|
||||
if (!Number.isFinite(n)) return '';
|
||||
const fill = Math.min(100, Math.max(0, n));
|
||||
const cls = colorClass(n);
|
||||
return `<span class="pu-win"><span class="pu-ring ${cls}" style="--pu:${fill}"></span><span class="pu-label">${label}</span><span class="pu-val ${cls}">${n}%</span><span class="pu-meter ${cls}"><i style="width:${fill}%"></i></span></span>`;
|
||||
return `<span class="pu-win"><span class="pu-label">${label}</span><span class="pu-val ${colorClass(n)}">${n}%</span></span>`;
|
||||
};
|
||||
// The provider label only earns its space when there is more than one
|
||||
// provider to tell apart: a machine with Claude alone shows bare windows.
|
||||
@@ -4027,36 +4021,6 @@ class CodemanApp {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The two words the Tiles header style shows for the connection indicator
|
||||
* (label over value), derived from the descriptor rather than added to it,
|
||||
* so the descriptor and its pinned strings stay exactly what they were. The
|
||||
* classic text line (queued bytes and all) stays in the DOM and the full
|
||||
* detail stays in the tooltip.
|
||||
* @param {{dotClass: string, text: string}} desc
|
||||
* @returns {{label: string, value: string, state: string}}
|
||||
*/
|
||||
_connectionTileWords(desc) {
|
||||
const state = (desc.dotClass || '').replace('connection-dot', '').trim();
|
||||
const text = desc.text || '';
|
||||
switch (state) {
|
||||
case 'connected':
|
||||
return { label: 'WS', value: 'live', state };
|
||||
case 'fallback':
|
||||
return { label: 'HTTP', value: 'fallback', state };
|
||||
case 'offline':
|
||||
return { label: 'NET', value: 'offline', state };
|
||||
case 'draining':
|
||||
return { label: 'SEND', value: 'queued', state };
|
||||
case 'reconnecting':
|
||||
// The same dot covers the terminal WebSocket and, with no session
|
||||
// open, the SSE event stream; the classic text already tells them apart.
|
||||
return { label: text.startsWith('WS') ? 'WS' : 'SSE', value: 'retry', state };
|
||||
default:
|
||||
return { label: '', value: '', state };
|
||||
}
|
||||
}
|
||||
|
||||
_updateConnectionIndicator() {
|
||||
const indicator = this.$('connectionIndicator');
|
||||
const dot = this.$('connectionDot');
|
||||
@@ -4084,14 +4048,6 @@ class CodemanApp {
|
||||
dot.className = next.dotClass;
|
||||
text.textContent = next.text;
|
||||
indicator.title = next.title;
|
||||
const tileLabel = this.$('connectionTileLabel');
|
||||
const tileValue = this.$('connectionTileValue');
|
||||
if (tileLabel && tileValue) {
|
||||
const words = this._connectionTileWords(next);
|
||||
tileLabel.textContent = words.label;
|
||||
tileValue.textContent = words.value;
|
||||
tileValue.className = `connection-tile-value ${words.state}`.trim();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4796,333 +4752,6 @@ class CodemanApp {
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* True when the tab list groups by state (`tabArrangement: 'state'`, the default;
|
||||
* Discussion #426 option C): a row per state in the header strip, a section
|
||||
* per state in the flat side rail and the sidebar, most urgent on top.
|
||||
*
|
||||
* Read off <html> like the rail gates (applyTabOrientation() owns the
|
||||
* attribute). Named groups in the vertical rail still win, because they are
|
||||
* the user's own structure: `_tabTriageLayout()` returns null while the
|
||||
* grouped projection is on, and the grouped tree renders as it always did.
|
||||
*/
|
||||
isTabTriage() {
|
||||
return document.documentElement.dataset.tabArrangement === 'state';
|
||||
}
|
||||
|
||||
/**
|
||||
* Order values and visible groups for one render pass, or null when the list
|
||||
* is not grouped by state. The pure core is `CodemanTabTriage.layout()`
|
||||
* (constants.js); this only feeds it the same classification both home
|
||||
* screens and the sorted rail use.
|
||||
*
|
||||
* Inside a group a row keeps its tab order on the header strip, so the strip
|
||||
* only moves a tab when its state changes. A sorted rail ranks rows inside
|
||||
* each section the way it ranks the whole flat rail (`railSortOrder`).
|
||||
*
|
||||
* @param {Array<string>} ids live session ids, in tab order
|
||||
* @param {object|null} groupProjection the grouped rail's projection, if any
|
||||
* @param {Map<string, number>|null} railSortOrder `_tabRailSortOrder(ids)`
|
||||
*/
|
||||
_tabTriageLayout(ids, groupProjection, railSortOrder) {
|
||||
if (groupProjection || !this.isTabTriage()) return null;
|
||||
if (!window.CodemanTabTriage || typeof this._mobileOverviewState !== 'function') return null;
|
||||
const rows = [];
|
||||
for (let i = 0; i < ids.length; i++) {
|
||||
const session = this.sessions.get(ids[i]);
|
||||
if (!session) continue;
|
||||
const state = this._mobileOverviewState(session, this.pendingHooks?.get(ids[i]));
|
||||
rows.push({
|
||||
id: ids[i],
|
||||
state,
|
||||
exited: !!this._mobileOverviewExit?.(state, session),
|
||||
pos: railSortOrder?.has(ids[i]) ? railSortOrder.get(ids[i]) : i,
|
||||
});
|
||||
}
|
||||
const webviewIds = (this.webviewOrder || []).filter((wid) => this.webviews?.has(wid));
|
||||
const reverse = document.documentElement.dataset.tabStateOrder === 'urgent-last';
|
||||
return window.CodemanTabTriage.layout(rows, webviewIds, { reverse });
|
||||
}
|
||||
|
||||
/**
|
||||
* Keep the state headings, the row breaks and the web tabs' `order` in step
|
||||
* with one pass's triage layout. Runs after BOTH render paths, because a
|
||||
* session changing state is an incremental pass (no tab is added or removed)
|
||||
* and can still empty a group or fill a new one.
|
||||
*
|
||||
* Headings and breaks are keyed by group and reconciled in place, never
|
||||
* rebuilt, so an SSE tick that changes nothing writes nothing. They are
|
||||
* direct children of #sessionTabs placed purely by `order`, so where they sit
|
||||
* in the DOM does not matter, and `aria-hidden` keeps them out of the tablist,
|
||||
* whose children must all be tabs. (A tab's state is not announced either way:
|
||||
* its status dot is aria-hidden, as before.) With `triage` null this removes
|
||||
* them all and clears the web tabs' inline order, which is what leaves the
|
||||
* ungrouped strip exactly as it was.
|
||||
*/
|
||||
_syncTabTriageChrome(container, triage) {
|
||||
if (!container) return;
|
||||
this._lastTabTriage = triage;
|
||||
container.classList.toggle('tabs-triage', !!triage);
|
||||
const wanted = new Map((triage?.groups || []).map((group) => [group.key, group]));
|
||||
for (const el of [...container.querySelectorAll(':scope > .tab-triage-head, :scope > .tab-triage-break')]) {
|
||||
if (!wanted.has(el.dataset.triageGroup)) el.remove();
|
||||
}
|
||||
const ensure = (className, key) => {
|
||||
let el = container.querySelector(`:scope > .${className}[data-triage-group="${key}"]`);
|
||||
if (!el) {
|
||||
el = document.createElement('div');
|
||||
el.className = className === 'tab-triage-head' ? `tab-triage-head tab-triage-head--${key}` : className;
|
||||
el.dataset.triageGroup = key;
|
||||
el.setAttribute('aria-hidden', 'true');
|
||||
container.appendChild(el);
|
||||
}
|
||||
return el;
|
||||
};
|
||||
for (const group of wanted.values()) {
|
||||
const head = ensure('tab-triage-head', group.key);
|
||||
// A quiet group (idle) keeps its heading as the row's anchor but draws
|
||||
// no label or count.
|
||||
head.classList.toggle('tab-triage-head--quiet', !!group.quiet);
|
||||
if (!group.quiet && !head.firstElementChild) {
|
||||
const label = document.createElement('span');
|
||||
label.className = 'tab-triage-label';
|
||||
label.textContent = group.label;
|
||||
const count = document.createElement('span');
|
||||
count.className = 'tab-triage-count';
|
||||
head.append(label, count);
|
||||
}
|
||||
const count = String(group.count);
|
||||
if (!group.quiet && head.lastElementChild.textContent !== count) head.lastElementChild.textContent = count;
|
||||
// The first row's heading is the one that starts beside the brand in the
|
||||
// header strip (styles.css); every later row starts under it.
|
||||
head.classList.toggle('tab-triage-head--lead', group === triage.groups[0]);
|
||||
const headOrder = String(group.headOrder);
|
||||
if (head.style.order !== headOrder) head.style.order = headOrder;
|
||||
const brk = ensure('tab-triage-break', group.key);
|
||||
const breakOrder = String(group.breakOrder);
|
||||
if (brk.style.order !== breakOrder) brk.style.order = breakOrder;
|
||||
}
|
||||
for (const web of container.querySelectorAll(':scope > .session-tab[data-webview-id]')) {
|
||||
const wid = web.dataset.webviewId;
|
||||
const value = triage?.webOrder.has(wid) ? String(triage.webOrder.get(wid)) : '';
|
||||
if (web.style.order !== value) web.style.order = value;
|
||||
}
|
||||
this._sizeTabTriageGutter(container, triage);
|
||||
}
|
||||
|
||||
/**
|
||||
* Size the header strip's two measured lengths (styles.css, "Header strip,
|
||||
* wrapping"): `--tab-triage-gutter`, the label column, as wide as the widest
|
||||
* label on screen so a row never carries a fixed gutter's worth of empty
|
||||
* space; and `--tab-triage-brand`, the brand's width, because the brand sits
|
||||
* over the strip's top-left corner and only the FIRST row starts beside it,
|
||||
* every later row starting under it.
|
||||
*
|
||||
* The labels are measured only when their text changes (a group appears,
|
||||
* goes, or its count gains a digit) and once more when the web fonts finish
|
||||
* loading. The brand is watched by a ResizeObserver (a display-name change,
|
||||
* the sidebar toggle appearing), so a render pass never forces a layout read
|
||||
* for it. The vertical lists use neither length and are never measured.
|
||||
*/
|
||||
_sizeTabTriageGutter(container, triage) {
|
||||
const inHeader = !!container.parentElement?.classList.contains('session-tabs-host');
|
||||
if (!triage || !inHeader) {
|
||||
if (container.style.getPropertyValue('--tab-triage-gutter')) container.style.removeProperty('--tab-triage-gutter');
|
||||
if (container.style.getPropertyValue('--tab-triage-brand')) container.style.removeProperty('--tab-triage-brand');
|
||||
this._tabTriageGutterKey = null;
|
||||
return;
|
||||
}
|
||||
this._watchTabTriageBrand(container);
|
||||
if (Number.isFinite(this._tabTriageBrandWidth)) {
|
||||
const brand = `${this._tabTriageBrandWidth}px`;
|
||||
if (container.style.getPropertyValue('--tab-triage-brand') !== brand) {
|
||||
container.style.setProperty('--tab-triage-brand', brand);
|
||||
}
|
||||
}
|
||||
const key = triage.groups.map((g) => `${g.key}:${g.count}`).join('|');
|
||||
if (key === this._tabTriageGutterKey) return;
|
||||
let widest = 0;
|
||||
for (const head of container.querySelectorAll(':scope > .tab-triage-head')) {
|
||||
let width = 0;
|
||||
for (const part of head.children) width += part.getBoundingClientRect().width;
|
||||
widest = Math.max(widest, width + 5 * Math.max(0, head.children.length - 1));
|
||||
}
|
||||
// Hidden (display: none on phones, or a detached strip): nothing to size.
|
||||
if (!widest) return;
|
||||
this._tabTriageGutterKey = key;
|
||||
container.style.setProperty('--tab-triage-gutter', `${Math.ceil(widest + 10)}px`);
|
||||
if (!this._tabTriageFontsHooked && document.fonts?.ready) {
|
||||
this._tabTriageFontsHooked = true;
|
||||
document.fonts.ready.then(() => {
|
||||
this._tabTriageGutterKey = null;
|
||||
this._sizeTabTriageGutter(this.$('sessionTabs'), this._lastTabTriage);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Keep `_tabTriageBrandWidth` (the header brand plus the gap after it) in
|
||||
* step with the brand, once per page. The first observation arrives right
|
||||
* after `observe()`, so the width is known from the first frame on.
|
||||
*/
|
||||
_watchTabTriageBrand(container) {
|
||||
if (this._tabTriageBrandObserver !== undefined) return;
|
||||
const brand = container.closest('.header')?.querySelector(':scope > .header-brand');
|
||||
if (!brand || typeof ResizeObserver !== 'function') {
|
||||
this._tabTriageBrandObserver = null;
|
||||
return;
|
||||
}
|
||||
const gap = 8;
|
||||
this._tabTriageBrandWidth = Math.ceil(brand.getBoundingClientRect().width + gap);
|
||||
this._tabTriageBrandObserver = new ResizeObserver((entries) => {
|
||||
const box = entries[0]?.borderBoxSize?.[0];
|
||||
const width = Math.ceil((box ? box.inlineSize : brand.getBoundingClientRect().width) + gap);
|
||||
if (width === this._tabTriageBrandWidth) return;
|
||||
this._tabTriageBrandWidth = width;
|
||||
this._sizeTabTriageGutter(this.$('sessionTabs'), this._lastTabTriage);
|
||||
});
|
||||
this._tabTriageBrandObserver.observe(brand);
|
||||
}
|
||||
|
||||
/**
|
||||
* A drag in a grouped strip (by state or by case) may only reorder WITHIN a
|
||||
* group. Inside a group the rows sit in tab order, so a drop there moves the
|
||||
* tab exactly where it was dropped; across groups the dragged tab would stay
|
||||
* in its own group (its state or case did not change) and land somewhere the
|
||||
* user did not put it. State groups are bands of `order` values, so comparing
|
||||
* bands is enough; case clusters are boxes, so the box decides.
|
||||
*/
|
||||
_isTabDropAcrossGroups(targetTab) {
|
||||
const container = this.$('sessionTabs');
|
||||
if (!container || !this.draggedTabId || !targetTab) return false;
|
||||
const triage = container.classList.contains('tabs-triage');
|
||||
const clusters = container.classList.contains('tabs-clusters');
|
||||
if (!triage && !clusters) return false;
|
||||
const dragged = container.querySelector(`.session-tab[data-id="${this.draggedTabId}"]`);
|
||||
if (!dragged) return false;
|
||||
// Clusters are real boxes: a drop belongs to the box it lands in.
|
||||
if (clusters) return dragged.closest('.tab-cluster') !== targetTab.closest('.tab-cluster');
|
||||
const stride = window.CodemanTabTriage?.STRIDE || 10000;
|
||||
const band = (el) => Math.floor((Number(el.style.order) || 0) / stride);
|
||||
return band(dragged) !== band(targetTab);
|
||||
}
|
||||
|
||||
/** True when the tab list is clustered by case (`tabArrangement: 'case'`, Discussion #426 option A). */
|
||||
isTabClusters() {
|
||||
return document.documentElement.dataset.tabArrangement === 'case';
|
||||
}
|
||||
|
||||
/**
|
||||
* True when the header strip is drawn as a ledger (`tabArrangement: 'ledger'`,
|
||||
* Discussion #426 option B): the flat list on an aligned column grid with a
|
||||
* status bar per cell. Pure CSS on `.tabs-ledger`, scoped to the desktop
|
||||
* header strip; the rail and the sidebar keep their flat list.
|
||||
*/
|
||||
isTabLedger() {
|
||||
return document.documentElement.dataset.tabArrangement === 'ledger';
|
||||
}
|
||||
|
||||
/**
|
||||
* Which case a session belongs to, for clustering: the case whose path is the
|
||||
* longest prefix of its working directory (`_mobileOverviewCaseFor()`, the
|
||||
* home screens' own match), else the directory itself, else the session alone.
|
||||
*/
|
||||
_tabClusterIdentity(session, id) {
|
||||
const dir = (session.workingDir || '').replace(/\/+$/, '');
|
||||
const match =
|
||||
dir && typeof this._mobileOverviewCaseFor === 'function' ? this._mobileOverviewCaseFor(dir, this.cases) : null;
|
||||
if (match) return { key: match.path, label: match.name || '' };
|
||||
if (dir) return { key: dir, label: dir.split('/').pop() || dir };
|
||||
return { key: `session:${id}`, label: '' };
|
||||
}
|
||||
|
||||
/**
|
||||
* The cluster layout for one render pass, or null when the list is not
|
||||
* clustered. Named groups in the vertical rail win, exactly as for the state
|
||||
* grouping. `key` is the whole structure as a string: the incremental render
|
||||
* path compares it with the last full render's and rebuilds when it differs,
|
||||
* because a cluster is a real box and a patch in place cannot move a tab into
|
||||
* another one. Membership only changes when sessions come and go (already a
|
||||
* full rebuild) or when the case list arrives, so this rarely fires.
|
||||
*
|
||||
* @param {Array<string>} ids live session ids, in tab order
|
||||
* @param {object|null} groupProjection the grouped rail's projection, if any
|
||||
*/
|
||||
_tabClusterLayout(ids, groupProjection) {
|
||||
if (groupProjection || !this.isTabClusters() || !window.CodemanTabClusters) return null;
|
||||
const rows = [];
|
||||
for (const id of ids) {
|
||||
const session = this.sessions.get(id);
|
||||
if (session) rows.push({ id, ...this._tabClusterIdentity(session, id) });
|
||||
}
|
||||
const clusters = window.CodemanTabClusters.compute(rows);
|
||||
// Only a cluster with company drops the case from its tab names.
|
||||
const labelFor = new Map();
|
||||
for (const cluster of clusters) {
|
||||
if (cluster.ids.length > 1) for (const id of cluster.ids) labelFor.set(id, cluster.label);
|
||||
}
|
||||
const webviewIds = (this.webviewOrder || []).filter((wid) => this.webviews?.has(wid));
|
||||
const key = JSON.stringify([clusters.map((c) => [c.key, c.label, c.ids]), webviewIds]);
|
||||
return { clusters, labelFor, webviewIds, key };
|
||||
}
|
||||
|
||||
/**
|
||||
* The cluster boxes for the full render: one box per case, labelled with its
|
||||
* colour swatch, name and count, and a box per open web tab, which has no
|
||||
* case. The header strip shows only the swatch for a case with one tab
|
||||
* (styles.css); the rail and the sidebar label every case. Rows are the
|
||||
* caller's own markup, so a tab is byte-identical to the flat strip's apart
|
||||
* from its name split.
|
||||
*/
|
||||
_renderTabClusters(layout, rowHtml, webviewSlotStart) {
|
||||
const parts = [];
|
||||
for (const cluster of layout.clusters) {
|
||||
const rows = cluster.ids.map((id) => rowHtml.get(id) || '').join('');
|
||||
const single = cluster.ids.length < 2;
|
||||
const head =
|
||||
'<span class="tab-cluster-label" aria-hidden="true"><span class="tab-cluster-swatch"></span>' +
|
||||
`<span class="tab-cluster-name" data-i18n-skip>${escapeHtml(cluster.label)}</span>` +
|
||||
`<span class="tab-cluster-count">${cluster.ids.length}</span></span>`;
|
||||
parts.push(
|
||||
`<div class="tab-cluster${single ? ' tab-cluster--single' : ''}" role="presentation" data-cluster-key="${escapeHtml(cluster.key)}" style="--cluster-color: var(--session-${cluster.color})">${head}${rows}</div>`
|
||||
);
|
||||
}
|
||||
layout.webviewIds.forEach((wid, i) => {
|
||||
const tab = this.renderWebviewTab?.(wid, webviewSlotStart + i) || '';
|
||||
if (tab) parts.push(`<div class="tab-cluster tab-cluster--single tab-cluster--web" role="presentation">${tab}</div>`);
|
||||
});
|
||||
return parts.join('');
|
||||
}
|
||||
|
||||
/**
|
||||
* The tab label, as markup. #232: a described name (`w3-x: fix login`) shows
|
||||
* just the description, the generated id kept in a hidden prefix span. Inside
|
||||
* a case cluster a generated `w75-api-gateway` shows `w75`, the `-api-gateway`
|
||||
* kept in a `.tab-name-case` span that only `.tabs-clusters` hides, so the full
|
||||
* name stays in the DOM (copy, find-in-page, the rename editor).
|
||||
*/
|
||||
_tabNameHtml(name, clusterLabel) {
|
||||
const parsed = parseSessionPrefix(name);
|
||||
if (parsed && parsed.suffix) {
|
||||
return `<span class="tab-name-prefix">${escapeHtml(parsed.prefix)}: </span>${escapeHtml(parsed.suffix)}`;
|
||||
}
|
||||
const split = clusterLabel ? window.CodemanTabClusters?.nameSplit(name, clusterLabel) : null;
|
||||
if (split) return `${escapeHtml(split.shown)}<span class="tab-name-case">${escapeHtml(split.hidden)}</span>`;
|
||||
return escapeHtml(name);
|
||||
}
|
||||
|
||||
/**
|
||||
* The arrangement classes on #sessionTabs that are not owned by a sync of
|
||||
* their own (`tabs-triage` is `_syncTabTriageChrome()`'s): `tabs-clusters`
|
||||
* while case clusters are drawn, `tabs-ledger` while the ledger is on. The
|
||||
* ledger never applies inside the grouped rail.
|
||||
*/
|
||||
_syncTabArrangementClasses(container, { clusters, groupProjection }) {
|
||||
if (!container) return;
|
||||
container.classList.toggle('tabs-clusters', !!clusters);
|
||||
container.classList.toggle('tabs-ledger', this.isTabLedger() && !groupProjection);
|
||||
}
|
||||
|
||||
/**
|
||||
* True where the sidebar is a MODAL off-canvas drawer over the terminal
|
||||
* instead of a docked column.
|
||||
@@ -5375,9 +5004,6 @@ class CodemanApp {
|
||||
const reachable =
|
||||
this.isSessionSidebarActive() && document.documentElement.dataset.sidebar !== 'collapsed';
|
||||
const needle = reachable ? this._sidebarFilter : '';
|
||||
// State headings count the whole group, so they step aside while a filter
|
||||
// is narrowing the rows under them (styles.css, .tabs-filtering).
|
||||
container.classList.toggle('tabs-filtering', !!needle);
|
||||
for (const tab of container.querySelectorAll('.session-tab')) {
|
||||
if (!needle) {
|
||||
tab.classList.remove('tab-filtered-out');
|
||||
@@ -5615,6 +5241,9 @@ class CodemanApp {
|
||||
// strip scrolls horizontally, so a tab selected from the palette, a swipe,
|
||||
// Alt+N or a push notification could stay parked off-screen.
|
||||
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();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -5786,16 +5415,10 @@ class CodemanApp {
|
||||
// The grouped rail's structure (sections, collapse, the shown exception) can
|
||||
// change while the id sets stay equal; the in-place patch below cannot move
|
||||
// or hide a row, so any structural change takes the full rebuild.
|
||||
// Case clusters are boxes too: the same rule, keyed on their structure.
|
||||
const clusterLayout = this._tabClusterLayout(
|
||||
this.sessionOrder.filter((sid) => this.sessions.has(sid)),
|
||||
groupProjection
|
||||
);
|
||||
const canIncremental = existingIds.size === currentIds.size &&
|
||||
[...existingIds].every(id => currentIds.has(id)) &&
|
||||
webTabsUnchanged &&
|
||||
!this._isTabGroupStructureStale(groupProjection) &&
|
||||
(clusterLayout ? clusterLayout.key : null) === (this._lastTabClusterKey ?? null);
|
||||
!this._isTabGroupStructureStale(groupProjection);
|
||||
|
||||
if (canIncremental) {
|
||||
// Read once for the whole pass, like the full-rebuild path: this touches
|
||||
@@ -5805,13 +5428,7 @@ class CodemanApp {
|
||||
// that sees one — a session going working→idle never adds or removes a
|
||||
// tab, so the full rebuild below is not reached. Recomputed per pass for
|
||||
// the same reason the rich meta line is: the order IS the state.
|
||||
const liveIds = this.sessionOrder.filter((sid) => this.sessions.has(sid));
|
||||
const railSortOrder = this._tabRailSortOrder(liveIds);
|
||||
// Grouped by state: same reasoning, a state change moves a tab between
|
||||
// rows. Its order values replace the rail sort's (which it already folded
|
||||
// in as the rank inside each section).
|
||||
const triage = this._tabTriageLayout(liveIds, groupProjection, railSortOrder);
|
||||
const listOrder = triage ? triage.order : railSortOrder;
|
||||
const railSortOrder = this._tabRailSortOrder(this.sessionOrder.filter((sid) => this.sessions.has(sid)));
|
||||
// Incremental update - only modify changed properties
|
||||
for (const [id, session] of this.sessions) {
|
||||
const tab = container.querySelector(`.session-tab[data-id="${id}"]`);
|
||||
@@ -5819,7 +5436,7 @@ class CodemanApp {
|
||||
|
||||
// An empty string clears the property, which is also what un-sorts the
|
||||
// rail when the setting (or the layout) flips without a full rebuild.
|
||||
const railOrder = listOrder?.has(id) ? String(listOrder.get(id)) : '';
|
||||
const railOrder = railSortOrder?.has(id) ? String(railSortOrder.get(id)) : '';
|
||||
if (tab.style.order !== railOrder) tab.style.order = railOrder;
|
||||
|
||||
// A web tab owns the active state while one is open. activeSessionId stays
|
||||
@@ -5918,17 +5535,11 @@ class CodemanApp {
|
||||
const _p = parseSessionPrefix(name);
|
||||
if (nameEl.dataset.fullName !== name) {
|
||||
nameEl.replaceChildren();
|
||||
const _split = _p && _p.suffix ? null : window.CodemanTabClusters?.nameSplit(name, clusterLayout?.labelFor.get(id));
|
||||
if (_p && _p.suffix) {
|
||||
const prefix = document.createElement('span');
|
||||
prefix.className = 'tab-name-prefix';
|
||||
prefix.textContent = `${_p.prefix}: `;
|
||||
nameEl.append(prefix, document.createTextNode(_p.suffix));
|
||||
} else if (_split) {
|
||||
const caseSpan = document.createElement('span');
|
||||
caseSpan.className = 'tab-name-case';
|
||||
caseSpan.textContent = _split.hidden;
|
||||
nameEl.append(document.createTextNode(_split.shown), caseSpan);
|
||||
} else {
|
||||
nameEl.textContent = name;
|
||||
}
|
||||
@@ -6003,8 +5614,6 @@ class CodemanApp {
|
||||
this._applyTabTreePositions(container);
|
||||
this._syncTabGroupHeaderAlerts(container, groupProjection);
|
||||
}
|
||||
this._syncTabTriageChrome(container, triage);
|
||||
this._syncTabArrangementClasses(container, { clusters: !!clusterLayout, groupProjection });
|
||||
} else {
|
||||
// Full rebuild needed (sessions added/removed)
|
||||
this._fullRenderSessionTabs();
|
||||
@@ -6027,13 +5636,15 @@ class CodemanApp {
|
||||
this._refreshHomeSessionsIfVisible?.();
|
||||
// The full-render path already redraws the connection SVG; this incremental
|
||||
// one does not, and a badge appearing widens a tab and shifts every tab after
|
||||
// it, sliding the lineage arcs off their anchors. Only pay for it when there
|
||||
// is something anchored to tab rects: lineage arcs, or — in a VERTICAL list
|
||||
// (sidebar, where lineage is skipped and the edge count stays 0, or the
|
||||
// rail, which can show connectors with zero lineage edges too) — the
|
||||
// subagent/ultracode connectors, whose rows a badge changes the HEIGHT of.
|
||||
// Same widening as the strip-scroll listener in session-lineage.js.
|
||||
if (this._lineageEdgeCount > 0 || this._isVerticalTabList()) this.updateConnectionLines();
|
||||
// it, sliding the lineage lines off their anchors. Only pay for it when there
|
||||
// is something anchored to tab rects: any lineage at all (not just what is
|
||||
// drawn now: a parentSessionId or a child's working state can arrive on this
|
||||
// path and change the selected family's tree), or, in a VERTICAL list (the
|
||||
// sidebar, where lineage is skipped, or the rail, which can show connectors
|
||||
// with zero lineage edges too), the subagent/ultracode connectors, whose
|
||||
// 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);
|
||||
}
|
||||
@@ -6045,6 +5656,10 @@ class CodemanApp {
|
||||
const container = this.$('sessionTabs');
|
||||
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 —
|
||||
// there is no row to overflow, and measuring it would fight the CSS.
|
||||
if (this.isSessionSidebarActive()) {
|
||||
@@ -6073,16 +5688,6 @@ class CodemanApp {
|
||||
return;
|
||||
}
|
||||
|
||||
// Grouped by state, the header strip IS rows (one per state), so it always
|
||||
// wraps: the row breaks only take effect in a wrapping flex line. The ledger
|
||||
// is a grid of rows, so the same holds. Narrower screens keep the single
|
||||
// scrolling row above, where state headings read as inline dividers and the
|
||||
// ledger stays the plain strip.
|
||||
if (container.classList.contains('tabs-triage') || container.classList.contains('tabs-ledger')) {
|
||||
container.classList.add('tabs-auto-wrap');
|
||||
return;
|
||||
}
|
||||
|
||||
// Measure the natural one-row overflow, then enable wrapping only if needed.
|
||||
container.classList.remove('tabs-auto-wrap');
|
||||
const shouldWrap = window.CodemanTabOverflow?.shouldAutoWrapTabs
|
||||
@@ -6175,17 +5780,7 @@ class CodemanApp {
|
||||
// below still counts the strip, not the sorted list. Null in every other
|
||||
// layout, and the tabs then carry no inline order at all — the header
|
||||
// strip's markup is byte-identical to before.
|
||||
const liveIds = tabOrder.filter((id) => this.sessions.has(id));
|
||||
const railSortOrder = this._tabRailSortOrder(liveIds);
|
||||
// Grouped by state (tabArrangement 'state', the default): the same `order` mechanism,
|
||||
// one band of values per state. Null in the grouped rail and with grouping
|
||||
// off, and the rows then carry exactly the inline order they did before.
|
||||
const groupProjection = this._projectTabGroups();
|
||||
const triage = this._tabTriageLayout(liveIds, groupProjection, railSortOrder);
|
||||
const listOrder = triage ? triage.order : railSortOrder;
|
||||
// Clustered by case: rows are wrapped in one box per case below, and a tab
|
||||
// in a cluster with company drops the `-<case>` from its name.
|
||||
const clusterLayout = this._tabClusterLayout(liveIds, groupProjection);
|
||||
const railSortOrder = this._tabRailSortOrder(tabOrder.filter((id) => this.sessions.has(id)));
|
||||
// One row per session, in tab order. The flat strip emits them as-is; the
|
||||
// grouped rail places the SAME markup into its sections, so a row never
|
||||
// differs between the two (badge = Alt+N slot in sessionOrder either way).
|
||||
@@ -6194,7 +5789,7 @@ class CodemanApp {
|
||||
for (const id of tabOrder) {
|
||||
const session = this.sessions.get(id);
|
||||
if (!session) continue; // Skip if session was removed
|
||||
const railOrderStyle = listOrder?.has(id) ? ` style="order:${listOrder.get(id)}"` : '';
|
||||
const railOrderStyle = railSortOrder?.has(id) ? ` style="order:${railSortOrder.get(id)}"` : '';
|
||||
|
||||
// See the note in the incremental path: a web tab owns the active highlight
|
||||
// while one is open, even though activeSessionId stays set.
|
||||
@@ -6227,7 +5822,9 @@ class CodemanApp {
|
||||
// JUST the description on the tab; the generated w<n>-<case> id moves to the
|
||||
// tooltip and stays visible in the session settings modal.
|
||||
const parsedName = parseSessionPrefix(name);
|
||||
const tabLabel = this._tabNameHtml(name, clusterLayout?.labelFor.get(id));
|
||||
const tabLabel = parsedName && parsedName.suffix
|
||||
? `<span class="tab-name-prefix">${escapeHtml(parsedName.prefix)}: </span>${escapeHtml(parsedName.suffix)}`
|
||||
: escapeHtml(name);
|
||||
const tabTooltip = parsedName && parsedName.suffix
|
||||
? (session.workingDir ? `${parsedName.prefix} (${session.workingDir})` : parsedName.prefix)
|
||||
: (session.workingDir || '');
|
||||
@@ -6274,12 +5871,8 @@ class CodemanApp {
|
||||
_tabIdx++;
|
||||
}
|
||||
|
||||
if (clusterLayout) {
|
||||
// Clustered by case. Web tabs keep their flat-strip Alt+N slot (after
|
||||
// every session), each in a box of its own.
|
||||
parts.push(this._renderTabClusters(clusterLayout, rowHtml, _tabIdx));
|
||||
this._hiddenTabGroupByRef = new Map();
|
||||
} else if (groupProjection) {
|
||||
const groupProjection = this._projectTabGroups();
|
||||
if (groupProjection) {
|
||||
// Grouped vertical rail. Web tabs keep their flat-strip Alt+N slot (after
|
||||
// every session), wherever their group puts them.
|
||||
const webviewSlots = new Map(
|
||||
@@ -6305,7 +5898,6 @@ class CodemanApp {
|
||||
this._hiddenTabGroupByRef = new Map();
|
||||
}
|
||||
this._lastTabGroupStructureKey = this._tabGroupStructureKey(groupProjection);
|
||||
this._lastTabClusterKey = clusterLayout ? clusterLayout.key : null;
|
||||
|
||||
container.innerHTML = parts.join('');
|
||||
container.classList.toggle('session-tabs--grouped', !!groupProjection);
|
||||
@@ -6314,8 +5906,6 @@ class CodemanApp {
|
||||
this._applyTabTreeSemantics(container, { identity: focusIdentity, refocus: focusWasInside });
|
||||
this._syncTabGroupHeaderAlerts(container, groupProjection);
|
||||
}
|
||||
this._syncTabTriageChrome(container, triage);
|
||||
this._syncTabArrangementClasses(container, { clusters: !!clusterLayout, groupProjection });
|
||||
|
||||
// Put the strip back where the user left it, then reveal the active tab
|
||||
// only when it CHANGED (or on the first paint). Restoring unconditionally
|
||||
@@ -6390,13 +5980,9 @@ class CodemanApp {
|
||||
// the inline one, or web tabs (pinned past the cards by a CSS `order: 9999`
|
||||
// rather than an inline style) read as 0 and the walk starts on them. Array
|
||||
// sort is stable, so equal orders keep DOM order, which is the unsorted case.
|
||||
if (this.isTabRailSorted() || container.classList.contains('tabs-triage')) {
|
||||
if (this.isTabRailSorted()) {
|
||||
const orderOf = (el) => Number(getComputedStyle(el).order) || 0;
|
||||
// Case clusters are boxes in DOM order and a sorted rail orders rows
|
||||
// INSIDE each one, so the box goes first in the key.
|
||||
const boxes = [...container.querySelectorAll(':scope > .tab-cluster')];
|
||||
const boxOf = (el) => boxes.indexOf(el.closest('.tab-cluster'));
|
||||
tabs.sort((a, b) => boxOf(a) - boxOf(b) || orderOf(a) - orderOf(b));
|
||||
tabs.sort((a, b) => orderOf(a) - orderOf(b));
|
||||
}
|
||||
const currentIndex = tabs.indexOf(document.activeElement);
|
||||
|
||||
@@ -7595,9 +7181,6 @@ class CodemanApp {
|
||||
});
|
||||
|
||||
tab.addEventListener('dragover', (e) => {
|
||||
// Grouped by state: a tab in another group is not a drop target, and
|
||||
// leaving the event alone (no preventDefault) is what shows "no drop".
|
||||
if (this._isTabDropAcrossGroups(tab)) return;
|
||||
e.preventDefault();
|
||||
if (!this.draggedTabId || this.draggedTabId === tab.dataset.id) return;
|
||||
|
||||
@@ -7627,7 +7210,6 @@ class CodemanApp {
|
||||
tab.classList.remove('drag-over-left', 'drag-over-right');
|
||||
|
||||
if (!this.draggedTabId || this.draggedTabId === tab.dataset.id) return;
|
||||
if (this._isTabDropAcrossGroups(tab)) return;
|
||||
|
||||
const targetId = tab.dataset.id;
|
||||
const draggedId = this.draggedTabId;
|
||||
|
||||
+228
-315
@@ -296,139 +296,251 @@ function computeTabScrollLeft(input) {
|
||||
return Math.min(Math.max(Math.round(target), 0), maxScroll);
|
||||
}
|
||||
|
||||
// Session lineage lines — geometry for the arc drawn between a tab and a tab it
|
||||
// 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
|
||||
// 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
|
||||
// shape (tab-bottom → window-top) has nothing to aim at: a U-bridge HANGING BELOW the
|
||||
// strip, from the parent's bottom edge to the child's bottom edge, so it reads as a
|
||||
// bracket joining two tabs rather than as a line crossing them. The dip grows with
|
||||
// horizontal distance and with `depth` (the child's index among its siblings), so
|
||||
// several children of one parent nest instead of overprinting.
|
||||
// ONE TREE PER SPAWNING TAB. Every family is drawn, the selected tab's emphasized
|
||||
// (session-lineage.js). Every route starts at the PARENT and ends at one child, so
|
||||
// a parent's routes share their first stretch exactly: overlaid, they read as one
|
||||
// trunk with a branch per child, and a dashed (working) route stays in phase with
|
||||
// its siblings along the shared part.
|
||||
//
|
||||
// ⚠ A WRAPPED STRIP USED TO GET ITS OWN SHAPE, AND THAT SHAPE WAS THE BUG. When the
|
||||
// desktop strip wraps (`tabs-two-rows` / `tabs-auto-wrap`) a parent on row 1 and its
|
||||
// child on row 2 are ~4px apart vertically, so the old parent-bottom → child-TOP bezier
|
||||
// had a 4px span to work with and drew a flat horizontal line inside the row gap
|
||||
// (reported as "they connect already, but the lines are straight and not easy visible"),
|
||||
// and three siblings drew three of them on top of each other. Aiming BOTH ends at the
|
||||
// tab BOTTOMS and putting the control points below the LOWER row gives the wrapped case
|
||||
// the same bracket as the flat case: it leaves the parent downward, crosses the lower
|
||||
// row once, and comes back up under the child. Same formula, no branch.
|
||||
// ⚠ ROUTES RUN IN THE GAPS, NEVER THROUGH A TAB. This replaced one bezier per
|
||||
// child hanging below the strip, which in a wrapped strip crossed every lower
|
||||
// row's labels and the terminal text (owner screenshot 2026-10-06: a parent on
|
||||
// row 3 with ten children, "too confusing"). A route now moves horizontally only
|
||||
// inside a row gap, and vertically only along a tab's own stem (its bottom edge to
|
||||
// the gap right under it) or along the SPINE, a channel left of every row that
|
||||
// joins the gaps of different rows. styles.css reserves that room
|
||||
// (`.session-tabs.lineage-tree`: a wider row gap, bottom padding for the last
|
||||
// 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
|
||||
// endpoint scrolled outside the strip. `.session-tabs` is `overflow-x: auto`, so a
|
||||
// scrolled-out tab still HAS a rect — one lying over the logo or the header
|
||||
// buttons. Skipping is honest; clamping would point at a tab that isn't there.
|
||||
// ⚠ THE DIP IS WHAT MAKES THE ARC AN ARC, and it has now been mis-tuned in BOTH
|
||||
// directions, so treat these numbers as a corridor rather than a dial to crank:
|
||||
// - Too shallow (the first ship, 44px cap): a skill worker is appended to the END of
|
||||
// the strip, so a lead-to-worker span is 800-1500px, and a 44px cap over 1300px is
|
||||
// a 33px sag, a line that reads as STRAIGHT across the terminal (#285).
|
||||
// - Too deep (the 104px cap that replaced it): in the wrapped-strip case the cap and
|
||||
// the FULL row offset stacked, bowing the bracket ~106px into the terminal text
|
||||
// (owner screenshot 2026-08-15, "die Linien machen einen grossen Bogen nach unten").
|
||||
// The dip is measured from the STRIP'S BOTTOM EDGE (falling back to the lower tab
|
||||
// bottom when the strip rect is missing or shorter than its tabs), which buys two
|
||||
// things at once: the bow needs no per-row offsets stacked on top, and a same-row
|
||||
// arc between ROW-1 tabs of a wrapped strip clears row 2's labels instead of being
|
||||
// drawn through them (the retune's own first draft had exactly that regression).
|
||||
const LINEAGE_DIP_BASE_PX = 14;
|
||||
const LINEAGE_DIP_PER_PX = 0.06;
|
||||
const LINEAGE_DIP_MIN_PX = 22;
|
||||
const LINEAGE_DIP_MAX_PX = 64;
|
||||
// Siblings nest by this much. Widened with the stroke: at 2.5px plus its glow, arcs 6px
|
||||
// apart bled into one thick band instead of reading as three separate lines.
|
||||
const LINEAGE_SIBLING_STEP_PX = 8;
|
||||
// Rows come from computeLineageRows() over EVERY tab in the strip, not only the
|
||||
// endpoints: a row's gap sits under its TALLEST tab (the active tab is 2px
|
||||
// taller), or siblings in one row would hang their bus at different heights.
|
||||
//
|
||||
// computeLineageTree() returns null when the parent cannot be drawn (missing or
|
||||
// degenerate rect, scrolled out of the strip); a child that cannot be drawn is
|
||||
// left out of `routes`. `.session-tabs` scrolls, so a scrolled-out tab still HAS a
|
||||
// rect, lying over the logo or the header buttons. Skipping is honest; clamping
|
||||
// would point at a tab that is not there.
|
||||
const LINEAGE_CORNER_RADIUS_PX = 10;
|
||||
// Families drawn together take separate lanes: gap lines this far apart, spines
|
||||
// LINEAGE_SPINE_STEP_PX apart.
|
||||
const LINEAGE_LANE_STEP_PX = 3.5;
|
||||
// Every family is drawn at once, and a 12px row gap only fits this many lanes at
|
||||
// LINEAGE_LANE_STEP_PX; session-lineage.js cycles families through them.
|
||||
const LINEAGE_MAX_LANES = 3;
|
||||
const LINEAGE_SPINE_INSET_PX = 6;
|
||||
const LINEAGE_SPINE_STEP_PX = 4;
|
||||
// The last row has no row below it; with no strip rect to measure, its gap is
|
||||
// taken to be this deep.
|
||||
const LINEAGE_LAST_GAP_PX = 12;
|
||||
const LINEAGE_ROW_TOLERANCE_PX = 6;
|
||||
const LINEAGE_STRIP_TOLERANCE_PX = 4;
|
||||
// How far the vertical bracket 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
|
||||
// glow, so a track at 6px had half of that glow clipped away and the arc read
|
||||
// as a thin thread pinned to the window frame. The rail reserves the channel
|
||||
// itself (`--lineage-vertical-gutter` on the rail's .session-tabs), and
|
||||
// computeLineagePath still clamps the track to stay left of both tabs.
|
||||
// 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, or the line reads as a thread pinned
|
||||
// to the window frame. The rail reserves the channel itself
|
||||
// (`--lineage-vertical-gutter` on the rail's .session-tabs), and the track is
|
||||
// still clamped to stay left of every endpoint.
|
||||
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;
|
||||
// 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
|
||||
// workers it spawns; a child that spawns in turn gets its own for the arcs below it.
|
||||
// (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 lines below it.
|
||||
// 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
|
||||
// (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.
|
||||
// (owner call 2026-08-15: matrix green, pinkish, violet, red, turquoise "and so on").
|
||||
const LINEAGE_COLORS = ['', '#00ff66', '#ff5ea8', '#a78bfa', '#ff5252', '#2dd4bf', '#ffa940'];
|
||||
|
||||
function computeLineagePath(input) {
|
||||
const parent = input?.parent;
|
||||
const child = input?.child;
|
||||
if (!parent || !child) return null;
|
||||
/** A rect normalized to numbers with its edges and center, or null if unusable. */
|
||||
function lineageRect(rect) {
|
||||
if (!rect) 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;
|
||||
const cw = Number(child.width) || 0;
|
||||
const ch = Number(child.height) || 0;
|
||||
if (pw <= 0 || ph <= 0 || cw <= 0 || ch <= 0) return null;
|
||||
/**
|
||||
* Group tab rects into the strip's visual rows, top to bottom. A row spans from its
|
||||
* highest top to its LOWEST bottom, so a taller tab (the active one) sets the row's
|
||||
* gap for everyone in it.
|
||||
*/
|
||||
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 strip = input?.strip;
|
||||
const depth = Math.max(0, Math.min(6, Number(input?.depth) || 0));
|
||||
const pLeft = Number(parent.left);
|
||||
const cLeft = Number(child.left);
|
||||
const pTop = Number(parent.top);
|
||||
const cTop = Number(child.top);
|
||||
if (![pLeft, cLeft, pTop, cTop].every(Number.isFinite)) return null;
|
||||
const laneCount = Math.max(1, Math.floor(Number(input?.laneCount)) || 1);
|
||||
const lane = Math.max(0, Math.min(laneCount - 1, Math.floor(Number(input?.lane)) || 0));
|
||||
const radius = Number.isFinite(Number(input?.radius)) ? Math.max(0, Number(input.radius)) : LINEAGE_CORNER_RADIUS_PX;
|
||||
const children = [];
|
||||
for (const child of input?.children || []) {
|
||||
const rect = lineageRect(child?.rect);
|
||||
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') {
|
||||
const py = pTop + ph / 2;
|
||||
const cy = cTop + ch / 2;
|
||||
if (strip && Number(strip.height) > 0) {
|
||||
const min = Number(strip.top) - LINEAGE_STRIP_TOLERANCE_PX;
|
||||
const max = Number(strip.top) + Number(strip.height) + LINEAGE_STRIP_TOLERANCE_PX;
|
||||
if (py < min || py > max || cy < min || cy > max) return null;
|
||||
// The rail: one track down the empty left gutter, shared by every sibling.
|
||||
if (!inStripY(parent)) return null;
|
||||
const visible = children.filter((c) => inStripY(c.rect));
|
||||
if (visible.length === 0) return { routes };
|
||||
const minLeft = Math.min(parent.left, ...visible.map((c) => c.rect.left));
|
||||
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) });
|
||||
}
|
||||
|
||||
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 };
|
||||
return { routes };
|
||||
}
|
||||
|
||||
const px = pLeft + pw / 2;
|
||||
const cx = cLeft + cw / 2;
|
||||
if (strip && Number(strip.width) > 0) {
|
||||
const min = Number(strip.left) - LINEAGE_STRIP_TOLERANCE_PX;
|
||||
const max = Number(strip.left) + Number(strip.width) + LINEAGE_STRIP_TOLERANCE_PX;
|
||||
if (px < min || px > max || cx < min || cx > max) return null;
|
||||
if (!inStripX(parent) || !inStripY(parent)) return null;
|
||||
const visible = children.filter((c) => inStripX(c.rect) && inStripY(c.rect));
|
||||
if (visible.length === 0) return { routes };
|
||||
|
||||
const rows = computeLineageRows([...(input?.tabs || []), parent, ...visible.map((c) => c.rect)]);
|
||||
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;
|
||||
const cBottom = cTop + ch;
|
||||
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 };
|
||||
function roundPoints(points) {
|
||||
return points.map(([x, y]) => [r1(x), r1(y)]);
|
||||
}
|
||||
|
||||
// One decimal is plenty for a screen-space path and keeps the `d` string short.
|
||||
@@ -658,194 +770,6 @@ function sortSessionsByActivity(rows) {
|
||||
return (Array.isArray(rows) ? rows.slice() : []).sort(compareSessionActivity);
|
||||
}
|
||||
|
||||
// Tab grouping by state (`tabArrangement: 'state'`, Discussion #426 option C).
|
||||
//
|
||||
// The tab list answers "who wants me?" the way the home screens do: a row (the
|
||||
// header strip) or a section (the flat side rail, the sidebar) per state, most
|
||||
// urgent on top. The states are the home screens' own, from
|
||||
// `_mobileOverviewState()` (mobile-overview.js); this only folds the six into
|
||||
// four groups a strip can hold:
|
||||
//
|
||||
// needs red: a permission or question dialog is blocking the agent. A
|
||||
// failed session joins it, since it also needs a human and the home
|
||||
// screens rank it right below.
|
||||
// waiting yellow: the agent finished its turn and is waiting on you.
|
||||
// working a turn is running.
|
||||
// idle everything quiet: idle, ended, and an agent that exited inside a
|
||||
// live pane (#446), which may still read as working on screen but is
|
||||
// running nothing. Web tabs close the group.
|
||||
//
|
||||
// Applied as the flex `order` property, never by reordering the DOM, the same
|
||||
// design as the sorted rail (`_tabRailSortOrder`, app.js): `#sessionTabs` stays
|
||||
// in `sessionOrder`, so Alt+N, drag-and-drop and the keyboard walk keep reading
|
||||
// the list they always read, and a state change moves one inline style instead
|
||||
// of rebuilding the strip. Each group owns a band of `TAB_TRIAGE_STRIDE` order
|
||||
// values: its heading at the start of the band, its rows after it, its web tabs
|
||||
// after those and the line break that ends the header row at the very end.
|
||||
//
|
||||
// Pure: no DOM, no `this`. Unit-tested in test/tab-triage.test.ts.
|
||||
// `quiet` groups keep their heading element (it anchors the row and holds the
|
||||
// row's place beside the brand) but draw no text: everything quiet is the
|
||||
// default state of a tab, so naming it only adds noise.
|
||||
const TAB_TRIAGE_GROUPS = [
|
||||
{ key: 'needs', label: 'Needs you' },
|
||||
{ key: 'waiting', label: 'Waiting' },
|
||||
{ key: 'working', label: 'Working' },
|
||||
{ key: 'idle', label: 'Idle', quiet: true },
|
||||
];
|
||||
|
||||
const TAB_TRIAGE_GROUP_OF_STATE = {
|
||||
needs: 'needs',
|
||||
error: 'needs',
|
||||
waiting: 'waiting',
|
||||
working: 'working',
|
||||
idle: 'idle',
|
||||
done: 'idle',
|
||||
};
|
||||
|
||||
const TAB_TRIAGE_STRIDE = 10000;
|
||||
/** Offset of a group's web tabs inside its band, past any plausible session count. */
|
||||
const TAB_TRIAGE_WEB_OFFSET = 5000;
|
||||
|
||||
/**
|
||||
* Which group a session belongs to.
|
||||
* @param {string} state a `_mobileOverviewState()` value
|
||||
* @param {boolean} exited the agent inside the pane has exited (`_mobileOverviewExit()` non-null)
|
||||
* @returns {'needs'|'waiting'|'working'|'idle'}
|
||||
*/
|
||||
function tabTriageGroupFor(state, exited) {
|
||||
const group = TAB_TRIAGE_GROUP_OF_STATE[state] || 'idle';
|
||||
return exited && group === 'working' ? 'idle' : group;
|
||||
}
|
||||
|
||||
/**
|
||||
* Order values and visible groups for one pass.
|
||||
*
|
||||
* @param {Array<{id: string, state: string, exited?: boolean, pos?: number}>} rows
|
||||
* live sessions; `pos` ranks a row inside its group (tab order on the header
|
||||
* strip, the activity sort's position on a sorted rail). Rows without one keep
|
||||
* the order they were passed in.
|
||||
* @param {Array<string>} webviewIds open web tabs, in their own tab order
|
||||
* @param {{reverse?: boolean}} [options] `reverse` puts the groups the other
|
||||
* way up (`tabStateOrder: 'urgent-last'`): idle first, needs you last, for a
|
||||
* strip read from the bottom. Rows inside a group keep their order.
|
||||
* @returns {{
|
||||
* order: Map<string, number>,
|
||||
* webOrder: Map<string, number>,
|
||||
* groups: Array<{key: string, label: string, quiet: boolean, count: number, headOrder: number, breakOrder: number}>
|
||||
* }} `groups` lists only the non-empty groups, in display order (most urgent
|
||||
* first, or last with `reverse`).
|
||||
*/
|
||||
function computeTabTriageLayout(rows, webviewIds, options) {
|
||||
const list = Array.isArray(rows) ? rows : [];
|
||||
const webs = Array.isArray(webviewIds) ? webviewIds : [];
|
||||
const sequence = options && options.reverse ? TAB_TRIAGE_GROUPS.slice().reverse() : TAB_TRIAGE_GROUPS;
|
||||
const baseOf = {};
|
||||
const counts = {};
|
||||
sequence.forEach((group, i) => {
|
||||
baseOf[group.key] = (i + 1) * TAB_TRIAGE_STRIDE;
|
||||
counts[group.key] = 0;
|
||||
});
|
||||
|
||||
const placed = list
|
||||
.filter((row) => row && typeof row.id === 'string')
|
||||
.map((row, i) => ({
|
||||
id: row.id,
|
||||
group: tabTriageGroupFor(row.state, !!row.exited),
|
||||
pos: Number.isFinite(row.pos) ? row.pos : i,
|
||||
index: i,
|
||||
}));
|
||||
const byGroup = {};
|
||||
for (const row of placed) (byGroup[row.group] = byGroup[row.group] || []).push(row);
|
||||
|
||||
const order = new Map();
|
||||
for (const key of Object.keys(byGroup)) {
|
||||
// Stable: equal positions keep the order the caller passed.
|
||||
byGroup[key].sort((a, b) => a.pos - b.pos || a.index - b.index);
|
||||
byGroup[key].forEach((row, i) => order.set(row.id, baseOf[key] + 1 + i));
|
||||
counts[key] = byGroup[key].length;
|
||||
}
|
||||
|
||||
const webOrder = new Map();
|
||||
webs.forEach((id, i) => {
|
||||
if (typeof id === 'string' && id) webOrder.set(id, baseOf.idle + TAB_TRIAGE_WEB_OFFSET + i);
|
||||
});
|
||||
counts.idle += webOrder.size;
|
||||
|
||||
const groups = sequence.filter((group) => counts[group.key] > 0).map((group) => ({
|
||||
key: group.key,
|
||||
label: group.label,
|
||||
quiet: !!group.quiet,
|
||||
count: counts[group.key],
|
||||
headOrder: baseOf[group.key],
|
||||
breakOrder: baseOf[group.key] + TAB_TRIAGE_STRIDE - 1,
|
||||
}));
|
||||
|
||||
return { order, webOrder, groups };
|
||||
}
|
||||
|
||||
// Tab clusters by case (`tabArrangement: 'case'`, Discussion #426 option A).
|
||||
//
|
||||
// One cluster per case, in the order the case first appears in the tab order,
|
||||
// so the strip keeps the user's arrangement at the case level. Inside a cluster
|
||||
// with two or more tabs the `-<case>` part of a generated `w<n>-<case>` name is
|
||||
// redundant and is hidden (`tabClusterNameSplit()`), which is what lets 18 tabs
|
||||
// read as 7 things. A case with one tab is still its own (unlabelled) box.
|
||||
//
|
||||
// Cluster colours come from the session palette (`--session-<colour>`) by a
|
||||
// stable hash of the case key, so a case keeps its colour across reloads and
|
||||
// devices without anything being stored.
|
||||
//
|
||||
// Pure: no DOM, no `this`. Unit-tested in test/tab-clusters.test.ts.
|
||||
const TAB_CLUSTER_COLORS = ['blue', 'green', 'purple', 'orange', 'pink', 'yellow', 'red'];
|
||||
|
||||
/** Palette colour for a cluster key: a djb2 hash, so the same key always gets the same colour. */
|
||||
function tabClusterColorFor(key) {
|
||||
const text = String(key || '');
|
||||
let hash = 5381;
|
||||
for (let i = 0; i < text.length; i++) hash = ((hash << 5) + hash + text.charCodeAt(i)) | 0;
|
||||
return TAB_CLUSTER_COLORS[Math.abs(hash) % TAB_CLUSTER_COLORS.length];
|
||||
}
|
||||
|
||||
/**
|
||||
* Group tabs by case.
|
||||
* @param {Array<{id: string, key: string, label: string}>} rows live sessions in
|
||||
* tab order; `key` identifies the case (its path), `label` names it
|
||||
* @returns {Array<{key: string, label: string, color: string, ids: string[]}>}
|
||||
* clusters in first-appearance order, members in tab order
|
||||
*/
|
||||
function computeTabClusters(rows) {
|
||||
const clusters = [];
|
||||
const byKey = new Map();
|
||||
for (const row of Array.isArray(rows) ? rows : []) {
|
||||
if (!row || typeof row.id !== 'string') continue;
|
||||
const key = typeof row.key === 'string' && row.key ? row.key : `session:${row.id}`;
|
||||
let cluster = byKey.get(key);
|
||||
if (!cluster) {
|
||||
cluster = { key, label: typeof row.label === 'string' ? row.label : '', color: tabClusterColorFor(key), ids: [] };
|
||||
byKey.set(key, cluster);
|
||||
clusters.push(cluster);
|
||||
}
|
||||
cluster.ids.push(row.id);
|
||||
}
|
||||
return clusters;
|
||||
}
|
||||
|
||||
/**
|
||||
* Split a generated `w<n>-<case>` / `s<n>-<case>` name into the part a cluster
|
||||
* shows and the case suffix it hides, or null when the name is anything else
|
||||
* (a custom name, a described `w3-x: fix login`, another case's name). Case is
|
||||
* compared case-insensitively; the hidden part keeps its original spelling so
|
||||
* the full name is still in the DOM.
|
||||
* @returns {{shown: string, hidden: string}|null}
|
||||
*/
|
||||
function tabClusterNameSplit(name, label) {
|
||||
if (typeof name !== 'string' || typeof label !== 'string' || !label) return null;
|
||||
const match = name.match(/^([ws]\d+)(-.+)$/);
|
||||
if (!match) return null;
|
||||
return match[2].slice(1).toLowerCase() === label.toLowerCase() ? { shown: match[1], hidden: match[2] } : null;
|
||||
}
|
||||
|
||||
// Terminal font stack — the single source for every xterm surface (the main
|
||||
// terminal in terminal-ui.js, the log-viewer terminal in panels-ui.js).
|
||||
// "Symbols Nerd Font Mono" is a bundled icons-only webfont (fonts/ +
|
||||
@@ -1149,12 +1073,13 @@ if (typeof window !== 'undefined') {
|
||||
plan: planWsReconnect,
|
||||
};
|
||||
window.CodemanLineage = {
|
||||
computePath: computeLineagePath,
|
||||
DIP_MIN_PX: LINEAGE_DIP_MIN_PX,
|
||||
DIP_MAX_PX: LINEAGE_DIP_MAX_PX,
|
||||
SIBLING_STEP_PX: LINEAGE_SIBLING_STEP_PX,
|
||||
computeTree: computeLineageTree,
|
||||
computeRows: computeLineageRows,
|
||||
CORNER_RADIUS_PX: LINEAGE_CORNER_RADIUS_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_SIBLING_STEP_PX: LINEAGE_VERTICAL_SIBLING_STEP_PX,
|
||||
COLORS: LINEAGE_COLORS,
|
||||
};
|
||||
window.CodemanConnectionLoss = {
|
||||
@@ -1171,18 +1096,6 @@ if (typeof window !== 'undefined') {
|
||||
compare: compareSessionActivity,
|
||||
sort: sortSessionsByActivity,
|
||||
};
|
||||
window.CodemanTabClusters = {
|
||||
COLORS: TAB_CLUSTER_COLORS,
|
||||
colorFor: tabClusterColorFor,
|
||||
compute: computeTabClusters,
|
||||
nameSplit: tabClusterNameSplit,
|
||||
};
|
||||
window.CodemanTabTriage = {
|
||||
GROUPS: TAB_TRIAGE_GROUPS,
|
||||
STRIDE: TAB_TRIAGE_STRIDE,
|
||||
groupFor: tabTriageGroupFor,
|
||||
layout: computeTabTriageLayout,
|
||||
};
|
||||
window.CodemanInputLimit = {
|
||||
FRAME_MAX_CHARS: INPUT_FRAME_MAX_CHARS,
|
||||
PASTE_MAX_CHARS: INPUT_PASTE_MAX_CHARS,
|
||||
|
||||
@@ -221,7 +221,6 @@
|
||||
Running: '运行中',
|
||||
Idle: '空闲',
|
||||
Working: '工作中',
|
||||
Waiting: '等待中',
|
||||
Today: '今天',
|
||||
Home: '主页',
|
||||
Local: '本地',
|
||||
|
||||
@@ -65,7 +65,7 @@
|
||||
app.js, NOT the handheld storage-key test `m`. Use a different predicate
|
||||
here and boot will contradict this value, animating the drawer open by
|
||||
itself on every load between 768 and 1023px. -->
|
||||
<script>try{var m=window.innerWidth<768||(('ontouchstart' in window||navigator.maxTouchPoints>0)&&window.innerWidth<1024);var k=m?'codeman-app-settings-mobile':'codeman-app-settings';var A=JSON.parse(localStorage.getItem(k)||'{}');var L=A.sessionListLayout;var F=Number(A.sessionSidebarFontSize);var solo=/^\/session\//.test(location.pathname);var C=localStorage.getItem('codeman-sidebar-collapsed');var S=(L==='sidebar'||L==='sidebar-rich')&&!solo;document.documentElement.dataset.sessionList=S?'sidebar':'header';document.documentElement.dataset.sidebarDetail=(S&&L==='sidebar-rich')?'rich':'simple';document.documentElement.dataset.sidebar=(C===null?window.innerWidth<1024:C==='1')?'collapsed':'expanded';var V=A.tabOrientation==='vertical'&&!S&&!solo&&window.innerWidth>=768;document.documentElement.dataset.tabOrientation=V?'vertical':'horizontal';document.documentElement.dataset.tabRailDetail=(A.tabRailDetail==='simple')?'simple':'rich';document.documentElement.dataset.tabRailSort=(A.tabRailSort==='manual')?'manual':'activity';var T=A.tabArrangement;document.documentElement.dataset.tabArrangement=(T==='case'||T==='ledger'||T==='classic')?T:'state';document.documentElement.dataset.tabStateOrder=(A.tabStateOrder==='urgent-last')?'urgent-last':'urgent-first';var H=A.headerStatsStyle;document.documentElement.dataset.headerStats=(window.innerWidth<768||solo)?'classic':(H==='classic'||H==='compact')?H:'tiles';var W=Number(A.tabRailWidth);if(V){if(Number.isInteger(W)&&W>=208&&W<=360)document.documentElement.style.setProperty('--tab-rail-width',W+'px');else if(document.documentElement.dataset.tabRailDetail!=='simple')document.documentElement.style.setProperty('--tab-rail-width','320px');}if(Number.isInteger(F)&&F>=11&&F<=18)document.documentElement.style.setProperty('--session-sidebar-name-font-size',F+'px');}catch(e){document.documentElement.dataset.sessionList='header';document.documentElement.dataset.sidebarDetail='simple';document.documentElement.dataset.sidebar='expanded';document.documentElement.dataset.tabOrientation='horizontal';document.documentElement.dataset.tabRailDetail='rich';document.documentElement.dataset.tabRailSort='activity';document.documentElement.dataset.tabArrangement='state';document.documentElement.dataset.tabStateOrder='urgent-first';document.documentElement.dataset.headerStats='classic';}</script>
|
||||
<script>try{var m=window.innerWidth<768||(('ontouchstart' in window||navigator.maxTouchPoints>0)&&window.innerWidth<1024);var k=m?'codeman-app-settings-mobile':'codeman-app-settings';var A=JSON.parse(localStorage.getItem(k)||'{}');var L=A.sessionListLayout;var F=Number(A.sessionSidebarFontSize);var solo=/^\/session\//.test(location.pathname);var C=localStorage.getItem('codeman-sidebar-collapsed');var S=(L==='sidebar'||L==='sidebar-rich')&&!solo;document.documentElement.dataset.sessionList=S?'sidebar':'header';document.documentElement.dataset.sidebarDetail=(S&&L==='sidebar-rich')?'rich':'simple';document.documentElement.dataset.sidebar=(C===null?window.innerWidth<1024:C==='1')?'collapsed':'expanded';var V=A.tabOrientation==='vertical'&&!S&&!solo&&window.innerWidth>=768;document.documentElement.dataset.tabOrientation=V?'vertical':'horizontal';document.documentElement.dataset.tabRailDetail=(A.tabRailDetail==='simple')?'simple':'rich';document.documentElement.dataset.tabRailSort=(A.tabRailSort==='manual')?'manual':'activity';var W=Number(A.tabRailWidth);if(V){if(Number.isInteger(W)&&W>=208&&W<=360)document.documentElement.style.setProperty('--tab-rail-width',W+'px');else if(document.documentElement.dataset.tabRailDetail!=='simple')document.documentElement.style.setProperty('--tab-rail-width','320px');}if(Number.isInteger(F)&&F>=11&&F<=18)document.documentElement.style.setProperty('--session-sidebar-name-font-size',F+'px');}catch(e){document.documentElement.dataset.sessionList='header';document.documentElement.dataset.sidebarDetail='simple';document.documentElement.dataset.sidebar='expanded';document.documentElement.dataset.tabOrientation='horizontal';document.documentElement.dataset.tabRailDetail='rich';document.documentElement.dataset.tabRailSort='activity';}</script>
|
||||
<!-- Inline critical CSS for instant skeleton paint (before styles.css loads) -->
|
||||
<style>
|
||||
.loading-skeleton{display:flex;flex-direction:column;height:100vh;height:100dvh;background:var(--bg-dark,#11151c)}
|
||||
@@ -154,7 +154,6 @@
|
||||
<div class="connection-indicator" id="connectionIndicator" style="display: none;">
|
||||
<span class="connection-dot" id="connectionDot"></span>
|
||||
<span class="connection-text" id="connectionText"></span>
|
||||
<span class="connection-tile"><span class="connection-tile-label" id="connectionTileLabel"></span><span class="connection-tile-value" id="connectionTileValue"></span></span>
|
||||
</div>
|
||||
<div class="header-font-controls">
|
||||
<button class="btn-icon-header btn-sm" onclick="app.decreaseFontSize()" title="Decrease font (Ctrl+-)" aria-label="Decrease font size">A-</button>
|
||||
@@ -163,7 +162,6 @@
|
||||
</div>
|
||||
<div class="header-system-stats" id="headerSystemStats" title="System resource usage">
|
||||
<div class="stat-item">
|
||||
<span class="stat-ring" id="statCpuRing" aria-hidden="true"></span>
|
||||
<span class="stat-label">CPU</span>
|
||||
<div class="stat-bar">
|
||||
<div class="stat-bar-fill stat-bar-cpu" id="statCpuBar"></div>
|
||||
@@ -171,7 +169,6 @@
|
||||
<span class="stat-value" id="statCpu">--%</span>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<span class="stat-ring" id="statMemRing" aria-hidden="true"></span>
|
||||
<span class="stat-label">MEM</span>
|
||||
<div class="stat-bar">
|
||||
<div class="stat-bar-fill stat-bar-mem" id="statMemBar"></div>
|
||||
@@ -1921,17 +1918,6 @@
|
||||
<label class="set-chip" data-preview="header" data-preview-order="13" data-preview-text="42%"><input type="checkbox" id="appSettingsShowPlanUsageLimits"><svg class="set-chip-ico" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4 18a8 8 0 1 1 16 0"/><path d="M12 18l4.5-5"/></svg><span>Plan Usage</span></label>
|
||||
<label class="set-chip" data-preview="header" data-preview-order="14"><input type="checkbox" id="appSettingsShowLifecycleLog"><svg class="set-chip-ico" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/></svg><span>Lifecycle Log</span></label>
|
||||
</div>
|
||||
<div class="set-row has-field" data-search="header stats style system cpu mem ws plan usage tiles compact rings">
|
||||
<div class="set-row-text">
|
||||
<span class="set-row-label">Header Stats Style <span class="set-tag">desktop</span></span>
|
||||
<span class="set-row-desc">How WS, CPU, MEM and the plan-usage windows are drawn. Compact puts a ring beside each value in two pills; Tiles put each label over its value with a bar underneath.</span>
|
||||
</div>
|
||||
<select id="appSettingsHeaderStatsStyle" class="set-select">
|
||||
<option value="classic">As before (bars)</option>
|
||||
<option value="compact">Compact (pill + rings)</option>
|
||||
<option value="tiles">Tiles (default)</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -2084,28 +2070,6 @@
|
||||
<div class="set-group">
|
||||
<div class="set-group-head"><h4>Tabs</h4><span class="set-scope">device</span></div>
|
||||
<div class="set-group-body">
|
||||
<div class="set-row has-field" data-search="tab layout grouping group by state triage needs you waiting working idle case clusters ledger columns classic old new">
|
||||
<div class="set-row-text">
|
||||
<span class="set-row-label">Tab Layout</span>
|
||||
<span class="set-row-desc">By state: a row each for needs you, waiting, working and idle. By case: one box per case. Ledger: an aligned column grid. Classic: the single list, as before. By state and By case group the side rail and sidebar too. Alt+1..9 keeps the tab order.</span>
|
||||
</div>
|
||||
<select id="appSettingsTabArrangement" class="set-select">
|
||||
<option value="state">By state (default)</option>
|
||||
<option value="case">By case (clusters)</option>
|
||||
<option value="ledger">Ledger (aligned columns)</option>
|
||||
<option value="classic">Classic (as before)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="set-row has-field" data-search="state order reverse needs you bottom top rows sections">
|
||||
<div class="set-row-text">
|
||||
<span class="set-row-label">State Order</span>
|
||||
<span class="set-row-desc">For Tab Layout by state. At the bottom flips the rows, so needs you sits right above the terminal.</span>
|
||||
</div>
|
||||
<select id="appSettingsTabStateOrder" class="set-select">
|
||||
<option value="urgent-first">Needs you on top (default)</option>
|
||||
<option value="urgent-last">Needs you at the bottom</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="set-row has-field" data-search="tab orientation horizontal vertical side rail">
|
||||
<div class="set-row-text">
|
||||
<span class="set-row-label">Tab Orientation</span>
|
||||
@@ -2129,7 +2093,7 @@
|
||||
<div class="set-row has-field" data-search="tab rail sort order activity manual drag reorder">
|
||||
<div class="set-row-text">
|
||||
<span class="set-row-label">Vertical Rail Order</span>
|
||||
<span class="set-row-desc">By activity uses the home screen's order: blocked on you first, then whatever has been running longest, then the most recently quiet. Manual keeps your tab order and is the only mode you can drag rows in. With Tab Layout by state or by case it orders the rows inside each section. Alt+1..9 always follows the tab order either way.</span>
|
||||
<span class="set-row-desc">By activity uses the home screen's order: blocked on you first, then whatever has been running longest, then the most recently quiet. Manual keeps your tab order and is the only mode you can drag rows in. Alt+1..9 always follows the tab order either way.</span>
|
||||
</div>
|
||||
<select id="appSettingsTabRailSort" class="set-select">
|
||||
<option value="activity">By activity (home screen order)</option>
|
||||
@@ -2189,7 +2153,7 @@
|
||||
<div class="set-row" id="appSettingsLineageLinesItem" data-search="lineage lines spawned worker parent connection">
|
||||
<div class="set-row-text">
|
||||
<span class="set-row-label">Spawn Lineage Lines <span class="set-tag">desktop</span></span>
|
||||
<span class="set-row-desc">Draw a line under the tab strip from a session to the sessions it spawned.</span>
|
||||
<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>
|
||||
<label class="switch switch-sm"><input type="checkbox" id="appSettingsLineageLines" checked><span class="slider"></span></label>
|
||||
</div>
|
||||
|
||||
@@ -755,25 +755,6 @@ html.mobile-init .file-browser-panel {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* Grouped by state (tabArrangement 'state'), the phone strip keeps its one scrolling
|
||||
row: the tabs still come in state order, most urgent first, but the
|
||||
headings would cost chips and the dots already say which state is which.
|
||||
The phone overview is where the labelled sections live. */
|
||||
:where(.header) .tab-triage-head {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Clustered by case, the same holds: the boxes dissolve into the one chip
|
||||
row (in cluster order) and the labels go, since every chip still names
|
||||
its session. */
|
||||
:where(.header) .session-tabs-host > .session-tabs.tabs-clusters > .tab-cluster {
|
||||
display: contents;
|
||||
}
|
||||
|
||||
:where(.header) .tab-cluster-label {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Only the active tab shows its action icons on a phone (see below), so on
|
||||
every other tab the container is empty but still a flex item, and its gap
|
||||
made the chip visibly wider on the right than on the left. */
|
||||
|
||||
@@ -6028,11 +6028,6 @@ Object.assign(CodemanApp.prototype, {
|
||||
}
|
||||
}
|
||||
|
||||
// Rings for the Compact header style, kept current in every style (two
|
||||
// style writes a poll) so switching styles never shows an empty ring.
|
||||
this._setStatRing('statCpuRing', stats.cpu);
|
||||
this._setStatRing('statMemRing', stats.memory?.percent);
|
||||
|
||||
if (memEl && memBar) {
|
||||
const memGB = (stats.memory.usedMB / 1024).toFixed(1);
|
||||
memEl.textContent = `${memGB}G`;
|
||||
@@ -6050,20 +6045,6 @@ Object.assign(CodemanApp.prototype, {
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Fill one stat ring (Compact header style) to `percent`, clamped to 0-100,
|
||||
* and flag it `high` above 80%, the same threshold at which the value next
|
||||
* to it turns red.
|
||||
*/
|
||||
_setStatRing(id, percent) {
|
||||
const ring = this.$(id);
|
||||
if (!ring) return;
|
||||
const value = Number(percent);
|
||||
const fill = Number.isFinite(value) ? Math.round(Math.min(100, Math.max(0, value))) : 0;
|
||||
ring.style.setProperty('--pu', String(fill));
|
||||
ring.classList.toggle('high', fill > 80);
|
||||
},
|
||||
|
||||
// ─── Clipboard ──────────────────────────────────────────────────────────────
|
||||
|
||||
async _onClipboardWrite(data) {
|
||||
|
||||
@@ -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,
|
||||
* which passes its own `$CODEMAN_SESSION_ID`) gets `parentSessionId` stamped on its
|
||||
* state server-side. This module turns that field into the same kind of glowing
|
||||
* connection line the subagent windows use, but tab → tab, so the strip shows at a
|
||||
* glance which tab spawned which.
|
||||
* state server-side. This module turns that field into a quiet orthogonal tree, one per
|
||||
* spawning tab, routed through the gaps between tab rows so it never crosses a label or
|
||||
* the terminal (geometry: `CodemanLineage.computeTree` in constants.js).
|
||||
*
|
||||
* EVERY FAMILY IS ALWAYS DRAWN, AND THE SELECTED TAB'S IS EMPHASIZED: the family the
|
||||
* active tab spawned, and the family it belongs to as a child, get the
|
||||
* `lineage-family--focus` group (thicker, full opacity, drawn last so nothing covers
|
||||
* it). Drawing ONLY the selected family was tried first and rejected by the owner
|
||||
* (2026-10-07: "I wanna see all the connections always"). Selection still has to
|
||||
* redraw to move the emphasis, which `_updateActiveTabImmediate()` does whenever any
|
||||
* lineage exists (`_lineageTotalEdges`).
|
||||
*
|
||||
* It is an ADDITIONAL LAYER on the existing SVG pass, not a second pass: the core
|
||||
* `_updateConnectionLinesImmediate()` (subagent-windows.js) calls
|
||||
* `_appendLineageConnectionLines(svg, rects)` at its tail, exactly like ultracode does,
|
||||
* so every layer shares ONE batched read → write reflow and one tab-rect cache.
|
||||
*
|
||||
* Two constraints that are not obvious from the code:
|
||||
* - DESKTOP ONLY. The overlay is `z-index: 999`; the desktop header is 100 (arcs paint
|
||||
* Constraints that are not obvious from the code:
|
||||
* - DESKTOP ONLY. The overlay is `z-index: 999`; the desktop header is 100 (lines paint
|
||||
* over it, which is what lets them touch tab bottoms), but under 1024px mobile.css
|
||||
* makes the header `position: fixed; z-index: 1200` and would bury them. The phone
|
||||
* strip is also a scroller where both endpoints are rarely on screen at once.
|
||||
* - The routing room is RESERVED in CSS (`.session-tabs.lineage-tree`), toggled by
|
||||
* `_syncLineageGutter()` from `updateTabOverflowMode()`. It keys on whether ANY
|
||||
* family exists, never on the selection, so switching tabs never resizes the header
|
||||
* (and with it the terminal and the PTY).
|
||||
* - Paths carry `data-agent-id="lineage:<childId>"` because that is the attribute
|
||||
* `_applyLineEntrances()` queries, so the draw-in animation and its
|
||||
* negative-`animation-delay` resume across `svg.innerHTML = ''` come for free.
|
||||
*
|
||||
* @mixin Extends CodemanApp.prototype via Object.assign
|
||||
* @dependency subagent-windows.js (_updateConnectionLinesImmediate, #connectionLines)
|
||||
* @dependency constants.js (window.CodemanLineage.computePath + .COLORS)
|
||||
* @dependency constants.js (window.CodemanLineage.computeTree + .COLORS)
|
||||
* @dependency settings-ui.js (loadAppSettingsFromStorage, getDefaultSettings)
|
||||
* @loadorder 15.6 (after ultracode-windows.js — appended to the same SVG pass)
|
||||
*/
|
||||
@@ -62,49 +74,99 @@ Object.assign(CodemanApp.prototype, {
|
||||
applyLineageLineSettings() {
|
||||
const prev = this._lineageLinesOn;
|
||||
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
|
||||
* (that index is what nests sibling arcs instead of overprinting them).
|
||||
* Reserve (or release) the strip's routing room: `.lineage-tree` on #sessionTabs
|
||||
* widens the row gap, pads the bottom for the last row's gap, and opens the spine
|
||||
* channel on the left of a wrapped strip (styles.css). Called at the top of
|
||||
* `updateTabOverflowMode()`, so it runs on every tab render, BEFORE the wrap is
|
||||
* measured.
|
||||
*
|
||||
* Walks `sessionOrder` rather than the sessions Map so sibling depth follows the
|
||||
* strip's own left-to-right order, which is what the user sees.
|
||||
* Keyed on whether any family exists at all, never on which one is selected: a
|
||||
* class that followed the selection would grow and shrink the header on every tab
|
||||
* switch, and the header's height is the terminal's height.
|
||||
*
|
||||
* Only the header strip routes through reserved gaps. The vertical rail keeps its
|
||||
* own `--lineage-vertical-gutter`, and the sidebar draws no lineage.
|
||||
*/
|
||||
_syncLineageGutter() {
|
||||
const edges = this._lineageLinesEnabled() ? this._collectLineageEdges() : [];
|
||||
this._lineageTotalEdges = edges.length;
|
||||
const strip = document.getElementById('sessionTabs');
|
||||
if (!strip) return;
|
||||
const want = edges.length > 0 && !this._isVerticalTabList?.();
|
||||
if (strip.classList.contains('lineage-tree') !== want) strip.classList.toggle('lineage-tree', want);
|
||||
},
|
||||
|
||||
/**
|
||||
* Every parent → child pair, in strip order. Walks `sessionOrder` rather than the
|
||||
* sessions Map so sibling order follows the strip's own left-to-right order, which
|
||||
* is what the user sees.
|
||||
*/
|
||||
_collectLineageEdges() {
|
||||
const edges = [];
|
||||
if (!this.sessions || this.sessions.size < 2) return edges;
|
||||
const order = this.sessionOrder && this.sessionOrder.length ? this.sessionOrder : [...this.sessions.keys()];
|
||||
const seenPerParent = new Map();
|
||||
for (const id of order) {
|
||||
const session = this.sessions.get(id);
|
||||
const parentId = session && session.parentSessionId;
|
||||
// A parent that is gone (closed, or never came back after a restart) draws
|
||||
// nothing: the field is decoration, so a dangling one is simply not rendered.
|
||||
if (!parentId || parentId === id || !this.sessions.has(parentId)) continue;
|
||||
const depth = seenPerParent.get(parentId) || 0;
|
||||
seenPerParent.set(parentId, depth + 1);
|
||||
edges.push({ parentId, childId: id, depth, status: session.status || 'idle' });
|
||||
edges.push({ parentId, childId: id, status: session.status || 'idle' });
|
||||
}
|
||||
return edges;
|
||||
},
|
||||
|
||||
/** Every family as `{ parentId, edges }`, in strip order of first appearance. */
|
||||
_lineageFamilies(edges) {
|
||||
const families = new Map();
|
||||
for (const edge of edges) {
|
||||
if (!families.has(edge.parentId)) families.set(edge.parentId, []);
|
||||
families.get(edge.parentId).push(edge);
|
||||
}
|
||||
return [...families].map(([parentId, familyEdges]) => ({ parentId, edges: familyEdges }));
|
||||
},
|
||||
|
||||
/**
|
||||
* 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
|
||||
* w1, those two came from w2". Keying it per child instead gave one tab's own
|
||||
* children a different colour each, which is the thing the colours exist to tell
|
||||
* apart. A child that goes on to spawn its own workers is a parent in its turn and
|
||||
* gets its own colour for the 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.
|
||||
*
|
||||
* Assigned in FIRST-SEEN order and remembered per parent id. First-seen rather than
|
||||
* draw-index keeps a colour stable across re-renders, tab reorders and sibling
|
||||
* closes (the SVG is wiped and rebuilt constantly, so an index-based colour would
|
||||
* flicker). 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.
|
||||
*/
|
||||
_lineageColorFor(parentId) {
|
||||
@@ -140,19 +202,20 @@ Object.assign(CodemanApp.prototype, {
|
||||
_appendLineageConnectionLines(svg, rects) {
|
||||
this._lineageEdgeCount = 0;
|
||||
if (!svg || !this._lineageLinesEnabled()) return;
|
||||
// Sidebar layout: computeLineagePath()'s whole geometry — the U-bridge hung
|
||||
// from the STRIP's bottom edge, the 64px dip corridor — assumes a horizontal
|
||||
// tab row. Against a vertical list the "strip bottom" is the bottom of the
|
||||
// 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.
|
||||
// Sidebar layout: the tree is routed for a horizontal strip or the vertical
|
||||
// rail. The sidebar is a vertical list with its own scroller and no reserved
|
||||
// channel; parent/child adjacency reads fine there without lines.
|
||||
if (this.isSessionSidebarActive?.()) return;
|
||||
const compute = window.CodemanLineage && window.CodemanLineage.computePath;
|
||||
if (!compute) return;
|
||||
const computeTree = window.CodemanLineage && window.CodemanLineage.computeTree;
|
||||
if (!computeTree) return;
|
||||
|
||||
const edges = this._collectLineageEdges();
|
||||
this._lineageTotalEdges = edges.length;
|
||||
if (edges.length === 0) return;
|
||||
// Claim colours in strip order for every family before drawing (_lineageColorFor).
|
||||
for (const edge of edges) this._lineageColorFor(edge.parentId);
|
||||
const families = this._lineageFamilies(edges);
|
||||
const focusParents = this._lineageFocusParents(edges);
|
||||
if (!rects) rects = new Map();
|
||||
|
||||
// PHASE 1 — reads.
|
||||
@@ -162,9 +225,9 @@ Object.assign(CodemanApp.prototype, {
|
||||
const orientation =
|
||||
document.documentElement.getAttribute('data-tab-orientation') === 'vertical' ? 'vertical' : 'horizontal';
|
||||
// A session hidden inside a collapsed group of the grouped rail has no row
|
||||
// to anchor to, so its end of the arc moves to that group's header (a
|
||||
// "proxied" endpoint, drawn quieter). Two endpoints proxied to the SAME
|
||||
// header would be an arc from a row to itself: skipped.
|
||||
// to anchor to, so its end of the line moves to that group's header (a
|
||||
// "proxied" endpoint, drawn quieter). A child proxied to the same header as
|
||||
// its parent would be a line from a row to itself: skipped.
|
||||
const resolveEndpoint = (id) => {
|
||||
const tab = strip.querySelector(`.session-tab[data-id="${CSS.escape(id)}"]`);
|
||||
if (tab) return { key: 'tab:' + id, element: tab, proxied: false };
|
||||
@@ -173,71 +236,125 @@ Object.assign(CodemanApp.prototype, {
|
||||
const header = strip.querySelector(`[data-tab-group-header="${CSS.escape(groupId)}"]`);
|
||||
return { key: 'group:' + groupId, element: header, proxied: !!header };
|
||||
};
|
||||
const resolvedEdges = [];
|
||||
for (const edge of edges) {
|
||||
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;
|
||||
const measure = (endpoint) => {
|
||||
if (!rects.has(endpoint.key)) {
|
||||
rects.set(endpoint.key, endpoint.element ? endpoint.element.getBoundingClientRect() : null);
|
||||
}
|
||||
return rects.get(endpoint.key);
|
||||
};
|
||||
const resolvedFamilies = [];
|
||||
for (const family of families) {
|
||||
const parentEndpoint = resolveEndpoint(family.parentId);
|
||||
const parentRect = measure(parentEndpoint);
|
||||
if (!parentRect) continue;
|
||||
const children = [];
|
||||
for (const edge of family.edges) {
|
||||
const childEndpoint = resolveEndpoint(edge.childId);
|
||||
if (childEndpoint.key === parentEndpoint.key) continue;
|
||||
const rect = measure(childEndpoint);
|
||||
if (rect) children.push({ edge, endpoint: childEndpoint, rect });
|
||||
}
|
||||
if (children.length > 0) resolvedFamilies.push({ family, parentEndpoint, parentRect, children });
|
||||
}
|
||||
if (resolvedFamilies.length === 0) return;
|
||||
// The header strip's rows come from EVERY tab in it (computeTree hangs a row's
|
||||
// gap under its tallest tab), web tabs included. The rail needs none.
|
||||
const tabRects = [];
|
||||
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.
|
||||
for (const { edge, parentEndpoint, childEndpoint } of resolvedEdges) {
|
||||
const parentRect = rects.get(parentEndpoint.key);
|
||||
const childRect = rects.get(childEndpoint.key);
|
||||
if (!parentRect || !childRect) continue;
|
||||
|
||||
const geom = compute({
|
||||
// Lanes follow STRIP order, so a family keeps its lane when the selection moves;
|
||||
// only the DRAW order changes (the emphasized families last, on top). A row gap
|
||||
// fits a few lanes, so they cycle: families that share one are told apart by colour.
|
||||
const laneLimit = Math.max(1, (window.CodemanLineage && window.CodemanLineage.MAX_LANES) || 3);
|
||||
const laneCount = Math.min(laneLimit, resolvedFamilies.length);
|
||||
const drawOrder = resolvedFamilies
|
||||
.map((resolved, index) => ({
|
||||
...resolved,
|
||||
lane: index % laneCount,
|
||||
focus: focusParents.has(resolved.family.parentId),
|
||||
}))
|
||||
.sort((a, b) => a.focus - b.focus);
|
||||
for (const { family, parentEndpoint, parentRect, children, lane, focus } of drawOrder) {
|
||||
const geom = computeTree({
|
||||
parent: parentRect,
|
||||
child: childRect,
|
||||
children: children.map((c) => ({ id: c.edge.childId, rect: c.rect })),
|
||||
strip: stripRect,
|
||||
depth: edge.depth,
|
||||
tabs: tabRects,
|
||||
orientation,
|
||||
lane,
|
||||
laneCount,
|
||||
});
|
||||
if (!geom) continue; // scrolled out of the strip, or a degenerate rect
|
||||
|
||||
const line = document.createElementNS('http://www.w3.org/2000/svg', 'path');
|
||||
line.setAttribute('d', geom.d);
|
||||
// The working class marches the dashes, so an active worker is visible along
|
||||
// the line itself. `status` is the CHILD's, which is the interesting end.
|
||||
const working = edge.status === 'working' ? ' lineage-line--working' : '';
|
||||
const proxied = parentEndpoint.proxied || childEndpoint.proxied;
|
||||
line.setAttribute('class', 'connection-line lineage-line' + working + (proxied ? ' lineage-line--proxied' : ''));
|
||||
// The PARENT's colour rides a CSS custom property so the stylesheet keeps owning
|
||||
// opacity, glow and dash; an empty colour leaves the --session-blue fallback.
|
||||
// Every arc out of one tab shares it — see _lineageColorFor().
|
||||
const color = this._lineageColorFor(edge.parentId);
|
||||
if (color) line.style.setProperty('--lineage-color', color);
|
||||
// `data-agent-id` is what _applyLineEntrances() queries — see the file header.
|
||||
line.setAttribute('data-agent-id', 'lineage:' + edge.childId);
|
||||
line.setAttribute('data-parent-tab', edge.parentId);
|
||||
line.setAttribute('data-child-tab', edge.childId);
|
||||
svg.appendChild(line);
|
||||
|
||||
// Direction marker at the CHILD end. A circle rather than an SVG <marker>:
|
||||
// markers need a <defs> block and fight the dash pattern.
|
||||
const dot = document.createElementNS('http://www.w3.org/2000/svg', 'circle');
|
||||
dot.setAttribute('cx', String(geom.endX));
|
||||
dot.setAttribute('cy', String(geom.endY));
|
||||
// Resting radius; `lineage-dot-pulse` breathes it 3.5 → 4.5 while the child
|
||||
// works, so the two have to be changed together.
|
||||
dot.setAttribute('r', '3.5');
|
||||
dot.setAttribute('class', 'lineage-line-dot' + working + (proxied ? ' lineage-line-dot--proxied' : ''));
|
||||
dot.setAttribute('data-child-tab', edge.childId);
|
||||
if (color) dot.style.setProperty('--lineage-color', color);
|
||||
svg.appendChild(dot);
|
||||
if (!geom || geom.routes.length === 0) continue;
|
||||
const byId = new Map(children.map((c) => [c.edge.childId, c]));
|
||||
const color = this._lineageColorFor(family.parentId);
|
||||
// One group per family: it carries the translucency, so the stretches its
|
||||
// routes share (the trunk) do not stack into a brighter line than the branches,
|
||||
// and the emphasis for the selected tab's families (styles.css).
|
||||
const group = document.createElementNS('http://www.w3.org/2000/svg', 'g');
|
||||
group.setAttribute('class', 'lineage-family' + (focus ? ' lineage-family--focus' : ''));
|
||||
group.setAttribute('data-parent-tab', family.parentId);
|
||||
// Working routes go in FIRST, so an idle sibling's solid stroke covers the
|
||||
// shared trunk and only the working child's own branch shows its dashes.
|
||||
const routes = geom.routes
|
||||
.map((route) => ({ route, child: byId.get(route.id) }))
|
||||
.filter((r) => r.child)
|
||||
.sort((a, b) => (b.child.edge.status === 'working') - (a.child.edge.status === 'working'));
|
||||
for (const { route, child } of routes) {
|
||||
const line = document.createElementNS('http://www.w3.org/2000/svg', 'path');
|
||||
line.setAttribute('d', route.d);
|
||||
// `status` is the CHILD's, which is the interesting end: a working child's
|
||||
// route is dashed (and marches, motion permitting).
|
||||
const working = child.edge.status === 'working' ? ' lineage-line--working' : '';
|
||||
const proxied = parentEndpoint.proxied || child.endpoint.proxied;
|
||||
line.setAttribute(
|
||||
'class',
|
||||
'connection-line lineage-line' + working + (proxied ? ' lineage-line--proxied' : '')
|
||||
);
|
||||
// The PARENT's colour rides a CSS custom property so the stylesheet keeps
|
||||
// owning weight and dash; an empty colour leaves the --session-blue fallback.
|
||||
if (color) line.style.setProperty('--lineage-color', color);
|
||||
// `data-agent-id` is what _applyLineEntrances() queries — see the file header.
|
||||
line.setAttribute('data-agent-id', 'lineage:' + child.edge.childId);
|
||||
line.setAttribute('data-parent-tab', family.parentId);
|
||||
line.setAttribute('data-child-tab', child.edge.childId);
|
||||
group.appendChild(line);
|
||||
}
|
||||
// Direction marker at each CHILD end, after every path so no stroke covers it.
|
||||
for (const { route, child } of routes) {
|
||||
const working = child.edge.status === 'working' ? ' lineage-line--working' : '';
|
||||
const proxied = parentEndpoint.proxied || child.endpoint.proxied;
|
||||
const dot = document.createElementNS('http://www.w3.org/2000/svg', 'circle');
|
||||
dot.setAttribute('cx', String(route.endX));
|
||||
dot.setAttribute('cy', String(route.endY));
|
||||
// Fallback radius only: styles.css sizes the dot through `--lineage-dot-r`
|
||||
// (larger in an emphasized family), and `lineage-dot-pulse` breathes from it.
|
||||
dot.setAttribute('r', '2.5');
|
||||
dot.setAttribute('class', 'lineage-line-dot' + working + (proxied ? ' lineage-line-dot--proxied' : ''));
|
||||
dot.setAttribute('data-child-tab', child.edge.childId);
|
||||
if (color) dot.style.setProperty('--lineage-color', color);
|
||||
group.appendChild(dot);
|
||||
}
|
||||
svg.appendChild(group);
|
||||
this._lineageEdgeCount += routes.length;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* The strip scrolls (desktop `overflow-x: auto` and every wrapped layout), and a
|
||||
* scroll moves both endpoints without firing any render, so the arcs would slide off
|
||||
* their tabs. Passive listener, and the redraw is the normal coalesced one.
|
||||
* scroll moves both endpoints without firing any render, so the lines would slide
|
||||
* off their tabs. Passive listener, and the redraw is the normal coalesced one.
|
||||
*
|
||||
* Installed once; the guard also keeps a re-init from stacking listeners.
|
||||
*/
|
||||
@@ -248,11 +365,24 @@ Object.assign(CodemanApp.prototype, {
|
||||
this._lineageScrollHandler = () => {
|
||||
// Sidebar layout and the vertical rail scroll the SAME element
|
||||
// vertically, and there the subagent/ultracode connectors anchor to tab
|
||||
// rects too (the sidebar skips lineage arcs entirely, and the rail can
|
||||
// show connectors with zero lineage edges, so _lineageEdgeCount alone
|
||||
// would never redraw them).
|
||||
// rects too (the sidebar skips lineage entirely, and the rail can show
|
||||
// connectors with zero lineage edges, so _lineageEdgeCount alone would
|
||||
// never redraw them).
|
||||
if (this._lineageEdgeCount > 0 || this._isVerticalTabList?.()) this.updateConnectionLines();
|
||||
};
|
||||
strip.addEventListener('scroll', this._lineageScrollHandler, { passive: true });
|
||||
|
||||
// ⚠ A SELECTION RESIZES TABS AFTER THE REDRAW. The active tab reveals its gear
|
||||
// and close icons by transitioning their padding (styles.css), so it keeps
|
||||
// widening for ~150ms after `_updateActiveTabImmediate()` has already redrawn,
|
||||
// and the tab it was selected from shrinks. That can move tabs or re-wrap a row,
|
||||
// which left a family's trunk hanging under the tab's OLD position. Redraw once
|
||||
// a size transition inside the strip ends (coalesced, like every other redraw).
|
||||
this._lineageTransitionHandler = (event) => {
|
||||
const prop = event.propertyName || '';
|
||||
if (!(prop === 'width' || prop === 'max-width' || prop.startsWith('padding'))) return;
|
||||
if (this._lineageTotalEdges > 0) this.updateConnectionLines();
|
||||
};
|
||||
strip.addEventListener('transitionend', this._lineageTransitionHandler);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -382,7 +382,6 @@ Object.assign(CodemanApp.prototype, {
|
||||
// Header visibility settings
|
||||
document.getElementById('appSettingsShowFontControls').checked = settings.showFontControls ?? defaults.showFontControls ?? false;
|
||||
document.getElementById('appSettingsShowSystemStats').checked = settings.showSystemStats ?? defaults.showSystemStats ?? true;
|
||||
document.getElementById('appSettingsHeaderStatsStyle').value = this.resolveHeaderStatsStyle(settings);
|
||||
document.getElementById('appSettingsShowLifecycleLog').checked = settings.showLifecycleLog ?? defaults.showLifecycleLog ?? false;
|
||||
document.getElementById('appSettingsShowResponseViewer').checked = settings.showResponseViewer ?? defaults.showResponseViewer ?? false;
|
||||
document.getElementById('appSettingsShowFileViewerButton').checked = settings.showFileViewerButton ?? defaults.showFileViewerButton ?? true;
|
||||
@@ -501,8 +500,6 @@ Object.assign(CodemanApp.prototype, {
|
||||
settings.tabRailDetail ?? defaults.tabRailDetail ?? 'rich';
|
||||
document.getElementById('appSettingsTabRailSort').value =
|
||||
settings.tabRailSort ?? defaults.tabRailSort ?? 'activity';
|
||||
document.getElementById('appSettingsTabArrangement').value = this.resolveTabArrangement(settings);
|
||||
document.getElementById('appSettingsTabStateOrder').value = this.resolveTabStateOrder(settings);
|
||||
document.getElementById('appSettingsShowTabDetachButton').checked = settings.showTabDetachButton ?? defaults.showTabDetachButton ?? false;
|
||||
document.getElementById('appSettingsSessionListLayout').value =
|
||||
settings.sessionListLayout ?? defaults.sessionListLayout ?? 'header';
|
||||
@@ -2478,7 +2475,6 @@ Object.assign(CodemanApp.prototype, {
|
||||
// Header visibility settings
|
||||
showFontControls: document.getElementById('appSettingsShowFontControls').checked,
|
||||
showSystemStats: document.getElementById('appSettingsShowSystemStats').checked,
|
||||
headerStatsStyle: document.getElementById('appSettingsHeaderStatsStyle').value,
|
||||
showLifecycleLog: document.getElementById('appSettingsShowLifecycleLog').checked,
|
||||
showResponseViewer: document.getElementById('appSettingsShowResponseViewer').checked,
|
||||
showFileViewerButton: document.getElementById('appSettingsShowFileViewerButton').checked,
|
||||
@@ -2528,8 +2524,6 @@ Object.assign(CodemanApp.prototype, {
|
||||
tabRailWidth: this.readTabRailWidthSetting?.() ?? 256,
|
||||
tabRailDetail: document.getElementById('appSettingsTabRailDetail').value,
|
||||
tabRailSort: document.getElementById('appSettingsTabRailSort').value,
|
||||
tabArrangement: document.getElementById('appSettingsTabArrangement').value,
|
||||
tabStateOrder: document.getElementById('appSettingsTabStateOrder').value,
|
||||
showTabDetachButton: document.getElementById('appSettingsShowTabDetachButton').checked,
|
||||
sessionListLayout: document.getElementById('appSettingsSessionListLayout').value,
|
||||
sessionSidebarFontSize: this.resolveSessionSidebarFontSize(
|
||||
@@ -3454,8 +3448,6 @@ Object.assign(CodemanApp.prototype, {
|
||||
tabRailWidth: 256,
|
||||
tabRailDetail: 'rich',
|
||||
tabRailSort: 'activity',
|
||||
tabArrangement: 'state',
|
||||
tabStateOrder: 'urgent-first',
|
||||
sessionListLayout: 'header',
|
||||
sessionSidebarFontSize: 12,
|
||||
cjkInputEnabled: false,
|
||||
@@ -3557,79 +3549,6 @@ Object.assign(CodemanApp.prototype, {
|
||||
return now === before ? undefined : now;
|
||||
},
|
||||
|
||||
/**
|
||||
* The stored tab layout, or the default. Anything but the four known values
|
||||
* (an absent key, a value from a newer build) reads as 'state', the default
|
||||
* (Discussion #426, option C).
|
||||
*/
|
||||
resolveTabArrangement(settings) {
|
||||
const value = settings?.tabArrangement ?? this.getDefaultSettings().tabArrangement;
|
||||
return value === 'case' || value === 'ledger' || value === 'classic' ? value : 'state';
|
||||
},
|
||||
|
||||
/** The stored state-group order: 'urgent-last' only when chosen, else 'urgent-first'. */
|
||||
resolveTabStateOrder(settings) {
|
||||
const value = settings?.tabStateOrder ?? this.getDefaultSettings().tabStateOrder;
|
||||
return value === 'urgent-last' ? 'urgent-last' : 'urgent-first';
|
||||
},
|
||||
|
||||
/**
|
||||
* The stored header-stats style, or the default. Anything but the three
|
||||
* known values (an absent key, a value from a newer build) reads as 'tiles',
|
||||
* the default (the tile variant of Discussion #426's option G).
|
||||
*/
|
||||
resolveHeaderStatsStyle(settings) {
|
||||
const value = settings?.headerStatsStyle ?? this.getDefaultSettings().headerStatsStyle;
|
||||
return value === 'classic' || value === 'compact' ? value : 'tiles';
|
||||
},
|
||||
|
||||
/**
|
||||
* Apply a header-stats style: the `data-header-stats` attribute every rule in
|
||||
* the "Header stats styles" block of styles.css keys on, plus the two DOM
|
||||
* moves the clustered styles need.
|
||||
*
|
||||
* The template keeps the classic order, where the connection indicator sits
|
||||
* before the font controls and the plan-usage chip near the end of the header.
|
||||
* Compact and Tiles draw them as ONE cluster (WS · CPU · MEM, then the plan
|
||||
* windows), so the indicator moves into #headerSystemStats as its first child
|
||||
* and the chip moves right after it. Comment anchors left at the template
|
||||
* positions are what 'classic' moves them back to, so switching back restores
|
||||
* the header exactly.
|
||||
*
|
||||
* ⚠️ The indicator only joins the pill while System Stats is shown: the pill
|
||||
* is hidden with `display: none`, and the WS readout must not disappear with
|
||||
* it. Both elements keep their ids, so every writer (setConnectionStatus,
|
||||
* updatePlanUsageChip) finds them wherever they sit.
|
||||
*
|
||||
* @param {{style: 'classic'|'compact'|'tiles', showSystemStats: boolean}} opts
|
||||
*/
|
||||
applyHeaderStatsStyle({ style, showSystemStats }) {
|
||||
document.documentElement.dataset.headerStats = style;
|
||||
const stats = document.getElementById('headerSystemStats');
|
||||
const conn = document.getElementById('connectionIndicator');
|
||||
const plan = document.getElementById('planUsageChip');
|
||||
if (!stats || !conn || !plan) return;
|
||||
if (!this._headerStatsAnchors) {
|
||||
const connAnchor = document.createComment(' connection indicator (classic position) ');
|
||||
const planAnchor = document.createComment(' plan usage chip (classic position) ');
|
||||
conn.before(connAnchor);
|
||||
plan.before(planAnchor);
|
||||
this._headerStatsAnchors = { conn: connAnchor, plan: planAnchor };
|
||||
}
|
||||
const anchors = this._headerStatsAnchors;
|
||||
const clustered = style !== 'classic';
|
||||
if (clustered && showSystemStats) {
|
||||
if (stats.firstElementChild !== conn) stats.prepend(conn);
|
||||
} else if (anchors.conn.nextSibling !== conn) {
|
||||
anchors.conn.after(conn);
|
||||
}
|
||||
if (clustered) {
|
||||
if (stats.nextElementSibling !== plan) stats.after(plan);
|
||||
} else if (anchors.plan.nextSibling !== plan) {
|
||||
anchors.plan.after(plan);
|
||||
}
|
||||
},
|
||||
|
||||
applyHeaderVisibilitySettings() {
|
||||
const settings = this.loadAppSettingsFromStorage();
|
||||
const defaults = this.getDefaultSettings();
|
||||
@@ -3660,12 +3579,6 @@ Object.assign(CodemanApp.prototype, {
|
||||
if (tokenCountEl) {
|
||||
tokenCountEl.style.display = showTokenCount ? '' : 'none';
|
||||
}
|
||||
// After the System Stats visibility above: whether WS joins the stats pill
|
||||
// depends on the pill being shown.
|
||||
this.applyHeaderStatsStyle({
|
||||
style: compactHeader || this.isSoloWindow ? 'classic' : this.resolveHeaderStatsStyle(settings),
|
||||
showSystemStats,
|
||||
});
|
||||
|
||||
// Hide lifecycle log button when setting is disabled
|
||||
// Default OFF: the lifecycle-log document icon is opt-in; the default header
|
||||
@@ -3833,19 +3746,6 @@ Object.assign(CodemanApp.prototype, {
|
||||
const sort = (settings.tabRailSort ?? defaults.tabRailSort ?? 'activity') === 'manual' ? 'manual' : 'activity';
|
||||
root.dataset.tabRailSort = sort;
|
||||
|
||||
// The tab layout rides on a fourth attribute, for the same reason: it is
|
||||
// applied by the render paths (inline `order` plus headings, or cluster
|
||||
// boxes), so a flip has to re-render, and the gates in app.js
|
||||
// (`isTabTriage()`, `isTabClusters()`, `isTabLedger()`) read one attribute
|
||||
// per pass instead of re-parsing localStorage.
|
||||
const previousArrangement = root.dataset.tabArrangement || 'state';
|
||||
const arrangement = this.resolveTabArrangement(settings);
|
||||
root.dataset.tabArrangement = arrangement;
|
||||
// Which end the state groups start from; read by _tabTriageLayout().
|
||||
const previousStateOrder = root.dataset.tabStateOrder || 'urgent-first';
|
||||
const stateOrder = this.resolveTabStateOrder(settings);
|
||||
root.dataset.tabStateOrder = stateOrder;
|
||||
|
||||
const tabsEl = document.getElementById('sessionTabs');
|
||||
const rail = document.getElementById('tabRail');
|
||||
const headerHost = document.getElementById('sessionTabsHost');
|
||||
@@ -3869,7 +3769,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
// the row template, not toggled by CSS — same reasoning as the sidebar's
|
||||
// detail half in applySessionListLayout(). Taller rows also move every
|
||||
// connector anchored to a tab rect.
|
||||
const changed = orientationChanged || previousDetail !== detail || previousSort !== sort || previousArrangement !== arrangement || previousStateOrder !== stateOrder;
|
||||
const changed = orientationChanged || previousDetail !== detail || previousSort !== sort;
|
||||
if (orientationChanged) {
|
||||
this.updateTabOverflowMode?.();
|
||||
if (!settleRailWidth) this.syncTerminalGeometry?.();
|
||||
@@ -4139,10 +4039,10 @@ Object.assign(CodemanApp.prototype, {
|
||||
// NOTE: Feature toggles (subagentTrackingEnabled, imageWatcherEnabled, ralphTrackerEnabled)
|
||||
// are NOT display keys — they control server-side behavior and must sync from server.
|
||||
const displayKeys = new Set([
|
||||
'showFontControls', 'showSystemStats', 'headerStatsStyle', 'showTokenCount', 'showCost',
|
||||
'showFontControls', 'showSystemStats', 'showTokenCount', 'showCost',
|
||||
'showLifecycleLog', 'showResponseViewer', 'showRedrawButton',
|
||||
'showMonitor', 'showProjectInsights', 'showFileBrowser', 'showSubagents',
|
||||
'subagentActiveTabOnly', 'tabTwoRows', 'tabOrientation', 'tabRailWidth', 'tabRailDetail', 'tabRailSort', 'tabArrangement', 'tabStateOrder', 'sessionListLayout', 'sessionSidebarFontSize', 'localEchoEnabled', 'cjkInputEnabled', 'extendedKeyboardBar',
|
||||
'subagentActiveTabOnly', 'tabTwoRows', 'tabOrientation', 'tabRailWidth', 'tabRailDetail', 'tabRailSort', 'sessionListLayout', 'sessionSidebarFontSize', 'localEchoEnabled', 'cjkInputEnabled', 'extendedKeyboardBar',
|
||||
'skin', 'showPlanUsageLimits', 'showAttachmentsButton', 'showFileViewerButton', 'webglRendererEnabled',
|
||||
'terminalFontFamily', 'terminalFontWeight', 'terminalFontWeightBold',
|
||||
'language',
|
||||
|
||||
+88
-727
@@ -596,6 +596,22 @@ body {
|
||||
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 {
|
||||
width: 4px;
|
||||
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 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
|
||||
here - as `line-enter-fade` above still does - lands every lineage line on the
|
||||
wrong opacity and snaps it when the class comes off. */
|
||||
0.9, a lineage line at 1 (its `.lineage-family` group carries the
|
||||
translucency), a proxied one at 0.5. Pinning 0.9 here - as `line-enter-fade`
|
||||
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 {
|
||||
animation-name: line-enter-blur;
|
||||
animation-duration: calc(380ms * var(--anim-enter-scale, 1));
|
||||
@@ -10665,112 +10682,121 @@ kbd {
|
||||
}
|
||||
|
||||
/* ===== Session lineage lines (tab → tab it spawned, session-lineage.js) =====
|
||||
Deliberately quieter and thinner than the subagent lines above so the two
|
||||
layers read as different things in the same SVG.
|
||||
One quiet orthogonal tree per spawning tab, every family always drawn and
|
||||
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
|
||||
per line from the CodemanLineage.COLORS palette (per child, first-seen order,
|
||||
owner call 2026-08-15: several connected tabs must get several colours). The
|
||||
FIRST line gets no override, so it falls through to --session-blue, which EVERY
|
||||
skin block already defines and tunes for its own background; a lone arc therefore
|
||||
still renders the skin-aware blue that shipped in 1.18.2. Do not add a per-skin
|
||||
`.lineage-line` override inside the html:not([data-skin="og"]) block: a bare
|
||||
class rule in there resolves to (0,2,1) and would outrank this one from a
|
||||
surprising place.
|
||||
per path and dot from the CodemanLineage.COLORS palette (per SPAWNING tab,
|
||||
first-seen order). The first spawning tab gets no override and falls through to
|
||||
--session-blue, which EVERY skin block already defines and tunes for its own
|
||||
background. Do not add a per-skin `.lineage-line` override inside the
|
||||
html:not([data-skin="og"]) block: a bare class rule in there resolves to
|
||||
(0,2,1) and would outrank this one from a surprising place.
|
||||
|
||||
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 {
|
||||
stroke: var(--lineage-color, var(--session-blue, #2b8fd9));
|
||||
stroke-width: 2.5;
|
||||
stroke-dasharray: 5 5;
|
||||
stroke-width: 1.5;
|
||||
stroke-dasharray: none;
|
||||
stroke-linecap: round;
|
||||
opacity: 0.72;
|
||||
--line-glow: drop-shadow(0 0 2px rgba(0, 0, 0, 0.7))
|
||||
drop-shadow(0 0 5px var(--lineage-color, var(--session-blue, #2b8fd9)))
|
||||
drop-shadow(0 0 11px var(--lineage-color, var(--session-blue, #2b8fd9)));
|
||||
stroke-linejoin: round;
|
||||
opacity: 1;
|
||||
--line-glow: drop-shadow(0 0 1px rgba(0, 0, 0, 0.9));
|
||||
filter: var(--line-glow);
|
||||
}
|
||||
|
||||
/* ⚠ OUTSIDE the reduced-motion block below on purpose. A working child is the case
|
||||
the line exists to signal, and pairing the brightness with the marching dashes
|
||||
left every worker's arc at the resting 0.72 for anyone who turns motion off. */
|
||||
/* The selected tab's families: thicker (the group above makes them opaque). */
|
||||
.lineage-family--focus .connection-line.lineage-line {
|
||||
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 {
|
||||
opacity: 0.95;
|
||||
}
|
||||
|
||||
.connection-line.lineage-line:hover {
|
||||
opacity: 1;
|
||||
stroke-width: 3;
|
||||
stroke-dasharray: 4 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-dot-r: 2.5px;
|
||||
r: var(--lineage-dot-r);
|
||||
fill: var(--lineage-color, var(--session-blue, #2b8fd9));
|
||||
opacity: 0.85;
|
||||
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)));
|
||||
filter: drop-shadow(0 0 1px rgba(0, 0, 0, 0.9));
|
||||
}
|
||||
|
||||
/* 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. */
|
||||
.connection-line.lineage-line--proxied {
|
||||
stroke-dasharray: 2 7;
|
||||
opacity: 0.46;
|
||||
stroke-dasharray: 1 5;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.connection-line.lineage-line--proxied.lineage-line--working {
|
||||
opacity: 0.62;
|
||||
opacity: 0.65;
|
||||
}
|
||||
|
||||
.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
|
||||
itself carries the signal. Motion is opt-out-able at the OS level. */
|
||||
/* The working route marches from parent to child, so the line itself carries
|
||||
the signal. Motion is opt-out-able at the OS level. */
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
.connection-line.lineage-line--working {
|
||||
animation: lineage-flow 1.1s linear infinite;
|
||||
}
|
||||
|
||||
.lineage-line-dot--working {
|
||||
opacity: 1;
|
||||
animation: lineage-dot-pulse 1.4s ease-in-out infinite;
|
||||
}
|
||||
}
|
||||
|
||||
/* Two full dash cycles, so the march loops seamlessly. Tied to `stroke-dasharray`
|
||||
above: at `5 5` the cycle is 10px, so this is -20 rather than the -16 that
|
||||
matched the old `4 4`. Leaving them out of step makes the dashes jump once per
|
||||
iteration. */
|
||||
/* Two full dash cycles, so the march loops seamlessly. Tied to the working
|
||||
`stroke-dasharray` above: at `4 3` the cycle is 7px, so this is -14. Leaving
|
||||
them out of step makes the dashes jump once per iteration. */
|
||||
@keyframes lineage-flow {
|
||||
to {
|
||||
stroke-dashoffset: -20;
|
||||
stroke-dashoffset: -14;
|
||||
}
|
||||
}
|
||||
|
||||
/* Breathes from the dot's own resting radius (`--lineage-dot-r`). */
|
||||
@keyframes lineage-dot-pulse {
|
||||
0%, 100% {
|
||||
opacity: 0.75;
|
||||
r: 3.5;
|
||||
r: var(--lineage-dot-r);
|
||||
}
|
||||
50% {
|
||||
opacity: 1;
|
||||
r: 4.5;
|
||||
r: calc(var(--lineage-dot-r) + 0.8px);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19788,668 +19814,3 @@ html .toolbar .btn-git-status[aria-expanded='true'] {
|
||||
padding-left: 0.4rem;
|
||||
border-left: 1px solid var(--border);
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
Tab grouping by state (tabArrangement: 'state', the default;
|
||||
Discussion #426 option C)
|
||||
============================================================
|
||||
app.js writes a flex `order` per tab plus one heading and one row break per
|
||||
non-empty state, all direct children of #sessionTabs (_syncTabTriageChrome).
|
||||
The DOM stays in tab order; computeTabTriageLayout() in constants.js owns the
|
||||
bands. With grouping off none of this matches anything. */
|
||||
.tab-triage-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
align-self: stretch;
|
||||
gap: 5px;
|
||||
flex: 0 0 auto;
|
||||
box-sizing: border-box;
|
||||
padding: 0 6px 0 2px;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
line-height: 1;
|
||||
text-transform: uppercase;
|
||||
white-space: nowrap;
|
||||
color: var(--text-muted);
|
||||
user-select: none;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.tab-triage-count {
|
||||
font-weight: 600;
|
||||
letter-spacing: 0;
|
||||
color: var(--text-muted);
|
||||
opacity: 0.75;
|
||||
}
|
||||
|
||||
.tab-triage-head--needs .tab-triage-label {
|
||||
color: var(--red);
|
||||
}
|
||||
|
||||
.tab-triage-head--waiting .tab-triage-label {
|
||||
color: var(--yellow);
|
||||
}
|
||||
|
||||
.tab-triage-head--working .tab-triage-label {
|
||||
color: var(--green);
|
||||
}
|
||||
|
||||
.tab-triage-break {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Counts describe the whole group, so they step aside while the sidebar
|
||||
filter narrows the rows under them. */
|
||||
.session-tabs.tabs-filtering > .tab-triage-head {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Header strip, wrapping (desktop): one row per state. Each heading sits in a
|
||||
left gutter, so a group too long for one line continues under its own tabs
|
||||
rather than under its heading, and each row ends with a full-width break. */
|
||||
/* The label column is as wide as the widest label on screen (app.js measures
|
||||
it into --tab-triage-gutter whenever the labels change; 92px is only the
|
||||
first-paint fallback) and the labels are left-aligned in it.
|
||||
|
||||
The brand leaves the flow and sits over the strip's top-left corner, so the
|
||||
strip starts at the header's left edge: the first row's heading (the lead)
|
||||
is pushed past the brand by --tab-triage-brand, and every later row starts
|
||||
at the edge, under "Codeman", instead of leaving that corner empty. The
|
||||
rows under the first one line up with each other in the label column. */
|
||||
.header:has(> .session-tabs-host > .session-tabs.tabs-triage:is(.tabs-auto-wrap, .tabs-two-rows)) > .header-brand {
|
||||
position: absolute;
|
||||
top: 0.35rem;
|
||||
left: 0.75rem;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.session-tabs-host > .session-tabs.tabs-triage:is(.tabs-auto-wrap, .tabs-two-rows) {
|
||||
padding-left: var(--tab-triage-gutter, 92px);
|
||||
}
|
||||
|
||||
.session-tabs-host > .session-tabs.tabs-triage:is(.tabs-auto-wrap, .tabs-two-rows) > .tab-triage-head {
|
||||
justify-content: flex-start;
|
||||
width: var(--tab-triage-gutter, 92px);
|
||||
margin-left: calc(-1 * var(--tab-triage-gutter, 92px));
|
||||
padding: 0 8px 0 0;
|
||||
}
|
||||
|
||||
/* The first row starts beside the brand and lines up with nothing else, so its
|
||||
label takes its own width and its tabs follow it directly. */
|
||||
.session-tabs-host > .session-tabs.tabs-triage:is(.tabs-auto-wrap, .tabs-two-rows) > .tab-triage-head--lead {
|
||||
width: auto;
|
||||
margin-left: calc(var(--tab-triage-brand, 100px) - var(--tab-triage-gutter, 92px));
|
||||
}
|
||||
|
||||
.session-tabs-host > .session-tabs.tabs-triage:is(.tabs-auto-wrap, .tabs-two-rows) > .tab-triage-break {
|
||||
display: block;
|
||||
flex: 0 0 100%;
|
||||
height: 0;
|
||||
}
|
||||
|
||||
/* Side rail and sidebar: each heading is a section row above its rows. */
|
||||
html[data-tab-orientation='vertical'] .tab-rail .session-tabs.tabs-triage > .tab-triage-head,
|
||||
html[data-session-list='sidebar'] .session-sidebar .session-tabs.tabs-triage > .tab-triage-head {
|
||||
align-self: auto;
|
||||
width: 100%;
|
||||
padding: 8px 6px 3px;
|
||||
}
|
||||
|
||||
/* The collapsed sidebar is a 44px icon rail with no room for a label. */
|
||||
html[data-session-list='sidebar'][data-sidebar='collapsed'] .session-sidebar .tab-triage-head {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
Tab clusters by case (tabArrangement: 'case'; Discussion #426 option A)
|
||||
============================================================
|
||||
app.js wraps each case's tabs in a .tab-cluster box (_renderTabClusters):
|
||||
a labelled box per case with two or more tabs, an unlabelled box with a
|
||||
colour swatch per case with one, and a box per web tab. --cluster-color is
|
||||
the case's colour from the session palette. Tabs inside keep their own
|
||||
markup; only the `-<case>` part of a generated name (.tab-name-case) is
|
||||
hidden, and only here. */
|
||||
.session-tabs.tabs-clusters .tab-name-case {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.tab-cluster-label {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
flex: none;
|
||||
padding: 0 6px 0 2px;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
line-height: 1;
|
||||
text-transform: uppercase;
|
||||
white-space: nowrap;
|
||||
color: var(--text-dim);
|
||||
user-select: none;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.tab-cluster-count {
|
||||
font-weight: 600;
|
||||
letter-spacing: 0;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.tab-cluster-swatch {
|
||||
flex: none;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 2px;
|
||||
background: var(--cluster-color, var(--text-muted));
|
||||
}
|
||||
|
||||
/* Header strip: each cluster is an inline box that wraps inside itself when a
|
||||
case has more tabs than fit on a line. */
|
||||
.session-tabs-host > .session-tabs.tabs-clusters {
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.session-tabs-host > .session-tabs.tabs-clusters > .tab-cluster {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 2px;
|
||||
max-width: 100%;
|
||||
min-width: 0;
|
||||
box-sizing: border-box;
|
||||
padding: 1px 3px 1px 8px;
|
||||
border: 1px solid var(--control-border);
|
||||
border-left: 3px solid var(--cluster-color, var(--control-border));
|
||||
border-radius: 8px;
|
||||
background: var(--control-bg);
|
||||
}
|
||||
|
||||
/* A case with one tab and a web tab are boxed but not labelled: the single
|
||||
case keeps its swatch, a web tab has no case at all. */
|
||||
.session-tabs-host > .session-tabs.tabs-clusters > .tab-cluster--single {
|
||||
border-left-width: 1px;
|
||||
padding-left: 6px;
|
||||
}
|
||||
|
||||
.session-tabs-host > .session-tabs.tabs-clusters > .tab-cluster--single .tab-cluster-name,
|
||||
.session-tabs-host > .session-tabs.tabs-clusters > .tab-cluster--single .tab-cluster-count {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.session-tabs-host > .session-tabs.tabs-clusters > .tab-cluster--single .tab-cluster-label {
|
||||
padding: 0 2px;
|
||||
}
|
||||
|
||||
.session-tabs-host > .session-tabs.tabs-clusters > .tab-cluster--web {
|
||||
padding-left: 3px;
|
||||
}
|
||||
|
||||
/* Side rail and sidebar: every case is a section with its label on top
|
||||
(vertical space is cheap there, so a one-tab case is labelled too). The
|
||||
label goes first by `order`, because a sorted rail orders the rows inside
|
||||
each cluster. */
|
||||
html[data-tab-orientation='vertical'] .tab-rail .session-tabs.tabs-clusters > .tab-cluster,
|
||||
html[data-session-list='sidebar'] .session-sidebar .session-tabs.tabs-clusters > .tab-cluster {
|
||||
display: flex;
|
||||
flex: 0 0 auto;
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
html[data-tab-orientation='vertical'] .tab-rail .session-tabs.tabs-clusters > .tab-cluster + .tab-cluster,
|
||||
html[data-session-list='sidebar'] .session-sidebar .session-tabs.tabs-clusters > .tab-cluster + .tab-cluster {
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
html[data-tab-orientation='vertical'] .tab-rail .session-tabs.tabs-clusters .tab-cluster-label,
|
||||
html[data-session-list='sidebar'] .session-sidebar .session-tabs.tabs-clusters .tab-cluster-label {
|
||||
order: -1;
|
||||
padding: 6px 6px 3px;
|
||||
}
|
||||
|
||||
/* The collapsed sidebar is a 44px icon rail with no room for a label. */
|
||||
html[data-session-list='sidebar'][data-sidebar='collapsed'] .session-sidebar .tab-cluster-label {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
Ledger (tabArrangement: 'ledger'; Discussion #426 option B)
|
||||
============================================================
|
||||
The flat list, in tab order, on an aligned column grid: equal cells, mono
|
||||
type, a 3px status bar on the left instead of the dot. Desktop header strip
|
||||
only (updateTabOverflowMode() keeps it wrapping); narrower strips, the rail
|
||||
and the sidebar keep the plain list. Nothing here touches the markup. */
|
||||
@media (min-width: 768px) {
|
||||
.session-tabs-host > .session-tabs.tabs-ledger {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(176px, 1fr));
|
||||
align-content: start;
|
||||
/* The strip's own align-items: flex-start applies to grid items too; a
|
||||
cell must stretch, or the active cell (taller with its actions) breaks
|
||||
its row. */
|
||||
align-items: stretch;
|
||||
gap: 4px 6px;
|
||||
}
|
||||
|
||||
.session-tabs-host > .session-tabs.tabs-ledger > .session-tab {
|
||||
--ledger-bar: var(--green);
|
||||
min-width: 0;
|
||||
/* Tall enough for the active cell's actions, so every row is one height. */
|
||||
min-height: 30px;
|
||||
box-sizing: border-box;
|
||||
gap: 7px;
|
||||
padding: 4px 8px 4px 11px;
|
||||
border: 1px solid var(--control-border);
|
||||
border-radius: 4px;
|
||||
background: var(--control-bg);
|
||||
box-shadow: inset 3px 0 0 var(--ledger-bar);
|
||||
font-family: 'JetBrains Mono', var(--font-mono, monospace);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.session-tabs-host > .session-tabs.tabs-ledger > .session-tab .tab-info {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.session-tabs-host > .session-tabs.tabs-ledger > .session-tab .tab-number {
|
||||
width: auto;
|
||||
min-width: 1ch;
|
||||
height: auto;
|
||||
border: 0;
|
||||
background: none;
|
||||
color: var(--text-muted);
|
||||
font-family: inherit;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.session-tabs-host > .session-tabs.tabs-ledger > .session-tab .tab-status {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.session-tabs-host > .session-tabs.tabs-ledger > .session-tab.tab-alert-idle {
|
||||
--ledger-bar: var(--yellow);
|
||||
}
|
||||
|
||||
.session-tabs-host > .session-tabs.tabs-ledger > .session-tab:is(.tab-alert-action, :has(> .tab-status.error)) {
|
||||
--ledger-bar: var(--red);
|
||||
}
|
||||
|
||||
.session-tabs-host > .session-tabs.tabs-ledger > .session-tab:is(.tab-agent-exited, .session-tab--web, :has(> .tab-status.stopped)) {
|
||||
--ledger-bar: var(--text-muted);
|
||||
}
|
||||
|
||||
/* The active cell has to win against a grid of look-alike cells. The skin's
|
||||
active rule (1px accent border, no shadow, both !important) is too quiet
|
||||
here, so an inset second pixel doubles the border without resizing the
|
||||
cell, and the status bar turns accent. */
|
||||
.session-tabs-host > .session-tabs.tabs-ledger > .session-tab.active {
|
||||
background: rgba(var(--accent-rgb), 0.2) !important;
|
||||
box-shadow: inset 0 0 0 1px var(--accent), inset 4px 0 0 var(--accent) !important;
|
||||
color: var(--text) !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
Header stats styles (headerStatsStyle; Discussion #426 option G)
|
||||
============================================================
|
||||
html[data-header-stats] picks how the WS / CPU / MEM / plan-usage cluster
|
||||
is drawn. 'classic' matches nothing below except the hidden parts, so the
|
||||
header looks exactly as before. 'compact' and 'tiles' are desktop only
|
||||
(settings-ui.js resolves anything narrower, and a solo window, to
|
||||
'classic'), and applyHeaderStatsStyle() has already moved
|
||||
#connectionIndicator into #headerSystemStats and #planUsageChip right after
|
||||
it, so the cluster is contiguous. */
|
||||
|
||||
/* Parts only the two new styles draw. */
|
||||
.stat-ring,
|
||||
.connection-tile,
|
||||
.header-plan-usage .pu-ring,
|
||||
.header-plan-usage .pu-meter {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* --- Compact: two pills, a ring beside every value ------------------------ */
|
||||
/* One system pill (WS, CPU, MEM) and one plan pill (5h, 7d). Every reading is
|
||||
the same shape, ring then label then value, so the two pills read as one
|
||||
set; CPU and MEM rings are the accent colour (red past 80%, like their
|
||||
value), the plan rings green, yellow or red by how much is used. */
|
||||
html[data-header-stats='compact'] .header-system-stats,
|
||||
html[data-header-stats='compact'] .header-plan-usage {
|
||||
align-items: center;
|
||||
height: 24px;
|
||||
min-height: 24px;
|
||||
padding: 0 11px;
|
||||
box-sizing: border-box;
|
||||
border-radius: 12px;
|
||||
background: var(--control-bg);
|
||||
border: 1px solid var(--control-border);
|
||||
font-family: 'JetBrains Mono', var(--font-mono, monospace);
|
||||
font-size: 0.68rem;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
html[data-header-stats='compact'] .header-system-stats {
|
||||
gap: 13px;
|
||||
}
|
||||
|
||||
html[data-header-stats='compact'] .header-system-stats > .connection-indicator {
|
||||
font-size: inherit;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
html[data-header-stats='compact'] .header-system-stats > .connection-indicator > .connection-dot {
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
}
|
||||
|
||||
html[data-header-stats='compact'] .header-system-stats .stat-item {
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
html[data-header-stats='compact'] .header-system-stats .stat-bar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* One label style and one value style across both pills. */
|
||||
html[data-header-stats='compact'] .header-system-stats .stat-label,
|
||||
html[data-header-stats='compact'] .header-plan-usage .pu-label {
|
||||
min-width: 0;
|
||||
font-size: 0.6rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.05em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-muted);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
html[data-header-stats='compact'] .header-system-stats .stat-value,
|
||||
html[data-header-stats='compact'] .header-plan-usage .pu-val {
|
||||
min-width: 0;
|
||||
text-align: left;
|
||||
font-weight: 600;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
html[data-header-stats='compact'] .header-system-stats .stat-value:not(.high) {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
html[data-header-stats='compact'] .header-plan-usage {
|
||||
flex-direction: row;
|
||||
gap: 13px;
|
||||
}
|
||||
|
||||
html[data-header-stats='compact'] .header-plan-usage .pu-row {
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
html[data-header-stats='compact'] .header-plan-usage .pu-provider {
|
||||
width: auto;
|
||||
flex: none;
|
||||
}
|
||||
|
||||
html[data-header-stats='compact'] .header-plan-usage .pu-windows {
|
||||
align-items: center;
|
||||
gap: 13px;
|
||||
}
|
||||
|
||||
html[data-header-stats='compact'] .header-plan-usage .pu-sep {
|
||||
display: none;
|
||||
}
|
||||
|
||||
html[data-header-stats='compact'] .header-plan-usage .pu-win {
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
/* A ring: a conic fill masked down to a 2px stroke. Its colour is currentColor,
|
||||
so a plan ring takes the pu-green/-yellow/-red class it shares with its
|
||||
value, and a stat ring the accent (red when .high). */
|
||||
html[data-header-stats='compact'] .stat-ring,
|
||||
html[data-header-stats='compact'] .header-plan-usage .pu-ring {
|
||||
display: inline-block;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
flex: none;
|
||||
border-radius: 50%;
|
||||
background: conic-gradient(currentColor calc(var(--pu, 0) * 1%), var(--control-border-hover, var(--control-border)) 0);
|
||||
-webkit-mask: radial-gradient(farthest-side, transparent calc(100% - 2.5px), #000 calc(100% - 2px));
|
||||
mask: radial-gradient(farthest-side, transparent calc(100% - 2.5px), #000 calc(100% - 2px));
|
||||
}
|
||||
|
||||
html[data-header-stats='compact'] .stat-ring {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
html[data-header-stats='compact'] .stat-ring.high {
|
||||
color: var(--red);
|
||||
}
|
||||
|
||||
/* --- Tiles: label over value, a bar underneath, no icons ----------------- */
|
||||
/* Each tile is a three-row grid (label, value, bar) with pixel line-heights,
|
||||
so its height comes from the layout, not from whichever monospace face the
|
||||
machine falls back to. The bar is a real row, never laid over the bottom
|
||||
edge: an absolutely placed bar under a fixed-height tile is what put the
|
||||
value on top of it with a taller system font (SF Mono). The bundled
|
||||
JetBrains Mono comes first so every machine draws the same tile. */
|
||||
html[data-header-stats='tiles'] .header-system-stats {
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
html[data-header-stats='tiles'] .header-system-stats > .stat-item,
|
||||
html[data-header-stats='tiles'] .header-plan-usage .pu-win,
|
||||
html[data-header-stats='tiles'] .header-system-stats > .connection-indicator > .connection-tile {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
grid-template-rows: 9px 14px 2px;
|
||||
row-gap: 2px;
|
||||
align-content: center;
|
||||
justify-items: start;
|
||||
min-width: 54px;
|
||||
/* 2 + 9 + 2 + 14 + 2 + 2 + 3 plus the 1px borders: 36px, the header's own
|
||||
height, so the tiles never make the header grow. */
|
||||
padding: 2px 10px 3px;
|
||||
box-sizing: border-box;
|
||||
border-radius: 7px;
|
||||
background: var(--control-bg);
|
||||
border: 1px solid var(--control-border);
|
||||
font-family: 'JetBrains Mono', var(--font-mono, monospace);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* The tile label and value: the stat items already carry a label and a value,
|
||||
the connection indicator gets its own pair (its text line keeps the classic
|
||||
wording, queued bytes included, and stays the accessible name via title). */
|
||||
html[data-header-stats='tiles'] .header-system-stats .stat-label,
|
||||
html[data-header-stats='tiles'] .connection-tile-label,
|
||||
html[data-header-stats='tiles'] .header-plan-usage .pu-label {
|
||||
display: block;
|
||||
grid-row: 1;
|
||||
min-width: 0;
|
||||
font-size: 9px;
|
||||
line-height: 9px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-muted);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
html[data-header-stats='tiles'] .header-system-stats .stat-value,
|
||||
html[data-header-stats='tiles'] .connection-tile-value,
|
||||
html[data-header-stats='tiles'] .header-plan-usage .pu-val {
|
||||
display: block;
|
||||
grid-row: 2;
|
||||
min-width: 0;
|
||||
text-align: left;
|
||||
font-size: 12px;
|
||||
line-height: 14px;
|
||||
font-weight: 600;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
/* Plan values keep their green/yellow/red; the stat values read as plain text
|
||||
unless they are running high. */
|
||||
html[data-header-stats='tiles'] .header-system-stats .stat-value:not(.high),
|
||||
html[data-header-stats='tiles'] .connection-tile-value {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
/* The WS tile: JS sets the indicator's `display` inline (flex, or none when
|
||||
there is nothing to report), so the grid lives on the .connection-tile span
|
||||
inside it and the indicator itself only keeps the flex box it already has. */
|
||||
html[data-header-stats='tiles'] .header-system-stats > .connection-indicator {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
html[data-header-stats='tiles'] .header-system-stats > .connection-indicator > .connection-dot,
|
||||
html[data-header-stats='tiles'] .header-system-stats > .connection-indicator > .connection-text {
|
||||
display: none;
|
||||
}
|
||||
|
||||
html[data-header-stats='tiles'] .connection-tile-value.connected {
|
||||
color: var(--green);
|
||||
}
|
||||
|
||||
html[data-header-stats='tiles'] .connection-tile-value.fallback,
|
||||
html[data-header-stats='tiles'] .connection-tile-value.reconnecting,
|
||||
html[data-header-stats='tiles'] .connection-tile-value.draining {
|
||||
color: var(--yellow);
|
||||
}
|
||||
|
||||
html[data-header-stats='tiles'] .connection-tile-value.offline {
|
||||
color: var(--red);
|
||||
}
|
||||
|
||||
/* The bar is the third row, full tile width. The WS tile has no bar, and its
|
||||
empty third row keeps it the same height as its neighbours. */
|
||||
html[data-header-stats='tiles'] .header-system-stats .stat-bar,
|
||||
html[data-header-stats='tiles'] .header-plan-usage .pu-meter {
|
||||
display: block;
|
||||
grid-row: 3;
|
||||
justify-self: stretch;
|
||||
width: auto;
|
||||
height: 2px;
|
||||
border-radius: 1px;
|
||||
background: rgba(127, 127, 127, 0.18);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
html[data-header-stats='tiles'] .header-plan-usage .pu-meter > i {
|
||||
display: block;
|
||||
height: 100%;
|
||||
border-radius: 1px;
|
||||
background: currentColor;
|
||||
}
|
||||
|
||||
html[data-header-stats='tiles'] .header-plan-usage {
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
min-height: 0;
|
||||
padding: 0;
|
||||
background: none;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
html[data-header-stats='tiles'] .header-plan-usage .pu-row {
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
html[data-header-stats='tiles'] .header-plan-usage .pu-provider {
|
||||
width: auto;
|
||||
flex: none;
|
||||
font-size: 0.55rem;
|
||||
}
|
||||
|
||||
html[data-header-stats='tiles'] .header-plan-usage .pu-windows {
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
html[data-header-stats='tiles'] .header-plan-usage .pu-sep {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* An idle window keeps its tile and dims the whole thing. */
|
||||
html[data-header-stats='tiles'] .header-plan-usage .pu-win-idle {
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
/* --- Header buttons next to the clustered stats ------------------------ */
|
||||
/* Bare 16px glyphs beside 36px tiles read as a different design. In Tiles the
|
||||
header's icon buttons take the tile box (same height, border and fill, a
|
||||
larger icon); in Compact they become round chips the height of the pills.
|
||||
Only the box changes: the marker classes still hide them, and the gear
|
||||
keeps its quarter turn on hover, on the glyph rather than the whole box. */
|
||||
html[data-header-stats='tiles'] .header-right > .btn-icon-header,
|
||||
html[data-header-stats='compact'] .header-right > .btn-icon-header {
|
||||
box-sizing: border-box;
|
||||
background: var(--control-bg);
|
||||
border: 1px solid var(--control-border);
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
html[data-header-stats='tiles'] .header-right > .btn-icon-header {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 7px;
|
||||
}
|
||||
|
||||
html[data-header-stats='compact'] .header-right > .btn-icon-header {
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
border-radius: 13px;
|
||||
}
|
||||
|
||||
html[data-header-stats='tiles'] .header-right > .btn-icon-header > svg {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
|
||||
html[data-header-stats='compact'] .header-right > .btn-icon-header > svg {
|
||||
width: 15px;
|
||||
height: 15px;
|
||||
}
|
||||
|
||||
html[data-header-stats='tiles'] .header-right > .btn-icon-header:hover,
|
||||
html[data-header-stats='compact'] .header-right > .btn-icon-header:hover {
|
||||
background: var(--control-bg-hover);
|
||||
border-color: var(--control-border-hover);
|
||||
color: var(--text);
|
||||
transform: none;
|
||||
}
|
||||
|
||||
html[data-header-stats='tiles'] .header-right > .btn-icon-header > svg,
|
||||
html[data-header-stats='compact'] .header-right > .btn-icon-header > svg {
|
||||
transition: transform var(--transition-smooth);
|
||||
}
|
||||
|
||||
html[data-header-stats='tiles'] .header-right > .btn-settings:hover > svg,
|
||||
html[data-header-stats='compact'] .header-right > .btn-settings:hover > svg {
|
||||
transform: rotate(45deg);
|
||||
}
|
||||
|
||||
/* Before the first plan reading the chip holds a bare em dash; give it a tile
|
||||
of its own so it does not read as a stray mark between the tiles. */
|
||||
html[data-header-stats='tiles'] .header-plan-usage:not(:has(.pu-row)) {
|
||||
justify-content: center;
|
||||
min-width: 54px;
|
||||
height: 36px;
|
||||
padding: 0 10px;
|
||||
box-sizing: border-box;
|
||||
border-radius: 7px;
|
||||
background: var(--control-bg);
|
||||
border: 1px solid var(--control-border);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
@@ -1403,25 +1403,6 @@ export const SettingsUpdateSchema = z
|
||||
* rail drag-reorderable.
|
||||
*/
|
||||
tabRailSort: z.enum(['activity', 'manual']).optional(),
|
||||
/**
|
||||
* Tab layout, the arrangement of the tab list (Discussion #426). Display key
|
||||
* (per-device).
|
||||
* 'state' = a row per state in the header strip (needs you, waiting,
|
||||
* working, idle; option C), sections in the flat side rail and
|
||||
* the sidebar. The default.
|
||||
* 'case' = one cluster per case (option A): a labelled box in the strip,
|
||||
* a section in the side rail and the sidebar.
|
||||
* 'ledger' = the flat list on an aligned column grid with a status bar
|
||||
* per cell (option B). Header strip on desktop only.
|
||||
* 'classic' = one flat list in tab order, as before.
|
||||
*/
|
||||
tabArrangement: z.enum(['state', 'case', 'ledger', 'classic']).optional(),
|
||||
/**
|
||||
* Which end the state groups start from when `tabArrangement` is 'state'.
|
||||
* Display key (per-device). 'urgent-first' = needs you on top (the
|
||||
* default); 'urgent-last' = the other way up, needs you in the bottom row.
|
||||
*/
|
||||
tabStateOrder: z.enum(['urgent-first', 'urgent-last']).optional(),
|
||||
/**
|
||||
* Session list layout. Display key (per-device).
|
||||
* 'header' = horizontal tab strip
|
||||
@@ -1453,14 +1434,6 @@ export const SettingsUpdateSchema = z
|
||||
// UI visibility
|
||||
showFontControls: z.boolean().optional(),
|
||||
showSystemStats: z.boolean().optional(),
|
||||
/**
|
||||
* How the header draws its WS / CPU / MEM / plan-usage cluster. Display key
|
||||
* (per-device), desktop only (the cluster is hidden below 768px).
|
||||
* 'classic' = the bars and the 5H · 7D chip, as before
|
||||
* 'compact' = two pills (WS/CPU/MEM, the plan windows), a ring beside every value
|
||||
* 'tiles' = label over value with a bar underneath, no icons. The default.
|
||||
*/
|
||||
headerStatsStyle: z.enum(['classic', 'compact', 'tiles']).optional(),
|
||||
showTokenCount: z.boolean().optional(),
|
||||
showCost: z.boolean().optional(),
|
||||
showLifecycleLog: z.boolean().optional(),
|
||||
|
||||
@@ -166,8 +166,9 @@ describe('entrance animation styles', () => {
|
||||
expect(blur).not.toBeNull();
|
||||
expect(blur.match(/var\(--line-glow\)/g)?.length).toBe(2);
|
||||
// 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
|
||||
// line, 0.95 on a working one. Pinning a number here snaps three of them.
|
||||
// element's own resting value: 0.9 on a subagent line, 1 on a lineage
|
||||
// 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).not.toMatch(/100%\s*\{[^}]*opacity/);
|
||||
});
|
||||
|
||||
@@ -1,239 +0,0 @@
|
||||
/**
|
||||
* @fileoverview The header-stats styles (`headerStatsStyle`, Discussion #426
|
||||
* option G): 'classic' (as before), 'compact' (one system pill with rings
|
||||
* plus a plan-ring pill) and 'tiles' (label over value, bar underneath; the
|
||||
* default).
|
||||
*
|
||||
* Pinned here:
|
||||
* - The default is 'tiles', and only the three known values are honoured.
|
||||
* - The two clustered styles move the connection indicator INTO the system
|
||||
* stats pill and the plan chip right after it, on the REAL header markup
|
||||
* from index.html, and 'classic' puts both back exactly where the template
|
||||
* had them.
|
||||
* - The WS readout never disappears with a hidden System Stats pill.
|
||||
* - The parts only the new styles draw (stat rings, plan rings, meters, tile words)
|
||||
* are rendered with sane values and hidden by default in CSS, which is what
|
||||
* keeps 'classic' looking exactly as before.
|
||||
*
|
||||
* The real modules run INSIDE a JSDOM window (runScripts: 'outside-only').
|
||||
*
|
||||
* Port: none.
|
||||
*/
|
||||
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { JSDOM } from 'jsdom';
|
||||
import { beforeAll, beforeEach, describe, expect, it } from 'vitest';
|
||||
|
||||
const PUBLIC = join(process.cwd(), 'src/web/public');
|
||||
const read = (name: string) => readFileSync(join(PUBLIC, name), 'utf8');
|
||||
const INDEX = read('index.html');
|
||||
const HEADER = INDEX.slice(INDEX.indexOf('<header class="header">'), INDEX.indexOf('</header>') + '</header>'.length);
|
||||
|
||||
let CodemanApp: { prototype: Record<string, any> };
|
||||
let window: any;
|
||||
let document: Document;
|
||||
|
||||
beforeAll(async () => {
|
||||
const dom = new JSDOM('<!DOCTYPE html><html><body></body></html>', {
|
||||
url: 'https://localhost/',
|
||||
runScripts: 'outside-only',
|
||||
});
|
||||
if (dom.window.document.readyState !== 'complete') {
|
||||
await new Promise((resolve) => dom.window.addEventListener('load', resolve));
|
||||
}
|
||||
window = dom.window;
|
||||
document = window.document;
|
||||
window.setInterval = () => 0;
|
||||
window.requestAnimationFrame = () => 0;
|
||||
window.eval(
|
||||
'var MobileDetection = { isTouchDevice: () => false, isHandheldDevice: () => false, getDeviceType: () => "desktop" }, ' +
|
||||
'KeyboardHandler = {}, SwipeHandler = {}, VoiceInput = {}, DeepgramProvider = {}, NotificationManager = function(){};\n' +
|
||||
read('constants.js') +
|
||||
'\n' +
|
||||
read('app.js') +
|
||||
'\n' +
|
||||
read('settings-ui.js') +
|
||||
'\n' +
|
||||
read('panels-ui.js') +
|
||||
'\n;window.__HeaderStatsCodemanApp = CodemanApp;'
|
||||
);
|
||||
CodemanApp = window.__HeaderStatsCodemanApp;
|
||||
});
|
||||
|
||||
function makeApp() {
|
||||
const app = Object.create(CodemanApp.prototype) as Record<string, any>;
|
||||
app.$ = (id: string) => document.getElementById(id);
|
||||
return app;
|
||||
}
|
||||
|
||||
/** Element children of the header's right side, by id (or first class). */
|
||||
const rightSide = () =>
|
||||
[...document.getElementById('headerRight')!.children].map((el) => el.id || el.classList[0]);
|
||||
const statsChildren = () =>
|
||||
[...document.getElementById('headerSystemStats')!.children].map((el) => el.id || el.classList[0]);
|
||||
|
||||
beforeEach(() => {
|
||||
document.body.innerHTML = HEADER;
|
||||
delete document.documentElement.dataset.headerStats;
|
||||
});
|
||||
|
||||
describe('resolveHeaderStatsStyle', () => {
|
||||
it("defaults to 'tiles' and honours only the three known styles", () => {
|
||||
const app = makeApp();
|
||||
expect(app.resolveHeaderStatsStyle({})).toBe('tiles');
|
||||
expect(app.resolveHeaderStatsStyle(undefined)).toBe('tiles');
|
||||
expect(app.resolveHeaderStatsStyle({ headerStatsStyle: 'classic' })).toBe('classic');
|
||||
expect(app.resolveHeaderStatsStyle({ headerStatsStyle: 'compact' })).toBe('compact');
|
||||
expect(app.resolveHeaderStatsStyle({ headerStatsStyle: 'tiles' })).toBe('tiles');
|
||||
expect(app.resolveHeaderStatsStyle({ headerStatsStyle: 'neon' })).toBe('tiles');
|
||||
});
|
||||
});
|
||||
|
||||
describe('applyHeaderStatsStyle', () => {
|
||||
it('clusters WS, the system stats and the plan chip for tiles and compact', () => {
|
||||
for (const style of ['tiles', 'compact']) {
|
||||
document.body.innerHTML = HEADER;
|
||||
const app = makeApp();
|
||||
app.applyHeaderStatsStyle({ style, showSystemStats: true });
|
||||
expect(document.documentElement.dataset.headerStats).toBe(style);
|
||||
expect(statsChildren()[0]).toBe('connectionIndicator');
|
||||
const right = rightSide();
|
||||
expect(right[right.indexOf('headerSystemStats') + 1]).toBe('planUsageChip');
|
||||
expect(right).not.toContain('connectionIndicator');
|
||||
}
|
||||
});
|
||||
|
||||
it("'classic' puts both back exactly where the template had them", () => {
|
||||
const template = rightSide();
|
||||
const app = makeApp();
|
||||
app.applyHeaderStatsStyle({ style: 'tiles', showSystemStats: true });
|
||||
app.applyHeaderStatsStyle({ style: 'compact', showSystemStats: true });
|
||||
app.applyHeaderStatsStyle({ style: 'classic', showSystemStats: true });
|
||||
expect(rightSide()).toEqual(template);
|
||||
expect(statsChildren()).not.toContain('connectionIndicator');
|
||||
expect(document.documentElement.dataset.headerStats).toBe('classic');
|
||||
});
|
||||
|
||||
it('keeps WS out of a hidden System Stats pill', () => {
|
||||
const template = rightSide();
|
||||
const app = makeApp();
|
||||
app.applyHeaderStatsStyle({ style: 'tiles', showSystemStats: true });
|
||||
app.applyHeaderStatsStyle({ style: 'tiles', showSystemStats: false });
|
||||
expect(statsChildren()).not.toContain('connectionIndicator');
|
||||
expect(rightSide().indexOf('connectionIndicator')).toBe(template.indexOf('connectionIndicator'));
|
||||
});
|
||||
|
||||
it('is idempotent: re-applying the same style moves nothing', () => {
|
||||
const app = makeApp();
|
||||
app.applyHeaderStatsStyle({ style: 'tiles', showSystemStats: true });
|
||||
const once = document.getElementById('headerRight')!.innerHTML;
|
||||
app.applyHeaderStatsStyle({ style: 'tiles', showSystemStats: true });
|
||||
expect(document.getElementById('headerRight')!.innerHTML).toBe(once);
|
||||
});
|
||||
});
|
||||
|
||||
describe('the parts the new styles draw', () => {
|
||||
it('gives every plan window a ring and a meter with a clamped fill, the label the real number', () => {
|
||||
const app = makeApp();
|
||||
app.updatePlanUsageChip({ fiveHour: { usedPercentage: 28 }, sevenDay: { usedPercentage: 104 } });
|
||||
const wins = [...document.querySelectorAll<HTMLElement>('#planUsageChip .pu-win')];
|
||||
expect(wins).toHaveLength(2);
|
||||
expect(wins[0].querySelector<HTMLElement>('.pu-ring')!.style.getPropertyValue('--pu')).toBe('28');
|
||||
expect(wins[0].querySelector<HTMLElement>('.pu-meter > i')!.style.width).toBe('28%');
|
||||
expect(wins[0].querySelector('.pu-ring')!.classList.contains('pu-green')).toBe(true);
|
||||
expect(wins[1].querySelector('.pu-val')!.textContent).toBe('104%');
|
||||
expect(wins[1].querySelector<HTMLElement>('.pu-meter > i')!.style.width).toBe('100%');
|
||||
expect(wins[1].querySelector('.pu-meter')!.classList.contains('pu-red')).toBe(true);
|
||||
});
|
||||
|
||||
it('keeps an idle Claude window as a dimmed slot with an empty ring and meter', () => {
|
||||
const app = makeApp();
|
||||
app.updatePlanUsageChip({ sevenDay: { usedPercentage: 35 } });
|
||||
const idle = document.querySelector<HTMLElement>('#planUsageChip .pu-win-idle')!;
|
||||
expect(idle.querySelector('.pu-val')!.textContent).toBe('—');
|
||||
expect(idle.querySelector<HTMLElement>('.pu-ring')!.style.getPropertyValue('--pu')).toBe('0');
|
||||
expect(idle.querySelector<HTMLElement>('.pu-meter > i')!.style.width).toBe('0%');
|
||||
});
|
||||
|
||||
it('names the connection state in two short words per dot state', () => {
|
||||
const app = makeApp();
|
||||
const words = (dotClass: string, text = '') => app._connectionTileWords({ dotClass, text });
|
||||
expect(words('connection-dot connected', 'WS')).toMatchObject({ label: 'WS', value: 'live', state: 'connected' });
|
||||
expect(words('connection-dot fallback', 'HTTP')).toMatchObject({ label: 'HTTP', value: 'fallback' });
|
||||
expect(words('connection-dot offline', 'Offline')).toMatchObject({ label: 'NET', value: 'offline' });
|
||||
expect(words('connection-dot draining', 'Sending...')).toMatchObject({ label: 'SEND', value: 'queued' });
|
||||
expect(words('connection-dot reconnecting', 'WS…')).toMatchObject({ label: 'WS', value: 'retry' });
|
||||
expect(words('connection-dot reconnecting', 'Reconnecting...')).toMatchObject({ label: 'SSE', value: 'retry' });
|
||||
});
|
||||
|
||||
it('writes the tile words beside the classic text, which stays as it was', () => {
|
||||
const app = makeApp();
|
||||
app._computeConnectionDescriptor = () => ({
|
||||
display: 'flex',
|
||||
dotClass: 'connection-dot connected',
|
||||
text: 'WS · 2.0KB queued',
|
||||
title: 'Terminal connected over WebSocket',
|
||||
});
|
||||
app._updateConnectionIndicator();
|
||||
expect(document.getElementById('connectionText')!.textContent).toBe('WS · 2.0KB queued');
|
||||
expect(document.getElementById('connectionTileLabel')!.textContent).toBe('WS');
|
||||
expect(document.getElementById('connectionTileValue')!.textContent).toBe('live');
|
||||
expect(document.getElementById('connectionTileValue')!.className).toBe('connection-tile-value connected');
|
||||
});
|
||||
|
||||
it('fills the CPU and MEM rings from the stats poll, clamped, red past 80%', () => {
|
||||
const app = makeApp();
|
||||
const ring = (id: string) => {
|
||||
const el = document.getElementById(id)!;
|
||||
return [el.style.getPropertyValue('--pu'), el.classList.contains('high')];
|
||||
};
|
||||
app.updateSystemStatsDisplay({ cpu: 22, memory: { usedMB: 14.4 * 1024, percent: 45.6 } });
|
||||
expect(ring('statCpuRing')).toEqual(['22', false]);
|
||||
expect(ring('statMemRing')).toEqual(['46', false]);
|
||||
app.updateSystemStatsDisplay({ cpu: 140, memory: { usedMB: 30 * 1024, percent: 81 } });
|
||||
expect(ring('statCpuRing')).toEqual(['100', true]);
|
||||
expect(ring('statMemRing')).toEqual(['81', true]);
|
||||
// A ring comes first in its stat, before the label, like the plan rings.
|
||||
expect(document.getElementById('statCpuRing')!.nextElementSibling!.className).toBe('stat-label');
|
||||
});
|
||||
});
|
||||
|
||||
describe('header stats wiring (static)', () => {
|
||||
const css = read('styles.css');
|
||||
|
||||
it('hides the new-style parts by default, so classic looks exactly as before', () => {
|
||||
expect(css).toMatch(
|
||||
/\.stat-ring,\s*\.connection-tile,\s*\.header-plan-usage \.pu-ring,\s*\.header-plan-usage \.pu-meter \{\s*display: none;/
|
||||
);
|
||||
});
|
||||
|
||||
it('lays a tile out as three rows, so its height never depends on the font', () => {
|
||||
// A bar laid over the bottom of a fixed-height tile is what let a taller
|
||||
// system mono (SF Mono) push the value into it.
|
||||
expect(css).toMatch(/grid-template-rows: 9px 14px 2px;\s*row-gap: 2px;/);
|
||||
const tiles = css.slice(css.indexOf('/* --- Tiles: label over value'));
|
||||
const bar = tiles.slice(tiles.indexOf("html[data-header-stats='tiles'] .header-system-stats .stat-bar,"));
|
||||
expect(bar.slice(0, bar.indexOf('}'))).not.toContain('position: absolute');
|
||||
expect(bar.slice(0, bar.indexOf('}'))).toContain('grid-row: 3;');
|
||||
});
|
||||
|
||||
it('gives the header buttons the tile box beside the tiles', () => {
|
||||
expect(css).toMatch(
|
||||
/html\[data-header-stats='tiles'\] \.header-right > \.btn-icon-header \{\s*width: 36px;\s*height: 36px;/
|
||||
);
|
||||
expect(css).toMatch(/html\[data-header-stats='tiles'\] \.header-right > \.btn-icon-header > svg \{\s*width: 18px;/);
|
||||
});
|
||||
|
||||
it('stamps data-header-stats before first paint, tiles by default and classic on narrow screens', () => {
|
||||
expect(INDEX).toContain(
|
||||
"dataset.headerStats=(window.innerWidth<768||solo)?'classic':(H==='classic'||H==='compact')?H:'tiles'"
|
||||
);
|
||||
});
|
||||
|
||||
it('offers the three styles with tiles marked as the default', () => {
|
||||
expect(INDEX).toMatch(
|
||||
/<select id="appSettingsHeaderStatsStyle"[^>]*>\s*<option value="classic">[^<]+<\/option>\s*<option value="compact">[^<]+<\/option>\s*<option value="tiles">Tiles \(default\)<\/option>/
|
||||
);
|
||||
});
|
||||
});
|
||||
+605
-325
File diff suppressed because it is too large
Load Diff
@@ -1,305 +0,0 @@
|
||||
/**
|
||||
* @fileoverview Tab layouts by case and ledger (`tabArrangement: 'case'` and
|
||||
* `'ledger'`, Discussion #426 options A and B).
|
||||
*
|
||||
* What is pinned, and why it matters:
|
||||
* - By case, each case's tabs sit in ONE box, in the order the case first
|
||||
* appears in the tab order, with a colour that is a pure function of the
|
||||
* case (no storage, the same on every device).
|
||||
* - Inside a box with company, a generated `w75-api-gateway` reads `w75`, but
|
||||
* the full name stays in the DOM and in the accessible name; a custom or
|
||||
* described name is never touched.
|
||||
* - The Alt+N badges keep counting the tab order, and an incremental pass
|
||||
* patches tabs in place unless the cluster structure changed.
|
||||
* - Drag only reorders inside a box. Named groups in the vertical rail win.
|
||||
* - The ledger changes no markup at all: it is a class on #sessionTabs and
|
||||
* CSS scoped to the desktop header strip.
|
||||
*
|
||||
* The real modules run INSIDE a JSDOM window (runScripts: 'outside-only').
|
||||
*
|
||||
* Port: none.
|
||||
*/
|
||||
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import vm from 'node:vm';
|
||||
import { JSDOM } from 'jsdom';
|
||||
import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const PUBLIC = join(process.cwd(), 'src/web/public');
|
||||
const read = (name: string) => readFileSync(join(PUBLIC, name), 'utf8');
|
||||
|
||||
type Cluster = { key: string; label: string; color: string; ids: string[] };
|
||||
type Clusters = {
|
||||
COLORS: string[];
|
||||
colorFor: (key: string) => string;
|
||||
compute: (rows: Array<{ id: string; key: string; label: string }>) => Cluster[];
|
||||
nameSplit: (name: string, label: string) => { shown: string; hidden: string } | null;
|
||||
};
|
||||
|
||||
function loadClusters(): Clusters {
|
||||
const context = vm.createContext({ window: {}, globalThis: {} });
|
||||
vm.runInContext(read('constants.js'), context, { filename: 'constants.js' });
|
||||
return (context.window as { CodemanTabClusters: Clusters }).CodemanTabClusters;
|
||||
}
|
||||
|
||||
describe('CodemanTabClusters (pure)', () => {
|
||||
const C = loadClusters();
|
||||
|
||||
it('groups by case in first-appearance order, members in tab order', () => {
|
||||
const out = C.compute([
|
||||
{ id: 'a', key: '/c/web', label: 'web' },
|
||||
{ id: 'b', key: '/c/api', label: 'api' },
|
||||
{ id: 'c', key: '/c/web', label: 'web' },
|
||||
]);
|
||||
expect(out.map((c) => [c.key, c.ids])).toEqual([
|
||||
['/c/web', ['a', 'c']],
|
||||
['/c/api', ['b']],
|
||||
]);
|
||||
});
|
||||
|
||||
it('gives a session with no case a cluster of its own', () => {
|
||||
const out = C.compute([
|
||||
{ id: 'a', key: '', label: '' },
|
||||
{ id: 'b', key: '', label: '' },
|
||||
]);
|
||||
expect(out.map((c) => c.ids)).toEqual([['a'], ['b']]);
|
||||
});
|
||||
|
||||
it('colours a case from the session palette, the same way every time', () => {
|
||||
for (const key of ['/c/web', '/c/api', '/home/x/codeman-cases/long-name', '']) {
|
||||
expect(C.COLORS).toContain(C.colorFor(key));
|
||||
expect(C.colorFor(key)).toBe(C.colorFor(key));
|
||||
}
|
||||
});
|
||||
|
||||
it('only shortens a generated name that carries this case', () => {
|
||||
expect(C.nameSplit('w75-api-gateway', 'api-gateway')).toEqual({ shown: 'w75', hidden: '-api-gateway' });
|
||||
expect(C.nameSplit('s2-API-Gateway', 'api-gateway')).toEqual({ shown: 's2', hidden: '-API-Gateway' });
|
||||
expect(C.nameSplit('w75-api-gateway', 'webshop')).toBeNull();
|
||||
expect(C.nameSplit('w3-x: fix login', 'x')).toBeNull();
|
||||
expect(C.nameSplit('DocsBot', 'docsbot')).toBeNull();
|
||||
expect(C.nameSplit('w1-webshop', '')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('tab layouts by case and ledger (app.js)', () => {
|
||||
let CodemanApp: { prototype: Record<string, any> };
|
||||
let window: any;
|
||||
let document: Document;
|
||||
|
||||
beforeAll(async () => {
|
||||
const dom = new JSDOM('<!DOCTYPE html><html><body></body></html>', {
|
||||
url: 'https://localhost/',
|
||||
runScripts: 'outside-only',
|
||||
});
|
||||
if (dom.window.document.readyState !== 'complete') {
|
||||
await new Promise((resolve) => dom.window.addEventListener('load', resolve));
|
||||
}
|
||||
window = dom.window;
|
||||
document = window.document;
|
||||
window.setInterval = () => 0;
|
||||
window.requestAnimationFrame = () => 0;
|
||||
window.CSS = { escape: (value: string) => value };
|
||||
window.eval(
|
||||
'var MobileDetection = { isTouchDevice: () => false, getDeviceType: () => "desktop" }, KeyboardHandler = {}, ' +
|
||||
'SwipeHandler = {}, VoiceInput = {}, DeepgramProvider = {}, NotificationManager = function(){};\n' +
|
||||
read('constants.js') +
|
||||
'\n' +
|
||||
read('tab-layout-browser.js') +
|
||||
'\n' +
|
||||
read('app.js') +
|
||||
'\n' +
|
||||
read('mobile-overview.js') +
|
||||
'\n' +
|
||||
read('webview-tabs.js') +
|
||||
'\n;window.__ClusterCodemanApp = CodemanApp;'
|
||||
);
|
||||
CodemanApp = window.__ClusterCodemanApp;
|
||||
});
|
||||
|
||||
function makeApp(arrangement: 'state' | 'case' | 'ledger' | 'classic' = 'case') {
|
||||
const app = Object.create(CodemanApp.prototype) as Record<string, any>;
|
||||
const root = document.documentElement;
|
||||
root.setAttribute('data-tab-orientation', 'horizontal');
|
||||
root.dataset.tabRailSort = 'activity';
|
||||
root.dataset.tabArrangement = arrangement;
|
||||
document.body.innerHTML = '<div class="session-tabs-host"><div id="sessionTabs" class="session-tabs"></div></div>';
|
||||
app.$ = (id: string) => document.getElementById(id);
|
||||
app.cases = [
|
||||
{ name: 'webshop', path: '/c/webshop' },
|
||||
{ name: 'api-gateway', path: '/c/api-gateway' },
|
||||
];
|
||||
app.sessions = new Map([
|
||||
['s1', { id: 's1', name: 'w1-webshop', status: 'idle', workingDir: '/c/webshop' }],
|
||||
['s2', { id: 's2', name: 'w75-api-gateway', status: 'busy', workingDir: '/c/api-gateway' }],
|
||||
['s3', { id: 's3', name: 'w3-webshop', status: 'idle', workingDir: '/c/webshop/sub' }],
|
||||
['s4', { id: 's4', name: 'landing', status: 'idle', workingDir: '/srv/landing/' }],
|
||||
['s5', { id: 's5', name: 'w4-webshop: fix login', status: 'busy', workingDir: '/c/webshop' }],
|
||||
]);
|
||||
app.sessionOrder = ['s1', 's2', 's3', 's4', 's5'];
|
||||
app.pendingHooks = new Map();
|
||||
app.webviews = new Map([['w1', { id: 'w1', name: 'Dashboard', url: 'https://example.test' }]]);
|
||||
app.webviewOrder = ['w1'];
|
||||
app.activeSessionId = 's2';
|
||||
app.activeWebviewId = null;
|
||||
app.tabLayout = null;
|
||||
app.collapsedTabGroupIds = new Set();
|
||||
app._hiddenTabGroupByRef = new Map();
|
||||
app._lastTabGroupStructureKey = null;
|
||||
app._inlineRenameActive = false;
|
||||
app.tabAlerts = new Map();
|
||||
app.terminalLoadStates = new Map();
|
||||
app.minimizedSubagents = new Map();
|
||||
app.hasTabDetachOverride = () => false;
|
||||
app.renderSubagentTabBadge = () => '';
|
||||
app.cancelHideSubagentDropdown = () => {};
|
||||
app.updateTabOverflowMode = () => {};
|
||||
app.updateConnectionLines = vi.fn();
|
||||
app._applyTabEntrances = () => {};
|
||||
app._scrollActiveTabIntoView = () => {};
|
||||
app._refreshMobileOverviewIfVisible = () => {};
|
||||
app._refreshHomeSessionsIfVisible = () => {};
|
||||
app.applySidebarFilter = () => {};
|
||||
return app;
|
||||
}
|
||||
|
||||
const container = () => document.getElementById('sessionTabs')!;
|
||||
const tab = (id: string) => container().querySelector<HTMLElement>(`.session-tab[data-id="${id}"]`)!;
|
||||
const boxes = () =>
|
||||
[...container().querySelectorAll<HTMLElement>(':scope > .tab-cluster')].map((box) => ({
|
||||
name: box.querySelector('.tab-cluster-name')?.textContent ?? null,
|
||||
count: box.querySelector('.tab-cluster-count')?.textContent ?? null,
|
||||
single: box.classList.contains('tab-cluster--single'),
|
||||
rows: [...box.querySelectorAll<HTMLElement>('.session-tab')].map((t) => t.dataset.id || `web:${t.dataset.webviewId}`),
|
||||
}));
|
||||
|
||||
beforeEach(() => {
|
||||
document.body.innerHTML = '';
|
||||
});
|
||||
|
||||
it('puts each case in one box, in first-appearance order, a web tab in its own', () => {
|
||||
makeApp()._fullRenderSessionTabs();
|
||||
expect(container().classList.contains('tabs-clusters')).toBe(true);
|
||||
expect(boxes()).toEqual([
|
||||
// s3 sits in a subdirectory of the case and still joins it.
|
||||
{ name: 'webshop', count: '3', single: false, rows: ['s1', 's3', 's5'] },
|
||||
{ name: 'api-gateway', count: '1', single: true, rows: ['s2'] },
|
||||
// No case matches: the directory names the cluster, trailing slash and all.
|
||||
{ name: 'landing', count: '1', single: true, rows: ['s4'] },
|
||||
{ name: null, count: null, single: true, rows: ['web:w1'] },
|
||||
]);
|
||||
for (const box of container().querySelectorAll<HTMLElement>(':scope > .tab-cluster:not(.tab-cluster--web)')) {
|
||||
expect(box.getAttribute('style')).toMatch(/^--cluster-color: var\(--session-(blue|green|purple|orange|pink|yellow|red)\)$/);
|
||||
expect(box.getAttribute('role')).toBe('presentation');
|
||||
}
|
||||
});
|
||||
|
||||
it('drops the case from a generated name inside a box with company, and only there', () => {
|
||||
makeApp()._fullRenderSessionTabs();
|
||||
const nameOf = (id: string) => tab(id).querySelector('.tab-name')!;
|
||||
expect(nameOf('s1').innerHTML).toBe('w1<span class="tab-name-case">-webshop</span>');
|
||||
expect(nameOf('s1').textContent).toBe('w1-webshop');
|
||||
expect(tab('s1').getAttribute('aria-label')).toContain('w1-webshop');
|
||||
// A described name keeps its #232 treatment.
|
||||
expect(nameOf('s5').querySelector('.tab-name-prefix')?.textContent).toBe('w4-webshop: ');
|
||||
// Alone in its box, nothing is redundant, so nothing is hidden.
|
||||
expect(nameOf('s2').innerHTML).toBe('w75-api-gateway');
|
||||
});
|
||||
|
||||
it('keeps the Alt+N badges on the tab order', () => {
|
||||
makeApp()._fullRenderSessionTabs();
|
||||
expect(tab('s3').querySelector('.tab-number')?.textContent).toBe('3');
|
||||
expect(tab('s2').querySelector('.tab-number')?.textContent).toBe('2');
|
||||
expect(container().querySelector('.session-tab[data-webview-id="w1"] .tab-number')?.textContent).toBe('6');
|
||||
});
|
||||
|
||||
it('patches in place while the clusters stay the same, and rebuilds when they change', () => {
|
||||
const app = makeApp();
|
||||
app._fullRenderSessionTabs();
|
||||
const s1 = tab('s1');
|
||||
const full = vi.spyOn(app, '_fullRenderSessionTabs');
|
||||
app.sessions.get('s1').status = 'busy';
|
||||
app._renderSessionTabsImmediate();
|
||||
expect(full).not.toHaveBeenCalled();
|
||||
expect(tab('s1')).toBe(s1);
|
||||
// s4 moves into the webshop case: a different box, so a full rebuild.
|
||||
app.sessions.get('s4').workingDir = '/c/webshop';
|
||||
app._renderSessionTabsImmediate();
|
||||
expect(full).toHaveBeenCalledTimes(1);
|
||||
expect(boxes()[0].rows).toEqual(['s1', 's3', 's4', 's5']);
|
||||
});
|
||||
|
||||
it('only accepts a drop inside the same box', () => {
|
||||
const app = makeApp();
|
||||
app._fullRenderSessionTabs();
|
||||
app.draggedTabId = 's1';
|
||||
expect(app._isTabDropAcrossGroups(tab('s3'))).toBe(false);
|
||||
expect(app._isTabDropAcrossGroups(tab('s2'))).toBe(true);
|
||||
});
|
||||
|
||||
it('lets named groups in the vertical rail win', () => {
|
||||
const app = makeApp();
|
||||
document.documentElement.setAttribute('data-tab-orientation', 'vertical');
|
||||
app.tabLayout = {
|
||||
version: 1,
|
||||
groups: [{ id: 'g1', name: 'Core', refs: [{ kind: 'session', id: 's1' }] }],
|
||||
ungrouped: [],
|
||||
};
|
||||
app._fullRenderSessionTabs();
|
||||
expect(container().classList.contains('session-tabs--grouped')).toBe(true);
|
||||
expect(container().classList.contains('tabs-clusters')).toBe(false);
|
||||
expect(container().querySelector('.tab-cluster, .tab-name-case')).toBeNull();
|
||||
});
|
||||
|
||||
it('switching back to classic leaves no boxes and no name splits', () => {
|
||||
const app = makeApp();
|
||||
app._fullRenderSessionTabs();
|
||||
document.documentElement.dataset.tabArrangement = 'classic';
|
||||
app._renderSessionTabsImmediate();
|
||||
expect(container().querySelector('.tab-cluster, .tab-name-case')).toBeNull();
|
||||
expect(container().classList.contains('tabs-clusters')).toBe(false);
|
||||
});
|
||||
|
||||
it('draws the ledger with the classic markup and one class', () => {
|
||||
makeApp('classic')._fullRenderSessionTabs();
|
||||
const classic = container().innerHTML;
|
||||
makeApp('ledger')._fullRenderSessionTabs();
|
||||
expect(container().innerHTML).toBe(classic);
|
||||
expect(container().classList.contains('tabs-ledger')).toBe(true);
|
||||
expect(container().classList.contains('tabs-triage')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('tab layouts by case and ledger (static)', () => {
|
||||
const css = read('styles.css');
|
||||
const mobileCss = read('mobile.css');
|
||||
|
||||
it('hides the case part of a name only in the clustered strip', () => {
|
||||
expect(css).toMatch(/\.session-tabs\.tabs-clusters \.tab-name-case \{\s*display: none;/);
|
||||
});
|
||||
|
||||
it('keeps the ledger to the desktop header strip', () => {
|
||||
const media = css.indexOf('@media (min-width: 768px) {\n .session-tabs-host > .session-tabs.tabs-ledger {');
|
||||
expect(media).toBeGreaterThan(-1);
|
||||
// Every ledger rule lives inside that block.
|
||||
const before = css.slice(0, media);
|
||||
expect(before).not.toContain('.tabs-ledger');
|
||||
});
|
||||
|
||||
it('keeps every ledger row one height and makes the active cell stand out', () => {
|
||||
const ledger = css.slice(css.indexOf('@media (min-width: 768px) {\n .session-tabs-host > .session-tabs.tabs-ledger {'));
|
||||
expect(ledger).toContain('align-items: stretch;');
|
||||
expect(ledger).toContain('min-height: 30px;');
|
||||
expect(ledger).toMatch(
|
||||
/\.tabs-ledger > \.session-tab\.active \{[^}]*box-shadow: inset 0 0 0 1px var\(--accent\), inset 4px 0 0 var\(--accent\) !important;/
|
||||
);
|
||||
});
|
||||
|
||||
it('dissolves the boxes into the one chip row on phones', () => {
|
||||
expect(mobileCss).toMatch(
|
||||
/:where\(\.header\) \.session-tabs-host > \.session-tabs\.tabs-clusters > \.tab-cluster \{\s*display: contents;/
|
||||
);
|
||||
expect(mobileCss).toMatch(/:where\(\.header\) \.tab-cluster-label \{\s*display: none;/);
|
||||
});
|
||||
});
|
||||
+10
-11
@@ -205,11 +205,10 @@ describe('vertical tab rail row order', () => {
|
||||
it('keeps the Alt+N badge on the tab index while the cards are sorted', () => {
|
||||
// `_tabIdx` counts the loop over sessionOrder, and only `style="order:…"`
|
||||
// moves the card — so the badge names a shortcut, not a row position.
|
||||
// `listOrder` is the rail sort's map, or the state grouping's when that is
|
||||
// on (test/tab-triage.test.ts); either way it only sets the inline order.
|
||||
expect(appJs).toContain('const liveIds = tabOrder.filter((id) => this.sessions.has(id));');
|
||||
expect(appJs).toContain('const railSortOrder = this._tabRailSortOrder(liveIds);');
|
||||
expect(appJs).toContain('` style="order:${listOrder.get(id)}"`');
|
||||
expect(appJs).toContain(
|
||||
'const railSortOrder = this._tabRailSortOrder(tabOrder.filter((id) => this.sessions.has(id)));'
|
||||
);
|
||||
expect(appJs).toContain('` style="order:${railSortOrder.get(id)}"`');
|
||||
expect(appJs).toMatch(/_tabIdx < 9 \? '<span class="tab-number">' \+ \(_tabIdx \+ 1\)/);
|
||||
// The loop itself still walks the user's order, which is what makes the
|
||||
// badge, drag-and-drop and the arrow-key walk agree with each other.
|
||||
@@ -220,11 +219,12 @@ describe('vertical tab rail row order', () => {
|
||||
// A session going working→idle never adds or removes a tab, so the full
|
||||
// rebuild is not reached — and a rebuild here would restart every card's
|
||||
// animation on every SSE tick anyway.
|
||||
expect(appJs).toContain('const liveIds = this.sessionOrder.filter((sid) => this.sessions.has(sid));');
|
||||
expect(appJs).toContain('const railSortOrder = this._tabRailSortOrder(liveIds);');
|
||||
expect(appJs).toContain(
|
||||
'const railSortOrder = this._tabRailSortOrder(this.sessionOrder.filter((sid) => this.sessions.has(sid)));'
|
||||
);
|
||||
expect(appJs).toContain('if (tab.style.order !== railOrder) tab.style.order = railOrder;');
|
||||
// An empty string is what clears the property when the rail stops sorting.
|
||||
expect(appJs).toContain("const railOrder = listOrder?.has(id) ? String(listOrder.get(id)) : '';");
|
||||
expect(appJs).toContain("const railOrder = railSortOrder?.has(id) ? String(railSortOrder.get(id)) : '';");
|
||||
});
|
||||
|
||||
it('drops the drag affordance while sorting, so a card cannot snap back', () => {
|
||||
@@ -280,10 +280,9 @@ describe('vertical tab rail sort setting', () => {
|
||||
expect(html).toContain("document.documentElement.dataset.tabRailSort='activity';");
|
||||
expect(settingsUi).toContain('root.dataset.tabRailSort = sort;');
|
||||
// A sort flip leaves orientation on 'vertical' both times, so it has to
|
||||
// count as a change of its own or nothing re-renders. The same holds for a
|
||||
// tab layout or state order flip (test/tab-triage.test.ts).
|
||||
// count as a change of its own or nothing re-renders.
|
||||
expect(settingsUi).toContain(
|
||||
'const changed = orientationChanged || previousDetail !== detail || previousSort !== sort || previousArrangement !== arrangement || previousStateOrder !== stateOrder;'
|
||||
'const changed = orientationChanged || previousDetail !== detail || previousSort !== sort;'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,466 +0,0 @@
|
||||
/**
|
||||
* @fileoverview Tab grouping by state (`tabArrangement: 'state'`, Discussion #426
|
||||
* option C): the pure layout in constants.js and the render paths in app.js.
|
||||
*
|
||||
* What is pinned, and why it matters:
|
||||
* - The four groups and their order (needs you, waiting, working, idle) and
|
||||
* which home-screen state lands in which, including the two folds that are
|
||||
* easy to get wrong: a failed session joins "needs you", and an agent that
|
||||
* exited inside a live pane (#446) is idle even while its status says busy.
|
||||
* - It is applied as flex `order` only. The DOM stays in tab order, so Alt+N
|
||||
* badges, drag and the keyboard walk keep reading the list they always read.
|
||||
* - A state change is an INCREMENTAL pass: the tab element survives, only its
|
||||
* `order` and the heading counts move, and a group that empties loses its
|
||||
* heading.
|
||||
* - `tabArrangement: 'classic'` leaves no trace (no headings, no inline order, no
|
||||
* class), and named groups in the vertical rail win over it.
|
||||
*
|
||||
* The real modules run INSIDE a JSDOM window (runScripts: 'outside-only'), so
|
||||
* `document` below is that window's.
|
||||
*
|
||||
* Port: none.
|
||||
*/
|
||||
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import vm from 'node:vm';
|
||||
import { JSDOM } from 'jsdom';
|
||||
import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const PUBLIC = join(process.cwd(), 'src/web/public');
|
||||
const read = (name: string) => readFileSync(join(PUBLIC, name), 'utf8');
|
||||
|
||||
type Row = { id: string; state: string; exited?: boolean; pos?: number };
|
||||
type Layout = {
|
||||
order: Map<string, number>;
|
||||
webOrder: Map<string, number>;
|
||||
groups: Array<{ key: string; label: string; count: number; headOrder: number; breakOrder: number }>;
|
||||
};
|
||||
type Triage = {
|
||||
GROUPS: Array<{ key: string; label: string }>;
|
||||
STRIDE: number;
|
||||
groupFor: (state: string, exited: boolean) => string;
|
||||
layout: (rows: Row[], webviewIds?: string[]) => Layout;
|
||||
};
|
||||
|
||||
function loadTriage(): Triage {
|
||||
const context = vm.createContext({ window: {}, globalThis: {} });
|
||||
vm.runInContext(read('constants.js'), context, { filename: 'constants.js' });
|
||||
return (context.window as { CodemanTabTriage: Triage }).CodemanTabTriage;
|
||||
}
|
||||
|
||||
describe('CodemanTabTriage (pure)', () => {
|
||||
const triage = loadTriage();
|
||||
|
||||
it('has four groups, most urgent first', () => {
|
||||
expect(triage.GROUPS.map((g) => g.key)).toEqual(['needs', 'waiting', 'working', 'idle']);
|
||||
expect(triage.GROUPS.map((g) => g.label)).toEqual(['Needs you', 'Waiting', 'Working', 'Idle']);
|
||||
// Only idle is quiet: it keeps a heading element but draws no label.
|
||||
expect(triage.GROUPS.filter((g) => (g as { quiet?: boolean }).quiet).map((g) => g.key)).toEqual(['idle']);
|
||||
});
|
||||
|
||||
it('folds the six home-screen states into the four groups', () => {
|
||||
expect(triage.groupFor('needs', false)).toBe('needs');
|
||||
expect(triage.groupFor('error', false)).toBe('needs');
|
||||
expect(triage.groupFor('waiting', false)).toBe('waiting');
|
||||
expect(triage.groupFor('working', false)).toBe('working');
|
||||
expect(triage.groupFor('idle', false)).toBe('idle');
|
||||
expect(triage.groupFor('done', false)).toBe('idle');
|
||||
expect(triage.groupFor('something-new', false)).toBe('idle');
|
||||
});
|
||||
|
||||
it('puts an exited agent with idle, never with working', () => {
|
||||
expect(triage.groupFor('working', true)).toBe('idle');
|
||||
expect(triage.groupFor('idle', true)).toBe('idle');
|
||||
// A human being blocked still outranks the agent having exited.
|
||||
expect(triage.groupFor('needs', true)).toBe('needs');
|
||||
expect(triage.groupFor('waiting', true)).toBe('waiting');
|
||||
});
|
||||
|
||||
it('gives each group a band of order values and lists only non-empty groups', () => {
|
||||
const out = triage.layout([
|
||||
{ id: 'a', state: 'idle', pos: 0 },
|
||||
{ id: 'b', state: 'needs', pos: 1 },
|
||||
{ id: 'c', state: 'idle', pos: 2 },
|
||||
]);
|
||||
expect(out.groups.map((g) => [g.key, g.count])).toEqual([
|
||||
['needs', 1],
|
||||
['idle', 2],
|
||||
]);
|
||||
const needs = out.groups[0];
|
||||
const idle = out.groups[1];
|
||||
expect(out.order.get('b')).toBeGreaterThan(needs.headOrder);
|
||||
expect(out.order.get('b')).toBeLessThan(needs.breakOrder);
|
||||
// Rows keep their relative position inside a group.
|
||||
expect(out.order.get('a')).toBeLessThan(out.order.get('c')!);
|
||||
expect(out.order.get('a')).toBeGreaterThan(idle.headOrder);
|
||||
// A heading opens its band, the break closes it, and bands never overlap.
|
||||
expect(needs.breakOrder).toBeLessThan(idle.headOrder);
|
||||
expect(Math.floor(out.order.get('b')! / triage.STRIDE)).toBe(Math.floor(needs.headOrder / triage.STRIDE));
|
||||
});
|
||||
|
||||
it('ranks rows inside a group by `pos`, stably', () => {
|
||||
const out = triage.layout([
|
||||
{ id: 'x', state: 'working', pos: 5 },
|
||||
{ id: 'y', state: 'working', pos: 1 },
|
||||
{ id: 'z', state: 'working', pos: 1 },
|
||||
]);
|
||||
const sorted = [...out.order.entries()].sort((p, q) => p[1] - q[1]).map(([id]) => id);
|
||||
expect(sorted).toEqual(['y', 'z', 'x']);
|
||||
});
|
||||
|
||||
it('closes the idle group with the web tabs and counts them', () => {
|
||||
const out = triage.layout([{ id: 's', state: 'idle', pos: 0 }], ['w1', 'w2']);
|
||||
expect(out.groups).toHaveLength(1);
|
||||
expect(out.groups[0]).toMatchObject({ key: 'idle', count: 3 });
|
||||
expect(out.webOrder.get('w1')).toBeGreaterThan(out.order.get('s')!);
|
||||
expect(out.webOrder.get('w2')).toBeGreaterThan(out.webOrder.get('w1')!);
|
||||
expect(out.webOrder.get('w2')).toBeLessThan(out.groups[0].breakOrder);
|
||||
});
|
||||
|
||||
it('turns the groups the other way up with reverse, rows unchanged inside them', () => {
|
||||
const rows = [
|
||||
{ id: 'a', state: 'idle', pos: 0 },
|
||||
{ id: 'b', state: 'needs', pos: 1 },
|
||||
{ id: 'c', state: 'working', pos: 2 },
|
||||
{ id: 'd', state: 'working', pos: 3 },
|
||||
];
|
||||
const out = triage.layout(rows, ['w1'], { reverse: true });
|
||||
expect(out.groups.map((g) => g.key)).toEqual(['idle', 'working', 'needs']);
|
||||
const sorted = [...out.order.entries()].sort((p, q) => p[1] - q[1]).map(([id]) => id);
|
||||
expect(sorted).toEqual(['a', 'c', 'd', 'b']);
|
||||
// Web tabs still close the idle group, which now comes first.
|
||||
expect(out.webOrder.get('w1')).toBeLessThan(out.order.get('c')!);
|
||||
for (let i = 1; i < out.groups.length; i++) {
|
||||
expect(out.groups[i].headOrder).toBeGreaterThan(out.groups[i - 1].breakOrder);
|
||||
}
|
||||
});
|
||||
|
||||
it('shows the idle group for web tabs alone, and nothing for nothing', () => {
|
||||
expect(triage.layout([], ['w1']).groups.map((g) => g.key)).toEqual(['idle']);
|
||||
expect(triage.layout([], []).groups).toEqual([]);
|
||||
expect(triage.layout(undefined as unknown as Row[], undefined).groups).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('tab grouping in the render paths (app.js)', () => {
|
||||
let CodemanApp: { prototype: Record<string, any> };
|
||||
let window: any;
|
||||
let document: Document;
|
||||
|
||||
beforeAll(async () => {
|
||||
const dom = new JSDOM('<!DOCTYPE html><html><body></body></html>', {
|
||||
url: 'https://localhost/',
|
||||
runScripts: 'outside-only',
|
||||
});
|
||||
if (dom.window.document.readyState !== 'complete') {
|
||||
await new Promise((resolve) => dom.window.addEventListener('load', resolve));
|
||||
}
|
||||
window = dom.window;
|
||||
document = window.document;
|
||||
window.setInterval = () => 0;
|
||||
window.requestAnimationFrame = () => 0;
|
||||
window.CSS = { escape: (value: string) => value };
|
||||
window.eval(
|
||||
'var MobileDetection = { isTouchDevice: () => false, getDeviceType: () => "desktop" }, KeyboardHandler = {}, ' +
|
||||
'SwipeHandler = {}, VoiceInput = {}, DeepgramProvider = {}, NotificationManager = function(){};\n' +
|
||||
read('constants.js') +
|
||||
'\n' +
|
||||
read('tab-layout-browser.js') +
|
||||
'\n' +
|
||||
read('app.js') +
|
||||
'\n' +
|
||||
read('mobile-overview.js') +
|
||||
'\n' +
|
||||
read('webview-tabs.js') +
|
||||
'\n;window.__TriageCodemanApp = CodemanApp;'
|
||||
);
|
||||
CodemanApp = window.__TriageCodemanApp;
|
||||
});
|
||||
|
||||
function makeApp(arrangement: 'state' | 'case' | 'ledger' | 'classic' = 'state') {
|
||||
const app = Object.create(CodemanApp.prototype) as Record<string, any>;
|
||||
const root = document.documentElement;
|
||||
root.setAttribute('data-tab-orientation', 'horizontal');
|
||||
root.dataset.tabRailSort = 'activity';
|
||||
root.dataset.tabArrangement = arrangement;
|
||||
delete root.dataset.tabStateOrder;
|
||||
document.body.innerHTML = '<div class="session-tabs-host"><div id="sessionTabs" class="session-tabs"></div></div>';
|
||||
app.$ = (id: string) => document.getElementById(id);
|
||||
app.sessions = new Map([
|
||||
['s1', { id: 's1', name: 'w1-alpha', status: 'idle' }],
|
||||
['s2', { id: 's2', name: 'w2-beta', status: 'busy' }],
|
||||
['s3', { id: 's3', name: 'w3-gamma', status: 'idle' }],
|
||||
['s4', { id: 's4', name: 'w4-delta', status: 'busy' }],
|
||||
]);
|
||||
app.sessionOrder = ['s1', 's2', 's3', 's4'];
|
||||
app.pendingHooks = new Map([
|
||||
['s3', new Set(['permission_prompt'])],
|
||||
['s1', new Set(['idle_prompt'])],
|
||||
]);
|
||||
app.webviews = new Map([['w1', { id: 'w1', name: 'Dashboard', url: 'https://example.test' }]]);
|
||||
app.webviewOrder = ['w1'];
|
||||
app.activeSessionId = 's2';
|
||||
app.activeWebviewId = null;
|
||||
app.tabLayout = null;
|
||||
app.collapsedTabGroupIds = new Set();
|
||||
app._hiddenTabGroupByRef = new Map();
|
||||
app._lastTabGroupStructureKey = null;
|
||||
app._inlineRenameActive = false;
|
||||
app.tabAlerts = new Map();
|
||||
app.terminalLoadStates = new Map();
|
||||
app.minimizedSubagents = new Map();
|
||||
app.hasTabDetachOverride = () => false;
|
||||
app.renderSubagentTabBadge = () => '';
|
||||
app.cancelHideSubagentDropdown = () => {};
|
||||
app.updateTabOverflowMode = () => {};
|
||||
app.updateConnectionLines = vi.fn();
|
||||
app._applyTabEntrances = () => {};
|
||||
app._scrollActiveTabIntoView = () => {};
|
||||
app._refreshMobileOverviewIfVisible = () => {};
|
||||
app._refreshHomeSessionsIfVisible = () => {};
|
||||
app.applySidebarFilter = () => {};
|
||||
return app;
|
||||
}
|
||||
|
||||
const container = () => document.getElementById('sessionTabs')!;
|
||||
const tab = (id: string) => container().querySelector<HTMLElement>(`.session-tab[data-id="${id}"]`)!;
|
||||
const orderOf = (el: HTMLElement) => Number(el.style.order);
|
||||
/** What the eye sees: every ordered child, by `order`, as `head:<key>` / `<id>`. */
|
||||
const visual = () =>
|
||||
[...container().children]
|
||||
.filter((el) => !(el as HTMLElement).classList.contains('tab-triage-break'))
|
||||
.sort((a, b) => orderOf(a as HTMLElement) - orderOf(b as HTMLElement))
|
||||
.map((el) => {
|
||||
const h = el as HTMLElement;
|
||||
if (h.classList.contains('tab-triage-head')) return `head:${h.dataset.triageGroup}`;
|
||||
return h.dataset.webviewId ? `web:${h.dataset.webviewId}` : h.dataset.id!;
|
||||
});
|
||||
const heads = () =>
|
||||
[...container().querySelectorAll<HTMLElement>(':scope > .tab-triage-head')]
|
||||
.sort((a, b) => orderOf(a) - orderOf(b))
|
||||
.map((h) =>
|
||||
h.classList.contains('tab-triage-head--quiet')
|
||||
? `${h.dataset.triageGroup}:quiet`
|
||||
: `${h.querySelector('.tab-triage-label')!.textContent}:${h.querySelector('.tab-triage-count')!.textContent}`
|
||||
);
|
||||
|
||||
beforeEach(() => {
|
||||
document.body.innerHTML = '';
|
||||
});
|
||||
|
||||
it('draws a heading per non-empty state and places every tab in its group', () => {
|
||||
makeApp()._fullRenderSessionTabs();
|
||||
expect(container().classList.contains('tabs-triage')).toBe(true);
|
||||
expect(heads()).toEqual(['Needs you:1', 'Waiting:1', 'Working:2', 'idle:quiet']);
|
||||
// The idle row keeps its heading as an anchor, with nothing drawn in it.
|
||||
expect(container().querySelector('.tab-triage-head[data-triage-group="idle"]')!.textContent).toBe('');
|
||||
expect(visual()).toEqual([
|
||||
'head:needs',
|
||||
's3',
|
||||
'head:waiting',
|
||||
's1',
|
||||
'head:working',
|
||||
's2',
|
||||
's4',
|
||||
'head:idle',
|
||||
'web:w1',
|
||||
]);
|
||||
// One row break per heading; headings and breaks stay out of the tablist.
|
||||
expect(container().querySelectorAll(':scope > .tab-triage-break')).toHaveLength(4);
|
||||
for (const el of container().querySelectorAll(':scope > .tab-triage-head, :scope > .tab-triage-break')) {
|
||||
expect(el.getAttribute('aria-hidden')).toBe('true');
|
||||
}
|
||||
});
|
||||
|
||||
it("puts needs you in the bottom row with tabStateOrder 'urgent-last'", () => {
|
||||
const app = makeApp();
|
||||
document.documentElement.dataset.tabStateOrder = 'urgent-last';
|
||||
app._fullRenderSessionTabs();
|
||||
expect(heads()).toEqual(['idle:quiet', 'Working:2', 'Waiting:1', 'Needs you:1']);
|
||||
expect(visual()).toEqual([
|
||||
'head:idle',
|
||||
'web:w1',
|
||||
'head:working',
|
||||
's2',
|
||||
's4',
|
||||
'head:waiting',
|
||||
's1',
|
||||
'head:needs',
|
||||
's3',
|
||||
]);
|
||||
});
|
||||
|
||||
it('marks the first row heading as the lead, in either state order', () => {
|
||||
const app = makeApp();
|
||||
app._fullRenderSessionTabs();
|
||||
const lead = () =>
|
||||
[...container().querySelectorAll<HTMLElement>(':scope > .tab-triage-head--lead')].map((h) => h.dataset.triageGroup);
|
||||
expect(lead()).toEqual(['needs']);
|
||||
document.documentElement.dataset.tabStateOrder = 'urgent-last';
|
||||
app._renderSessionTabsImmediate();
|
||||
expect(lead()).toEqual(['idle']);
|
||||
});
|
||||
|
||||
it('keeps the DOM, and with it the Alt+N badges, in tab order', () => {
|
||||
makeApp()._fullRenderSessionTabs();
|
||||
const domOrder = [...container().querySelectorAll<HTMLElement>('.session-tab[data-id]')].map((t) => t.dataset.id);
|
||||
expect(domOrder).toEqual(['s1', 's2', 's3', 's4']);
|
||||
expect(tab('s3').querySelector('.tab-number')?.textContent).toBe('3');
|
||||
});
|
||||
|
||||
it('moves a tab between groups on an incremental pass, without rebuilding it', () => {
|
||||
const app = makeApp();
|
||||
app._fullRenderSessionTabs();
|
||||
const s2 = tab('s2');
|
||||
const fullRender = vi.spyOn(app, '_fullRenderSessionTabs');
|
||||
// s2 finishes its turn; the dialog on s3 is answered.
|
||||
app.sessions.get('s2').status = 'idle';
|
||||
app.pendingHooks.delete('s3');
|
||||
app._renderSessionTabsImmediate();
|
||||
expect(fullRender).not.toHaveBeenCalled();
|
||||
expect(tab('s2')).toBe(s2);
|
||||
expect(heads()).toEqual(['Waiting:1', 'Working:1', 'idle:quiet']);
|
||||
expect(container().querySelector('.tab-triage-head[data-triage-group="needs"]')).toBeNull();
|
||||
expect(container().querySelector('.tab-triage-break[data-triage-group="needs"]')).toBeNull();
|
||||
expect(visual()).toEqual(['head:waiting', 's1', 'head:working', 's4', 'head:idle', 's2', 's3', 'web:w1']);
|
||||
});
|
||||
|
||||
it('reconciles headings in place: an unchanged pass rewrites nothing', () => {
|
||||
const app = makeApp();
|
||||
app._fullRenderSessionTabs();
|
||||
app._renderSessionTabsImmediate();
|
||||
const head = container().querySelector('.tab-triage-head[data-triage-group="working"]');
|
||||
app._renderSessionTabsImmediate();
|
||||
expect(container().querySelector('.tab-triage-head[data-triage-group="working"]')).toBe(head);
|
||||
expect(container().querySelectorAll(':scope > .tab-triage-head')).toHaveLength(4);
|
||||
});
|
||||
|
||||
it('files an agent that exited in a live pane under idle, a failed session under needs you', () => {
|
||||
const app = makeApp();
|
||||
app.pendingHooks = new Map();
|
||||
app.sessions.get('s2').paneExit = { at: 1, status: 0 };
|
||||
app.sessions.get('s1').status = 'error';
|
||||
app._fullRenderSessionTabs();
|
||||
expect(visual()).toEqual(['head:needs', 's1', 'head:working', 's4', 'head:idle', 's2', 's3', 'web:w1']);
|
||||
});
|
||||
|
||||
it("leaves no trace with tabArrangement 'classic'", () => {
|
||||
makeApp('classic')._fullRenderSessionTabs();
|
||||
expect(container().classList.contains('tabs-triage')).toBe(false);
|
||||
expect(container().querySelector('.tab-triage-head, .tab-triage-break')).toBeNull();
|
||||
expect(container().innerHTML).not.toContain('order:');
|
||||
expect(container().querySelector<HTMLElement>('.session-tab[data-webview-id]')!.style.order).toBe('');
|
||||
});
|
||||
|
||||
it('switching off removes the headings and every inline order it wrote', () => {
|
||||
const app = makeApp();
|
||||
app._fullRenderSessionTabs();
|
||||
document.documentElement.dataset.tabArrangement = 'classic';
|
||||
app._renderSessionTabsImmediate();
|
||||
expect(container().querySelector('.tab-triage-head, .tab-triage-break')).toBeNull();
|
||||
for (const el of container().querySelectorAll<HTMLElement>('.session-tab')) expect(el.style.order).toBe('');
|
||||
expect(container().classList.contains('tabs-triage')).toBe(false);
|
||||
});
|
||||
|
||||
it('ranks rows inside each section by the activity sort on a sorted rail', () => {
|
||||
const app = makeApp();
|
||||
document.documentElement.setAttribute('data-tab-orientation', 'vertical');
|
||||
app.pendingHooks = new Map();
|
||||
// Both working; s4 has been running longer, so the activity sort puts it first.
|
||||
app.sessions.get('s2').lastSubmitAt = 2_000;
|
||||
app.sessions.get('s4').lastSubmitAt = 1_000;
|
||||
app._fullRenderSessionTabs();
|
||||
expect(visual()).toEqual(['head:working', 's4', 's2', 'head:idle', 's1', 's3', 'web:w1']);
|
||||
});
|
||||
|
||||
it('keeps tab order inside each row on the header strip', () => {
|
||||
const app = makeApp();
|
||||
app.pendingHooks = new Map();
|
||||
app.sessions.get('s2').lastSubmitAt = 2_000;
|
||||
app.sessions.get('s4').lastSubmitAt = 1_000;
|
||||
app._fullRenderSessionTabs();
|
||||
expect(visual()).toEqual(['head:working', 's2', 's4', 'head:idle', 's1', 's3', 'web:w1']);
|
||||
});
|
||||
|
||||
it('lets named groups in the vertical rail win', () => {
|
||||
const app = makeApp();
|
||||
document.documentElement.setAttribute('data-tab-orientation', 'vertical');
|
||||
app.tabLayout = {
|
||||
version: 1,
|
||||
groups: [{ id: 'g1', name: 'Core', refs: [{ kind: 'session', id: 's1' }] }],
|
||||
ungrouped: [],
|
||||
};
|
||||
app._fullRenderSessionTabs();
|
||||
expect(container().classList.contains('session-tabs--grouped')).toBe(true);
|
||||
expect(container().classList.contains('tabs-triage')).toBe(false);
|
||||
expect(container().querySelector('.tab-triage-head')).toBeNull();
|
||||
});
|
||||
|
||||
it('refuses a drop into another group and allows one inside the same group', () => {
|
||||
const app = makeApp();
|
||||
app._fullRenderSessionTabs();
|
||||
app.draggedTabId = 's2';
|
||||
expect(app._isTabDropAcrossGroups(tab('s4'))).toBe(false);
|
||||
expect(app._isTabDropAcrossGroups(tab('s1'))).toBe(true);
|
||||
expect(app._isTabDropAcrossGroups(tab('s3'))).toBe(true);
|
||||
document.documentElement.dataset.tabArrangement = 'classic';
|
||||
app._fullRenderSessionTabs();
|
||||
expect(app._isTabDropAcrossGroups(tab('s1'))).toBe(false);
|
||||
});
|
||||
|
||||
it('degrades to the flat strip when mobile-overview.js is stale or missing', () => {
|
||||
const app = makeApp();
|
||||
app._mobileOverviewState = undefined;
|
||||
app._fullRenderSessionTabs();
|
||||
expect(container().querySelector('.tab-triage-head')).toBeNull();
|
||||
expect(container().classList.contains('tabs-triage')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('tab grouping wiring (static)', () => {
|
||||
const html = read('index.html');
|
||||
const css = read('styles.css');
|
||||
const mobileCss = read('mobile.css');
|
||||
|
||||
it('stamps data-tab-arrangement and data-tab-state-order before first paint', () => {
|
||||
expect(html).toContain(
|
||||
"dataset.tabArrangement=(T==='case'||T==='ledger'||T==='classic')?T:'state'"
|
||||
);
|
||||
expect(html).toContain("dataset.tabStateOrder=(A.tabStateOrder==='urgent-last')?'urgent-last':'urgent-first'");
|
||||
// The catch branch (localStorage threw) must set both too.
|
||||
expect(html).toContain(
|
||||
"document.documentElement.dataset.tabArrangement='state';document.documentElement.dataset.tabStateOrder='urgent-first';"
|
||||
);
|
||||
});
|
||||
|
||||
it('offers the four layouts with "By state" as the default, and the state order', () => {
|
||||
expect(html).toMatch(
|
||||
/<select id="appSettingsTabArrangement"[^>]*>\s*<option value="state">By state \(default\)<\/option>\s*<option value="case">[^<]+<\/option>\s*<option value="ledger">[^<]+<\/option>\s*<option value="classic">Classic \(as before\)<\/option>/
|
||||
);
|
||||
expect(html).toMatch(
|
||||
/<select id="appSettingsTabStateOrder"[^>]*>\s*<option value="urgent-first">Needs you on top \(default\)<\/option>\s*<option value="urgent-last">/
|
||||
);
|
||||
});
|
||||
|
||||
it('only shows row breaks in a wrapping header strip', () => {
|
||||
expect(css).toMatch(/\.tab-triage-break \{\s*display: none;/);
|
||||
expect(css).toContain(
|
||||
'.session-tabs-host > .session-tabs.tabs-triage:is(.tabs-auto-wrap, .tabs-two-rows) > .tab-triage-break'
|
||||
);
|
||||
});
|
||||
|
||||
it('lets the rows after the first start under the brand, labels left-aligned', () => {
|
||||
expect(css).toMatch(
|
||||
/\.header:has\(> \.session-tabs-host > \.session-tabs\.tabs-triage:is\(\.tabs-auto-wrap, \.tabs-two-rows\)\) > \.header-brand \{\s*position: absolute;/
|
||||
);
|
||||
expect(css).toMatch(/> \.tab-triage-head \{\s*justify-content: flex-start;/);
|
||||
expect(css).toMatch(
|
||||
/> \.tab-triage-head--lead \{\s*width: auto;\s*margin-left: calc\(var\(--tab-triage-brand, 100px\) - var\(--tab-triage-gutter, 92px\)\);/
|
||||
);
|
||||
});
|
||||
|
||||
it('hides the headings on phones, whose strip stays one scrolling row', () => {
|
||||
expect(mobileCss).toMatch(/:where\(\.header\) \.tab-triage-head \{\s*display: none;/);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user