Compare commits

...
Author SHA1 Message Date
Codeman maintainer 96f89718a7 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
#planUsageChip after it, and classic moves them back to comment anchors;
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-05 01:57:25 +02:00
16 changed files with 1543 additions and 26 deletions
+4
View File
@@ -330,6 +330,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 grouping by state** (`tabGrouping`, per-device, default `state`; Discussion #426 option C): the tab list splits into needs you / waiting / working / idle, most urgent on top: rows in the desktop header strip, sections in the flat rail and the sidebar, inline dividers on the tablet strip, group order with no headings on phones. Classification is `_mobileOverviewState()` + `_mobileOverviewExit()`; the fold and the order bands are pure in `CodemanTabTriage` (constants.js). ⚠️ It is the flex `order` property plus `aria-hidden` heading/break elements reconciled in place by `_syncTabTriageChrome()` after BOTH render paths, never a DOM reorder (Alt+N, drag and the keyboard walk keep reading tab order). ⚠️ Drag only reorders within a group (`_isTabDropAcrossTriageGroups()`). ⚠️ Named groups in the vertical rail win: triage is off whenever `_projectTabGroups()` is non-null. ⚠️ `'none'` must leave no trace (no headings, no inline order, no `tabs-triage` class). Tests: `test/tab-triage.test.ts`. → [architecture-invariants#tab-grouping-by-state](docs/architecture-invariants.md#tab-grouping-by-state)
**Header stats styles** (`headerStatsStyle`, per-device, desktop only, default `tiles`; Discussion #426 option G): `tiles` / `compact` (pill + sparklines + plan rings) / `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 (sparklines, tile words, 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). 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)
+18
View File
@@ -865,6 +865,24 @@ 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 grouping by state
**Tab grouping by state** (`tabGrouping`, per-device, default `state`; Discussion #426 option C): the tab list is split into four groups, most urgent first: **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). 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; 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, and the fold into four groups is pure in `CodemanTabTriage` (constants.js, `computeTabTriageLayout()`).
⚠️ **It 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 already 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 (no tab added or removed) and can still empty a group or fill a new one. An unchanged pass writes nothing.
⚠️ Inside a group a header row keeps TAB order, so the strip only moves a tab when its state changes; a sorted rail ranks each section the way it ranks the flat rail (`_tabRailSortOrder()` feeds `pos`). Drag stays on in the strip, but `_isTabDropAcrossTriageGroups()` refuses a drop on a tab in another band: the dragged tab would stay in its own group and land where the user did not put it.
⚠️ **Named groups win**: in the vertical rail with owner tab layouts (`_projectTabGroups()` non-null) triage is off and the grouped tree renders as before. With `tabGrouping: 'none'` nothing is left behind: no headings, no breaks, no inline order, no `tabs-triage` class. A stale cached mobile-overview.js degrades to the flat strip. The header strip only wraps into rows on desktop, where `updateTabOverflowMode()` forces `tabs-auto-wrap` while `tabs-triage` is on; the breaks only display in a wrapping strip, since a `flex-basis: 100%` break in a nowrap scroller would steal width. Tests: `test/tab-triage.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 one `WS · CPU · MEM` pill with a 4-sample sparkline per stat plus a pill with a ring per plan window; `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-spark`, `.connection-tile-label`/`-value`, `.pu-ring`, `.pu-meter`) are always rendered and hidden by default in styles.css, which is what keeps `classic` looking exactly as before. 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
@@ -62,6 +62,9 @@ Project Insights, File Browser, Subagents, Approvals Inbox, Read My Mind, Ultrac
Ultracode Windows, Cron. Ultracode Windows, Cron.
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* (one pill with sparklines plus
plan-usage rings) 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.
@@ -79,7 +82,8 @@ 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 Grouping | *By state* (default) splits the tabs into needs you, waiting, working and idle: rows in the header, sections in the rail and sidebar. *None* keeps one list in tab order. See [The Dashboard](The-Dashboard#tab-grouping-by-state). |
| 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 grouping by state 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. |
+40 -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 (see [Tab grouping by state](#tab-grouping-by-state)); 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. 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. Desktop and tablet only. |
@@ -35,6 +35,31 @@ 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 grouping by state
By default the tabs are grouped by what each session needs from you, 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; a group with more
tabs than fit on one line continues under its own tabs. The vertical rail and the left
sidebar show the same groups as sections. Empty groups are not shown. These are the same
states the phone overview and the desktop home rail use.
Tabs move between groups on their own as their state changes. Inside a group they 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. On a phone the strip stays a
single scrolling row: the tabs come in group order, without the headings. If you have named
tab groups in the vertical rail, those take precedence there.
Turn it off with **App Settings → Appearance → Tabs → Tab Grouping → None** to get one list
in tab order. Per device.
## 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 +127,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 +146,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** | One pill with `WS · CPU · MEM` and a tiny sparkline of the last few samples, then a pill with a ring per plan window. 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).
+204 -8
View File
@@ -3208,14 +3208,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.
@@ -4000,6 +4006,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');
@@ -4027,6 +4063,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();
}
} }
} }
@@ -4731,6 +4775,128 @@ class CodemanApp {
return out; return out;
} }
/**
* True when the tab list groups by state (`tabGrouping: '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.tabGrouping === '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));
return window.CodemanTabTriage.layout(rows, webviewIds);
}
/**
* 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;
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);
if (!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 (head.lastElementChild.textContent !== count) head.lastElementChild.textContent = count;
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;
}
}
/**
* A drag in a state-grouped strip 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 did not change) and land somewhere the user did not put it.
* Groups are bands of `order` values, so comparing bands is enough.
*/
_isTabDropAcrossTriageGroups(targetTab) {
const container = this.$('sessionTabs');
if (!container?.classList.contains('tabs-triage') || !this.draggedTabId || !targetTab) return false;
const dragged = container.querySelector(`.session-tab[data-id="${this.draggedTabId}"]`);
if (!dragged) return false;
const stride = window.CodemanTabTriage?.STRIDE || 10000;
const band = (el) => Math.floor((Number(el.style.order) || 0) / stride);
return band(dragged) !== band(targetTab);
}
/** /**
* 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.
@@ -4983,6 +5149,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');
@@ -5404,7 +5573,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}"]`);
@@ -5412,7 +5587,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
@@ -5590,6 +5765,7 @@ class CodemanApp {
this._applyTabTreePositions(container); this._applyTabTreePositions(container);
this._syncTabGroupHeaderAlerts(container, groupProjection); this._syncTabGroupHeaderAlerts(container, groupProjection);
} }
this._syncTabTriageChrome(container, triage);
} else { } else {
// Full rebuild needed (sessions added/removed) // Full rebuild needed (sessions added/removed)
this._fullRenderSessionTabs(); this._fullRenderSessionTabs();
@@ -5658,6 +5834,15 @@ 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. Narrower
// screens keep the single scrolling row above, where the headings read as
// inline dividers instead.
if (container.classList.contains('tabs-triage')) {
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
@@ -5746,7 +5931,14 @@ 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 (tabGrouping, 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;
// 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).
@@ -5755,7 +5947,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.
@@ -5837,7 +6029,6 @@ class CodemanApp {
_tabIdx++; _tabIdx++;
} }
const groupProjection = this._projectTabGroups();
if (groupProjection) { 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.
@@ -5872,6 +6063,7 @@ 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);
// 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
@@ -5943,7 +6135,7 @@ 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)); tabs.sort((a, b) => orderOf(a) - orderOf(b));
} }
@@ -6485,6 +6677,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._isTabDropAcrossTriageGroups(tab)) return;
e.preventDefault(); e.preventDefault();
if (!this.draggedTabId || this.draggedTabId === tab.dataset.id) return; if (!this.draggedTabId || this.draggedTabId === tab.dataset.id) return;
@@ -6514,6 +6709,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._isTabDropAcrossTriageGroups(tab)) return;
const targetId = tab.dataset.id; const targetId = tab.dataset.id;
const draggedId = this.draggedTabId; const draggedId = this.draggedTabId;
+123
View File
@@ -658,6 +658,123 @@ function sortSessionsByActivity(rows) {
return (Array.isArray(rows) ? rows.slice() : []).sort(compareSessionActivity); return (Array.isArray(rows) ? rows.slice() : []).sort(compareSessionActivity);
} }
// Tab grouping by state (`tabGrouping: '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.
const TAB_TRIAGE_GROUPS = [
{ key: 'needs', label: 'Needs you' },
{ key: 'waiting', label: 'Waiting' },
{ key: 'working', label: 'Working' },
{ key: 'idle', label: 'Idle' },
];
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
* @returns {{
* order: Map<string, number>,
* webOrder: Map<string, number>,
* groups: Array<{key: string, label: string, count: number, headOrder: number, breakOrder: number}>
* }} `groups` lists only the non-empty groups, most urgent first.
*/
function computeTabTriageLayout(rows, webviewIds) {
const list = Array.isArray(rows) ? rows : [];
const webs = Array.isArray(webviewIds) ? webviewIds : [];
const baseOf = {};
const counts = {};
TAB_TRIAGE_GROUPS.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 = TAB_TRIAGE_GROUPS.filter((group) => counts[group.key] > 0).map((group) => ({
key: group.key,
label: group.label,
count: counts[group.key],
headOrder: baseOf[group.key],
breakOrder: baseOf[group.key] + TAB_TRIAGE_STRIDE - 1,
}));
return { order, webOrder, groups };
}
// 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 +1100,12 @@ if (typeof window !== 'undefined') {
compare: compareSessionActivity, compare: compareSessionActivity,
sort: sortSessionsByActivity, sort: sortSessionsByActivity,
}; };
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
@@ -203,6 +203,7 @@
Running: '运行中', Running: '运行中',
Idle: '空闲', Idle: '空闲',
Working: '工作中', Working: '工作中',
Waiting: '等待中',
Today: '今天', Today: '今天',
Home: '主页', Home: '主页',
Local: '本地', Local: '本地',
+27 -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';document.documentElement.dataset.tabGrouping=(A.tabGrouping==='none')?'none':'state';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.tabGrouping='state';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,8 @@
<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-label" id="connectionTileLabel"></span>
<span class="connection-tile-value" id="connectionTileValue"></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>
@@ -166,6 +168,7 @@
<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>
</div> </div>
<span class="stat-spark" id="statCpuSpark" aria-hidden="true"><i></i><i></i><i></i><i></i></span>
<span class="stat-value" id="statCpu">--%</span> <span class="stat-value" id="statCpu">--%</span>
</div> </div>
<div class="stat-item"> <div class="stat-item">
@@ -173,6 +176,7 @@
<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>
</div> </div>
<span class="stat-spark" id="statMemSpark" aria-hidden="true"><i></i><i></i><i></i><i></i></span>
<span class="stat-value" id="statMem">--</span> <span class="stat-value" id="statMem">--</span>
</div> </div>
</div> </div>
@@ -1898,6 +1902,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 sparkline">
<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 is one system pill with sparklines plus usage rings; 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>
@@ -2040,6 +2055,16 @@
<option value="vertical">Vertical (side rail)</option> <option value="vertical">Vertical (side rail)</option>
</select> </select>
</div> </div>
<div class="set-row has-field" data-search="tab grouping group by state triage needs you waiting working idle rows sections">
<div class="set-row-text">
<span class="set-row-label">Tab Grouping</span>
<span class="set-row-desc">By state puts what needs you on top: a row each for needs you (red), waiting (yellow), working and idle in the header, and the same sections in the side rail and sidebar. Tabs move between groups as their state changes; Alt+1..9 keeps the tab order. Named tab groups in the side rail take precedence.</span>
</div>
<select id="appSettingsTabGrouping" class="set-select">
<option value="state">By state (default)</option>
<option value="none">None (tab order)</option>
</select>
</div>
<div class="set-row has-field" data-search="tab rail detail rows created working idle status pill simple"> <div class="set-row has-field" data-search="tab rail detail rows created working idle status pill simple">
<div class="set-row-text"> <div class="set-row-text">
<span class="set-row-label">Vertical Rail Rows</span> <span class="set-row-label">Vertical Rail Rows</span>
@@ -2053,7 +2078,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 Grouping by state 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>
+8
View File
@@ -755,6 +755,14 @@ html.mobile-init .file-browser-panel {
font-weight: 500; font-weight: 500;
} }
/* Grouped by state (tabGrouping), 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;
}
/* 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. */
+26
View File
@@ -6028,6 +6028,11 @@ Object.assign(CodemanApp.prototype, {
} }
} }
// Sparklines for the Compact header style. Always recorded (four numbers a
// poll), so switching styles shows history at once instead of empty bars.
this._pushStatSpark('cpu', stats.cpu);
this._pushStatSpark('mem', 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,27 @@ Object.assign(CodemanApp.prototype, {
} }
}, },
/**
* Record one percentage sample and redraw that stat's sparkline: one bar per
* sample, newest on the right, as many samples as the template has bars.
* Bars stay 10% tall at minimum so an idle machine still draws a baseline.
*/
_pushStatSpark(key, percent) {
const history = (this._statSparkHistory ||= { cpu: [], mem: [] })[key];
if (!history) return;
const el = this.$(key === 'cpu' ? 'statCpuSpark' : 'statMemSpark');
const size = el?.children.length || 4;
const value = Number(percent);
history.push(Number.isFinite(value) ? Math.min(100, Math.max(0, value)) : 0);
while (history.length > size) history.shift();
if (!el) return;
const offset = size - history.length;
for (let i = 0; i < el.children.length; i++) {
const sample = history[i - offset];
el.children[i].style.height = sample === undefined ? '' : `${Math.max(10, Math.round(sample))}%`;
}
},
// ─── Clipboard ────────────────────────────────────────────────────────────── // ─── Clipboard ──────────────────────────────────────────────────────────────
async _onClipboardWrite(data) { async _onClipboardWrite(data) {
+81 -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;
@@ -497,6 +498,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('appSettingsTabGrouping').value =
(settings.tabGrouping ?? defaults.tabGrouping ?? 'state') === 'none' ? 'none' : 'state';
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';
@@ -2397,6 +2400,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,
@@ -2444,6 +2448,7 @@ 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,
tabGrouping: document.getElementById('appSettingsTabGrouping').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(
@@ -3365,6 +3370,7 @@ Object.assign(CodemanApp.prototype, {
tabRailWidth: 256, tabRailWidth: 256,
tabRailDetail: 'rich', tabRailDetail: 'rich',
tabRailSort: 'activity', tabRailSort: 'activity',
tabGrouping: 'state',
sessionListLayout: 'header', sessionListLayout: 'header',
sessionSidebarFontSize: 12, sessionSidebarFontSize: 12,
cjkInputEnabled: false, cjkInputEnabled: false,
@@ -3466,6 +3472,63 @@ Object.assign(CodemanApp.prototype, {
return now === before ? undefined : now; return now === before ? undefined : now;
}, },
/**
* 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();
@@ -3496,6 +3559,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
@@ -3659,6 +3728,15 @@ 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;
// Grouping rides on a fourth attribute, for the same reason: it is applied
// as inline `order` plus heading elements the render paths emit, so a flip
// has to re-render, and `isTabTriage()` (app.js) reads one attribute per
// pass instead of re-parsing localStorage. Anything but an explicit 'none'
// is 'state', the default (Discussion #426, option C).
const previousGrouping = root.dataset.tabGrouping || 'state';
const grouping = (settings.tabGrouping ?? defaults.tabGrouping ?? 'state') === 'none' ? 'none' : 'state';
root.dataset.tabGrouping = grouping;
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');
@@ -3682,7 +3760,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 || previousGrouping !== grouping;
if (orientationChanged) { if (orientationChanged) {
this.updateTabOverflowMode?.(); this.updateTabOverflowMode?.();
if (!settleRailWidth) this.syncTerminalGeometry?.(); if (!settleRailWidth) this.syncTerminalGeometry?.();
@@ -3952,10 +4030,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', 'tabGrouping', 'sessionListLayout', 'sessionSidebarFontSize', 'localEchoEnabled', 'cjkInputEnabled', 'extendedKeyboardBar',
'skin', 'showPlanUsageLimits', 'showAttachmentsButton', 'showFileViewerButton', 'webglRendererEnabled', 'skin', 'showPlanUsageLimits', 'showAttachmentsButton', 'showFileViewerButton', 'webglRendererEnabled',
'terminalFontFamily', 'terminalFontWeight', 'terminalFontWeightBold', 'terminalFontFamily', 'terminalFontWeight', 'terminalFontWeightBold',
'language', 'language',
+370
View File
@@ -19274,3 +19274,373 @@ html[data-session-list="sidebar"][data-sidebar="collapsed"] .btn-sidebar-toggle
font-size: 13px; font-size: 13px;
color: var(--text-muted); color: var(--text-muted);
} }
/* ============================================================
Tab grouping by state (tabGrouping: '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. */
.session-tabs-host > .session-tabs.tabs-triage:is(.tabs-auto-wrap, .tabs-two-rows) {
--tab-triage-gutter: 92px;
padding-left: calc(0.25rem + var(--tab-triage-gutter));
}
.session-tabs-host > .session-tabs.tabs-triage:is(.tabs-auto-wrap, .tabs-two-rows) > .tab-triage-head {
width: var(--tab-triage-gutter);
margin-left: calc(-1 * var(--tab-triage-gutter));
}
.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;
}
/* ============================================================
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-spark,
.connection-tile-label,
.connection-tile-value,
.header-plan-usage .pu-ring,
.header-plan-usage .pu-meter {
display: none;
}
/* --- Compact: one system pill with sparklines, one plan-ring pill ------- */
html[data-header-stats='compact'] .header-system-stats {
align-items: center;
gap: 0;
height: 24px;
padding: 0 10px;
box-sizing: border-box;
border-radius: 12px;
background: var(--control-bg);
border: 1px solid var(--control-border);
font-size: 0.68rem;
color: var(--text);
}
html[data-header-stats='compact'] .header-system-stats > .connection-indicator {
color: var(--text);
}
html[data-header-stats='compact'] .header-system-stats > .connection-indicator::after,
html[data-header-stats='compact'] .header-system-stats > .stat-item + .stat-item::before {
content: '·';
margin: 0 8px;
color: var(--text-muted);
}
html[data-header-stats='compact'] .header-system-stats .stat-label {
min-width: 0;
font-size: 0.62rem;
color: var(--text-muted);
}
html[data-header-stats='compact'] .header-system-stats .stat-bar {
display: none;
}
html[data-header-stats='compact'] .header-system-stats .stat-value {
min-width: 0;
text-align: left;
color: var(--text);
}
html[data-header-stats='compact'] .stat-spark {
display: inline-flex;
align-items: flex-end;
gap: 1px;
height: 10px;
}
html[data-header-stats='compact'] .stat-spark i {
width: 3px;
height: 10%;
border-radius: 1px;
background: var(--accent);
transition: height 0.3s ease;
}
html[data-header-stats='compact'] .header-plan-usage {
flex-direction: row;
align-items: center;
gap: 12px;
height: 24px;
min-height: 24px;
padding: 0 10px;
box-sizing: border-box;
border-radius: 12px;
background: var(--control-bg);
border-color: var(--control-border);
}
html[data-header-stats='compact'] .header-plan-usage .pu-row {
align-items: center;
gap: 6px;
}
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: 10px;
}
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;
}
html[data-header-stats='compact'] .header-plan-usage .pu-label {
font-weight: 500;
text-transform: none;
color: var(--text-dim);
}
/* A ring per window: a conic fill masked down to a 2px stroke, coloured like
the value next to it (the pu-green/-yellow/-red class sits on both). */
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) 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));
}
/* --- Tiles: label over value, a bar underneath, no icons ----------------- */
html[data-header-stats='tiles'] .header-system-stats {
align-items: center;
gap: 6px;
padding: 0;
}
html[data-header-stats='tiles'] .header-system-stats > .connection-indicator,
html[data-header-stats='tiles'] .header-system-stats > .stat-item,
html[data-header-stats='tiles'] .header-plan-usage .pu-win {
position: relative;
display: flex;
flex-direction: column;
align-items: flex-start;
justify-content: center;
gap: 1px;
min-width: 44px;
height: 28px;
padding: 0 8px 3px;
box-sizing: border-box;
border-radius: 6px;
background: var(--control-bg);
border: 1px solid var(--control-border);
overflow: hidden;
font-family: 'SF Mono', Monaco, monospace;
line-height: 1.05;
}
/* 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;
min-width: 0;
font-size: 0.55rem;
font-weight: 600;
letter-spacing: 0.06em;
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;
min-width: 0;
text-align: left;
font-size: 0.72rem;
font-weight: 600;
}
/* 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);
}
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 runs along the bottom edge of the tile. */
html[data-header-stats='tiles'] .header-system-stats .stat-bar,
html[data-header-stats='tiles'] .header-plan-usage .pu-meter {
display: block;
position: absolute;
left: 6px;
right: 6px;
bottom: 3px;
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;
}
/* 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: 44px;
height: 28px;
padding: 0 8px;
box-sizing: border-box;
border-radius: 6px;
background: var(--control-bg);
border: 1px solid var(--control-border);
color: var(--text-muted);
}
+16
View File
@@ -1396,6 +1396,14 @@ export const SettingsUpdateSchema = z
* rail drag-reorderable. * rail drag-reorderable.
*/ */
tabRailSort: z.enum(['activity', 'manual']).optional(), tabRailSort: z.enum(['activity', 'manual']).optional(),
/**
* Tab grouping. Display key (per-device).
* 'state' = the strip is split into state rows (needs you, waiting,
* working, idle; Discussion #426 option C), and the flat side
* rail and the sidebar get the same sections. The default.
* 'none' = one flat list in tab order, as before.
*/
tabGrouping: z.enum(['state', 'none']).optional(),
/** /**
* Session list layout. Display key (per-device). * Session list layout. Display key (per-device).
* 'header' = horizontal tab strip * 'header' = horizontal tab strip
@@ -1427,6 +1435,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' = one system pill with sparklines plus a plan-usage ring pill
* '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(),
+219
View File
@@ -0,0 +1,219 @@
/**
* @fileoverview The header-stats styles (`headerStatsStyle`, Discussion #426
* option G): 'classic' (as before), 'compact' (one system pill with sparklines
* 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 (sparklines, 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('draws the sparkline newest-right, with a 10% floor and only real samples', () => {
const app = makeApp();
const bars = () =>
[...document.querySelectorAll<HTMLElement>('#statCpuSpark > i')].map((bar) => bar.style.height);
app._pushStatSpark('cpu', 40);
expect(bars()).toEqual(['', '', '', '40%']);
app._pushStatSpark('cpu', 2);
app._pushStatSpark('cpu', 250);
app._pushStatSpark('cpu', 60);
app._pushStatSpark('cpu', 70);
expect(bars()).toEqual(['10%', '100%', '60%', '70%']);
});
});
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-spark,\s*\.connection-tile-label,\s*\.connection-tile-value,\s*\.header-plan-usage \.pu-ring,\s*\.header-plan-usage \.pu-meter \{\s*display: none;/
);
});
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>/
);
});
});
+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
// grouping flip (tabGrouping, 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 || previousGrouping !== grouping;'
); );
}); });
}); });
+390
View File
@@ -0,0 +1,390 @@
/**
* @fileoverview Tab grouping by state (`tabGrouping: '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.
* - `tabGrouping: 'none'` 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']);
});
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('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(grouping: 'state' | 'none' = '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.tabGrouping = grouping;
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.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:1']);
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('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:3']);
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 tabGrouping 'none'", () => {
makeApp('none')._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.tabGrouping = 'none';
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._isTabDropAcrossTriageGroups(tab('s4'))).toBe(false);
expect(app._isTabDropAcrossTriageGroups(tab('s1'))).toBe(true);
expect(app._isTabDropAcrossTriageGroups(tab('s3'))).toBe(true);
document.documentElement.dataset.tabGrouping = 'none';
app._fullRenderSessionTabs();
expect(app._isTabDropAcrossTriageGroups(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-grouping before first paint, defaulting to state', () => {
expect(html).toContain("dataset.tabGrouping=(A.tabGrouping==='none')?'none':'state'");
// The catch branch (localStorage threw) must set it too.
expect(html).toContain("document.documentElement.dataset.tabGrouping='state';");
});
it('offers the setting with "By state" as the default', () => {
expect(html).toMatch(/<select id="appSettingsTabGrouping"[^>]*>\s*<option value="state">By state \(default\)<\/option>\s*<option value="none">/);
});
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('hides the headings on phones, whose strip stays one scrolling row', () => {
expect(mobileCss).toMatch(/:where\(\.header\) \.tab-triage-head \{\s*display: none;/);
});
});