Compare commits

...
Author SHA1 Message Date
Codeman maintainer 310f20b288 feat(header): rings for CPU and MEM in the Compact style (#426)
Compact now draws every reading the same way, ring then label then
value: CPU and MEM get an accent ring (red past 80%, like their value)
in place of the sparklines, the plan windows keep their green, yellow
or red rings, the dot separators go, and both pills share one label and
one value style. The sparkline markup, history and CSS are removed.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-06 08:44:20 +02:00
Codeman maintainer 55cafc282f feat(tabs): no label on the idle row (#426)
Idle is the default state of a tab, so naming it only adds noise. The
idle group is now quiet: its heading element stays (it anchors the row's
order band and, as the first row, holds its place beside the brand) but
draws no label or count. Needs you, Waiting and Working are unchanged.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-06 06:29:40 +02:00
Codeman maintainer bb5fd5ee97 fix(tabs): state rows start under the brand, labels left-aligned (#426)
In the desktop header strip grouped by state, the brand leaves the flow
and sits over the strip's top-left corner, so every row after the first
starts at the left edge under "Codeman" instead of leaving that corner
empty. Labels are left-aligned in the measured column; the first row's
heading takes its natural width beside the brand (--tab-triage-brand,
kept by a ResizeObserver so no render pass reads layout for it).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-06 06:25:26 +02:00
Codeman maintainer bebf0db792 fix(header,tabs): tile-shaped header buttons beside the clustered stats, a bolder active ledger cell, one ledger row height (#426)
Header icon buttons next to the Tiles take the tile box (36px, border,
fill, 18px glyph) and turn into round chips beside the Compact pills;
the gear's quarter turn moves onto the glyph. The ledger's active cell
gets an inset second pixel and an accent bar (the skin's 1px !important
border was too quiet in a grid of look-alike cells), and every ledger
cell stretches to a 30px minimum so rows stay one height.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-06 04:08:02 +02:00
Codeman maintainer e3dfbf6591 feat(tabs): Tab Layout setting with by case (A) and ledger (B), reversible state order, cleaner tiles (#426)
Tab Grouping becomes Tab Layout (tabArrangement: 'state' | 'case' |
'ledger' | 'classic', default 'state'), the first row of App Settings,
Appearance, Tabs, so the old and the new strip are one choice apart.

- By case (option A): each case's tabs sit in one .tab-cluster box in
  first-appearance order, labelled with the case and its count, coloured
  by a stable hash into the session palette. Membership is
  _mobileOverviewCaseFor(), the home screens' own match. Inside a box with
  company a generated w75-api-gateway reads w75; the -<case> stays in the
  DOM in a .tab-name-case span only .tabs-clusters hides. The incremental
  render path rebuilds only when the cluster structure key changes. The
  rail and the sidebar get a labelled section per case; phones dissolve
  the boxes into the chip row. Drag stays inside one box.
- Ledger (option B): CSS only on .tabs-ledger, desktop header strip: an
  auto-fill column grid of equal cells in mono type with a 3px status
  bar. Its markup is identical to classic's.
- State Order (tabStateOrder: 'urgent-first' | 'urgent-last'): flips the
  by-state groups so needs you can be the bottom row.
- By state: the label column is measured to the widest label on screen
  and the labels are right-aligned in it, instead of a fixed 92px gutter
  that left short labels far from their tabs.
- Tiles: a three-row grid (label, value, bar) with pixel line-heights in
  the bundled JetBrains Mono, 36px like the header. The bar used to lie
  over a fixed 28px tile, and a taller system mono (SF Mono) pushed the
  value into it. The WS tile's grid moved onto an inner .connection-tile
  span because JS writes the indicator's display inline. Compact uses the
  same font and a matched WS size.

Tests: test/tab-clusters.test.ts (new), plus the rename and the reversed
order in test/tab-triage.test.ts.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-06 04:08:02 +02:00
Codeman maintainer 9b0d305223 feat(tabs,header): group tabs by state and add header stats styles (#426)
Two directions from Discussion #426, each a per-device setting and the
new default.

Tab Grouping (tabGrouping: 'state' | 'none', default 'state', option C):
tabs are grouped needs you (red, plus failed sessions), waiting (yellow),
working and idle (also ended, exited panes and web tabs), most urgent on
top. The desktop header strip draws a row per group with its label and
count in a left gutter; the flat vertical rail and the sidebar draw a
section per group; tablets keep their scrolling row with inline dividers;
phones keep the chip row in group order without headings. Classification
is the home screens' own (_mobileOverviewState/_mobileOverviewExit), the
fold into four groups is pure in CodemanTabTriage (constants.js). It is
flex `order` plus aria-hidden heading/break elements reconciled in place
after both render paths, never a DOM reorder, so Alt+N, the keyboard walk
and drag keep reading tab order; a drop is refused across groups. Named
groups in the vertical rail take precedence.

Header Stats Style (headerStatsStyle: 'classic' | 'compact' | 'tiles',
default 'tiles', option G): tiles give WS, CPU, MEM and each plan window
a label-over-value tile with a bar underneath; compact is one WS/CPU/MEM
pill with sparklines plus a plan-ring pill; classic is the header as
before. Desktop only (classic below 768px and in solo windows). The
clustered styles move #connectionIndicator into #headerSystemStats and
WS stays out of a hidden System Stats pill. The extra parts are always
rendered and hidden by default in CSS, so classic is unchanged.

Tests: test/tab-triage.test.ts, test/header-stats-style.test.ts; three
source pins in test/tab-rail-order.test.ts follow renamed lines.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-06 04:08:01 +02:00
17 changed files with 2616 additions and 32 deletions
+4
View File
@@ -333,6 +333,10 @@ 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) **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) **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) **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)
+24
View File
@@ -867,6 +867,30 @@ 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. ⚠️ 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
**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. **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.
+5 -1
View File
@@ -69,6 +69,8 @@ by folder** (per device, on by default) shows changed files under collapsed fold
window; off lists every file by its full path. 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. 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 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 setting — the button and the feature both stay off below a ~1180px viewport, where two
resizable panes plus their divider have nowhere to go. resizable panes plus their divider have nowhere to go.
@@ -86,7 +88,9 @@ every session or only the active tab.
| Interface Language | English or Simplified Chinese. Per device. | | 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). | | 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 Orientation | Keeps the header list but turns the strip vertical beside the terminal, resizable, with detailed rows by default. Desktop and tablet only. |
| Vertical Rail Order | *By activity* (default) sorts the rail the way the home screens are sorted; *Manual* keeps your tab order and drag-reordering. | | 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. |
| Tall Tabs | Taller tab strip. | | Tall Tabs | Taller tab strip. |
| Pop-out Button on Tabs | Adds the detach control to tabs, with a per-tab override. | | Pop-out Button on Tabs | Adds the detach control to tabs, with a per-tab override. |
| Spawn Lineage Lines | Arcs from a parent tab to sessions it spawned. Desktop only, on by default. | | Spawn Lineage Lines | Arcs from a parent tab to sessions it spawned. Desktop only, on by default. |
+49 -2
View File
@@ -27,7 +27,7 @@ Session List Layout** can move it into a vertical sidebar on the left instead, a
| Layout | Behaviour | | Layout | Behaviour |
| -------------------- | --------------------------------------------------------------------------------- | | -------------------- | --------------------------------------------------------------------------------- |
| **Header tab strip** | The default. Wraps to a second row on desktop, scrolls sideways on a phone. | | **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. |
| **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. | | **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. | | **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,6 +35,40 @@ 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 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. 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 ## Session tabs
One tab per session, in your order, and that order syncs across your devices. One tab per session, in your order, and that order syncs across your devices.
@@ -102,7 +136,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. | | Connection dot | Always on | SSE connection health. Green is connected. |
| Font size `-` / `+` | Always on | `Ctrl +` / `Ctrl -` do the same. | | Font size `-` / `+` | Always on | `Ctrl +` / `Ctrl -` do the same. |
| CPU / MEM bars | On | Server resource use. | | CPU / MEM | On | Server resource use. Drawn as tiles by default; see Header Stats Style below. |
| File Viewer | On | Toggles the file browser panel. | | File Viewer | On | Toggles the file browser panel. |
| Settings gear | Always on | App Settings. | | 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. | | Plan usage chip | On, desktop only | Live Claude subscription usage. Claude-only, and needs its telemetry exporter, which the same setting installs. |
@@ -121,6 +155,19 @@ 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. | | Tunnel indicator | When a tunnel runs | Cloudflare tunnel status. |
| Admin panel | Multi-user only | User administration. | | 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 New header controls never appear on phones. Phone layout is deliberately minimal and is
covered in [Mobile Guide](Mobile-Guide). covered in [Mobile Guide](Mobile-Guide).
+441 -14
View File
@@ -3229,14 +3229,20 @@ class CodemanApp {
// broken feature rather than as an idle window (reported 2026-09-01). A // 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 — // missing CODEX bucket means the opposite — that plan has no such limit —
// so those stay omitted rather than showing a dash forever. // 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) => { const seg = (label, p, idle) => {
if (p === null) { if (p === null) {
if (!idle) return ''; if (!idle) return '';
return `<span class="pu-win pu-win-idle"><span class="pu-label">${label}</span><span class="pu-val">—</span></span>`; 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>`;
} }
const n = Math.round(Number(p)); const n = Math.round(Number(p));
if (!Number.isFinite(n)) return ''; if (!Number.isFinite(n)) return '';
return `<span class="pu-win"><span class="pu-label">${label}</span><span class="pu-val ${colorClass(n)}">${n}%</span></span>`; 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>`;
}; };
// The provider label only earns its space when there is more than one // 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. // provider to tell apart: a machine with Claude alone shows bare windows.
@@ -4021,6 +4027,36 @@ 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() { _updateConnectionIndicator() {
const indicator = this.$('connectionIndicator'); const indicator = this.$('connectionIndicator');
const dot = this.$('connectionDot'); const dot = this.$('connectionDot');
@@ -4048,6 +4084,14 @@ class CodemanApp {
dot.className = next.dotClass; dot.className = next.dotClass;
text.textContent = next.text; text.textContent = next.text;
indicator.title = next.title; 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();
}
} }
} }
@@ -4752,6 +4796,333 @@ class CodemanApp {
return out; 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 * True where the sidebar is a MODAL off-canvas drawer over the terminal
* instead of a docked column. * instead of a docked column.
@@ -5004,6 +5375,9 @@ class CodemanApp {
const reachable = const reachable =
this.isSessionSidebarActive() && document.documentElement.dataset.sidebar !== 'collapsed'; this.isSessionSidebarActive() && document.documentElement.dataset.sidebar !== 'collapsed';
const needle = reachable ? this._sidebarFilter : ''; 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')) { for (const tab of container.querySelectorAll('.session-tab')) {
if (!needle) { if (!needle) {
tab.classList.remove('tab-filtered-out'); tab.classList.remove('tab-filtered-out');
@@ -5412,10 +5786,16 @@ class CodemanApp {
// The grouped rail's structure (sections, collapse, the shown exception) can // 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 // 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. // 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 && const canIncremental = existingIds.size === currentIds.size &&
[...existingIds].every(id => currentIds.has(id)) && [...existingIds].every(id => currentIds.has(id)) &&
webTabsUnchanged && webTabsUnchanged &&
!this._isTabGroupStructureStale(groupProjection); !this._isTabGroupStructureStale(groupProjection) &&
(clusterLayout ? clusterLayout.key : null) === (this._lastTabClusterKey ?? null);
if (canIncremental) { if (canIncremental) {
// Read once for the whole pass, like the full-rebuild path: this touches // Read once for the whole pass, like the full-rebuild path: this touches
@@ -5425,7 +5805,13 @@ class CodemanApp {
// that sees one — a session going working→idle never adds or removes a // 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 // 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. // the same reason the rich meta line is: the order IS the state.
const railSortOrder = this._tabRailSortOrder(this.sessionOrder.filter((sid) => this.sessions.has(sid))); 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;
// Incremental update - only modify changed properties // Incremental update - only modify changed properties
for (const [id, session] of this.sessions) { for (const [id, session] of this.sessions) {
const tab = container.querySelector(`.session-tab[data-id="${id}"]`); const tab = container.querySelector(`.session-tab[data-id="${id}"]`);
@@ -5433,7 +5819,7 @@ class CodemanApp {
// An empty string clears the property, which is also what un-sorts the // 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. // rail when the setting (or the layout) flips without a full rebuild.
const railOrder = railSortOrder?.has(id) ? String(railSortOrder.get(id)) : ''; const railOrder = listOrder?.has(id) ? String(listOrder.get(id)) : '';
if (tab.style.order !== railOrder) tab.style.order = railOrder; if (tab.style.order !== railOrder) tab.style.order = railOrder;
// A web tab owns the active state while one is open. activeSessionId stays // A web tab owns the active state while one is open. activeSessionId stays
@@ -5532,11 +5918,17 @@ class CodemanApp {
const _p = parseSessionPrefix(name); const _p = parseSessionPrefix(name);
if (nameEl.dataset.fullName !== name) { if (nameEl.dataset.fullName !== name) {
nameEl.replaceChildren(); nameEl.replaceChildren();
const _split = _p && _p.suffix ? null : window.CodemanTabClusters?.nameSplit(name, clusterLayout?.labelFor.get(id));
if (_p && _p.suffix) { if (_p && _p.suffix) {
const prefix = document.createElement('span'); const prefix = document.createElement('span');
prefix.className = 'tab-name-prefix'; prefix.className = 'tab-name-prefix';
prefix.textContent = `${_p.prefix}: `; prefix.textContent = `${_p.prefix}: `;
nameEl.append(prefix, document.createTextNode(_p.suffix)); 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 { } else {
nameEl.textContent = name; nameEl.textContent = name;
} }
@@ -5611,6 +6003,8 @@ class CodemanApp {
this._applyTabTreePositions(container); this._applyTabTreePositions(container);
this._syncTabGroupHeaderAlerts(container, groupProjection); this._syncTabGroupHeaderAlerts(container, groupProjection);
} }
this._syncTabTriageChrome(container, triage);
this._syncTabArrangementClasses(container, { clusters: !!clusterLayout, groupProjection });
} else { } else {
// Full rebuild needed (sessions added/removed) // Full rebuild needed (sessions added/removed)
this._fullRenderSessionTabs(); this._fullRenderSessionTabs();
@@ -5679,6 +6073,16 @@ class CodemanApp {
return; 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. // Measure the natural one-row overflow, then enable wrapping only if needed.
container.classList.remove('tabs-auto-wrap'); container.classList.remove('tabs-auto-wrap');
const shouldWrap = window.CodemanTabOverflow?.shouldAutoWrapTabs const shouldWrap = window.CodemanTabOverflow?.shouldAutoWrapTabs
@@ -5771,7 +6175,17 @@ class CodemanApp {
// below still counts the strip, not the sorted list. Null in every other // 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 // layout, and the tabs then carry no inline order at all — the header
// strip's markup is byte-identical to before. // strip's markup is byte-identical to before.
const railSortOrder = this._tabRailSortOrder(tabOrder.filter((id) => this.sessions.has(id))); 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);
// One row per session, in tab order. The flat strip emits them as-is; the // 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 // 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). // differs between the two (badge = Alt+N slot in sessionOrder either way).
@@ -5780,7 +6194,7 @@ class CodemanApp {
for (const id of tabOrder) { for (const id of tabOrder) {
const session = this.sessions.get(id); const session = this.sessions.get(id);
if (!session) continue; // Skip if session was removed if (!session) continue; // Skip if session was removed
const railOrderStyle = railSortOrder?.has(id) ? ` style="order:${railSortOrder.get(id)}"` : ''; const railOrderStyle = listOrder?.has(id) ? ` style="order:${listOrder.get(id)}"` : '';
// See the note in the incremental path: a web tab owns the active highlight // See the note in the incremental path: a web tab owns the active highlight
// while one is open, even though activeSessionId stays set. // while one is open, even though activeSessionId stays set.
@@ -5813,9 +6227,7 @@ class CodemanApp {
// JUST the description on the tab; the generated w<n>-<case> id moves to the // JUST the description on the tab; the generated w<n>-<case> id moves to the
// tooltip and stays visible in the session settings modal. // tooltip and stays visible in the session settings modal.
const parsedName = parseSessionPrefix(name); const parsedName = parseSessionPrefix(name);
const tabLabel = parsedName && parsedName.suffix const tabLabel = this._tabNameHtml(name, clusterLayout?.labelFor.get(id));
? `<span class="tab-name-prefix">${escapeHtml(parsedName.prefix)}: </span>${escapeHtml(parsedName.suffix)}`
: escapeHtml(name);
const tabTooltip = parsedName && parsedName.suffix const tabTooltip = parsedName && parsedName.suffix
? (session.workingDir ? `${parsedName.prefix} (${session.workingDir})` : parsedName.prefix) ? (session.workingDir ? `${parsedName.prefix} (${session.workingDir})` : parsedName.prefix)
: (session.workingDir || ''); : (session.workingDir || '');
@@ -5862,8 +6274,12 @@ class CodemanApp {
_tabIdx++; _tabIdx++;
} }
const groupProjection = this._projectTabGroups(); if (clusterLayout) {
if (groupProjection) { // 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) {
// Grouped vertical rail. Web tabs keep their flat-strip Alt+N slot (after // Grouped vertical rail. Web tabs keep their flat-strip Alt+N slot (after
// every session), wherever their group puts them. // every session), wherever their group puts them.
const webviewSlots = new Map( const webviewSlots = new Map(
@@ -5889,6 +6305,7 @@ class CodemanApp {
this._hiddenTabGroupByRef = new Map(); this._hiddenTabGroupByRef = new Map();
} }
this._lastTabGroupStructureKey = this._tabGroupStructureKey(groupProjection); this._lastTabGroupStructureKey = this._tabGroupStructureKey(groupProjection);
this._lastTabClusterKey = clusterLayout ? clusterLayout.key : null;
container.innerHTML = parts.join(''); container.innerHTML = parts.join('');
container.classList.toggle('session-tabs--grouped', !!groupProjection); container.classList.toggle('session-tabs--grouped', !!groupProjection);
@@ -5897,6 +6314,8 @@ class CodemanApp {
this._applyTabTreeSemantics(container, { identity: focusIdentity, refocus: focusWasInside }); this._applyTabTreeSemantics(container, { identity: focusIdentity, refocus: focusWasInside });
this._syncTabGroupHeaderAlerts(container, groupProjection); 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 // 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 // only when it CHANGED (or on the first paint). Restoring unconditionally
@@ -5971,9 +6390,13 @@ class CodemanApp {
// the inline one, or web tabs (pinned past the cards by a CSS `order: 9999` // 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 // 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. // sort is stable, so equal orders keep DOM order, which is the unsorted case.
if (this.isTabRailSorted()) { if (this.isTabRailSorted() || container.classList.contains('tabs-triage')) {
const orderOf = (el) => Number(getComputedStyle(el).order) || 0; const orderOf = (el) => Number(getComputedStyle(el).order) || 0;
tabs.sort((a, b) => orderOf(a) - orderOf(b)); // 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));
} }
const currentIndex = tabs.indexOf(document.activeElement); const currentIndex = tabs.indexOf(document.activeElement);
@@ -7172,6 +7595,9 @@ class CodemanApp {
}); });
tab.addEventListener('dragover', (e) => { 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(); e.preventDefault();
if (!this.draggedTabId || this.draggedTabId === tab.dataset.id) return; if (!this.draggedTabId || this.draggedTabId === tab.dataset.id) return;
@@ -7201,6 +7627,7 @@ class CodemanApp {
tab.classList.remove('drag-over-left', 'drag-over-right'); tab.classList.remove('drag-over-left', 'drag-over-right');
if (!this.draggedTabId || this.draggedTabId === tab.dataset.id) return; if (!this.draggedTabId || this.draggedTabId === tab.dataset.id) return;
if (this._isTabDropAcrossGroups(tab)) return;
const targetId = tab.dataset.id; const targetId = tab.dataset.id;
const draggedId = this.draggedTabId; const draggedId = this.draggedTabId;
+200
View File
@@ -658,6 +658,194 @@ function sortSessionsByActivity(rows) {
return (Array.isArray(rows) ? rows.slice() : []).sort(compareSessionActivity); 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 font stack — the single source for every xterm surface (the main
// terminal in terminal-ui.js, the log-viewer terminal in panels-ui.js). // terminal in terminal-ui.js, the log-viewer terminal in panels-ui.js).
// "Symbols Nerd Font Mono" is a bundled icons-only webfont (fonts/ + // "Symbols Nerd Font Mono" is a bundled icons-only webfont (fonts/ +
@@ -983,6 +1171,18 @@ if (typeof window !== 'undefined') {
compare: compareSessionActivity, compare: compareSessionActivity,
sort: sortSessionsByActivity, 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 = { window.CodemanInputLimit = {
FRAME_MAX_CHARS: INPUT_FRAME_MAX_CHARS, FRAME_MAX_CHARS: INPUT_FRAME_MAX_CHARS,
PASTE_MAX_CHARS: INPUT_PASTE_MAX_CHARS, PASTE_MAX_CHARS: INPUT_PASTE_MAX_CHARS,
+1
View File
@@ -221,6 +221,7 @@
Running: '运行中', Running: '运行中',
Idle: '空闲', Idle: '空闲',
Working: '工作中', Working: '工作中',
Waiting: '等待中',
Today: '今天', Today: '今天',
Home: '主页', Home: '主页',
Local: '本地', Local: '本地',
+38 -2
View File
@@ -65,7 +65,7 @@
app.js, NOT the handheld storage-key test `m`. Use a different predicate 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 here and boot will contradict this value, animating the drawer open by
itself on every load between 768 and 1023px. --> 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 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> <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>
<!-- Inline critical CSS for instant skeleton paint (before styles.css loads) --> <!-- Inline critical CSS for instant skeleton paint (before styles.css loads) -->
<style> <style>
.loading-skeleton{display:flex;flex-direction:column;height:100vh;height:100dvh;background:var(--bg-dark,#11151c)} .loading-skeleton{display:flex;flex-direction:column;height:100vh;height:100dvh;background:var(--bg-dark,#11151c)}
@@ -154,6 +154,7 @@
<div class="connection-indicator" id="connectionIndicator" style="display: none;"> <div class="connection-indicator" id="connectionIndicator" style="display: none;">
<span class="connection-dot" id="connectionDot"></span> <span class="connection-dot" id="connectionDot"></span>
<span class="connection-text" id="connectionText"></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>
<div class="header-font-controls"> <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> <button class="btn-icon-header btn-sm" onclick="app.decreaseFontSize()" title="Decrease font (Ctrl+-)" aria-label="Decrease font size">A-</button>
@@ -162,6 +163,7 @@
</div> </div>
<div class="header-system-stats" id="headerSystemStats" title="System resource usage"> <div class="header-system-stats" id="headerSystemStats" title="System resource usage">
<div class="stat-item"> <div class="stat-item">
<span class="stat-ring" id="statCpuRing" aria-hidden="true"></span>
<span class="stat-label">CPU</span> <span class="stat-label">CPU</span>
<div class="stat-bar"> <div class="stat-bar">
<div class="stat-bar-fill stat-bar-cpu" id="statCpuBar"></div> <div class="stat-bar-fill stat-bar-cpu" id="statCpuBar"></div>
@@ -169,6 +171,7 @@
<span class="stat-value" id="statCpu">--%</span> <span class="stat-value" id="statCpu">--%</span>
</div> </div>
<div class="stat-item"> <div class="stat-item">
<span class="stat-ring" id="statMemRing" aria-hidden="true"></span>
<span class="stat-label">MEM</span> <span class="stat-label">MEM</span>
<div class="stat-bar"> <div class="stat-bar">
<div class="stat-bar-fill stat-bar-mem" id="statMemBar"></div> <div class="stat-bar-fill stat-bar-mem" id="statMemBar"></div>
@@ -1918,6 +1921,17 @@
<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="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> <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>
<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>
</div> </div>
@@ -2070,6 +2084,28 @@
<div class="set-group"> <div class="set-group">
<div class="set-group-head"><h4>Tabs</h4><span class="set-scope">device</span></div> <div class="set-group-head"><h4>Tabs</h4><span class="set-scope">device</span></div>
<div class="set-group-body"> <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 has-field" data-search="tab orientation horizontal vertical side rail">
<div class="set-row-text"> <div class="set-row-text">
<span class="set-row-label">Tab Orientation</span> <span class="set-row-label">Tab Orientation</span>
@@ -2093,7 +2129,7 @@
<div class="set-row has-field" data-search="tab rail sort order activity manual drag reorder"> <div class="set-row has-field" data-search="tab rail sort order activity manual drag reorder">
<div class="set-row-text"> <div class="set-row-text">
<span class="set-row-label">Vertical Rail Order</span> <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. 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. 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>
</div> </div>
<select id="appSettingsTabRailSort" class="set-select"> <select id="appSettingsTabRailSort" class="set-select">
<option value="activity">By activity (home screen order)</option> <option value="activity">By activity (home screen order)</option>
+19
View File
@@ -755,6 +755,25 @@ html.mobile-init .file-browser-panel {
font-weight: 500; 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 /* 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 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. */ made the chip visibly wider on the right than on the left. */
+19
View File
@@ -6028,6 +6028,11 @@ 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) { if (memEl && memBar) {
const memGB = (stats.memory.usedMB / 1024).toFixed(1); const memGB = (stats.memory.usedMB / 1024).toFixed(1);
memEl.textContent = `${memGB}G`; memEl.textContent = `${memGB}G`;
@@ -6045,6 +6050,20 @@ 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 ────────────────────────────────────────────────────────────── // ─── Clipboard ──────────────────────────────────────────────────────────────
async _onClipboardWrite(data) { async _onClipboardWrite(data) {
+103 -3
View File
@@ -382,6 +382,7 @@ Object.assign(CodemanApp.prototype, {
// Header visibility settings // Header visibility settings
document.getElementById('appSettingsShowFontControls').checked = settings.showFontControls ?? defaults.showFontControls ?? false; document.getElementById('appSettingsShowFontControls').checked = settings.showFontControls ?? defaults.showFontControls ?? false;
document.getElementById('appSettingsShowSystemStats').checked = settings.showSystemStats ?? defaults.showSystemStats ?? true; 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('appSettingsShowLifecycleLog').checked = settings.showLifecycleLog ?? defaults.showLifecycleLog ?? false;
document.getElementById('appSettingsShowResponseViewer').checked = settings.showResponseViewer ?? defaults.showResponseViewer ?? false; document.getElementById('appSettingsShowResponseViewer').checked = settings.showResponseViewer ?? defaults.showResponseViewer ?? false;
document.getElementById('appSettingsShowFileViewerButton').checked = settings.showFileViewerButton ?? defaults.showFileViewerButton ?? true; document.getElementById('appSettingsShowFileViewerButton').checked = settings.showFileViewerButton ?? defaults.showFileViewerButton ?? true;
@@ -500,6 +501,8 @@ Object.assign(CodemanApp.prototype, {
settings.tabRailDetail ?? defaults.tabRailDetail ?? 'rich'; settings.tabRailDetail ?? defaults.tabRailDetail ?? 'rich';
document.getElementById('appSettingsTabRailSort').value = document.getElementById('appSettingsTabRailSort').value =
settings.tabRailSort ?? defaults.tabRailSort ?? 'activity'; 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('appSettingsShowTabDetachButton').checked = settings.showTabDetachButton ?? defaults.showTabDetachButton ?? false;
document.getElementById('appSettingsSessionListLayout').value = document.getElementById('appSettingsSessionListLayout').value =
settings.sessionListLayout ?? defaults.sessionListLayout ?? 'header'; settings.sessionListLayout ?? defaults.sessionListLayout ?? 'header';
@@ -2475,6 +2478,7 @@ Object.assign(CodemanApp.prototype, {
// Header visibility settings // Header visibility settings
showFontControls: document.getElementById('appSettingsShowFontControls').checked, showFontControls: document.getElementById('appSettingsShowFontControls').checked,
showSystemStats: document.getElementById('appSettingsShowSystemStats').checked, showSystemStats: document.getElementById('appSettingsShowSystemStats').checked,
headerStatsStyle: document.getElementById('appSettingsHeaderStatsStyle').value,
showLifecycleLog: document.getElementById('appSettingsShowLifecycleLog').checked, showLifecycleLog: document.getElementById('appSettingsShowLifecycleLog').checked,
showResponseViewer: document.getElementById('appSettingsShowResponseViewer').checked, showResponseViewer: document.getElementById('appSettingsShowResponseViewer').checked,
showFileViewerButton: document.getElementById('appSettingsShowFileViewerButton').checked, showFileViewerButton: document.getElementById('appSettingsShowFileViewerButton').checked,
@@ -2524,6 +2528,8 @@ Object.assign(CodemanApp.prototype, {
tabRailWidth: this.readTabRailWidthSetting?.() ?? 256, tabRailWidth: this.readTabRailWidthSetting?.() ?? 256,
tabRailDetail: document.getElementById('appSettingsTabRailDetail').value, tabRailDetail: document.getElementById('appSettingsTabRailDetail').value,
tabRailSort: document.getElementById('appSettingsTabRailSort').value, tabRailSort: document.getElementById('appSettingsTabRailSort').value,
tabArrangement: document.getElementById('appSettingsTabArrangement').value,
tabStateOrder: document.getElementById('appSettingsTabStateOrder').value,
showTabDetachButton: document.getElementById('appSettingsShowTabDetachButton').checked, showTabDetachButton: document.getElementById('appSettingsShowTabDetachButton').checked,
sessionListLayout: document.getElementById('appSettingsSessionListLayout').value, sessionListLayout: document.getElementById('appSettingsSessionListLayout').value,
sessionSidebarFontSize: this.resolveSessionSidebarFontSize( sessionSidebarFontSize: this.resolveSessionSidebarFontSize(
@@ -3448,6 +3454,8 @@ Object.assign(CodemanApp.prototype, {
tabRailWidth: 256, tabRailWidth: 256,
tabRailDetail: 'rich', tabRailDetail: 'rich',
tabRailSort: 'activity', tabRailSort: 'activity',
tabArrangement: 'state',
tabStateOrder: 'urgent-first',
sessionListLayout: 'header', sessionListLayout: 'header',
sessionSidebarFontSize: 12, sessionSidebarFontSize: 12,
cjkInputEnabled: false, cjkInputEnabled: false,
@@ -3549,6 +3557,79 @@ Object.assign(CodemanApp.prototype, {
return now === before ? undefined : now; 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() { applyHeaderVisibilitySettings() {
const settings = this.loadAppSettingsFromStorage(); const settings = this.loadAppSettingsFromStorage();
const defaults = this.getDefaultSettings(); const defaults = this.getDefaultSettings();
@@ -3579,6 +3660,12 @@ Object.assign(CodemanApp.prototype, {
if (tokenCountEl) { if (tokenCountEl) {
tokenCountEl.style.display = showTokenCount ? '' : 'none'; 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 // Hide lifecycle log button when setting is disabled
// Default OFF: the lifecycle-log document icon is opt-in; the default header // Default OFF: the lifecycle-log document icon is opt-in; the default header
@@ -3746,6 +3833,19 @@ Object.assign(CodemanApp.prototype, {
const sort = (settings.tabRailSort ?? defaults.tabRailSort ?? 'activity') === 'manual' ? 'manual' : 'activity'; const sort = (settings.tabRailSort ?? defaults.tabRailSort ?? 'activity') === 'manual' ? 'manual' : 'activity';
root.dataset.tabRailSort = sort; 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 tabsEl = document.getElementById('sessionTabs');
const rail = document.getElementById('tabRail'); const rail = document.getElementById('tabRail');
const headerHost = document.getElementById('sessionTabsHost'); const headerHost = document.getElementById('sessionTabsHost');
@@ -3769,7 +3869,7 @@ Object.assign(CodemanApp.prototype, {
// the row template, not toggled by CSS — same reasoning as the sidebar's // the row template, not toggled by CSS — same reasoning as the sidebar's
// detail half in applySessionListLayout(). Taller rows also move every // detail half in applySessionListLayout(). Taller rows also move every
// connector anchored to a tab rect. // connector anchored to a tab rect.
const changed = orientationChanged || previousDetail !== detail || previousSort !== sort; const changed = orientationChanged || previousDetail !== detail || previousSort !== sort || previousArrangement !== arrangement || previousStateOrder !== stateOrder;
if (orientationChanged) { if (orientationChanged) {
this.updateTabOverflowMode?.(); this.updateTabOverflowMode?.();
if (!settleRailWidth) this.syncTerminalGeometry?.(); if (!settleRailWidth) this.syncTerminalGeometry?.();
@@ -4039,10 +4139,10 @@ Object.assign(CodemanApp.prototype, {
// NOTE: Feature toggles (subagentTrackingEnabled, imageWatcherEnabled, ralphTrackerEnabled) // NOTE: Feature toggles (subagentTrackingEnabled, imageWatcherEnabled, ralphTrackerEnabled)
// are NOT display keys — they control server-side behavior and must sync from server. // are NOT display keys — they control server-side behavior and must sync from server.
const displayKeys = new Set([ const displayKeys = new Set([
'showFontControls', 'showSystemStats', 'showTokenCount', 'showCost', 'showFontControls', 'showSystemStats', 'headerStatsStyle', 'showTokenCount', 'showCost',
'showLifecycleLog', 'showResponseViewer', 'showRedrawButton', 'showLifecycleLog', 'showResponseViewer', 'showRedrawButton',
'showMonitor', 'showProjectInsights', 'showFileBrowser', 'showSubagents', 'showMonitor', 'showProjectInsights', 'showFileBrowser', 'showSubagents',
'subagentActiveTabOnly', 'tabTwoRows', 'tabOrientation', 'tabRailWidth', 'tabRailDetail', 'tabRailSort', 'sessionListLayout', 'sessionSidebarFontSize', 'localEchoEnabled', 'cjkInputEnabled', 'extendedKeyboardBar', 'subagentActiveTabOnly', 'tabTwoRows', 'tabOrientation', 'tabRailWidth', 'tabRailDetail', 'tabRailSort', 'tabArrangement', 'tabStateOrder', 'sessionListLayout', 'sessionSidebarFontSize', 'localEchoEnabled', 'cjkInputEnabled', 'extendedKeyboardBar',
'skin', 'showPlanUsageLimits', 'showAttachmentsButton', 'showFileViewerButton', 'webglRendererEnabled', 'skin', 'showPlanUsageLimits', 'showAttachmentsButton', 'showFileViewerButton', 'webglRendererEnabled',
'terminalFontFamily', 'terminalFontWeight', 'terminalFontWeightBold', 'terminalFontFamily', 'terminalFontWeight', 'terminalFontWeightBold',
'language', 'language',
+665
View File
@@ -19788,3 +19788,668 @@ html .toolbar .btn-git-status[aria-expanded='true'] {
padding-left: 0.4rem; padding-left: 0.4rem;
border-left: 1px solid var(--border); 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);
}
+27
View File
@@ -1403,6 +1403,25 @@ export const SettingsUpdateSchema = z
* rail drag-reorderable. * rail drag-reorderable.
*/ */
tabRailSort: z.enum(['activity', 'manual']).optional(), 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). * Session list layout. Display key (per-device).
* 'header' = horizontal tab strip * 'header' = horizontal tab strip
@@ -1434,6 +1453,14 @@ export const SettingsUpdateSchema = z
// UI visibility // UI visibility
showFontControls: z.boolean().optional(), showFontControls: z.boolean().optional(),
showSystemStats: 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(), showTokenCount: z.boolean().optional(),
showCost: z.boolean().optional(), showCost: z.boolean().optional(),
showLifecycleLog: z.boolean().optional(), showLifecycleLog: z.boolean().optional(),
+239
View File
@@ -0,0 +1,239 @@
/**
* @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>/
);
});
});
+305
View File
@@ -0,0 +1,305 @@
/**
* @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;/);
});
});
+11 -10
View File
@@ -205,10 +205,11 @@ describe('vertical tab rail row order', () => {
it('keeps the Alt+N badge on the tab index while the cards are sorted', () => { 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:…"` // `_tabIdx` counts the loop over sessionOrder, and only `style="order:…"`
// moves the card — so the badge names a shortcut, not a row position. // moves the card — so the badge names a shortcut, not a row position.
expect(appJs).toContain( // `listOrder` is the rail sort's map, or the state grouping's when that is
'const railSortOrder = this._tabRailSortOrder(tabOrder.filter((id) => this.sessions.has(id)));' // 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('` style="order:${railSortOrder.get(id)}"`'); expect(appJs).toContain('const railSortOrder = this._tabRailSortOrder(liveIds);');
expect(appJs).toContain('` style="order:${listOrder.get(id)}"`');
expect(appJs).toMatch(/_tabIdx < 9 \? '<span class="tab-number">' \+ \(_tabIdx \+ 1\)/); 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 // 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. // badge, drag-and-drop and the arrow-key walk agree with each other.
@@ -219,12 +220,11 @@ describe('vertical tab rail row order', () => {
// A session going working→idle never adds or removes a tab, so the full // 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 // rebuild is not reached — and a rebuild here would restart every card's
// animation on every SSE tick anyway. // animation on every SSE tick anyway.
expect(appJs).toContain( expect(appJs).toContain('const liveIds = this.sessionOrder.filter((sid) => this.sessions.has(sid));');
'const railSortOrder = this._tabRailSortOrder(this.sessionOrder.filter((sid) => this.sessions.has(sid)));' expect(appJs).toContain('const railSortOrder = this._tabRailSortOrder(liveIds);');
);
expect(appJs).toContain('if (tab.style.order !== railOrder) tab.style.order = railOrder;'); 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. // An empty string is what clears the property when the rail stops sorting.
expect(appJs).toContain("const railOrder = railSortOrder?.has(id) ? String(railSortOrder.get(id)) : '';"); expect(appJs).toContain("const railOrder = listOrder?.has(id) ? String(listOrder.get(id)) : '';");
}); });
it('drops the drag affordance while sorting, so a card cannot snap back', () => { it('drops the drag affordance while sorting, so a card cannot snap back', () => {
@@ -280,9 +280,10 @@ describe('vertical tab rail sort setting', () => {
expect(html).toContain("document.documentElement.dataset.tabRailSort='activity';"); expect(html).toContain("document.documentElement.dataset.tabRailSort='activity';");
expect(settingsUi).toContain('root.dataset.tabRailSort = sort;'); expect(settingsUi).toContain('root.dataset.tabRailSort = sort;');
// A sort flip leaves orientation on 'vertical' both times, so it has to // A sort flip leaves orientation on 'vertical' both times, so it has to
// count as a change of its own or nothing re-renders. // 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).
expect(settingsUi).toContain( expect(settingsUi).toContain(
'const changed = orientationChanged || previousDetail !== detail || previousSort !== sort;' 'const changed = orientationChanged || previousDetail !== detail || previousSort !== sort || previousArrangement !== arrangement || previousStateOrder !== stateOrder;'
); );
}); });
}); });
+466
View File
@@ -0,0 +1,466 @@
/**
* @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;/);
});
});