mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-06 07:29:42 +02:00
Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
55cafc282f | ||
|
|
bb5fd5ee97 | ||
|
|
bebf0db792 | ||
|
|
e3dfbf6591 | ||
|
|
9b0d305223 |
@@ -333,6 +333,10 @@ Frontend JS modules have `@fileoverview` with `@dependency`/`@loadorder` tags. L
|
||||
|
||||
**Session list layout: header strip or left sidebar** (`sessionListLayout`, default `header`; per-device via `displayKeys`, also in `SettingsUpdateSchema`): the list can move into a collapsible `<aside>` (Alt+B, `toggleSessionSidebar`) or, via `tabOrientation`, a resizable vertical `#tabRail` (desktop/tablet only). ⚠️ There is ONE `#sessionTabs`, MOVED between hosts, never a second list: `applySessionListLayout()` runs first, then `applyTabOrientation()`, both BEFORE `applyTabWrapSettings()`, and both arm/disarm `_startSidebarRichClock()`. ⚠️ Axis decisions use `_isVerticalTabList()`, never `isSessionSidebarActive()` alone. ⚠️ Rich rows share one gate, `isRichTabRows()`; rich CSS pairs sidebar+rail with comma-grouped selectors, never `:is()`, and card rules stay rail-scoped. ⚠️ Rail sort (`tabRailSort`, default `activity`) is the flex `order` property only, never a DOM reorder; the arrow-key walk alone follows computed `order`. ⚠️ Leaving sidebar mode clears `_sidebarFilter`; the handheld overlay drawer is `inert` when closed, the docked rail never. → [architecture-invariants#session-list-layout-header-strip-vs-left-sidebar](docs/architecture-invariants.md#session-list-layout-header-strip-vs-left-sidebar)
|
||||
|
||||
**Tab layouts** (`tabArrangement`, per-device, default `state`; Discussion #426): `state` (option C) splits the list into needs you / waiting / working / idle (idle is `quiet`: its heading stays as the row's anchor but draws no label; `tabStateOrder: 'urgent-last'` flips the order, needs you at the bottom): rows in the desktop header strip with left-aligned labels in a measured column (the brand sits over the strip's corner, so the rows after the first start under "Codeman"), sections in the flat rail and the sidebar, inline dividers on the tablet strip, group order with no headings on phones. `case` (option A) wraps each case's tabs in one `.tab-cluster` box and hides the `-<case>` of a generated name (`.tab-name-case`). `ledger` (option B) is a CSS-only grid on the desktop header strip. `classic` is the old strip. Pure cores: `CodemanTabTriage` and `CodemanTabClusters` (constants.js). ⚠️ `state` is the flex `order` property plus `aria-hidden` heading/break elements reconciled in place by `_syncTabTriageChrome()` after BOTH render paths, never a DOM reorder (a state change is an incremental pass). ⚠️ `case` boxes ARE a DOM grouping, so the incremental path rebuilds whenever `_tabClusterLayout().key` changes; membership follows `_mobileOverviewCaseFor()` like the home screens. ⚠️ Drag only reorders within a group or box (`_isTabDropAcrossGroups()`). ⚠️ Named groups in the vertical rail win over both groupings. ⚠️ `classic` must leave no trace (no headings, no inline order, no boxes, no name split), and the ledger must not change the markup. Tests: `test/tab-triage.test.ts`, `test/tab-clusters.test.ts`. → [architecture-invariants#tab-layouts-by-state-by-case-ledger](docs/architecture-invariants.md#tab-layouts-by-state-by-case-ledger)
|
||||
|
||||
**Header stats styles** (`headerStatsStyle`, per-device, desktop only, default `tiles`; Discussion #426 option G): `tiles` / `compact` (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). ⚠️ A tile is a three-row grid (label, value, bar) with pixel line-heights and the bundled JetBrains Mono; never lay the bar over a fixed-height tile (a taller system mono put the value on top of it). The WS tile's grid lives on the inner `.connection-tile`, because JS writes the indicator's `display` inline. Tests: `test/header-stats-style.test.ts`. → [architecture-invariants#header-stats-styles](docs/architecture-invariants.md#header-stats-styles)
|
||||
|
||||
**Phone overview home screen** (`mobile-overview.js`, per-device `mobileOverviewEnabled`, default ON): under 600px the "C" logo shows NEEDS YOU / CURRENT / PAST SESSIONS instead of the welcome overlay, branched in `showWelcome()`/`hideWelcome()` via width-driven `shouldUseMobileOverview()`. ⚠️ The container ships `hidden` and only this module removes it: never give `.mobile-overview` a bare `display` rule (desktop does not load `mobile.css`). ⚠️ The split Run button must carry the toolbar's own classes (`btn-toolbar btn-run mode-<backend>` / `btn-run-gear`) and mobile.css must set no `background`/`color` on it; row status must mirror the session-tab alert language. PAST rows resume through the shared `resumeHistorySession()`. Status pills carry `data-i18n-skip`. → [architecture-invariants#phone-overview-home-screen](docs/architecture-invariants.md#phone-overview-home-screen)
|
||||
|
||||
**Desktop home tab rail** (`home-sessions.js`, desktop only): the welcome overlay's left gutter carries the open tabs as a rail docked flush left, full height, in overview order, each row showing `created … · <state> <duration>` from `_mobileOverviewSince()`; state classification is reused from mobile-overview.js (so it loads after it). ⚠️ The number badge is the Alt+1..9 tab-strip index, never renumber it to row position. ⚠️ The width gate lives in two places that must stay equal: `HOME_SESSIONS_MIN_WIDTH` (1180) and a `max-width: 1179px` media query. ⚠️ `.home-sessions[hidden]` must re-assert `display: none`. ⚠️ Size all children in `em` off the one `clamp()` knob, never `rem`/px. Age stamps tick in place (`_tickHomeSessionsTimes()`), never by re-render. Test: `test/home-sessions.test.ts`. → [architecture-invariants#desktop-home-tab-rail](docs/architecture-invariants.md#desktop-home-tab-rail)
|
||||
|
||||
@@ -867,6 +867,30 @@ Tests: `test/terminal-touch-tap.test.ts`.
|
||||
|
||||
⚠️ Mobile no longer hoists the active session to the front of the strip: that reordering ran on full renders only, so tab order flipped depending on which render path fired, and it renumbered the Alt+N badges. Scroll-into-view replaces it; do not reintroduce it.
|
||||
|
||||
### Tab layouts (by state, by case, ledger)
|
||||
|
||||
**Tab layouts** (`tabArrangement`, per-device, default `state`; Discussion #426): App Settings → Appearance → Tabs → **Tab Layout**. Four values: `state` (option C), `case` (option A), `ledger` (option B) and `classic`, the strip as before. `<html data-tab-arrangement>` is stamped pre-paint and by `applyTabOrientation()`; the render paths read it through `isTabTriage()`, `isTabClusters()` and `isTabLedger()`. Named groups in the vertical rail (owner tab layouts, `_projectTabGroups()` non-null) win over both groupings, and the grouped tree renders as before.
|
||||
|
||||
**By state.** The list is split into four groups: **needs you** (a permission or question dialog; a failed session joins it), **waiting** (idle prompt pending), **working**, **idle** (also ended sessions, an agent that exited inside a live pane, and web tabs). Idle is `quiet` (`TAB_TRIAGE_GROUPS`): its heading element still exists, because it anchors the row's `order` band and, as the lead, holds the row's place beside the brand, but it draws no label or count (`.tab-triage-head--quiet`). `tabStateOrder` (`urgent-first` default, `urgent-last`) decides which end they start from; only the group order flips, never the rows inside a group. The desktop header strip draws a row per group with its label left-aligned in a column that `_sizeTabTriageGutter()` measures to the widest label on screen (re-measured only when the label text changes, and once on `document.fonts.ready`), so no row carries a fixed gutter's worth of empty space. The brand leaves the flow and sits over the strip's top-left corner (`.header:has(...)`), so the first row's heading (`.tab-triage-head--lead`, natural width) is pushed past it by `--tab-triage-brand` (a ResizeObserver keeps that width, so no render pass reads layout for it) and every later row starts at the edge, under "Codeman"; the flat rail and the sidebar draw a section per group; the tablet strip (600-767px) keeps its scrolling row with the headings as inline dividers; phones keep their chip row in group order with the headings hidden (mobile.css). Classification is `_mobileOverviewState()` + `_mobileOverviewExit()`, the home screens' own; the fold into four groups and the order bands are pure in `CodemanTabTriage` (constants.js, `computeTabTriageLayout()`).
|
||||
|
||||
⚠️ **By state is the flex `order` property, never a DOM reorder**, exactly like the sorted rail: `#sessionTabs` stays in `sessionOrder`, so the Alt+N badges, drag, the keyboard walk (which sorts by COMPUTED order) and the sidebar filter keep reading the list they always read. Each group owns a band of `TAB_TRIAGE_STRIDE` order values: its heading, its rows, its web tabs, then the row break that ends the header line. The headings and breaks are `aria-hidden` direct children of `#sessionTabs`, reconciled in place by `_syncTabTriageChrome()` after BOTH render paths, because a state change is an incremental pass and can still empty a group or fill a new one. Inside a header row tabs keep TAB order; a sorted rail ranks each section the way it ranks the flat rail. The header strip only wraps into rows on desktop, where `updateTabOverflowMode()` forces `tabs-auto-wrap`; the breaks only display in a wrapping strip, since a `flex-basis: 100%` break in a nowrap scroller would steal width.
|
||||
|
||||
**By case.** Each case's tabs sit in one `.tab-cluster` box (`_renderTabClusters()`), in the order the case first appears in the tab order; membership is `_mobileOverviewCaseFor()` (longest case path that prefixes the working directory), else the directory, else the session alone. A box with two or more tabs is labelled (swatch, name, count) and its generated `w<n>-<case>` names drop the `-<case>`, which stays in the DOM in a `.tab-name-case` span only `.tabs-clusters` hides; a one-tab case gets only the swatch in the header strip; a web tab gets a box of its own. The colour is `--session-<colour>` by a stable hash of the case key (`CodemanTabClusters.colorFor()`), so nothing is stored. The rail and the sidebar draw every case as a labelled section; phones dissolve the boxes (`display: contents`) into the one chip row.
|
||||
|
||||
⚠️ **The boxes ARE a DOM grouping**, unlike by state, which is safe only because a tab changes case far less often than it changes state: `_tabClusterLayout().key` is the whole structure, and the incremental path rebuilds whenever it differs from the last full render's. The keyboard walk keeps boxes together (box index first, then computed order, since a sorted rail orders rows inside each box). Drag only reorders inside one box, as it only reorders inside one state group (`_isTabDropAcrossGroups()`).
|
||||
|
||||
**Ledger.** CSS only, on `.tabs-ledger` inside `@media (min-width: 768px)` and the header host: the flat list on an auto-fill column grid with equal cells, mono type and a 3px status bar (`--ledger-bar`: yellow for a waiting alert, red for an action alert or an error, muted for stopped, exited and web tabs) instead of the dot. ⚠️ It must not change the markup (pinned: the ledger's innerHTML equals classic's); the rail, the sidebar and narrower strips show the plain list.
|
||||
|
||||
With `classic` nothing is left behind: no headings, no breaks, no inline order, no boxes, no name split, no arrangement class. A stale cached mobile-overview.js degrades every grouping to the flat strip. Tests: `test/tab-triage.test.ts`, `test/tab-clusters.test.ts`.
|
||||
|
||||
### Header stats styles
|
||||
|
||||
**Header stats styles** (`headerStatsStyle`, per-device, desktop only, default `tiles`; Discussion #426 option G): how the WS readout, CPU, MEM and the plan-usage windows are drawn. `tiles` gives each its own label-over-value tile with a bar along the bottom edge; `compact` is 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`, `.pu-ring`, `.pu-meter`) are always rendered and hidden by default in styles.css, which is what keeps `classic` looking exactly as before. ⚠️ A tile is a three-row grid (9px label, 14px value, 2px bar) with pixel line-heights, 36px tall like the header, in the bundled JetBrains Mono. The first version laid the bar over the bottom of a fixed 28px tile, and a taller system mono (SF Mono) pushed the value into it; the bar is now a real row, so the height comes from the layout, not the font. The WS tile keeps its grid on the inner `.connection-tile` span, because `_updateConnectionIndicator()` writes the indicator's own `display` inline. The tile words come from `_connectionTileWords()`, DERIVED from the connection descriptor rather than added to it, because `test/connection-indicator.test.ts` pins the descriptor's exact shape. Plan rings and meters clamp their fill to 0-100 while the label keeps the real number. Tests: `test/header-stats-style.test.ts`.
|
||||
|
||||
### Phone overview home screen
|
||||
|
||||
**Phone overview home screen** (`mobile-overview.js`, phones only, per-device `mobileOverviewEnabled`, default ON): under 600px the "C" logo shows a session overview (NEEDS YOU / CURRENT SESSIONS / PAST SESSIONS) instead of the welcome overlay; tablet and desktop are unchanged. The branch lives in `showWelcome()`/`hideWelcome()` (terminal-ui.js) behind `shouldUseMobileOverview()`, which is **width-driven** (`getDeviceType() === 'mobile'`) because this is a layout decision, unlike the settings namespace which stays handheld-based.
|
||||
|
||||
@@ -69,6 +69,9 @@ by folder** (per device, on by default) shows changed files under collapsed fold
|
||||
window; off lists every file by its full path.
|
||||
|
||||
Most default to off. The stock desktop header is system stats, File Viewer, and the gear.
|
||||
**Header Stats Style** picks how the system stats and plan usage are drawn: *Tiles*
|
||||
(default; label over value with a bar underneath), *Compact* (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
|
||||
setting — the button and the feature both stay off below a ~1180px viewport, where two
|
||||
resizable panes plus their divider have nowhere to go.
|
||||
@@ -86,7 +89,9 @@ every session or only the active tab.
|
||||
| Interface Language | English or Simplified Chinese. Per device. |
|
||||
| Session List Layout | Header tab strip (default), a collapsible left sidebar, or the sidebar with detailed rows. See [The Dashboard](The-Dashboard#session-list-layout). |
|
||||
| Tab Orientation | Keeps the header list but turns the strip vertical beside the terminal, resizable, with detailed rows by default. Desktop and tablet only. |
|
||||
| Vertical Rail Order | *By activity* (default) sorts the rail the way the home screens are sorted; *Manual* keeps your tab order and drag-reordering. |
|
||||
| Tab Layout | *By state* (default): a row each for needs you, waiting, working and idle, sections in the rail and sidebar. *By case*: one box per case. *Ledger*: an aligned column grid. *Classic*: the single list as before. See [The Dashboard](The-Dashboard#tab-layouts). |
|
||||
| State Order | For *By state*: needs you on top (default) or at the bottom, right above the terminal. |
|
||||
| Vertical Rail Order | *By activity* (default) sorts the rail the way the home screens are sorted; *Manual* keeps your tab order and drag-reordering. With *By state* or *By case* it orders the rows inside each section. |
|
||||
| Tall Tabs | Taller tab strip. |
|
||||
| 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. |
|
||||
|
||||
@@ -27,7 +27,7 @@ Session List Layout** can move it into a vertical sidebar on the left instead, a
|
||||
|
||||
| Layout | Behaviour |
|
||||
| -------------------- | --------------------------------------------------------------------------------- |
|
||||
| **Header tab strip** | The default. Wraps to a second row on desktop, scrolls sideways on a phone. |
|
||||
| **Header tab strip** | The default. On desktop it is one row per state by default (see [Tab layouts](#tab-layouts)); it scrolls sideways on a phone. |
|
||||
| **Left sidebar** | A vertical list with a filter box and a live session count. `Alt+B` collapses it to a narrow rail that keeps the status dots and task badges visible. On a phone it is an off-canvas drawer rather than a docked rail. A detailed variant adds the home screen's per-session line (`created 3d ago · working 12m`) and a status pill. |
|
||||
| **Vertical rail** | The strip turned vertical beside the terminal, resizable, with detailed rows by default. **Vertical Rail Order** sorts it by activity (blocked on you first, then longest running, then most recently quiet), the same order as the home screens; pick *Manual* to get your own order and drag-reordering back. **Tab groups:** pick *Move to new group* from a row's ⋯ menu (or Shift+F10 on it) to make the first one; a group header's menu (right-click, Shift+F10 or its ⋯ glyph) renames it (also F2), reorders or deletes it, rows move between groups from their own menu or by dragging with a mouse or pen, and a collapsed group stays collapsed on that device. Desktop and tablet only. |
|
||||
|
||||
@@ -35,6 +35,40 @@ It is the same list either way, just re-hosted: tab order, drag-to-reorder, the
|
||||
to `Alt+9` numbers and every status colour below behave identically in both. The setting is
|
||||
per device, so a sidebar on your desktop does not force one onto your phone.
|
||||
|
||||
## Tab layouts
|
||||
|
||||
**App Settings → Appearance → Tabs → Tab Layout** picks how the tabs are arranged. Per device.
|
||||
|
||||
| Layout | What it does |
|
||||
| ---------------------- | ------------------------------------------------------------------------------------ |
|
||||
| **By state** (default) | Groups the tabs by what each session needs from you (below). |
|
||||
| **By case** | One box per case, labelled with the case and its tab count. Inside a box, `w75-api-gateway` reads just `w75`. A case with one tab gets a box with a colour swatch. |
|
||||
| **Ledger** | The same list on an aligned column grid: equal cells, monospace names, a coloured bar on the left of each cell instead of the dot (yellow waiting, red needs you). Desktop header only. |
|
||||
| **Classic** | The single list in tab order, as before. |
|
||||
|
||||
**By state** groups the tabs like this, most urgent on top:
|
||||
|
||||
| Group | Who is in it |
|
||||
| ------------- | ----------------------------------------------------------------------------------- |
|
||||
| **Needs you** | Red: a question or permission prompt is blocking the agent. A failed session too. |
|
||||
| **Waiting** | Yellow: the agent finished its turn and is waiting for your next prompt. |
|
||||
| **Working** | A turn is running. |
|
||||
| **Idle** | Everything quiet, including ended sessions, agents that exited inside their pane, and web tabs. |
|
||||
|
||||
In the header each group is a row with its name and count on the left (Idle, the quiet
|
||||
default, carries no label); a group with more tabs than fit on one line continues on the
|
||||
next line. **State Order → Needs you at the
|
||||
bottom** turns the rows the other way up, so the needs-you row sits right above the
|
||||
terminal. Empty groups are not shown. These are the same states the phone overview and the
|
||||
desktop home rail use, and tabs move between groups on their own as their state changes.
|
||||
|
||||
Both groupings also apply to the vertical rail and the left sidebar, as labelled sections.
|
||||
Inside a group or a box tabs keep your tab order (on a rail sorted *By activity*, the
|
||||
activity order), and the `Alt+1` to `Alt+9` numbers never change. Dragging reorders tabs
|
||||
within a group or box. On a phone the strip stays a single scrolling row in group order,
|
||||
without labels or boxes. If you have named tab groups in the vertical rail, those take
|
||||
precedence there.
|
||||
|
||||
## Session tabs
|
||||
|
||||
One tab per session, in your order, and that order syncs across your devices.
|
||||
@@ -102,7 +136,7 @@ The right side of the header. Almost all of these are off until you enable them
|
||||
| ---------------------- | ------------------ | ------------------------------------------------------------------------------- |
|
||||
| Connection dot | Always on | SSE connection health. Green is connected. |
|
||||
| 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. |
|
||||
| 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. |
|
||||
@@ -121,6 +155,19 @@ The right side of the header. Almost all of these are off until you enable them
|
||||
| Tunnel indicator | When a tunnel runs | Cloudflare tunnel status. |
|
||||
| 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
|
||||
covered in [Mobile Guide](Mobile-Guide).
|
||||
|
||||
|
||||
+441
-14
@@ -3229,14 +3229,20 @@ class CodemanApp {
|
||||
// broken feature rather than as an idle window (reported 2026-09-01). A
|
||||
// missing CODEX bucket means the opposite — that plan has no such limit —
|
||||
// so those stay omitted rather than showing a dash forever.
|
||||
// Every window also carries a ring (the Compact header style) and a meter
|
||||
// (Tiles). styles.css hides both in the classic style, so the chip there
|
||||
// reads exactly as before. `fill` is clamped for the two graphics only; the
|
||||
// label keeps the real number.
|
||||
const seg = (label, p, idle) => {
|
||||
if (p === null) {
|
||||
if (!idle) return '';
|
||||
return `<span class="pu-win pu-win-idle"><span class="pu-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));
|
||||
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
|
||||
// provider to tell apart: a machine with Claude alone shows bare windows.
|
||||
@@ -4021,6 +4027,36 @@ class CodemanApp {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The two words the Tiles header style shows for the connection indicator
|
||||
* (label over value), derived from the descriptor rather than added to it,
|
||||
* so the descriptor and its pinned strings stay exactly what they were. The
|
||||
* classic text line (queued bytes and all) stays in the DOM and the full
|
||||
* detail stays in the tooltip.
|
||||
* @param {{dotClass: string, text: string}} desc
|
||||
* @returns {{label: string, value: string, state: string}}
|
||||
*/
|
||||
_connectionTileWords(desc) {
|
||||
const state = (desc.dotClass || '').replace('connection-dot', '').trim();
|
||||
const text = desc.text || '';
|
||||
switch (state) {
|
||||
case 'connected':
|
||||
return { label: 'WS', value: 'live', state };
|
||||
case 'fallback':
|
||||
return { label: 'HTTP', value: 'fallback', state };
|
||||
case 'offline':
|
||||
return { label: 'NET', value: 'offline', state };
|
||||
case 'draining':
|
||||
return { label: 'SEND', value: 'queued', state };
|
||||
case 'reconnecting':
|
||||
// The same dot covers the terminal WebSocket and, with no session
|
||||
// open, the SSE event stream; the classic text already tells them apart.
|
||||
return { label: text.startsWith('WS') ? 'WS' : 'SSE', value: 'retry', state };
|
||||
default:
|
||||
return { label: '', value: '', state };
|
||||
}
|
||||
}
|
||||
|
||||
_updateConnectionIndicator() {
|
||||
const indicator = this.$('connectionIndicator');
|
||||
const dot = this.$('connectionDot');
|
||||
@@ -4048,6 +4084,14 @@ class CodemanApp {
|
||||
dot.className = next.dotClass;
|
||||
text.textContent = next.text;
|
||||
indicator.title = next.title;
|
||||
const tileLabel = this.$('connectionTileLabel');
|
||||
const tileValue = this.$('connectionTileValue');
|
||||
if (tileLabel && tileValue) {
|
||||
const words = this._connectionTileWords(next);
|
||||
tileLabel.textContent = words.label;
|
||||
tileValue.textContent = words.value;
|
||||
tileValue.className = `connection-tile-value ${words.state}`.trim();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4752,6 +4796,333 @@ class CodemanApp {
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* True when the tab list groups by state (`tabArrangement: 'state'`, the default;
|
||||
* Discussion #426 option C): a row per state in the header strip, a section
|
||||
* per state in the flat side rail and the sidebar, most urgent on top.
|
||||
*
|
||||
* Read off <html> like the rail gates (applyTabOrientation() owns the
|
||||
* attribute). Named groups in the vertical rail still win, because they are
|
||||
* the user's own structure: `_tabTriageLayout()` returns null while the
|
||||
* grouped projection is on, and the grouped tree renders as it always did.
|
||||
*/
|
||||
isTabTriage() {
|
||||
return document.documentElement.dataset.tabArrangement === 'state';
|
||||
}
|
||||
|
||||
/**
|
||||
* Order values and visible groups for one render pass, or null when the list
|
||||
* is not grouped by state. The pure core is `CodemanTabTriage.layout()`
|
||||
* (constants.js); this only feeds it the same classification both home
|
||||
* screens and the sorted rail use.
|
||||
*
|
||||
* Inside a group a row keeps its tab order on the header strip, so the strip
|
||||
* only moves a tab when its state changes. A sorted rail ranks rows inside
|
||||
* each section the way it ranks the whole flat rail (`railSortOrder`).
|
||||
*
|
||||
* @param {Array<string>} ids live session ids, in tab order
|
||||
* @param {object|null} groupProjection the grouped rail's projection, if any
|
||||
* @param {Map<string, number>|null} railSortOrder `_tabRailSortOrder(ids)`
|
||||
*/
|
||||
_tabTriageLayout(ids, groupProjection, railSortOrder) {
|
||||
if (groupProjection || !this.isTabTriage()) return null;
|
||||
if (!window.CodemanTabTriage || typeof this._mobileOverviewState !== 'function') return null;
|
||||
const rows = [];
|
||||
for (let i = 0; i < ids.length; i++) {
|
||||
const session = this.sessions.get(ids[i]);
|
||||
if (!session) continue;
|
||||
const state = this._mobileOverviewState(session, this.pendingHooks?.get(ids[i]));
|
||||
rows.push({
|
||||
id: ids[i],
|
||||
state,
|
||||
exited: !!this._mobileOverviewExit?.(state, session),
|
||||
pos: railSortOrder?.has(ids[i]) ? railSortOrder.get(ids[i]) : i,
|
||||
});
|
||||
}
|
||||
const webviewIds = (this.webviewOrder || []).filter((wid) => this.webviews?.has(wid));
|
||||
const reverse = document.documentElement.dataset.tabStateOrder === 'urgent-last';
|
||||
return window.CodemanTabTriage.layout(rows, webviewIds, { reverse });
|
||||
}
|
||||
|
||||
/**
|
||||
* Keep the state headings, the row breaks and the web tabs' `order` in step
|
||||
* with one pass's triage layout. Runs after BOTH render paths, because a
|
||||
* session changing state is an incremental pass (no tab is added or removed)
|
||||
* and can still empty a group or fill a new one.
|
||||
*
|
||||
* Headings and breaks are keyed by group and reconciled in place, never
|
||||
* rebuilt, so an SSE tick that changes nothing writes nothing. They are
|
||||
* direct children of #sessionTabs placed purely by `order`, so where they sit
|
||||
* in the DOM does not matter, and `aria-hidden` keeps them out of the tablist,
|
||||
* whose children must all be tabs. (A tab's state is not announced either way:
|
||||
* its status dot is aria-hidden, as before.) With `triage` null this removes
|
||||
* them all and clears the web tabs' inline order, which is what leaves the
|
||||
* ungrouped strip exactly as it was.
|
||||
*/
|
||||
_syncTabTriageChrome(container, triage) {
|
||||
if (!container) return;
|
||||
this._lastTabTriage = triage;
|
||||
container.classList.toggle('tabs-triage', !!triage);
|
||||
const wanted = new Map((triage?.groups || []).map((group) => [group.key, group]));
|
||||
for (const el of [...container.querySelectorAll(':scope > .tab-triage-head, :scope > .tab-triage-break')]) {
|
||||
if (!wanted.has(el.dataset.triageGroup)) el.remove();
|
||||
}
|
||||
const ensure = (className, key) => {
|
||||
let el = container.querySelector(`:scope > .${className}[data-triage-group="${key}"]`);
|
||||
if (!el) {
|
||||
el = document.createElement('div');
|
||||
el.className = className === 'tab-triage-head' ? `tab-triage-head tab-triage-head--${key}` : className;
|
||||
el.dataset.triageGroup = key;
|
||||
el.setAttribute('aria-hidden', 'true');
|
||||
container.appendChild(el);
|
||||
}
|
||||
return el;
|
||||
};
|
||||
for (const group of wanted.values()) {
|
||||
const head = ensure('tab-triage-head', group.key);
|
||||
// A quiet group (idle) keeps its heading as the row's anchor but draws
|
||||
// no label or count.
|
||||
head.classList.toggle('tab-triage-head--quiet', !!group.quiet);
|
||||
if (!group.quiet && !head.firstElementChild) {
|
||||
const label = document.createElement('span');
|
||||
label.className = 'tab-triage-label';
|
||||
label.textContent = group.label;
|
||||
const count = document.createElement('span');
|
||||
count.className = 'tab-triage-count';
|
||||
head.append(label, count);
|
||||
}
|
||||
const count = String(group.count);
|
||||
if (!group.quiet && head.lastElementChild.textContent !== count) head.lastElementChild.textContent = count;
|
||||
// The first row's heading is the one that starts beside the brand in the
|
||||
// header strip (styles.css); every later row starts under it.
|
||||
head.classList.toggle('tab-triage-head--lead', group === triage.groups[0]);
|
||||
const headOrder = String(group.headOrder);
|
||||
if (head.style.order !== headOrder) head.style.order = headOrder;
|
||||
const brk = ensure('tab-triage-break', group.key);
|
||||
const breakOrder = String(group.breakOrder);
|
||||
if (brk.style.order !== breakOrder) brk.style.order = breakOrder;
|
||||
}
|
||||
for (const web of container.querySelectorAll(':scope > .session-tab[data-webview-id]')) {
|
||||
const wid = web.dataset.webviewId;
|
||||
const value = triage?.webOrder.has(wid) ? String(triage.webOrder.get(wid)) : '';
|
||||
if (web.style.order !== value) web.style.order = value;
|
||||
}
|
||||
this._sizeTabTriageGutter(container, triage);
|
||||
}
|
||||
|
||||
/**
|
||||
* Size the header strip's two measured lengths (styles.css, "Header strip,
|
||||
* wrapping"): `--tab-triage-gutter`, the label column, as wide as the widest
|
||||
* label on screen so a row never carries a fixed gutter's worth of empty
|
||||
* space; and `--tab-triage-brand`, the brand's width, because the brand sits
|
||||
* over the strip's top-left corner and only the FIRST row starts beside it,
|
||||
* every later row starting under it.
|
||||
*
|
||||
* The labels are measured only when their text changes (a group appears,
|
||||
* goes, or its count gains a digit) and once more when the web fonts finish
|
||||
* loading. The brand is watched by a ResizeObserver (a display-name change,
|
||||
* the sidebar toggle appearing), so a render pass never forces a layout read
|
||||
* for it. The vertical lists use neither length and are never measured.
|
||||
*/
|
||||
_sizeTabTriageGutter(container, triage) {
|
||||
const inHeader = !!container.parentElement?.classList.contains('session-tabs-host');
|
||||
if (!triage || !inHeader) {
|
||||
if (container.style.getPropertyValue('--tab-triage-gutter')) container.style.removeProperty('--tab-triage-gutter');
|
||||
if (container.style.getPropertyValue('--tab-triage-brand')) container.style.removeProperty('--tab-triage-brand');
|
||||
this._tabTriageGutterKey = null;
|
||||
return;
|
||||
}
|
||||
this._watchTabTriageBrand(container);
|
||||
if (Number.isFinite(this._tabTriageBrandWidth)) {
|
||||
const brand = `${this._tabTriageBrandWidth}px`;
|
||||
if (container.style.getPropertyValue('--tab-triage-brand') !== brand) {
|
||||
container.style.setProperty('--tab-triage-brand', brand);
|
||||
}
|
||||
}
|
||||
const key = triage.groups.map((g) => `${g.key}:${g.count}`).join('|');
|
||||
if (key === this._tabTriageGutterKey) return;
|
||||
let widest = 0;
|
||||
for (const head of container.querySelectorAll(':scope > .tab-triage-head')) {
|
||||
let width = 0;
|
||||
for (const part of head.children) width += part.getBoundingClientRect().width;
|
||||
widest = Math.max(widest, width + 5 * Math.max(0, head.children.length - 1));
|
||||
}
|
||||
// Hidden (display: none on phones, or a detached strip): nothing to size.
|
||||
if (!widest) return;
|
||||
this._tabTriageGutterKey = key;
|
||||
container.style.setProperty('--tab-triage-gutter', `${Math.ceil(widest + 10)}px`);
|
||||
if (!this._tabTriageFontsHooked && document.fonts?.ready) {
|
||||
this._tabTriageFontsHooked = true;
|
||||
document.fonts.ready.then(() => {
|
||||
this._tabTriageGutterKey = null;
|
||||
this._sizeTabTriageGutter(this.$('sessionTabs'), this._lastTabTriage);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Keep `_tabTriageBrandWidth` (the header brand plus the gap after it) in
|
||||
* step with the brand, once per page. The first observation arrives right
|
||||
* after `observe()`, so the width is known from the first frame on.
|
||||
*/
|
||||
_watchTabTriageBrand(container) {
|
||||
if (this._tabTriageBrandObserver !== undefined) return;
|
||||
const brand = container.closest('.header')?.querySelector(':scope > .header-brand');
|
||||
if (!brand || typeof ResizeObserver !== 'function') {
|
||||
this._tabTriageBrandObserver = null;
|
||||
return;
|
||||
}
|
||||
const gap = 8;
|
||||
this._tabTriageBrandWidth = Math.ceil(brand.getBoundingClientRect().width + gap);
|
||||
this._tabTriageBrandObserver = new ResizeObserver((entries) => {
|
||||
const box = entries[0]?.borderBoxSize?.[0];
|
||||
const width = Math.ceil((box ? box.inlineSize : brand.getBoundingClientRect().width) + gap);
|
||||
if (width === this._tabTriageBrandWidth) return;
|
||||
this._tabTriageBrandWidth = width;
|
||||
this._sizeTabTriageGutter(this.$('sessionTabs'), this._lastTabTriage);
|
||||
});
|
||||
this._tabTriageBrandObserver.observe(brand);
|
||||
}
|
||||
|
||||
/**
|
||||
* A drag in a grouped strip (by state or by case) may only reorder WITHIN a
|
||||
* group. Inside a group the rows sit in tab order, so a drop there moves the
|
||||
* tab exactly where it was dropped; across groups the dragged tab would stay
|
||||
* in its own group (its state or case did not change) and land somewhere the
|
||||
* user did not put it. State groups are bands of `order` values, so comparing
|
||||
* bands is enough; case clusters are boxes, so the box decides.
|
||||
*/
|
||||
_isTabDropAcrossGroups(targetTab) {
|
||||
const container = this.$('sessionTabs');
|
||||
if (!container || !this.draggedTabId || !targetTab) return false;
|
||||
const triage = container.classList.contains('tabs-triage');
|
||||
const clusters = container.classList.contains('tabs-clusters');
|
||||
if (!triage && !clusters) return false;
|
||||
const dragged = container.querySelector(`.session-tab[data-id="${this.draggedTabId}"]`);
|
||||
if (!dragged) return false;
|
||||
// Clusters are real boxes: a drop belongs to the box it lands in.
|
||||
if (clusters) return dragged.closest('.tab-cluster') !== targetTab.closest('.tab-cluster');
|
||||
const stride = window.CodemanTabTriage?.STRIDE || 10000;
|
||||
const band = (el) => Math.floor((Number(el.style.order) || 0) / stride);
|
||||
return band(dragged) !== band(targetTab);
|
||||
}
|
||||
|
||||
/** True when the tab list is clustered by case (`tabArrangement: 'case'`, Discussion #426 option A). */
|
||||
isTabClusters() {
|
||||
return document.documentElement.dataset.tabArrangement === 'case';
|
||||
}
|
||||
|
||||
/**
|
||||
* True when the header strip is drawn as a ledger (`tabArrangement: 'ledger'`,
|
||||
* Discussion #426 option B): the flat list on an aligned column grid with a
|
||||
* status bar per cell. Pure CSS on `.tabs-ledger`, scoped to the desktop
|
||||
* header strip; the rail and the sidebar keep their flat list.
|
||||
*/
|
||||
isTabLedger() {
|
||||
return document.documentElement.dataset.tabArrangement === 'ledger';
|
||||
}
|
||||
|
||||
/**
|
||||
* Which case a session belongs to, for clustering: the case whose path is the
|
||||
* longest prefix of its working directory (`_mobileOverviewCaseFor()`, the
|
||||
* home screens' own match), else the directory itself, else the session alone.
|
||||
*/
|
||||
_tabClusterIdentity(session, id) {
|
||||
const dir = (session.workingDir || '').replace(/\/+$/, '');
|
||||
const match =
|
||||
dir && typeof this._mobileOverviewCaseFor === 'function' ? this._mobileOverviewCaseFor(dir, this.cases) : null;
|
||||
if (match) return { key: match.path, label: match.name || '' };
|
||||
if (dir) return { key: dir, label: dir.split('/').pop() || dir };
|
||||
return { key: `session:${id}`, label: '' };
|
||||
}
|
||||
|
||||
/**
|
||||
* The cluster layout for one render pass, or null when the list is not
|
||||
* clustered. Named groups in the vertical rail win, exactly as for the state
|
||||
* grouping. `key` is the whole structure as a string: the incremental render
|
||||
* path compares it with the last full render's and rebuilds when it differs,
|
||||
* because a cluster is a real box and a patch in place cannot move a tab into
|
||||
* another one. Membership only changes when sessions come and go (already a
|
||||
* full rebuild) or when the case list arrives, so this rarely fires.
|
||||
*
|
||||
* @param {Array<string>} ids live session ids, in tab order
|
||||
* @param {object|null} groupProjection the grouped rail's projection, if any
|
||||
*/
|
||||
_tabClusterLayout(ids, groupProjection) {
|
||||
if (groupProjection || !this.isTabClusters() || !window.CodemanTabClusters) return null;
|
||||
const rows = [];
|
||||
for (const id of ids) {
|
||||
const session = this.sessions.get(id);
|
||||
if (session) rows.push({ id, ...this._tabClusterIdentity(session, id) });
|
||||
}
|
||||
const clusters = window.CodemanTabClusters.compute(rows);
|
||||
// Only a cluster with company drops the case from its tab names.
|
||||
const labelFor = new Map();
|
||||
for (const cluster of clusters) {
|
||||
if (cluster.ids.length > 1) for (const id of cluster.ids) labelFor.set(id, cluster.label);
|
||||
}
|
||||
const webviewIds = (this.webviewOrder || []).filter((wid) => this.webviews?.has(wid));
|
||||
const key = JSON.stringify([clusters.map((c) => [c.key, c.label, c.ids]), webviewIds]);
|
||||
return { clusters, labelFor, webviewIds, key };
|
||||
}
|
||||
|
||||
/**
|
||||
* The cluster boxes for the full render: one box per case, labelled with its
|
||||
* colour swatch, name and count, and a box per open web tab, which has no
|
||||
* case. The header strip shows only the swatch for a case with one tab
|
||||
* (styles.css); the rail and the sidebar label every case. Rows are the
|
||||
* caller's own markup, so a tab is byte-identical to the flat strip's apart
|
||||
* from its name split.
|
||||
*/
|
||||
_renderTabClusters(layout, rowHtml, webviewSlotStart) {
|
||||
const parts = [];
|
||||
for (const cluster of layout.clusters) {
|
||||
const rows = cluster.ids.map((id) => rowHtml.get(id) || '').join('');
|
||||
const single = cluster.ids.length < 2;
|
||||
const head =
|
||||
'<span class="tab-cluster-label" aria-hidden="true"><span class="tab-cluster-swatch"></span>' +
|
||||
`<span class="tab-cluster-name" data-i18n-skip>${escapeHtml(cluster.label)}</span>` +
|
||||
`<span class="tab-cluster-count">${cluster.ids.length}</span></span>`;
|
||||
parts.push(
|
||||
`<div class="tab-cluster${single ? ' tab-cluster--single' : ''}" role="presentation" data-cluster-key="${escapeHtml(cluster.key)}" style="--cluster-color: var(--session-${cluster.color})">${head}${rows}</div>`
|
||||
);
|
||||
}
|
||||
layout.webviewIds.forEach((wid, i) => {
|
||||
const tab = this.renderWebviewTab?.(wid, webviewSlotStart + i) || '';
|
||||
if (tab) parts.push(`<div class="tab-cluster tab-cluster--single tab-cluster--web" role="presentation">${tab}</div>`);
|
||||
});
|
||||
return parts.join('');
|
||||
}
|
||||
|
||||
/**
|
||||
* The tab label, as markup. #232: a described name (`w3-x: fix login`) shows
|
||||
* just the description, the generated id kept in a hidden prefix span. Inside
|
||||
* a case cluster a generated `w75-api-gateway` shows `w75`, the `-api-gateway`
|
||||
* kept in a `.tab-name-case` span that only `.tabs-clusters` hides, so the full
|
||||
* name stays in the DOM (copy, find-in-page, the rename editor).
|
||||
*/
|
||||
_tabNameHtml(name, clusterLabel) {
|
||||
const parsed = parseSessionPrefix(name);
|
||||
if (parsed && parsed.suffix) {
|
||||
return `<span class="tab-name-prefix">${escapeHtml(parsed.prefix)}: </span>${escapeHtml(parsed.suffix)}`;
|
||||
}
|
||||
const split = clusterLabel ? window.CodemanTabClusters?.nameSplit(name, clusterLabel) : null;
|
||||
if (split) return `${escapeHtml(split.shown)}<span class="tab-name-case">${escapeHtml(split.hidden)}</span>`;
|
||||
return escapeHtml(name);
|
||||
}
|
||||
|
||||
/**
|
||||
* The arrangement classes on #sessionTabs that are not owned by a sync of
|
||||
* their own (`tabs-triage` is `_syncTabTriageChrome()`'s): `tabs-clusters`
|
||||
* while case clusters are drawn, `tabs-ledger` while the ledger is on. The
|
||||
* ledger never applies inside the grouped rail.
|
||||
*/
|
||||
_syncTabArrangementClasses(container, { clusters, groupProjection }) {
|
||||
if (!container) return;
|
||||
container.classList.toggle('tabs-clusters', !!clusters);
|
||||
container.classList.toggle('tabs-ledger', this.isTabLedger() && !groupProjection);
|
||||
}
|
||||
|
||||
/**
|
||||
* True where the sidebar is a MODAL off-canvas drawer over the terminal
|
||||
* instead of a docked column.
|
||||
@@ -5004,6 +5375,9 @@ class CodemanApp {
|
||||
const reachable =
|
||||
this.isSessionSidebarActive() && document.documentElement.dataset.sidebar !== 'collapsed';
|
||||
const needle = reachable ? this._sidebarFilter : '';
|
||||
// State headings count the whole group, so they step aside while a filter
|
||||
// is narrowing the rows under them (styles.css, .tabs-filtering).
|
||||
container.classList.toggle('tabs-filtering', !!needle);
|
||||
for (const tab of container.querySelectorAll('.session-tab')) {
|
||||
if (!needle) {
|
||||
tab.classList.remove('tab-filtered-out');
|
||||
@@ -5412,10 +5786,16 @@ class CodemanApp {
|
||||
// The grouped rail's structure (sections, collapse, the shown exception) can
|
||||
// change while the id sets stay equal; the in-place patch below cannot move
|
||||
// or hide a row, so any structural change takes the full rebuild.
|
||||
// Case clusters are boxes too: the same rule, keyed on their structure.
|
||||
const clusterLayout = this._tabClusterLayout(
|
||||
this.sessionOrder.filter((sid) => this.sessions.has(sid)),
|
||||
groupProjection
|
||||
);
|
||||
const canIncremental = existingIds.size === currentIds.size &&
|
||||
[...existingIds].every(id => currentIds.has(id)) &&
|
||||
webTabsUnchanged &&
|
||||
!this._isTabGroupStructureStale(groupProjection);
|
||||
!this._isTabGroupStructureStale(groupProjection) &&
|
||||
(clusterLayout ? clusterLayout.key : null) === (this._lastTabClusterKey ?? null);
|
||||
|
||||
if (canIncremental) {
|
||||
// Read once for the whole pass, like the full-rebuild path: this touches
|
||||
@@ -5425,7 +5805,13 @@ class CodemanApp {
|
||||
// that sees one — a session going working→idle never adds or removes a
|
||||
// tab, so the full rebuild below is not reached. Recomputed per pass for
|
||||
// the same reason the rich meta line is: the order IS the state.
|
||||
const 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
|
||||
for (const [id, session] of this.sessions) {
|
||||
const tab = container.querySelector(`.session-tab[data-id="${id}"]`);
|
||||
@@ -5433,7 +5819,7 @@ class CodemanApp {
|
||||
|
||||
// An empty string clears the property, which is also what un-sorts the
|
||||
// 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;
|
||||
|
||||
// A web tab owns the active state while one is open. activeSessionId stays
|
||||
@@ -5532,11 +5918,17 @@ class CodemanApp {
|
||||
const _p = parseSessionPrefix(name);
|
||||
if (nameEl.dataset.fullName !== name) {
|
||||
nameEl.replaceChildren();
|
||||
const _split = _p && _p.suffix ? null : window.CodemanTabClusters?.nameSplit(name, clusterLayout?.labelFor.get(id));
|
||||
if (_p && _p.suffix) {
|
||||
const prefix = document.createElement('span');
|
||||
prefix.className = 'tab-name-prefix';
|
||||
prefix.textContent = `${_p.prefix}: `;
|
||||
nameEl.append(prefix, document.createTextNode(_p.suffix));
|
||||
} else if (_split) {
|
||||
const caseSpan = document.createElement('span');
|
||||
caseSpan.className = 'tab-name-case';
|
||||
caseSpan.textContent = _split.hidden;
|
||||
nameEl.append(document.createTextNode(_split.shown), caseSpan);
|
||||
} else {
|
||||
nameEl.textContent = name;
|
||||
}
|
||||
@@ -5611,6 +6003,8 @@ class CodemanApp {
|
||||
this._applyTabTreePositions(container);
|
||||
this._syncTabGroupHeaderAlerts(container, groupProjection);
|
||||
}
|
||||
this._syncTabTriageChrome(container, triage);
|
||||
this._syncTabArrangementClasses(container, { clusters: !!clusterLayout, groupProjection });
|
||||
} else {
|
||||
// Full rebuild needed (sessions added/removed)
|
||||
this._fullRenderSessionTabs();
|
||||
@@ -5679,6 +6073,16 @@ class CodemanApp {
|
||||
return;
|
||||
}
|
||||
|
||||
// Grouped by state, the header strip IS rows (one per state), so it always
|
||||
// wraps: the row breaks only take effect in a wrapping flex line. The ledger
|
||||
// is a grid of rows, so the same holds. Narrower screens keep the single
|
||||
// scrolling row above, where state headings read as inline dividers and the
|
||||
// ledger stays the plain strip.
|
||||
if (container.classList.contains('tabs-triage') || container.classList.contains('tabs-ledger')) {
|
||||
container.classList.add('tabs-auto-wrap');
|
||||
return;
|
||||
}
|
||||
|
||||
// Measure the natural one-row overflow, then enable wrapping only if needed.
|
||||
container.classList.remove('tabs-auto-wrap');
|
||||
const shouldWrap = window.CodemanTabOverflow?.shouldAutoWrapTabs
|
||||
@@ -5771,7 +6175,17 @@ class CodemanApp {
|
||||
// below still counts the strip, not the sorted list. Null in every other
|
||||
// layout, and the tabs then carry no inline order at all — the header
|
||||
// strip's markup is byte-identical to before.
|
||||
const railSortOrder = this._tabRailSortOrder(tabOrder.filter((id) => this.sessions.has(id)));
|
||||
const liveIds = tabOrder.filter((id) => this.sessions.has(id));
|
||||
const railSortOrder = this._tabRailSortOrder(liveIds);
|
||||
// Grouped by state (tabArrangement 'state', the default): the same `order` mechanism,
|
||||
// one band of values per state. Null in the grouped rail and with grouping
|
||||
// off, and the rows then carry exactly the inline order they did before.
|
||||
const groupProjection = this._projectTabGroups();
|
||||
const triage = this._tabTriageLayout(liveIds, groupProjection, railSortOrder);
|
||||
const listOrder = triage ? triage.order : railSortOrder;
|
||||
// Clustered by case: rows are wrapped in one box per case below, and a tab
|
||||
// in a cluster with company drops the `-<case>` from its name.
|
||||
const clusterLayout = this._tabClusterLayout(liveIds, groupProjection);
|
||||
// One row per session, in tab order. The flat strip emits them as-is; the
|
||||
// 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).
|
||||
@@ -5780,7 +6194,7 @@ class CodemanApp {
|
||||
for (const id of tabOrder) {
|
||||
const session = this.sessions.get(id);
|
||||
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
|
||||
// while one is open, even though activeSessionId stays set.
|
||||
@@ -5813,9 +6227,7 @@ class CodemanApp {
|
||||
// JUST the description on the tab; the generated w<n>-<case> id moves to the
|
||||
// tooltip and stays visible in the session settings modal.
|
||||
const parsedName = parseSessionPrefix(name);
|
||||
const tabLabel = parsedName && parsedName.suffix
|
||||
? `<span class="tab-name-prefix">${escapeHtml(parsedName.prefix)}: </span>${escapeHtml(parsedName.suffix)}`
|
||||
: escapeHtml(name);
|
||||
const tabLabel = this._tabNameHtml(name, clusterLayout?.labelFor.get(id));
|
||||
const tabTooltip = parsedName && parsedName.suffix
|
||||
? (session.workingDir ? `${parsedName.prefix} (${session.workingDir})` : parsedName.prefix)
|
||||
: (session.workingDir || '');
|
||||
@@ -5862,8 +6274,12 @@ class CodemanApp {
|
||||
_tabIdx++;
|
||||
}
|
||||
|
||||
const groupProjection = this._projectTabGroups();
|
||||
if (groupProjection) {
|
||||
if (clusterLayout) {
|
||||
// Clustered by case. Web tabs keep their flat-strip Alt+N slot (after
|
||||
// every session), each in a box of its own.
|
||||
parts.push(this._renderTabClusters(clusterLayout, rowHtml, _tabIdx));
|
||||
this._hiddenTabGroupByRef = new Map();
|
||||
} else if (groupProjection) {
|
||||
// Grouped vertical rail. Web tabs keep their flat-strip Alt+N slot (after
|
||||
// every session), wherever their group puts them.
|
||||
const webviewSlots = new Map(
|
||||
@@ -5889,6 +6305,7 @@ class CodemanApp {
|
||||
this._hiddenTabGroupByRef = new Map();
|
||||
}
|
||||
this._lastTabGroupStructureKey = this._tabGroupStructureKey(groupProjection);
|
||||
this._lastTabClusterKey = clusterLayout ? clusterLayout.key : null;
|
||||
|
||||
container.innerHTML = parts.join('');
|
||||
container.classList.toggle('session-tabs--grouped', !!groupProjection);
|
||||
@@ -5897,6 +6314,8 @@ class CodemanApp {
|
||||
this._applyTabTreeSemantics(container, { identity: focusIdentity, refocus: focusWasInside });
|
||||
this._syncTabGroupHeaderAlerts(container, groupProjection);
|
||||
}
|
||||
this._syncTabTriageChrome(container, triage);
|
||||
this._syncTabArrangementClasses(container, { clusters: !!clusterLayout, groupProjection });
|
||||
|
||||
// Put the strip back where the user left it, then reveal the active tab
|
||||
// only when it CHANGED (or on the first paint). Restoring unconditionally
|
||||
@@ -5971,9 +6390,13 @@ class CodemanApp {
|
||||
// the inline one, or web tabs (pinned past the cards by a CSS `order: 9999`
|
||||
// rather than an inline style) read as 0 and the walk starts on them. Array
|
||||
// sort is stable, so equal orders keep DOM order, which is the unsorted case.
|
||||
if (this.isTabRailSorted()) {
|
||||
if (this.isTabRailSorted() || container.classList.contains('tabs-triage')) {
|
||||
const orderOf = (el) => Number(getComputedStyle(el).order) || 0;
|
||||
tabs.sort((a, b) => orderOf(a) - orderOf(b));
|
||||
// Case clusters are boxes in DOM order and a sorted rail orders rows
|
||||
// INSIDE each one, so the box goes first in the key.
|
||||
const boxes = [...container.querySelectorAll(':scope > .tab-cluster')];
|
||||
const boxOf = (el) => boxes.indexOf(el.closest('.tab-cluster'));
|
||||
tabs.sort((a, b) => boxOf(a) - boxOf(b) || orderOf(a) - orderOf(b));
|
||||
}
|
||||
const currentIndex = tabs.indexOf(document.activeElement);
|
||||
|
||||
@@ -7172,6 +7595,9 @@ class CodemanApp {
|
||||
});
|
||||
|
||||
tab.addEventListener('dragover', (e) => {
|
||||
// Grouped by state: a tab in another group is not a drop target, and
|
||||
// leaving the event alone (no preventDefault) is what shows "no drop".
|
||||
if (this._isTabDropAcrossGroups(tab)) return;
|
||||
e.preventDefault();
|
||||
if (!this.draggedTabId || this.draggedTabId === tab.dataset.id) return;
|
||||
|
||||
@@ -7201,6 +7627,7 @@ class CodemanApp {
|
||||
tab.classList.remove('drag-over-left', 'drag-over-right');
|
||||
|
||||
if (!this.draggedTabId || this.draggedTabId === tab.dataset.id) return;
|
||||
if (this._isTabDropAcrossGroups(tab)) return;
|
||||
|
||||
const targetId = tab.dataset.id;
|
||||
const draggedId = this.draggedTabId;
|
||||
|
||||
@@ -658,6 +658,194 @@ function sortSessionsByActivity(rows) {
|
||||
return (Array.isArray(rows) ? rows.slice() : []).sort(compareSessionActivity);
|
||||
}
|
||||
|
||||
// Tab grouping by state (`tabArrangement: 'state'`, Discussion #426 option C).
|
||||
//
|
||||
// The tab list answers "who wants me?" the way the home screens do: a row (the
|
||||
// header strip) or a section (the flat side rail, the sidebar) per state, most
|
||||
// urgent on top. The states are the home screens' own, from
|
||||
// `_mobileOverviewState()` (mobile-overview.js); this only folds the six into
|
||||
// four groups a strip can hold:
|
||||
//
|
||||
// needs red: a permission or question dialog is blocking the agent. A
|
||||
// failed session joins it, since it also needs a human and the home
|
||||
// screens rank it right below.
|
||||
// waiting yellow: the agent finished its turn and is waiting on you.
|
||||
// working a turn is running.
|
||||
// idle everything quiet: idle, ended, and an agent that exited inside a
|
||||
// live pane (#446), which may still read as working on screen but is
|
||||
// running nothing. Web tabs close the group.
|
||||
//
|
||||
// Applied as the flex `order` property, never by reordering the DOM, the same
|
||||
// design as the sorted rail (`_tabRailSortOrder`, app.js): `#sessionTabs` stays
|
||||
// in `sessionOrder`, so Alt+N, drag-and-drop and the keyboard walk keep reading
|
||||
// the list they always read, and a state change moves one inline style instead
|
||||
// of rebuilding the strip. Each group owns a band of `TAB_TRIAGE_STRIDE` order
|
||||
// values: its heading at the start of the band, its rows after it, its web tabs
|
||||
// after those and the line break that ends the header row at the very end.
|
||||
//
|
||||
// Pure: no DOM, no `this`. Unit-tested in test/tab-triage.test.ts.
|
||||
// `quiet` groups keep their heading element (it anchors the row and holds the
|
||||
// row's place beside the brand) but draw no text: everything quiet is the
|
||||
// default state of a tab, so naming it only adds noise.
|
||||
const TAB_TRIAGE_GROUPS = [
|
||||
{ key: 'needs', label: 'Needs you' },
|
||||
{ key: 'waiting', label: 'Waiting' },
|
||||
{ key: 'working', label: 'Working' },
|
||||
{ key: 'idle', label: 'Idle', quiet: true },
|
||||
];
|
||||
|
||||
const TAB_TRIAGE_GROUP_OF_STATE = {
|
||||
needs: 'needs',
|
||||
error: 'needs',
|
||||
waiting: 'waiting',
|
||||
working: 'working',
|
||||
idle: 'idle',
|
||||
done: 'idle',
|
||||
};
|
||||
|
||||
const TAB_TRIAGE_STRIDE = 10000;
|
||||
/** Offset of a group's web tabs inside its band, past any plausible session count. */
|
||||
const TAB_TRIAGE_WEB_OFFSET = 5000;
|
||||
|
||||
/**
|
||||
* Which group a session belongs to.
|
||||
* @param {string} state a `_mobileOverviewState()` value
|
||||
* @param {boolean} exited the agent inside the pane has exited (`_mobileOverviewExit()` non-null)
|
||||
* @returns {'needs'|'waiting'|'working'|'idle'}
|
||||
*/
|
||||
function tabTriageGroupFor(state, exited) {
|
||||
const group = TAB_TRIAGE_GROUP_OF_STATE[state] || 'idle';
|
||||
return exited && group === 'working' ? 'idle' : group;
|
||||
}
|
||||
|
||||
/**
|
||||
* Order values and visible groups for one pass.
|
||||
*
|
||||
* @param {Array<{id: string, state: string, exited?: boolean, pos?: number}>} rows
|
||||
* live sessions; `pos` ranks a row inside its group (tab order on the header
|
||||
* strip, the activity sort's position on a sorted rail). Rows without one keep
|
||||
* the order they were passed in.
|
||||
* @param {Array<string>} webviewIds open web tabs, in their own tab order
|
||||
* @param {{reverse?: boolean}} [options] `reverse` puts the groups the other
|
||||
* way up (`tabStateOrder: 'urgent-last'`): idle first, needs you last, for a
|
||||
* strip read from the bottom. Rows inside a group keep their order.
|
||||
* @returns {{
|
||||
* order: Map<string, number>,
|
||||
* webOrder: Map<string, number>,
|
||||
* groups: Array<{key: string, label: string, quiet: boolean, count: number, headOrder: number, breakOrder: number}>
|
||||
* }} `groups` lists only the non-empty groups, in display order (most urgent
|
||||
* first, or last with `reverse`).
|
||||
*/
|
||||
function computeTabTriageLayout(rows, webviewIds, options) {
|
||||
const list = Array.isArray(rows) ? rows : [];
|
||||
const webs = Array.isArray(webviewIds) ? webviewIds : [];
|
||||
const sequence = options && options.reverse ? TAB_TRIAGE_GROUPS.slice().reverse() : TAB_TRIAGE_GROUPS;
|
||||
const baseOf = {};
|
||||
const counts = {};
|
||||
sequence.forEach((group, i) => {
|
||||
baseOf[group.key] = (i + 1) * TAB_TRIAGE_STRIDE;
|
||||
counts[group.key] = 0;
|
||||
});
|
||||
|
||||
const placed = list
|
||||
.filter((row) => row && typeof row.id === 'string')
|
||||
.map((row, i) => ({
|
||||
id: row.id,
|
||||
group: tabTriageGroupFor(row.state, !!row.exited),
|
||||
pos: Number.isFinite(row.pos) ? row.pos : i,
|
||||
index: i,
|
||||
}));
|
||||
const byGroup = {};
|
||||
for (const row of placed) (byGroup[row.group] = byGroup[row.group] || []).push(row);
|
||||
|
||||
const order = new Map();
|
||||
for (const key of Object.keys(byGroup)) {
|
||||
// Stable: equal positions keep the order the caller passed.
|
||||
byGroup[key].sort((a, b) => a.pos - b.pos || a.index - b.index);
|
||||
byGroup[key].forEach((row, i) => order.set(row.id, baseOf[key] + 1 + i));
|
||||
counts[key] = byGroup[key].length;
|
||||
}
|
||||
|
||||
const webOrder = new Map();
|
||||
webs.forEach((id, i) => {
|
||||
if (typeof id === 'string' && id) webOrder.set(id, baseOf.idle + TAB_TRIAGE_WEB_OFFSET + i);
|
||||
});
|
||||
counts.idle += webOrder.size;
|
||||
|
||||
const groups = sequence.filter((group) => counts[group.key] > 0).map((group) => ({
|
||||
key: group.key,
|
||||
label: group.label,
|
||||
quiet: !!group.quiet,
|
||||
count: counts[group.key],
|
||||
headOrder: baseOf[group.key],
|
||||
breakOrder: baseOf[group.key] + TAB_TRIAGE_STRIDE - 1,
|
||||
}));
|
||||
|
||||
return { order, webOrder, groups };
|
||||
}
|
||||
|
||||
// Tab clusters by case (`tabArrangement: 'case'`, Discussion #426 option A).
|
||||
//
|
||||
// One cluster per case, in the order the case first appears in the tab order,
|
||||
// so the strip keeps the user's arrangement at the case level. Inside a cluster
|
||||
// with two or more tabs the `-<case>` part of a generated `w<n>-<case>` name is
|
||||
// redundant and is hidden (`tabClusterNameSplit()`), which is what lets 18 tabs
|
||||
// read as 7 things. A case with one tab is still its own (unlabelled) box.
|
||||
//
|
||||
// Cluster colours come from the session palette (`--session-<colour>`) by a
|
||||
// stable hash of the case key, so a case keeps its colour across reloads and
|
||||
// devices without anything being stored.
|
||||
//
|
||||
// Pure: no DOM, no `this`. Unit-tested in test/tab-clusters.test.ts.
|
||||
const TAB_CLUSTER_COLORS = ['blue', 'green', 'purple', 'orange', 'pink', 'yellow', 'red'];
|
||||
|
||||
/** Palette colour for a cluster key: a djb2 hash, so the same key always gets the same colour. */
|
||||
function tabClusterColorFor(key) {
|
||||
const text = String(key || '');
|
||||
let hash = 5381;
|
||||
for (let i = 0; i < text.length; i++) hash = ((hash << 5) + hash + text.charCodeAt(i)) | 0;
|
||||
return TAB_CLUSTER_COLORS[Math.abs(hash) % TAB_CLUSTER_COLORS.length];
|
||||
}
|
||||
|
||||
/**
|
||||
* Group tabs by case.
|
||||
* @param {Array<{id: string, key: string, label: string}>} rows live sessions in
|
||||
* tab order; `key` identifies the case (its path), `label` names it
|
||||
* @returns {Array<{key: string, label: string, color: string, ids: string[]}>}
|
||||
* clusters in first-appearance order, members in tab order
|
||||
*/
|
||||
function computeTabClusters(rows) {
|
||||
const clusters = [];
|
||||
const byKey = new Map();
|
||||
for (const row of Array.isArray(rows) ? rows : []) {
|
||||
if (!row || typeof row.id !== 'string') continue;
|
||||
const key = typeof row.key === 'string' && row.key ? row.key : `session:${row.id}`;
|
||||
let cluster = byKey.get(key);
|
||||
if (!cluster) {
|
||||
cluster = { key, label: typeof row.label === 'string' ? row.label : '', color: tabClusterColorFor(key), ids: [] };
|
||||
byKey.set(key, cluster);
|
||||
clusters.push(cluster);
|
||||
}
|
||||
cluster.ids.push(row.id);
|
||||
}
|
||||
return clusters;
|
||||
}
|
||||
|
||||
/**
|
||||
* Split a generated `w<n>-<case>` / `s<n>-<case>` name into the part a cluster
|
||||
* shows and the case suffix it hides, or null when the name is anything else
|
||||
* (a custom name, a described `w3-x: fix login`, another case's name). Case is
|
||||
* compared case-insensitively; the hidden part keeps its original spelling so
|
||||
* the full name is still in the DOM.
|
||||
* @returns {{shown: string, hidden: string}|null}
|
||||
*/
|
||||
function tabClusterNameSplit(name, label) {
|
||||
if (typeof name !== 'string' || typeof label !== 'string' || !label) return null;
|
||||
const match = name.match(/^([ws]\d+)(-.+)$/);
|
||||
if (!match) return null;
|
||||
return match[2].slice(1).toLowerCase() === label.toLowerCase() ? { shown: match[1], hidden: match[2] } : null;
|
||||
}
|
||||
|
||||
// Terminal font stack — the single source for every xterm surface (the main
|
||||
// terminal in terminal-ui.js, the log-viewer terminal in panels-ui.js).
|
||||
// "Symbols Nerd Font Mono" is a bundled icons-only webfont (fonts/ +
|
||||
@@ -983,6 +1171,18 @@ if (typeof window !== 'undefined') {
|
||||
compare: compareSessionActivity,
|
||||
sort: sortSessionsByActivity,
|
||||
};
|
||||
window.CodemanTabClusters = {
|
||||
COLORS: TAB_CLUSTER_COLORS,
|
||||
colorFor: tabClusterColorFor,
|
||||
compute: computeTabClusters,
|
||||
nameSplit: tabClusterNameSplit,
|
||||
};
|
||||
window.CodemanTabTriage = {
|
||||
GROUPS: TAB_TRIAGE_GROUPS,
|
||||
STRIDE: TAB_TRIAGE_STRIDE,
|
||||
groupFor: tabTriageGroupFor,
|
||||
layout: computeTabTriageLayout,
|
||||
};
|
||||
window.CodemanInputLimit = {
|
||||
FRAME_MAX_CHARS: INPUT_FRAME_MAX_CHARS,
|
||||
PASTE_MAX_CHARS: INPUT_PASTE_MAX_CHARS,
|
||||
|
||||
@@ -221,6 +221,7 @@
|
||||
Running: '运行中',
|
||||
Idle: '空闲',
|
||||
Working: '工作中',
|
||||
Waiting: '等待中',
|
||||
Today: '今天',
|
||||
Home: '主页',
|
||||
Local: '本地',
|
||||
|
||||
@@ -65,7 +65,7 @@
|
||||
app.js, NOT the handheld storage-key test `m`. Use a different predicate
|
||||
here and boot will contradict this value, animating the drawer open by
|
||||
itself on every load between 768 and 1023px. -->
|
||||
<script>try{var m=window.innerWidth<768||(('ontouchstart' in window||navigator.maxTouchPoints>0)&&window.innerWidth<1024);var k=m?'codeman-app-settings-mobile':'codeman-app-settings';var A=JSON.parse(localStorage.getItem(k)||'{}');var L=A.sessionListLayout;var F=Number(A.sessionSidebarFontSize);var solo=/^\/session\//.test(location.pathname);var C=localStorage.getItem('codeman-sidebar-collapsed');var S=(L==='sidebar'||L==='sidebar-rich')&&!solo;document.documentElement.dataset.sessionList=S?'sidebar':'header';document.documentElement.dataset.sidebarDetail=(S&&L==='sidebar-rich')?'rich':'simple';document.documentElement.dataset.sidebar=(C===null?window.innerWidth<1024:C==='1')?'collapsed':'expanded';var V=A.tabOrientation==='vertical'&&!S&&!solo&&window.innerWidth>=768;document.documentElement.dataset.tabOrientation=V?'vertical':'horizontal';document.documentElement.dataset.tabRailDetail=(A.tabRailDetail==='simple')?'simple':'rich';document.documentElement.dataset.tabRailSort=(A.tabRailSort==='manual')?'manual':'activity';var W=Number(A.tabRailWidth);if(V){if(Number.isInteger(W)&&W>=208&&W<=360)document.documentElement.style.setProperty('--tab-rail-width',W+'px');else if(document.documentElement.dataset.tabRailDetail!=='simple')document.documentElement.style.setProperty('--tab-rail-width','320px');}if(Number.isInteger(F)&&F>=11&&F<=18)document.documentElement.style.setProperty('--session-sidebar-name-font-size',F+'px');}catch(e){document.documentElement.dataset.sessionList='header';document.documentElement.dataset.sidebarDetail='simple';document.documentElement.dataset.sidebar='expanded';document.documentElement.dataset.tabOrientation='horizontal';document.documentElement.dataset.tabRailDetail='rich';document.documentElement.dataset.tabRailSort='activity';}</script>
|
||||
<script>try{var m=window.innerWidth<768||(('ontouchstart' in window||navigator.maxTouchPoints>0)&&window.innerWidth<1024);var k=m?'codeman-app-settings-mobile':'codeman-app-settings';var A=JSON.parse(localStorage.getItem(k)||'{}');var L=A.sessionListLayout;var F=Number(A.sessionSidebarFontSize);var solo=/^\/session\//.test(location.pathname);var C=localStorage.getItem('codeman-sidebar-collapsed');var S=(L==='sidebar'||L==='sidebar-rich')&&!solo;document.documentElement.dataset.sessionList=S?'sidebar':'header';document.documentElement.dataset.sidebarDetail=(S&&L==='sidebar-rich')?'rich':'simple';document.documentElement.dataset.sidebar=(C===null?window.innerWidth<1024:C==='1')?'collapsed':'expanded';var V=A.tabOrientation==='vertical'&&!S&&!solo&&window.innerWidth>=768;document.documentElement.dataset.tabOrientation=V?'vertical':'horizontal';document.documentElement.dataset.tabRailDetail=(A.tabRailDetail==='simple')?'simple':'rich';document.documentElement.dataset.tabRailSort=(A.tabRailSort==='manual')?'manual':'activity';var T=A.tabArrangement;document.documentElement.dataset.tabArrangement=(T==='case'||T==='ledger'||T==='classic')?T:'state';document.documentElement.dataset.tabStateOrder=(A.tabStateOrder==='urgent-last')?'urgent-last':'urgent-first';var H=A.headerStatsStyle;document.documentElement.dataset.headerStats=(window.innerWidth<768||solo)?'classic':(H==='classic'||H==='compact')?H:'tiles';var W=Number(A.tabRailWidth);if(V){if(Number.isInteger(W)&&W>=208&&W<=360)document.documentElement.style.setProperty('--tab-rail-width',W+'px');else if(document.documentElement.dataset.tabRailDetail!=='simple')document.documentElement.style.setProperty('--tab-rail-width','320px');}if(Number.isInteger(F)&&F>=11&&F<=18)document.documentElement.style.setProperty('--session-sidebar-name-font-size',F+'px');}catch(e){document.documentElement.dataset.sessionList='header';document.documentElement.dataset.sidebarDetail='simple';document.documentElement.dataset.sidebar='expanded';document.documentElement.dataset.tabOrientation='horizontal';document.documentElement.dataset.tabRailDetail='rich';document.documentElement.dataset.tabRailSort='activity';document.documentElement.dataset.tabArrangement='state';document.documentElement.dataset.tabStateOrder='urgent-first';document.documentElement.dataset.headerStats='classic';}</script>
|
||||
<!-- Inline critical CSS for instant skeleton paint (before styles.css loads) -->
|
||||
<style>
|
||||
.loading-skeleton{display:flex;flex-direction:column;height:100vh;height:100dvh;background:var(--bg-dark,#11151c)}
|
||||
@@ -154,6 +154,7 @@
|
||||
<div class="connection-indicator" id="connectionIndicator" style="display: none;">
|
||||
<span class="connection-dot" id="connectionDot"></span>
|
||||
<span class="connection-text" id="connectionText"></span>
|
||||
<span class="connection-tile"><span class="connection-tile-label" id="connectionTileLabel"></span><span class="connection-tile-value" id="connectionTileValue"></span></span>
|
||||
</div>
|
||||
<div class="header-font-controls">
|
||||
<button class="btn-icon-header btn-sm" onclick="app.decreaseFontSize()" title="Decrease font (Ctrl+-)" aria-label="Decrease font size">A-</button>
|
||||
@@ -166,6 +167,7 @@
|
||||
<div class="stat-bar">
|
||||
<div class="stat-bar-fill stat-bar-cpu" id="statCpuBar"></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>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
@@ -173,6 +175,7 @@
|
||||
<div class="stat-bar">
|
||||
<div class="stat-bar-fill stat-bar-mem" id="statMemBar"></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>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1918,6 +1921,17 @@
|
||||
<label class="set-chip" data-preview="header" data-preview-order="13" data-preview-text="42%"><input type="checkbox" id="appSettingsShowPlanUsageLimits"><svg class="set-chip-ico" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4 18a8 8 0 1 1 16 0"/><path d="M12 18l4.5-5"/></svg><span>Plan Usage</span></label>
|
||||
<label class="set-chip" data-preview="header" data-preview-order="14"><input type="checkbox" id="appSettingsShowLifecycleLog"><svg class="set-chip-ico" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/></svg><span>Lifecycle Log</span></label>
|
||||
</div>
|
||||
<div class="set-row has-field" data-search="header stats style system cpu mem ws plan usage tiles compact rings 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>
|
||||
|
||||
@@ -2070,6 +2084,28 @@
|
||||
<div class="set-group">
|
||||
<div class="set-group-head"><h4>Tabs</h4><span class="set-scope">device</span></div>
|
||||
<div class="set-group-body">
|
||||
<div class="set-row has-field" data-search="tab layout grouping group by state triage needs you waiting working idle case clusters ledger columns classic old new">
|
||||
<div class="set-row-text">
|
||||
<span class="set-row-label">Tab Layout</span>
|
||||
<span class="set-row-desc">By state: a row each for needs you, waiting, working and idle. By case: one box per case. Ledger: an aligned column grid. Classic: the single list, as before. By state and By case group the side rail and sidebar too. Alt+1..9 keeps the tab order.</span>
|
||||
</div>
|
||||
<select id="appSettingsTabArrangement" class="set-select">
|
||||
<option value="state">By state (default)</option>
|
||||
<option value="case">By case (clusters)</option>
|
||||
<option value="ledger">Ledger (aligned columns)</option>
|
||||
<option value="classic">Classic (as before)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="set-row has-field" data-search="state order reverse needs you bottom top rows sections">
|
||||
<div class="set-row-text">
|
||||
<span class="set-row-label">State Order</span>
|
||||
<span class="set-row-desc">For Tab Layout by state. At the bottom flips the rows, so needs you sits right above the terminal.</span>
|
||||
</div>
|
||||
<select id="appSettingsTabStateOrder" class="set-select">
|
||||
<option value="urgent-first">Needs you on top (default)</option>
|
||||
<option value="urgent-last">Needs you at the bottom</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="set-row has-field" data-search="tab orientation horizontal vertical side rail">
|
||||
<div class="set-row-text">
|
||||
<span class="set-row-label">Tab Orientation</span>
|
||||
@@ -2093,7 +2129,7 @@
|
||||
<div class="set-row has-field" data-search="tab rail sort order activity manual drag reorder">
|
||||
<div class="set-row-text">
|
||||
<span class="set-row-label">Vertical Rail Order</span>
|
||||
<span class="set-row-desc">By activity uses the home screen's order: blocked on you first, then whatever has been running longest, then the most recently quiet. Manual keeps your tab order and is the only mode you can drag rows in. Alt+1..9 always follows the tab order either way.</span>
|
||||
<span class="set-row-desc">By activity uses the home screen's order: blocked on you first, then whatever has been running longest, then the most recently quiet. Manual keeps your tab order and is the only mode you can drag rows in. With Tab Layout by state or by case it orders the rows inside each section. Alt+1..9 always follows the tab order either way.</span>
|
||||
</div>
|
||||
<select id="appSettingsTabRailSort" class="set-select">
|
||||
<option value="activity">By activity (home screen order)</option>
|
||||
|
||||
@@ -755,6 +755,25 @@ html.mobile-init .file-browser-panel {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* Grouped by state (tabArrangement 'state'), the phone strip keeps its one scrolling
|
||||
row: the tabs still come in state order, most urgent first, but the
|
||||
headings would cost chips and the dots already say which state is which.
|
||||
The phone overview is where the labelled sections live. */
|
||||
:where(.header) .tab-triage-head {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Clustered by case, the same holds: the boxes dissolve into the one chip
|
||||
row (in cluster order) and the labels go, since every chip still names
|
||||
its session. */
|
||||
:where(.header) .session-tabs-host > .session-tabs.tabs-clusters > .tab-cluster {
|
||||
display: contents;
|
||||
}
|
||||
|
||||
:where(.header) .tab-cluster-label {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Only the active tab shows its action icons on a phone (see below), so on
|
||||
every other tab the container is empty but still a flex item, and its gap
|
||||
made the chip visibly wider on the right than on the left. */
|
||||
|
||||
@@ -6028,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) {
|
||||
const memGB = (stats.memory.usedMB / 1024).toFixed(1);
|
||||
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 ──────────────────────────────────────────────────────────────
|
||||
|
||||
async _onClipboardWrite(data) {
|
||||
|
||||
@@ -382,6 +382,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
// Header visibility settings
|
||||
document.getElementById('appSettingsShowFontControls').checked = settings.showFontControls ?? defaults.showFontControls ?? false;
|
||||
document.getElementById('appSettingsShowSystemStats').checked = settings.showSystemStats ?? defaults.showSystemStats ?? true;
|
||||
document.getElementById('appSettingsHeaderStatsStyle').value = this.resolveHeaderStatsStyle(settings);
|
||||
document.getElementById('appSettingsShowLifecycleLog').checked = settings.showLifecycleLog ?? defaults.showLifecycleLog ?? false;
|
||||
document.getElementById('appSettingsShowResponseViewer').checked = settings.showResponseViewer ?? defaults.showResponseViewer ?? false;
|
||||
document.getElementById('appSettingsShowFileViewerButton').checked = settings.showFileViewerButton ?? defaults.showFileViewerButton ?? true;
|
||||
@@ -500,6 +501,8 @@ Object.assign(CodemanApp.prototype, {
|
||||
settings.tabRailDetail ?? defaults.tabRailDetail ?? 'rich';
|
||||
document.getElementById('appSettingsTabRailSort').value =
|
||||
settings.tabRailSort ?? defaults.tabRailSort ?? 'activity';
|
||||
document.getElementById('appSettingsTabArrangement').value = this.resolveTabArrangement(settings);
|
||||
document.getElementById('appSettingsTabStateOrder').value = this.resolveTabStateOrder(settings);
|
||||
document.getElementById('appSettingsShowTabDetachButton').checked = settings.showTabDetachButton ?? defaults.showTabDetachButton ?? false;
|
||||
document.getElementById('appSettingsSessionListLayout').value =
|
||||
settings.sessionListLayout ?? defaults.sessionListLayout ?? 'header';
|
||||
@@ -2475,6 +2478,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
// Header visibility settings
|
||||
showFontControls: document.getElementById('appSettingsShowFontControls').checked,
|
||||
showSystemStats: document.getElementById('appSettingsShowSystemStats').checked,
|
||||
headerStatsStyle: document.getElementById('appSettingsHeaderStatsStyle').value,
|
||||
showLifecycleLog: document.getElementById('appSettingsShowLifecycleLog').checked,
|
||||
showResponseViewer: document.getElementById('appSettingsShowResponseViewer').checked,
|
||||
showFileViewerButton: document.getElementById('appSettingsShowFileViewerButton').checked,
|
||||
@@ -2524,6 +2528,8 @@ Object.assign(CodemanApp.prototype, {
|
||||
tabRailWidth: this.readTabRailWidthSetting?.() ?? 256,
|
||||
tabRailDetail: document.getElementById('appSettingsTabRailDetail').value,
|
||||
tabRailSort: document.getElementById('appSettingsTabRailSort').value,
|
||||
tabArrangement: document.getElementById('appSettingsTabArrangement').value,
|
||||
tabStateOrder: document.getElementById('appSettingsTabStateOrder').value,
|
||||
showTabDetachButton: document.getElementById('appSettingsShowTabDetachButton').checked,
|
||||
sessionListLayout: document.getElementById('appSettingsSessionListLayout').value,
|
||||
sessionSidebarFontSize: this.resolveSessionSidebarFontSize(
|
||||
@@ -3448,6 +3454,8 @@ Object.assign(CodemanApp.prototype, {
|
||||
tabRailWidth: 256,
|
||||
tabRailDetail: 'rich',
|
||||
tabRailSort: 'activity',
|
||||
tabArrangement: 'state',
|
||||
tabStateOrder: 'urgent-first',
|
||||
sessionListLayout: 'header',
|
||||
sessionSidebarFontSize: 12,
|
||||
cjkInputEnabled: false,
|
||||
@@ -3549,6 +3557,79 @@ Object.assign(CodemanApp.prototype, {
|
||||
return now === before ? undefined : now;
|
||||
},
|
||||
|
||||
/**
|
||||
* The stored tab layout, or the default. Anything but the four known values
|
||||
* (an absent key, a value from a newer build) reads as 'state', the default
|
||||
* (Discussion #426, option C).
|
||||
*/
|
||||
resolveTabArrangement(settings) {
|
||||
const value = settings?.tabArrangement ?? this.getDefaultSettings().tabArrangement;
|
||||
return value === 'case' || value === 'ledger' || value === 'classic' ? value : 'state';
|
||||
},
|
||||
|
||||
/** The stored state-group order: 'urgent-last' only when chosen, else 'urgent-first'. */
|
||||
resolveTabStateOrder(settings) {
|
||||
const value = settings?.tabStateOrder ?? this.getDefaultSettings().tabStateOrder;
|
||||
return value === 'urgent-last' ? 'urgent-last' : 'urgent-first';
|
||||
},
|
||||
|
||||
/**
|
||||
* The stored header-stats style, or the default. Anything but the three
|
||||
* known values (an absent key, a value from a newer build) reads as 'tiles',
|
||||
* the default (the tile variant of Discussion #426's option G).
|
||||
*/
|
||||
resolveHeaderStatsStyle(settings) {
|
||||
const value = settings?.headerStatsStyle ?? this.getDefaultSettings().headerStatsStyle;
|
||||
return value === 'classic' || value === 'compact' ? value : 'tiles';
|
||||
},
|
||||
|
||||
/**
|
||||
* Apply a header-stats style: the `data-header-stats` attribute every rule in
|
||||
* the "Header stats styles" block of styles.css keys on, plus the two DOM
|
||||
* moves the clustered styles need.
|
||||
*
|
||||
* The template keeps the classic order, where the connection indicator sits
|
||||
* before the font controls and the plan-usage chip near the end of the header.
|
||||
* Compact and Tiles draw them as ONE cluster (WS · CPU · MEM, then the plan
|
||||
* windows), so the indicator moves into #headerSystemStats as its first child
|
||||
* and the chip moves right after it. Comment anchors left at the template
|
||||
* positions are what 'classic' moves them back to, so switching back restores
|
||||
* the header exactly.
|
||||
*
|
||||
* ⚠️ The indicator only joins the pill while System Stats is shown: the pill
|
||||
* is hidden with `display: none`, and the WS readout must not disappear with
|
||||
* it. Both elements keep their ids, so every writer (setConnectionStatus,
|
||||
* updatePlanUsageChip) finds them wherever they sit.
|
||||
*
|
||||
* @param {{style: 'classic'|'compact'|'tiles', showSystemStats: boolean}} opts
|
||||
*/
|
||||
applyHeaderStatsStyle({ style, showSystemStats }) {
|
||||
document.documentElement.dataset.headerStats = style;
|
||||
const stats = document.getElementById('headerSystemStats');
|
||||
const conn = document.getElementById('connectionIndicator');
|
||||
const plan = document.getElementById('planUsageChip');
|
||||
if (!stats || !conn || !plan) return;
|
||||
if (!this._headerStatsAnchors) {
|
||||
const connAnchor = document.createComment(' connection indicator (classic position) ');
|
||||
const planAnchor = document.createComment(' plan usage chip (classic position) ');
|
||||
conn.before(connAnchor);
|
||||
plan.before(planAnchor);
|
||||
this._headerStatsAnchors = { conn: connAnchor, plan: planAnchor };
|
||||
}
|
||||
const anchors = this._headerStatsAnchors;
|
||||
const clustered = style !== 'classic';
|
||||
if (clustered && showSystemStats) {
|
||||
if (stats.firstElementChild !== conn) stats.prepend(conn);
|
||||
} else if (anchors.conn.nextSibling !== conn) {
|
||||
anchors.conn.after(conn);
|
||||
}
|
||||
if (clustered) {
|
||||
if (stats.nextElementSibling !== plan) stats.after(plan);
|
||||
} else if (anchors.plan.nextSibling !== plan) {
|
||||
anchors.plan.after(plan);
|
||||
}
|
||||
},
|
||||
|
||||
applyHeaderVisibilitySettings() {
|
||||
const settings = this.loadAppSettingsFromStorage();
|
||||
const defaults = this.getDefaultSettings();
|
||||
@@ -3579,6 +3660,12 @@ Object.assign(CodemanApp.prototype, {
|
||||
if (tokenCountEl) {
|
||||
tokenCountEl.style.display = showTokenCount ? '' : 'none';
|
||||
}
|
||||
// After the System Stats visibility above: whether WS joins the stats pill
|
||||
// depends on the pill being shown.
|
||||
this.applyHeaderStatsStyle({
|
||||
style: compactHeader || this.isSoloWindow ? 'classic' : this.resolveHeaderStatsStyle(settings),
|
||||
showSystemStats,
|
||||
});
|
||||
|
||||
// Hide lifecycle log button when setting is disabled
|
||||
// Default OFF: the lifecycle-log document icon is opt-in; the default header
|
||||
@@ -3746,6 +3833,19 @@ Object.assign(CodemanApp.prototype, {
|
||||
const sort = (settings.tabRailSort ?? defaults.tabRailSort ?? 'activity') === 'manual' ? 'manual' : 'activity';
|
||||
root.dataset.tabRailSort = sort;
|
||||
|
||||
// The tab layout rides on a fourth attribute, for the same reason: it is
|
||||
// applied by the render paths (inline `order` plus headings, or cluster
|
||||
// boxes), so a flip has to re-render, and the gates in app.js
|
||||
// (`isTabTriage()`, `isTabClusters()`, `isTabLedger()`) read one attribute
|
||||
// per pass instead of re-parsing localStorage.
|
||||
const previousArrangement = root.dataset.tabArrangement || 'state';
|
||||
const arrangement = this.resolveTabArrangement(settings);
|
||||
root.dataset.tabArrangement = arrangement;
|
||||
// Which end the state groups start from; read by _tabTriageLayout().
|
||||
const previousStateOrder = root.dataset.tabStateOrder || 'urgent-first';
|
||||
const stateOrder = this.resolveTabStateOrder(settings);
|
||||
root.dataset.tabStateOrder = stateOrder;
|
||||
|
||||
const tabsEl = document.getElementById('sessionTabs');
|
||||
const rail = document.getElementById('tabRail');
|
||||
const headerHost = document.getElementById('sessionTabsHost');
|
||||
@@ -3769,7 +3869,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
// the row template, not toggled by CSS — same reasoning as the sidebar's
|
||||
// detail half in applySessionListLayout(). Taller rows also move every
|
||||
// connector anchored to a tab rect.
|
||||
const changed = orientationChanged || previousDetail !== detail || previousSort !== sort;
|
||||
const changed = orientationChanged || previousDetail !== detail || previousSort !== sort || previousArrangement !== arrangement || previousStateOrder !== stateOrder;
|
||||
if (orientationChanged) {
|
||||
this.updateTabOverflowMode?.();
|
||||
if (!settleRailWidth) this.syncTerminalGeometry?.();
|
||||
@@ -4039,10 +4139,10 @@ Object.assign(CodemanApp.prototype, {
|
||||
// NOTE: Feature toggles (subagentTrackingEnabled, imageWatcherEnabled, ralphTrackerEnabled)
|
||||
// are NOT display keys — they control server-side behavior and must sync from server.
|
||||
const displayKeys = new Set([
|
||||
'showFontControls', 'showSystemStats', 'showTokenCount', 'showCost',
|
||||
'showFontControls', 'showSystemStats', 'headerStatsStyle', 'showTokenCount', 'showCost',
|
||||
'showLifecycleLog', 'showResponseViewer', 'showRedrawButton',
|
||||
'showMonitor', 'showProjectInsights', 'showFileBrowser', 'showSubagents',
|
||||
'subagentActiveTabOnly', 'tabTwoRows', 'tabOrientation', 'tabRailWidth', 'tabRailDetail', 'tabRailSort', 'sessionListLayout', 'sessionSidebarFontSize', 'localEchoEnabled', 'cjkInputEnabled', 'extendedKeyboardBar',
|
||||
'subagentActiveTabOnly', 'tabTwoRows', 'tabOrientation', 'tabRailWidth', 'tabRailDetail', 'tabRailSort', 'tabArrangement', 'tabStateOrder', 'sessionListLayout', 'sessionSidebarFontSize', 'localEchoEnabled', 'cjkInputEnabled', 'extendedKeyboardBar',
|
||||
'skin', 'showPlanUsageLimits', 'showAttachmentsButton', 'showFileViewerButton', 'webglRendererEnabled',
|
||||
'terminalFontFamily', 'terminalFontWeight', 'terminalFontWeightBold',
|
||||
'language',
|
||||
|
||||
@@ -19788,3 +19788,673 @@ html .toolbar .btn-git-status[aria-expanded='true'] {
|
||||
padding-left: 0.4rem;
|
||||
border-left: 1px solid var(--border);
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
Tab grouping by state (tabArrangement: 'state', the default;
|
||||
Discussion #426 option C)
|
||||
============================================================
|
||||
app.js writes a flex `order` per tab plus one heading and one row break per
|
||||
non-empty state, all direct children of #sessionTabs (_syncTabTriageChrome).
|
||||
The DOM stays in tab order; computeTabTriageLayout() in constants.js owns the
|
||||
bands. With grouping off none of this matches anything. */
|
||||
.tab-triage-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
align-self: stretch;
|
||||
gap: 5px;
|
||||
flex: 0 0 auto;
|
||||
box-sizing: border-box;
|
||||
padding: 0 6px 0 2px;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
line-height: 1;
|
||||
text-transform: uppercase;
|
||||
white-space: nowrap;
|
||||
color: var(--text-muted);
|
||||
user-select: none;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.tab-triage-count {
|
||||
font-weight: 600;
|
||||
letter-spacing: 0;
|
||||
color: var(--text-muted);
|
||||
opacity: 0.75;
|
||||
}
|
||||
|
||||
.tab-triage-head--needs .tab-triage-label {
|
||||
color: var(--red);
|
||||
}
|
||||
|
||||
.tab-triage-head--waiting .tab-triage-label {
|
||||
color: var(--yellow);
|
||||
}
|
||||
|
||||
.tab-triage-head--working .tab-triage-label {
|
||||
color: var(--green);
|
||||
}
|
||||
|
||||
.tab-triage-break {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Counts describe the whole group, so they step aside while the sidebar
|
||||
filter narrows the rows under them. */
|
||||
.session-tabs.tabs-filtering > .tab-triage-head {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Header strip, wrapping (desktop): one row per state. Each heading sits in a
|
||||
left gutter, so a group too long for one line continues under its own tabs
|
||||
rather than under its heading, and each row ends with a full-width break. */
|
||||
/* The label column is as wide as the widest label on screen (app.js measures
|
||||
it into --tab-triage-gutter whenever the labels change; 92px is only the
|
||||
first-paint fallback) and the labels are left-aligned in it.
|
||||
|
||||
The brand leaves the flow and sits over the strip's top-left corner, so the
|
||||
strip starts at the header's left edge: the first row's heading (the lead)
|
||||
is pushed past the brand by --tab-triage-brand, and every later row starts
|
||||
at the edge, under "Codeman", instead of leaving that corner empty. The
|
||||
rows under the first one line up with each other in the label column. */
|
||||
.header:has(> .session-tabs-host > .session-tabs.tabs-triage:is(.tabs-auto-wrap, .tabs-two-rows)) > .header-brand {
|
||||
position: absolute;
|
||||
top: 0.35rem;
|
||||
left: 0.75rem;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.session-tabs-host > .session-tabs.tabs-triage:is(.tabs-auto-wrap, .tabs-two-rows) {
|
||||
padding-left: var(--tab-triage-gutter, 92px);
|
||||
}
|
||||
|
||||
.session-tabs-host > .session-tabs.tabs-triage:is(.tabs-auto-wrap, .tabs-two-rows) > .tab-triage-head {
|
||||
justify-content: flex-start;
|
||||
width: var(--tab-triage-gutter, 92px);
|
||||
margin-left: calc(-1 * var(--tab-triage-gutter, 92px));
|
||||
padding: 0 8px 0 0;
|
||||
}
|
||||
|
||||
/* The first row starts beside the brand and lines up with nothing else, so its
|
||||
label takes its own width and its tabs follow it directly. */
|
||||
.session-tabs-host > .session-tabs.tabs-triage:is(.tabs-auto-wrap, .tabs-two-rows) > .tab-triage-head--lead {
|
||||
width: auto;
|
||||
margin-left: calc(var(--tab-triage-brand, 100px) - var(--tab-triage-gutter, 92px));
|
||||
}
|
||||
|
||||
.session-tabs-host > .session-tabs.tabs-triage:is(.tabs-auto-wrap, .tabs-two-rows) > .tab-triage-break {
|
||||
display: block;
|
||||
flex: 0 0 100%;
|
||||
height: 0;
|
||||
}
|
||||
|
||||
/* Side rail and sidebar: each heading is a section row above its rows. */
|
||||
html[data-tab-orientation='vertical'] .tab-rail .session-tabs.tabs-triage > .tab-triage-head,
|
||||
html[data-session-list='sidebar'] .session-sidebar .session-tabs.tabs-triage > .tab-triage-head {
|
||||
align-self: auto;
|
||||
width: 100%;
|
||||
padding: 8px 6px 3px;
|
||||
}
|
||||
|
||||
/* The collapsed sidebar is a 44px icon rail with no room for a label. */
|
||||
html[data-session-list='sidebar'][data-sidebar='collapsed'] .session-sidebar .tab-triage-head {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
Tab clusters by case (tabArrangement: 'case'; Discussion #426 option A)
|
||||
============================================================
|
||||
app.js wraps each case's tabs in a .tab-cluster box (_renderTabClusters):
|
||||
a labelled box per case with two or more tabs, an unlabelled box with a
|
||||
colour swatch per case with one, and a box per web tab. --cluster-color is
|
||||
the case's colour from the session palette. Tabs inside keep their own
|
||||
markup; only the `-<case>` part of a generated name (.tab-name-case) is
|
||||
hidden, and only here. */
|
||||
.session-tabs.tabs-clusters .tab-name-case {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.tab-cluster-label {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
flex: none;
|
||||
padding: 0 6px 0 2px;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
line-height: 1;
|
||||
text-transform: uppercase;
|
||||
white-space: nowrap;
|
||||
color: var(--text-dim);
|
||||
user-select: none;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.tab-cluster-count {
|
||||
font-weight: 600;
|
||||
letter-spacing: 0;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.tab-cluster-swatch {
|
||||
flex: none;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 2px;
|
||||
background: var(--cluster-color, var(--text-muted));
|
||||
}
|
||||
|
||||
/* Header strip: each cluster is an inline box that wraps inside itself when a
|
||||
case has more tabs than fit on a line. */
|
||||
.session-tabs-host > .session-tabs.tabs-clusters {
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.session-tabs-host > .session-tabs.tabs-clusters > .tab-cluster {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 2px;
|
||||
max-width: 100%;
|
||||
min-width: 0;
|
||||
box-sizing: border-box;
|
||||
padding: 1px 3px 1px 8px;
|
||||
border: 1px solid var(--control-border);
|
||||
border-left: 3px solid var(--cluster-color, var(--control-border));
|
||||
border-radius: 8px;
|
||||
background: var(--control-bg);
|
||||
}
|
||||
|
||||
/* A case with one tab and a web tab are boxed but not labelled: the single
|
||||
case keeps its swatch, a web tab has no case at all. */
|
||||
.session-tabs-host > .session-tabs.tabs-clusters > .tab-cluster--single {
|
||||
border-left-width: 1px;
|
||||
padding-left: 6px;
|
||||
}
|
||||
|
||||
.session-tabs-host > .session-tabs.tabs-clusters > .tab-cluster--single .tab-cluster-name,
|
||||
.session-tabs-host > .session-tabs.tabs-clusters > .tab-cluster--single .tab-cluster-count {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.session-tabs-host > .session-tabs.tabs-clusters > .tab-cluster--single .tab-cluster-label {
|
||||
padding: 0 2px;
|
||||
}
|
||||
|
||||
.session-tabs-host > .session-tabs.tabs-clusters > .tab-cluster--web {
|
||||
padding-left: 3px;
|
||||
}
|
||||
|
||||
/* Side rail and sidebar: every case is a section with its label on top
|
||||
(vertical space is cheap there, so a one-tab case is labelled too). The
|
||||
label goes first by `order`, because a sorted rail orders the rows inside
|
||||
each cluster. */
|
||||
html[data-tab-orientation='vertical'] .tab-rail .session-tabs.tabs-clusters > .tab-cluster,
|
||||
html[data-session-list='sidebar'] .session-sidebar .session-tabs.tabs-clusters > .tab-cluster {
|
||||
display: flex;
|
||||
flex: 0 0 auto;
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
html[data-tab-orientation='vertical'] .tab-rail .session-tabs.tabs-clusters > .tab-cluster + .tab-cluster,
|
||||
html[data-session-list='sidebar'] .session-sidebar .session-tabs.tabs-clusters > .tab-cluster + .tab-cluster {
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
html[data-tab-orientation='vertical'] .tab-rail .session-tabs.tabs-clusters .tab-cluster-label,
|
||||
html[data-session-list='sidebar'] .session-sidebar .session-tabs.tabs-clusters .tab-cluster-label {
|
||||
order: -1;
|
||||
padding: 6px 6px 3px;
|
||||
}
|
||||
|
||||
/* The collapsed sidebar is a 44px icon rail with no room for a label. */
|
||||
html[data-session-list='sidebar'][data-sidebar='collapsed'] .session-sidebar .tab-cluster-label {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
Ledger (tabArrangement: 'ledger'; Discussion #426 option B)
|
||||
============================================================
|
||||
The flat list, in tab order, on an aligned column grid: equal cells, mono
|
||||
type, a 3px status bar on the left instead of the dot. Desktop header strip
|
||||
only (updateTabOverflowMode() keeps it wrapping); narrower strips, the rail
|
||||
and the sidebar keep the plain list. Nothing here touches the markup. */
|
||||
@media (min-width: 768px) {
|
||||
.session-tabs-host > .session-tabs.tabs-ledger {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(176px, 1fr));
|
||||
align-content: start;
|
||||
/* The strip's own align-items: flex-start applies to grid items too; a
|
||||
cell must stretch, or the active cell (taller with its actions) breaks
|
||||
its row. */
|
||||
align-items: stretch;
|
||||
gap: 4px 6px;
|
||||
}
|
||||
|
||||
.session-tabs-host > .session-tabs.tabs-ledger > .session-tab {
|
||||
--ledger-bar: var(--green);
|
||||
min-width: 0;
|
||||
/* Tall enough for the active cell's actions, so every row is one height. */
|
||||
min-height: 30px;
|
||||
box-sizing: border-box;
|
||||
gap: 7px;
|
||||
padding: 4px 8px 4px 11px;
|
||||
border: 1px solid var(--control-border);
|
||||
border-radius: 4px;
|
||||
background: var(--control-bg);
|
||||
box-shadow: inset 3px 0 0 var(--ledger-bar);
|
||||
font-family: 'JetBrains Mono', var(--font-mono, monospace);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.session-tabs-host > .session-tabs.tabs-ledger > .session-tab .tab-info {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.session-tabs-host > .session-tabs.tabs-ledger > .session-tab .tab-number {
|
||||
width: auto;
|
||||
min-width: 1ch;
|
||||
height: auto;
|
||||
border: 0;
|
||||
background: none;
|
||||
color: var(--text-muted);
|
||||
font-family: inherit;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.session-tabs-host > .session-tabs.tabs-ledger > .session-tab .tab-status {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.session-tabs-host > .session-tabs.tabs-ledger > .session-tab.tab-alert-idle {
|
||||
--ledger-bar: var(--yellow);
|
||||
}
|
||||
|
||||
.session-tabs-host > .session-tabs.tabs-ledger > .session-tab:is(.tab-alert-action, :has(> .tab-status.error)) {
|
||||
--ledger-bar: var(--red);
|
||||
}
|
||||
|
||||
.session-tabs-host > .session-tabs.tabs-ledger > .session-tab:is(.tab-agent-exited, .session-tab--web, :has(> .tab-status.stopped)) {
|
||||
--ledger-bar: var(--text-muted);
|
||||
}
|
||||
|
||||
/* The active cell has to win against a grid of look-alike cells. The skin's
|
||||
active rule (1px accent border, no shadow, both !important) is too quiet
|
||||
here, so an inset second pixel doubles the border without resizing the
|
||||
cell, and the status bar turns accent. */
|
||||
.session-tabs-host > .session-tabs.tabs-ledger > .session-tab.active {
|
||||
background: rgba(var(--accent-rgb), 0.2) !important;
|
||||
box-shadow: inset 0 0 0 1px var(--accent), inset 4px 0 0 var(--accent) !important;
|
||||
color: var(--text) !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
Header stats styles (headerStatsStyle; Discussion #426 option G)
|
||||
============================================================
|
||||
html[data-header-stats] picks how the WS / CPU / MEM / plan-usage cluster
|
||||
is drawn. 'classic' matches nothing below except the hidden parts, so the
|
||||
header looks exactly as before. 'compact' and 'tiles' are desktop only
|
||||
(settings-ui.js resolves anything narrower, and a solo window, to
|
||||
'classic'), and applyHeaderStatsStyle() has already moved
|
||||
#connectionIndicator into #headerSystemStats and #planUsageChip right after
|
||||
it, so the cluster is contiguous. */
|
||||
|
||||
/* Parts only the two new styles draw. */
|
||||
.stat-spark,
|
||||
.connection-tile,
|
||||
.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 {
|
||||
font-size: inherit;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
html[data-header-stats='compact'] .header-system-stats > .connection-indicator > .connection-dot {
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
}
|
||||
|
||||
/* Same bundled face as Tiles, so the pills read the same on every machine. */
|
||||
html[data-header-stats='compact'] .header-system-stats,
|
||||
html[data-header-stats='compact'] .header-plan-usage {
|
||||
font-family: 'JetBrains Mono', var(--font-mono, monospace);
|
||||
}
|
||||
|
||||
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 ----------------- */
|
||||
/* Each tile is a three-row grid (label, value, bar) with pixel line-heights,
|
||||
so its height comes from the layout, not from whichever monospace face the
|
||||
machine falls back to. The bar is a real row, never laid over the bottom
|
||||
edge: an absolutely placed bar under a fixed-height tile is what put the
|
||||
value on top of it with a taller system font (SF Mono). The bundled
|
||||
JetBrains Mono comes first so every machine draws the same tile. */
|
||||
html[data-header-stats='tiles'] .header-system-stats {
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
html[data-header-stats='tiles'] .header-system-stats > .stat-item,
|
||||
html[data-header-stats='tiles'] .header-plan-usage .pu-win,
|
||||
html[data-header-stats='tiles'] .header-system-stats > .connection-indicator > .connection-tile {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
grid-template-rows: 9px 14px 2px;
|
||||
row-gap: 2px;
|
||||
align-content: center;
|
||||
justify-items: start;
|
||||
min-width: 54px;
|
||||
/* 2 + 9 + 2 + 14 + 2 + 2 + 3 plus the 1px borders: 36px, the header's own
|
||||
height, so the tiles never make the header grow. */
|
||||
padding: 2px 10px 3px;
|
||||
box-sizing: border-box;
|
||||
border-radius: 7px;
|
||||
background: var(--control-bg);
|
||||
border: 1px solid var(--control-border);
|
||||
font-family: 'JetBrains Mono', var(--font-mono, monospace);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* The tile label and value: the stat items already carry a label and a value,
|
||||
the connection indicator gets its own pair (its text line keeps the classic
|
||||
wording, queued bytes included, and stays the accessible name via title). */
|
||||
html[data-header-stats='tiles'] .header-system-stats .stat-label,
|
||||
html[data-header-stats='tiles'] .connection-tile-label,
|
||||
html[data-header-stats='tiles'] .header-plan-usage .pu-label {
|
||||
display: block;
|
||||
grid-row: 1;
|
||||
min-width: 0;
|
||||
font-size: 9px;
|
||||
line-height: 9px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-muted);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
html[data-header-stats='tiles'] .header-system-stats .stat-value,
|
||||
html[data-header-stats='tiles'] .connection-tile-value,
|
||||
html[data-header-stats='tiles'] .header-plan-usage .pu-val {
|
||||
display: block;
|
||||
grid-row: 2;
|
||||
min-width: 0;
|
||||
text-align: left;
|
||||
font-size: 12px;
|
||||
line-height: 14px;
|
||||
font-weight: 600;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
/* Plan values keep their green/yellow/red; the stat values read as plain text
|
||||
unless they are running high. */
|
||||
html[data-header-stats='tiles'] .header-system-stats .stat-value:not(.high),
|
||||
html[data-header-stats='tiles'] .connection-tile-value {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
/* The WS tile: JS sets the indicator's `display` inline (flex, or none when
|
||||
there is nothing to report), so the grid lives on the .connection-tile span
|
||||
inside it and the indicator itself only keeps the flex box it already has. */
|
||||
html[data-header-stats='tiles'] .header-system-stats > .connection-indicator {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
html[data-header-stats='tiles'] .header-system-stats > .connection-indicator > .connection-dot,
|
||||
html[data-header-stats='tiles'] .header-system-stats > .connection-indicator > .connection-text {
|
||||
display: none;
|
||||
}
|
||||
|
||||
html[data-header-stats='tiles'] .connection-tile-value.connected {
|
||||
color: var(--green);
|
||||
}
|
||||
|
||||
html[data-header-stats='tiles'] .connection-tile-value.fallback,
|
||||
html[data-header-stats='tiles'] .connection-tile-value.reconnecting,
|
||||
html[data-header-stats='tiles'] .connection-tile-value.draining {
|
||||
color: var(--yellow);
|
||||
}
|
||||
|
||||
html[data-header-stats='tiles'] .connection-tile-value.offline {
|
||||
color: var(--red);
|
||||
}
|
||||
|
||||
/* The bar is the third row, full tile width. The WS tile has no bar, and its
|
||||
empty third row keeps it the same height as its neighbours. */
|
||||
html[data-header-stats='tiles'] .header-system-stats .stat-bar,
|
||||
html[data-header-stats='tiles'] .header-plan-usage .pu-meter {
|
||||
display: block;
|
||||
grid-row: 3;
|
||||
justify-self: stretch;
|
||||
width: auto;
|
||||
height: 2px;
|
||||
border-radius: 1px;
|
||||
background: rgba(127, 127, 127, 0.18);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
html[data-header-stats='tiles'] .header-plan-usage .pu-meter > i {
|
||||
display: block;
|
||||
height: 100%;
|
||||
border-radius: 1px;
|
||||
background: currentColor;
|
||||
}
|
||||
|
||||
html[data-header-stats='tiles'] .header-plan-usage {
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
min-height: 0;
|
||||
padding: 0;
|
||||
background: none;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
html[data-header-stats='tiles'] .header-plan-usage .pu-row {
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
html[data-header-stats='tiles'] .header-plan-usage .pu-provider {
|
||||
width: auto;
|
||||
flex: none;
|
||||
font-size: 0.55rem;
|
||||
}
|
||||
|
||||
html[data-header-stats='tiles'] .header-plan-usage .pu-windows {
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
html[data-header-stats='tiles'] .header-plan-usage .pu-sep {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* An idle window keeps its tile and dims the whole thing. */
|
||||
html[data-header-stats='tiles'] .header-plan-usage .pu-win-idle {
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
/* --- Header buttons next to the clustered stats ------------------------ */
|
||||
/* Bare 16px glyphs beside 36px tiles read as a different design. In Tiles the
|
||||
header's icon buttons take the tile box (same height, border and fill, a
|
||||
larger icon); in Compact they become round chips the height of the pills.
|
||||
Only the box changes: the marker classes still hide them, and the gear
|
||||
keeps its quarter turn on hover, on the glyph rather than the whole box. */
|
||||
html[data-header-stats='tiles'] .header-right > .btn-icon-header,
|
||||
html[data-header-stats='compact'] .header-right > .btn-icon-header {
|
||||
box-sizing: border-box;
|
||||
background: var(--control-bg);
|
||||
border: 1px solid var(--control-border);
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
html[data-header-stats='tiles'] .header-right > .btn-icon-header {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 7px;
|
||||
}
|
||||
|
||||
html[data-header-stats='compact'] .header-right > .btn-icon-header {
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
border-radius: 13px;
|
||||
}
|
||||
|
||||
html[data-header-stats='tiles'] .header-right > .btn-icon-header > svg {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
|
||||
html[data-header-stats='compact'] .header-right > .btn-icon-header > svg {
|
||||
width: 15px;
|
||||
height: 15px;
|
||||
}
|
||||
|
||||
html[data-header-stats='tiles'] .header-right > .btn-icon-header:hover,
|
||||
html[data-header-stats='compact'] .header-right > .btn-icon-header:hover {
|
||||
background: var(--control-bg-hover);
|
||||
border-color: var(--control-border-hover);
|
||||
color: var(--text);
|
||||
transform: none;
|
||||
}
|
||||
|
||||
html[data-header-stats='tiles'] .header-right > .btn-icon-header > svg,
|
||||
html[data-header-stats='compact'] .header-right > .btn-icon-header > svg {
|
||||
transition: transform var(--transition-smooth);
|
||||
}
|
||||
|
||||
html[data-header-stats='tiles'] .header-right > .btn-settings:hover > svg,
|
||||
html[data-header-stats='compact'] .header-right > .btn-settings:hover > svg {
|
||||
transform: rotate(45deg);
|
||||
}
|
||||
|
||||
/* Before the first plan reading the chip holds a bare em dash; give it a tile
|
||||
of its own so it does not read as a stray mark between the tiles. */
|
||||
html[data-header-stats='tiles'] .header-plan-usage:not(:has(.pu-row)) {
|
||||
justify-content: center;
|
||||
min-width: 54px;
|
||||
height: 36px;
|
||||
padding: 0 10px;
|
||||
box-sizing: border-box;
|
||||
border-radius: 7px;
|
||||
background: var(--control-bg);
|
||||
border: 1px solid var(--control-border);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
@@ -1403,6 +1403,25 @@ export const SettingsUpdateSchema = z
|
||||
* rail drag-reorderable.
|
||||
*/
|
||||
tabRailSort: z.enum(['activity', 'manual']).optional(),
|
||||
/**
|
||||
* Tab layout, the arrangement of the tab list (Discussion #426). Display key
|
||||
* (per-device).
|
||||
* 'state' = a row per state in the header strip (needs you, waiting,
|
||||
* working, idle; option C), sections in the flat side rail and
|
||||
* the sidebar. The default.
|
||||
* 'case' = one cluster per case (option A): a labelled box in the strip,
|
||||
* a section in the side rail and the sidebar.
|
||||
* 'ledger' = the flat list on an aligned column grid with a status bar
|
||||
* per cell (option B). Header strip on desktop only.
|
||||
* 'classic' = one flat list in tab order, as before.
|
||||
*/
|
||||
tabArrangement: z.enum(['state', 'case', 'ledger', 'classic']).optional(),
|
||||
/**
|
||||
* Which end the state groups start from when `tabArrangement` is 'state'.
|
||||
* Display key (per-device). 'urgent-first' = needs you on top (the
|
||||
* default); 'urgent-last' = the other way up, needs you in the bottom row.
|
||||
*/
|
||||
tabStateOrder: z.enum(['urgent-first', 'urgent-last']).optional(),
|
||||
/**
|
||||
* Session list layout. Display key (per-device).
|
||||
* 'header' = horizontal tab strip
|
||||
@@ -1434,6 +1453,14 @@ export const SettingsUpdateSchema = z
|
||||
// UI visibility
|
||||
showFontControls: z.boolean().optional(),
|
||||
showSystemStats: z.boolean().optional(),
|
||||
/**
|
||||
* How the header draws its WS / CPU / MEM / plan-usage cluster. Display key
|
||||
* (per-device), desktop only (the cluster is hidden below 768px).
|
||||
* 'classic' = the bars and the 5H · 7D chip, as before
|
||||
* 'compact' = 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(),
|
||||
showCost: z.boolean().optional(),
|
||||
showLifecycleLog: z.boolean().optional(),
|
||||
|
||||
@@ -0,0 +1,236 @@
|
||||
/**
|
||||
* @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,\s*\.header-plan-usage \.pu-ring,\s*\.header-plan-usage \.pu-meter \{\s*display: none;/
|
||||
);
|
||||
});
|
||||
|
||||
it('lays a tile out as three rows, so its height never depends on the font', () => {
|
||||
// A bar laid over the bottom of a fixed-height tile is what let a taller
|
||||
// system mono (SF Mono) push the value into it.
|
||||
expect(css).toMatch(/grid-template-rows: 9px 14px 2px;\s*row-gap: 2px;/);
|
||||
const tiles = css.slice(css.indexOf('/* --- Tiles: label over value'));
|
||||
const bar = tiles.slice(tiles.indexOf("html[data-header-stats='tiles'] .header-system-stats .stat-bar,"));
|
||||
expect(bar.slice(0, bar.indexOf('}'))).not.toContain('position: absolute');
|
||||
expect(bar.slice(0, bar.indexOf('}'))).toContain('grid-row: 3;');
|
||||
});
|
||||
|
||||
it('gives the header buttons the tile box beside the tiles', () => {
|
||||
expect(css).toMatch(
|
||||
/html\[data-header-stats='tiles'\] \.header-right > \.btn-icon-header \{\s*width: 36px;\s*height: 36px;/
|
||||
);
|
||||
expect(css).toMatch(/html\[data-header-stats='tiles'\] \.header-right > \.btn-icon-header > svg \{\s*width: 18px;/);
|
||||
});
|
||||
|
||||
it('stamps data-header-stats before first paint, tiles by default and classic on narrow screens', () => {
|
||||
expect(INDEX).toContain(
|
||||
"dataset.headerStats=(window.innerWidth<768||solo)?'classic':(H==='classic'||H==='compact')?H:'tiles'"
|
||||
);
|
||||
});
|
||||
|
||||
it('offers the three styles with tiles marked as the default', () => {
|
||||
expect(INDEX).toMatch(
|
||||
/<select id="appSettingsHeaderStatsStyle"[^>]*>\s*<option value="classic">[^<]+<\/option>\s*<option value="compact">[^<]+<\/option>\s*<option value="tiles">Tiles \(default\)<\/option>/
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,305 @@
|
||||
/**
|
||||
* @fileoverview Tab layouts by case and ledger (`tabArrangement: 'case'` and
|
||||
* `'ledger'`, Discussion #426 options A and B).
|
||||
*
|
||||
* What is pinned, and why it matters:
|
||||
* - By case, each case's tabs sit in ONE box, in the order the case first
|
||||
* appears in the tab order, with a colour that is a pure function of the
|
||||
* case (no storage, the same on every device).
|
||||
* - Inside a box with company, a generated `w75-api-gateway` reads `w75`, but
|
||||
* the full name stays in the DOM and in the accessible name; a custom or
|
||||
* described name is never touched.
|
||||
* - The Alt+N badges keep counting the tab order, and an incremental pass
|
||||
* patches tabs in place unless the cluster structure changed.
|
||||
* - Drag only reorders inside a box. Named groups in the vertical rail win.
|
||||
* - The ledger changes no markup at all: it is a class on #sessionTabs and
|
||||
* CSS scoped to the desktop header strip.
|
||||
*
|
||||
* The real modules run INSIDE a JSDOM window (runScripts: 'outside-only').
|
||||
*
|
||||
* Port: none.
|
||||
*/
|
||||
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import vm from 'node:vm';
|
||||
import { JSDOM } from 'jsdom';
|
||||
import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const PUBLIC = join(process.cwd(), 'src/web/public');
|
||||
const read = (name: string) => readFileSync(join(PUBLIC, name), 'utf8');
|
||||
|
||||
type Cluster = { key: string; label: string; color: string; ids: string[] };
|
||||
type Clusters = {
|
||||
COLORS: string[];
|
||||
colorFor: (key: string) => string;
|
||||
compute: (rows: Array<{ id: string; key: string; label: string }>) => Cluster[];
|
||||
nameSplit: (name: string, label: string) => { shown: string; hidden: string } | null;
|
||||
};
|
||||
|
||||
function loadClusters(): Clusters {
|
||||
const context = vm.createContext({ window: {}, globalThis: {} });
|
||||
vm.runInContext(read('constants.js'), context, { filename: 'constants.js' });
|
||||
return (context.window as { CodemanTabClusters: Clusters }).CodemanTabClusters;
|
||||
}
|
||||
|
||||
describe('CodemanTabClusters (pure)', () => {
|
||||
const C = loadClusters();
|
||||
|
||||
it('groups by case in first-appearance order, members in tab order', () => {
|
||||
const out = C.compute([
|
||||
{ id: 'a', key: '/c/web', label: 'web' },
|
||||
{ id: 'b', key: '/c/api', label: 'api' },
|
||||
{ id: 'c', key: '/c/web', label: 'web' },
|
||||
]);
|
||||
expect(out.map((c) => [c.key, c.ids])).toEqual([
|
||||
['/c/web', ['a', 'c']],
|
||||
['/c/api', ['b']],
|
||||
]);
|
||||
});
|
||||
|
||||
it('gives a session with no case a cluster of its own', () => {
|
||||
const out = C.compute([
|
||||
{ id: 'a', key: '', label: '' },
|
||||
{ id: 'b', key: '', label: '' },
|
||||
]);
|
||||
expect(out.map((c) => c.ids)).toEqual([['a'], ['b']]);
|
||||
});
|
||||
|
||||
it('colours a case from the session palette, the same way every time', () => {
|
||||
for (const key of ['/c/web', '/c/api', '/home/x/codeman-cases/long-name', '']) {
|
||||
expect(C.COLORS).toContain(C.colorFor(key));
|
||||
expect(C.colorFor(key)).toBe(C.colorFor(key));
|
||||
}
|
||||
});
|
||||
|
||||
it('only shortens a generated name that carries this case', () => {
|
||||
expect(C.nameSplit('w75-api-gateway', 'api-gateway')).toEqual({ shown: 'w75', hidden: '-api-gateway' });
|
||||
expect(C.nameSplit('s2-API-Gateway', 'api-gateway')).toEqual({ shown: 's2', hidden: '-API-Gateway' });
|
||||
expect(C.nameSplit('w75-api-gateway', 'webshop')).toBeNull();
|
||||
expect(C.nameSplit('w3-x: fix login', 'x')).toBeNull();
|
||||
expect(C.nameSplit('DocsBot', 'docsbot')).toBeNull();
|
||||
expect(C.nameSplit('w1-webshop', '')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('tab layouts by case and ledger (app.js)', () => {
|
||||
let CodemanApp: { prototype: Record<string, any> };
|
||||
let window: any;
|
||||
let document: Document;
|
||||
|
||||
beforeAll(async () => {
|
||||
const dom = new JSDOM('<!DOCTYPE html><html><body></body></html>', {
|
||||
url: 'https://localhost/',
|
||||
runScripts: 'outside-only',
|
||||
});
|
||||
if (dom.window.document.readyState !== 'complete') {
|
||||
await new Promise((resolve) => dom.window.addEventListener('load', resolve));
|
||||
}
|
||||
window = dom.window;
|
||||
document = window.document;
|
||||
window.setInterval = () => 0;
|
||||
window.requestAnimationFrame = () => 0;
|
||||
window.CSS = { escape: (value: string) => value };
|
||||
window.eval(
|
||||
'var MobileDetection = { isTouchDevice: () => false, getDeviceType: () => "desktop" }, KeyboardHandler = {}, ' +
|
||||
'SwipeHandler = {}, VoiceInput = {}, DeepgramProvider = {}, NotificationManager = function(){};\n' +
|
||||
read('constants.js') +
|
||||
'\n' +
|
||||
read('tab-layout-browser.js') +
|
||||
'\n' +
|
||||
read('app.js') +
|
||||
'\n' +
|
||||
read('mobile-overview.js') +
|
||||
'\n' +
|
||||
read('webview-tabs.js') +
|
||||
'\n;window.__ClusterCodemanApp = CodemanApp;'
|
||||
);
|
||||
CodemanApp = window.__ClusterCodemanApp;
|
||||
});
|
||||
|
||||
function makeApp(arrangement: 'state' | 'case' | 'ledger' | 'classic' = 'case') {
|
||||
const app = Object.create(CodemanApp.prototype) as Record<string, any>;
|
||||
const root = document.documentElement;
|
||||
root.setAttribute('data-tab-orientation', 'horizontal');
|
||||
root.dataset.tabRailSort = 'activity';
|
||||
root.dataset.tabArrangement = arrangement;
|
||||
document.body.innerHTML = '<div class="session-tabs-host"><div id="sessionTabs" class="session-tabs"></div></div>';
|
||||
app.$ = (id: string) => document.getElementById(id);
|
||||
app.cases = [
|
||||
{ name: 'webshop', path: '/c/webshop' },
|
||||
{ name: 'api-gateway', path: '/c/api-gateway' },
|
||||
];
|
||||
app.sessions = new Map([
|
||||
['s1', { id: 's1', name: 'w1-webshop', status: 'idle', workingDir: '/c/webshop' }],
|
||||
['s2', { id: 's2', name: 'w75-api-gateway', status: 'busy', workingDir: '/c/api-gateway' }],
|
||||
['s3', { id: 's3', name: 'w3-webshop', status: 'idle', workingDir: '/c/webshop/sub' }],
|
||||
['s4', { id: 's4', name: 'landing', status: 'idle', workingDir: '/srv/landing/' }],
|
||||
['s5', { id: 's5', name: 'w4-webshop: fix login', status: 'busy', workingDir: '/c/webshop' }],
|
||||
]);
|
||||
app.sessionOrder = ['s1', 's2', 's3', 's4', 's5'];
|
||||
app.pendingHooks = new Map();
|
||||
app.webviews = new Map([['w1', { id: 'w1', name: 'Dashboard', url: 'https://example.test' }]]);
|
||||
app.webviewOrder = ['w1'];
|
||||
app.activeSessionId = 's2';
|
||||
app.activeWebviewId = null;
|
||||
app.tabLayout = null;
|
||||
app.collapsedTabGroupIds = new Set();
|
||||
app._hiddenTabGroupByRef = new Map();
|
||||
app._lastTabGroupStructureKey = null;
|
||||
app._inlineRenameActive = false;
|
||||
app.tabAlerts = new Map();
|
||||
app.terminalLoadStates = new Map();
|
||||
app.minimizedSubagents = new Map();
|
||||
app.hasTabDetachOverride = () => false;
|
||||
app.renderSubagentTabBadge = () => '';
|
||||
app.cancelHideSubagentDropdown = () => {};
|
||||
app.updateTabOverflowMode = () => {};
|
||||
app.updateConnectionLines = vi.fn();
|
||||
app._applyTabEntrances = () => {};
|
||||
app._scrollActiveTabIntoView = () => {};
|
||||
app._refreshMobileOverviewIfVisible = () => {};
|
||||
app._refreshHomeSessionsIfVisible = () => {};
|
||||
app.applySidebarFilter = () => {};
|
||||
return app;
|
||||
}
|
||||
|
||||
const container = () => document.getElementById('sessionTabs')!;
|
||||
const tab = (id: string) => container().querySelector<HTMLElement>(`.session-tab[data-id="${id}"]`)!;
|
||||
const boxes = () =>
|
||||
[...container().querySelectorAll<HTMLElement>(':scope > .tab-cluster')].map((box) => ({
|
||||
name: box.querySelector('.tab-cluster-name')?.textContent ?? null,
|
||||
count: box.querySelector('.tab-cluster-count')?.textContent ?? null,
|
||||
single: box.classList.contains('tab-cluster--single'),
|
||||
rows: [...box.querySelectorAll<HTMLElement>('.session-tab')].map((t) => t.dataset.id || `web:${t.dataset.webviewId}`),
|
||||
}));
|
||||
|
||||
beforeEach(() => {
|
||||
document.body.innerHTML = '';
|
||||
});
|
||||
|
||||
it('puts each case in one box, in first-appearance order, a web tab in its own', () => {
|
||||
makeApp()._fullRenderSessionTabs();
|
||||
expect(container().classList.contains('tabs-clusters')).toBe(true);
|
||||
expect(boxes()).toEqual([
|
||||
// s3 sits in a subdirectory of the case and still joins it.
|
||||
{ name: 'webshop', count: '3', single: false, rows: ['s1', 's3', 's5'] },
|
||||
{ name: 'api-gateway', count: '1', single: true, rows: ['s2'] },
|
||||
// No case matches: the directory names the cluster, trailing slash and all.
|
||||
{ name: 'landing', count: '1', single: true, rows: ['s4'] },
|
||||
{ name: null, count: null, single: true, rows: ['web:w1'] },
|
||||
]);
|
||||
for (const box of container().querySelectorAll<HTMLElement>(':scope > .tab-cluster:not(.tab-cluster--web)')) {
|
||||
expect(box.getAttribute('style')).toMatch(/^--cluster-color: var\(--session-(blue|green|purple|orange|pink|yellow|red)\)$/);
|
||||
expect(box.getAttribute('role')).toBe('presentation');
|
||||
}
|
||||
});
|
||||
|
||||
it('drops the case from a generated name inside a box with company, and only there', () => {
|
||||
makeApp()._fullRenderSessionTabs();
|
||||
const nameOf = (id: string) => tab(id).querySelector('.tab-name')!;
|
||||
expect(nameOf('s1').innerHTML).toBe('w1<span class="tab-name-case">-webshop</span>');
|
||||
expect(nameOf('s1').textContent).toBe('w1-webshop');
|
||||
expect(tab('s1').getAttribute('aria-label')).toContain('w1-webshop');
|
||||
// A described name keeps its #232 treatment.
|
||||
expect(nameOf('s5').querySelector('.tab-name-prefix')?.textContent).toBe('w4-webshop: ');
|
||||
// Alone in its box, nothing is redundant, so nothing is hidden.
|
||||
expect(nameOf('s2').innerHTML).toBe('w75-api-gateway');
|
||||
});
|
||||
|
||||
it('keeps the Alt+N badges on the tab order', () => {
|
||||
makeApp()._fullRenderSessionTabs();
|
||||
expect(tab('s3').querySelector('.tab-number')?.textContent).toBe('3');
|
||||
expect(tab('s2').querySelector('.tab-number')?.textContent).toBe('2');
|
||||
expect(container().querySelector('.session-tab[data-webview-id="w1"] .tab-number')?.textContent).toBe('6');
|
||||
});
|
||||
|
||||
it('patches in place while the clusters stay the same, and rebuilds when they change', () => {
|
||||
const app = makeApp();
|
||||
app._fullRenderSessionTabs();
|
||||
const s1 = tab('s1');
|
||||
const full = vi.spyOn(app, '_fullRenderSessionTabs');
|
||||
app.sessions.get('s1').status = 'busy';
|
||||
app._renderSessionTabsImmediate();
|
||||
expect(full).not.toHaveBeenCalled();
|
||||
expect(tab('s1')).toBe(s1);
|
||||
// s4 moves into the webshop case: a different box, so a full rebuild.
|
||||
app.sessions.get('s4').workingDir = '/c/webshop';
|
||||
app._renderSessionTabsImmediate();
|
||||
expect(full).toHaveBeenCalledTimes(1);
|
||||
expect(boxes()[0].rows).toEqual(['s1', 's3', 's4', 's5']);
|
||||
});
|
||||
|
||||
it('only accepts a drop inside the same box', () => {
|
||||
const app = makeApp();
|
||||
app._fullRenderSessionTabs();
|
||||
app.draggedTabId = 's1';
|
||||
expect(app._isTabDropAcrossGroups(tab('s3'))).toBe(false);
|
||||
expect(app._isTabDropAcrossGroups(tab('s2'))).toBe(true);
|
||||
});
|
||||
|
||||
it('lets named groups in the vertical rail win', () => {
|
||||
const app = makeApp();
|
||||
document.documentElement.setAttribute('data-tab-orientation', 'vertical');
|
||||
app.tabLayout = {
|
||||
version: 1,
|
||||
groups: [{ id: 'g1', name: 'Core', refs: [{ kind: 'session', id: 's1' }] }],
|
||||
ungrouped: [],
|
||||
};
|
||||
app._fullRenderSessionTabs();
|
||||
expect(container().classList.contains('session-tabs--grouped')).toBe(true);
|
||||
expect(container().classList.contains('tabs-clusters')).toBe(false);
|
||||
expect(container().querySelector('.tab-cluster, .tab-name-case')).toBeNull();
|
||||
});
|
||||
|
||||
it('switching back to classic leaves no boxes and no name splits', () => {
|
||||
const app = makeApp();
|
||||
app._fullRenderSessionTabs();
|
||||
document.documentElement.dataset.tabArrangement = 'classic';
|
||||
app._renderSessionTabsImmediate();
|
||||
expect(container().querySelector('.tab-cluster, .tab-name-case')).toBeNull();
|
||||
expect(container().classList.contains('tabs-clusters')).toBe(false);
|
||||
});
|
||||
|
||||
it('draws the ledger with the classic markup and one class', () => {
|
||||
makeApp('classic')._fullRenderSessionTabs();
|
||||
const classic = container().innerHTML;
|
||||
makeApp('ledger')._fullRenderSessionTabs();
|
||||
expect(container().innerHTML).toBe(classic);
|
||||
expect(container().classList.contains('tabs-ledger')).toBe(true);
|
||||
expect(container().classList.contains('tabs-triage')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('tab layouts by case and ledger (static)', () => {
|
||||
const css = read('styles.css');
|
||||
const mobileCss = read('mobile.css');
|
||||
|
||||
it('hides the case part of a name only in the clustered strip', () => {
|
||||
expect(css).toMatch(/\.session-tabs\.tabs-clusters \.tab-name-case \{\s*display: none;/);
|
||||
});
|
||||
|
||||
it('keeps the ledger to the desktop header strip', () => {
|
||||
const media = css.indexOf('@media (min-width: 768px) {\n .session-tabs-host > .session-tabs.tabs-ledger {');
|
||||
expect(media).toBeGreaterThan(-1);
|
||||
// Every ledger rule lives inside that block.
|
||||
const before = css.slice(0, media);
|
||||
expect(before).not.toContain('.tabs-ledger');
|
||||
});
|
||||
|
||||
it('keeps every ledger row one height and makes the active cell stand out', () => {
|
||||
const ledger = css.slice(css.indexOf('@media (min-width: 768px) {\n .session-tabs-host > .session-tabs.tabs-ledger {'));
|
||||
expect(ledger).toContain('align-items: stretch;');
|
||||
expect(ledger).toContain('min-height: 30px;');
|
||||
expect(ledger).toMatch(
|
||||
/\.tabs-ledger > \.session-tab\.active \{[^}]*box-shadow: inset 0 0 0 1px var\(--accent\), inset 4px 0 0 var\(--accent\) !important;/
|
||||
);
|
||||
});
|
||||
|
||||
it('dissolves the boxes into the one chip row on phones', () => {
|
||||
expect(mobileCss).toMatch(
|
||||
/:where\(\.header\) \.session-tabs-host > \.session-tabs\.tabs-clusters > \.tab-cluster \{\s*display: contents;/
|
||||
);
|
||||
expect(mobileCss).toMatch(/:where\(\.header\) \.tab-cluster-label \{\s*display: none;/);
|
||||
});
|
||||
});
|
||||
+11
-10
@@ -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', () => {
|
||||
// `_tabIdx` counts the loop over sessionOrder, and only `style="order:…"`
|
||||
// moves the card — so the badge names a shortcut, not a row position.
|
||||
expect(appJs).toContain(
|
||||
'const railSortOrder = this._tabRailSortOrder(tabOrder.filter((id) => this.sessions.has(id)));'
|
||||
);
|
||||
expect(appJs).toContain('` style="order:${railSortOrder.get(id)}"`');
|
||||
// `listOrder` is the rail sort's map, or the state grouping's when that is
|
||||
// on (test/tab-triage.test.ts); either way it only sets the inline order.
|
||||
expect(appJs).toContain('const liveIds = tabOrder.filter((id) => this.sessions.has(id));');
|
||||
expect(appJs).toContain('const railSortOrder = this._tabRailSortOrder(liveIds);');
|
||||
expect(appJs).toContain('` style="order:${listOrder.get(id)}"`');
|
||||
expect(appJs).toMatch(/_tabIdx < 9 \? '<span class="tab-number">' \+ \(_tabIdx \+ 1\)/);
|
||||
// The loop itself still walks the user's order, which is what makes the
|
||||
// badge, drag-and-drop and the arrow-key walk agree with each other.
|
||||
@@ -219,12 +220,11 @@ describe('vertical tab rail row order', () => {
|
||||
// A session going working→idle never adds or removes a tab, so the full
|
||||
// rebuild is not reached — and a rebuild here would restart every card's
|
||||
// animation on every SSE tick anyway.
|
||||
expect(appJs).toContain(
|
||||
'const railSortOrder = this._tabRailSortOrder(this.sessionOrder.filter((sid) => this.sessions.has(sid)));'
|
||||
);
|
||||
expect(appJs).toContain('const liveIds = 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;');
|
||||
// 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', () => {
|
||||
@@ -280,9 +280,10 @@ describe('vertical tab rail sort setting', () => {
|
||||
expect(html).toContain("document.documentElement.dataset.tabRailSort='activity';");
|
||||
expect(settingsUi).toContain('root.dataset.tabRailSort = sort;');
|
||||
// A sort flip leaves orientation on 'vertical' both times, so it has to
|
||||
// count as a change of its own or nothing re-renders.
|
||||
// count as a change of its own or nothing re-renders. The same holds for a
|
||||
// tab layout or state order flip (test/tab-triage.test.ts).
|
||||
expect(settingsUi).toContain(
|
||||
'const changed = orientationChanged || previousDetail !== detail || previousSort !== sort;'
|
||||
'const changed = orientationChanged || previousDetail !== detail || previousSort !== sort || previousArrangement !== arrangement || previousStateOrder !== stateOrder;'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,466 @@
|
||||
/**
|
||||
* @fileoverview Tab grouping by state (`tabArrangement: 'state'`, Discussion #426
|
||||
* option C): the pure layout in constants.js and the render paths in app.js.
|
||||
*
|
||||
* What is pinned, and why it matters:
|
||||
* - The four groups and their order (needs you, waiting, working, idle) and
|
||||
* which home-screen state lands in which, including the two folds that are
|
||||
* easy to get wrong: a failed session joins "needs you", and an agent that
|
||||
* exited inside a live pane (#446) is idle even while its status says busy.
|
||||
* - It is applied as flex `order` only. The DOM stays in tab order, so Alt+N
|
||||
* badges, drag and the keyboard walk keep reading the list they always read.
|
||||
* - A state change is an INCREMENTAL pass: the tab element survives, only its
|
||||
* `order` and the heading counts move, and a group that empties loses its
|
||||
* heading.
|
||||
* - `tabArrangement: 'classic'` leaves no trace (no headings, no inline order, no
|
||||
* class), and named groups in the vertical rail win over it.
|
||||
*
|
||||
* The real modules run INSIDE a JSDOM window (runScripts: 'outside-only'), so
|
||||
* `document` below is that window's.
|
||||
*
|
||||
* Port: none.
|
||||
*/
|
||||
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import vm from 'node:vm';
|
||||
import { JSDOM } from 'jsdom';
|
||||
import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const PUBLIC = join(process.cwd(), 'src/web/public');
|
||||
const read = (name: string) => readFileSync(join(PUBLIC, name), 'utf8');
|
||||
|
||||
type Row = { id: string; state: string; exited?: boolean; pos?: number };
|
||||
type Layout = {
|
||||
order: Map<string, number>;
|
||||
webOrder: Map<string, number>;
|
||||
groups: Array<{ key: string; label: string; count: number; headOrder: number; breakOrder: number }>;
|
||||
};
|
||||
type Triage = {
|
||||
GROUPS: Array<{ key: string; label: string }>;
|
||||
STRIDE: number;
|
||||
groupFor: (state: string, exited: boolean) => string;
|
||||
layout: (rows: Row[], webviewIds?: string[]) => Layout;
|
||||
};
|
||||
|
||||
function loadTriage(): Triage {
|
||||
const context = vm.createContext({ window: {}, globalThis: {} });
|
||||
vm.runInContext(read('constants.js'), context, { filename: 'constants.js' });
|
||||
return (context.window as { CodemanTabTriage: Triage }).CodemanTabTriage;
|
||||
}
|
||||
|
||||
describe('CodemanTabTriage (pure)', () => {
|
||||
const triage = loadTriage();
|
||||
|
||||
it('has four groups, most urgent first', () => {
|
||||
expect(triage.GROUPS.map((g) => g.key)).toEqual(['needs', 'waiting', 'working', 'idle']);
|
||||
expect(triage.GROUPS.map((g) => g.label)).toEqual(['Needs you', 'Waiting', 'Working', 'Idle']);
|
||||
// Only idle is quiet: it keeps a heading element but draws no label.
|
||||
expect(triage.GROUPS.filter((g) => (g as { quiet?: boolean }).quiet).map((g) => g.key)).toEqual(['idle']);
|
||||
});
|
||||
|
||||
it('folds the six home-screen states into the four groups', () => {
|
||||
expect(triage.groupFor('needs', false)).toBe('needs');
|
||||
expect(triage.groupFor('error', false)).toBe('needs');
|
||||
expect(triage.groupFor('waiting', false)).toBe('waiting');
|
||||
expect(triage.groupFor('working', false)).toBe('working');
|
||||
expect(triage.groupFor('idle', false)).toBe('idle');
|
||||
expect(triage.groupFor('done', false)).toBe('idle');
|
||||
expect(triage.groupFor('something-new', false)).toBe('idle');
|
||||
});
|
||||
|
||||
it('puts an exited agent with idle, never with working', () => {
|
||||
expect(triage.groupFor('working', true)).toBe('idle');
|
||||
expect(triage.groupFor('idle', true)).toBe('idle');
|
||||
// A human being blocked still outranks the agent having exited.
|
||||
expect(triage.groupFor('needs', true)).toBe('needs');
|
||||
expect(triage.groupFor('waiting', true)).toBe('waiting');
|
||||
});
|
||||
|
||||
it('gives each group a band of order values and lists only non-empty groups', () => {
|
||||
const out = triage.layout([
|
||||
{ id: 'a', state: 'idle', pos: 0 },
|
||||
{ id: 'b', state: 'needs', pos: 1 },
|
||||
{ id: 'c', state: 'idle', pos: 2 },
|
||||
]);
|
||||
expect(out.groups.map((g) => [g.key, g.count])).toEqual([
|
||||
['needs', 1],
|
||||
['idle', 2],
|
||||
]);
|
||||
const needs = out.groups[0];
|
||||
const idle = out.groups[1];
|
||||
expect(out.order.get('b')).toBeGreaterThan(needs.headOrder);
|
||||
expect(out.order.get('b')).toBeLessThan(needs.breakOrder);
|
||||
// Rows keep their relative position inside a group.
|
||||
expect(out.order.get('a')).toBeLessThan(out.order.get('c')!);
|
||||
expect(out.order.get('a')).toBeGreaterThan(idle.headOrder);
|
||||
// A heading opens its band, the break closes it, and bands never overlap.
|
||||
expect(needs.breakOrder).toBeLessThan(idle.headOrder);
|
||||
expect(Math.floor(out.order.get('b')! / triage.STRIDE)).toBe(Math.floor(needs.headOrder / triage.STRIDE));
|
||||
});
|
||||
|
||||
it('ranks rows inside a group by `pos`, stably', () => {
|
||||
const out = triage.layout([
|
||||
{ id: 'x', state: 'working', pos: 5 },
|
||||
{ id: 'y', state: 'working', pos: 1 },
|
||||
{ id: 'z', state: 'working', pos: 1 },
|
||||
]);
|
||||
const sorted = [...out.order.entries()].sort((p, q) => p[1] - q[1]).map(([id]) => id);
|
||||
expect(sorted).toEqual(['y', 'z', 'x']);
|
||||
});
|
||||
|
||||
it('closes the idle group with the web tabs and counts them', () => {
|
||||
const out = triage.layout([{ id: 's', state: 'idle', pos: 0 }], ['w1', 'w2']);
|
||||
expect(out.groups).toHaveLength(1);
|
||||
expect(out.groups[0]).toMatchObject({ key: 'idle', count: 3 });
|
||||
expect(out.webOrder.get('w1')).toBeGreaterThan(out.order.get('s')!);
|
||||
expect(out.webOrder.get('w2')).toBeGreaterThan(out.webOrder.get('w1')!);
|
||||
expect(out.webOrder.get('w2')).toBeLessThan(out.groups[0].breakOrder);
|
||||
});
|
||||
|
||||
it('turns the groups the other way up with reverse, rows unchanged inside them', () => {
|
||||
const rows = [
|
||||
{ id: 'a', state: 'idle', pos: 0 },
|
||||
{ id: 'b', state: 'needs', pos: 1 },
|
||||
{ id: 'c', state: 'working', pos: 2 },
|
||||
{ id: 'd', state: 'working', pos: 3 },
|
||||
];
|
||||
const out = triage.layout(rows, ['w1'], { reverse: true });
|
||||
expect(out.groups.map((g) => g.key)).toEqual(['idle', 'working', 'needs']);
|
||||
const sorted = [...out.order.entries()].sort((p, q) => p[1] - q[1]).map(([id]) => id);
|
||||
expect(sorted).toEqual(['a', 'c', 'd', 'b']);
|
||||
// Web tabs still close the idle group, which now comes first.
|
||||
expect(out.webOrder.get('w1')).toBeLessThan(out.order.get('c')!);
|
||||
for (let i = 1; i < out.groups.length; i++) {
|
||||
expect(out.groups[i].headOrder).toBeGreaterThan(out.groups[i - 1].breakOrder);
|
||||
}
|
||||
});
|
||||
|
||||
it('shows the idle group for web tabs alone, and nothing for nothing', () => {
|
||||
expect(triage.layout([], ['w1']).groups.map((g) => g.key)).toEqual(['idle']);
|
||||
expect(triage.layout([], []).groups).toEqual([]);
|
||||
expect(triage.layout(undefined as unknown as Row[], undefined).groups).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('tab grouping in the render paths (app.js)', () => {
|
||||
let CodemanApp: { prototype: Record<string, any> };
|
||||
let window: any;
|
||||
let document: Document;
|
||||
|
||||
beforeAll(async () => {
|
||||
const dom = new JSDOM('<!DOCTYPE html><html><body></body></html>', {
|
||||
url: 'https://localhost/',
|
||||
runScripts: 'outside-only',
|
||||
});
|
||||
if (dom.window.document.readyState !== 'complete') {
|
||||
await new Promise((resolve) => dom.window.addEventListener('load', resolve));
|
||||
}
|
||||
window = dom.window;
|
||||
document = window.document;
|
||||
window.setInterval = () => 0;
|
||||
window.requestAnimationFrame = () => 0;
|
||||
window.CSS = { escape: (value: string) => value };
|
||||
window.eval(
|
||||
'var MobileDetection = { isTouchDevice: () => false, getDeviceType: () => "desktop" }, KeyboardHandler = {}, ' +
|
||||
'SwipeHandler = {}, VoiceInput = {}, DeepgramProvider = {}, NotificationManager = function(){};\n' +
|
||||
read('constants.js') +
|
||||
'\n' +
|
||||
read('tab-layout-browser.js') +
|
||||
'\n' +
|
||||
read('app.js') +
|
||||
'\n' +
|
||||
read('mobile-overview.js') +
|
||||
'\n' +
|
||||
read('webview-tabs.js') +
|
||||
'\n;window.__TriageCodemanApp = CodemanApp;'
|
||||
);
|
||||
CodemanApp = window.__TriageCodemanApp;
|
||||
});
|
||||
|
||||
function makeApp(arrangement: 'state' | 'case' | 'ledger' | 'classic' = 'state') {
|
||||
const app = Object.create(CodemanApp.prototype) as Record<string, any>;
|
||||
const root = document.documentElement;
|
||||
root.setAttribute('data-tab-orientation', 'horizontal');
|
||||
root.dataset.tabRailSort = 'activity';
|
||||
root.dataset.tabArrangement = arrangement;
|
||||
delete root.dataset.tabStateOrder;
|
||||
document.body.innerHTML = '<div class="session-tabs-host"><div id="sessionTabs" class="session-tabs"></div></div>';
|
||||
app.$ = (id: string) => document.getElementById(id);
|
||||
app.sessions = new Map([
|
||||
['s1', { id: 's1', name: 'w1-alpha', status: 'idle' }],
|
||||
['s2', { id: 's2', name: 'w2-beta', status: 'busy' }],
|
||||
['s3', { id: 's3', name: 'w3-gamma', status: 'idle' }],
|
||||
['s4', { id: 's4', name: 'w4-delta', status: 'busy' }],
|
||||
]);
|
||||
app.sessionOrder = ['s1', 's2', 's3', 's4'];
|
||||
app.pendingHooks = new Map([
|
||||
['s3', new Set(['permission_prompt'])],
|
||||
['s1', new Set(['idle_prompt'])],
|
||||
]);
|
||||
app.webviews = new Map([['w1', { id: 'w1', name: 'Dashboard', url: 'https://example.test' }]]);
|
||||
app.webviewOrder = ['w1'];
|
||||
app.activeSessionId = 's2';
|
||||
app.activeWebviewId = null;
|
||||
app.tabLayout = null;
|
||||
app.collapsedTabGroupIds = new Set();
|
||||
app._hiddenTabGroupByRef = new Map();
|
||||
app._lastTabGroupStructureKey = null;
|
||||
app._inlineRenameActive = false;
|
||||
app.tabAlerts = new Map();
|
||||
app.terminalLoadStates = new Map();
|
||||
app.minimizedSubagents = new Map();
|
||||
app.hasTabDetachOverride = () => false;
|
||||
app.renderSubagentTabBadge = () => '';
|
||||
app.cancelHideSubagentDropdown = () => {};
|
||||
app.updateTabOverflowMode = () => {};
|
||||
app.updateConnectionLines = vi.fn();
|
||||
app._applyTabEntrances = () => {};
|
||||
app._scrollActiveTabIntoView = () => {};
|
||||
app._refreshMobileOverviewIfVisible = () => {};
|
||||
app._refreshHomeSessionsIfVisible = () => {};
|
||||
app.applySidebarFilter = () => {};
|
||||
return app;
|
||||
}
|
||||
|
||||
const container = () => document.getElementById('sessionTabs')!;
|
||||
const tab = (id: string) => container().querySelector<HTMLElement>(`.session-tab[data-id="${id}"]`)!;
|
||||
const orderOf = (el: HTMLElement) => Number(el.style.order);
|
||||
/** What the eye sees: every ordered child, by `order`, as `head:<key>` / `<id>`. */
|
||||
const visual = () =>
|
||||
[...container().children]
|
||||
.filter((el) => !(el as HTMLElement).classList.contains('tab-triage-break'))
|
||||
.sort((a, b) => orderOf(a as HTMLElement) - orderOf(b as HTMLElement))
|
||||
.map((el) => {
|
||||
const h = el as HTMLElement;
|
||||
if (h.classList.contains('tab-triage-head')) return `head:${h.dataset.triageGroup}`;
|
||||
return h.dataset.webviewId ? `web:${h.dataset.webviewId}` : h.dataset.id!;
|
||||
});
|
||||
const heads = () =>
|
||||
[...container().querySelectorAll<HTMLElement>(':scope > .tab-triage-head')]
|
||||
.sort((a, b) => orderOf(a) - orderOf(b))
|
||||
.map((h) =>
|
||||
h.classList.contains('tab-triage-head--quiet')
|
||||
? `${h.dataset.triageGroup}:quiet`
|
||||
: `${h.querySelector('.tab-triage-label')!.textContent}:${h.querySelector('.tab-triage-count')!.textContent}`
|
||||
);
|
||||
|
||||
beforeEach(() => {
|
||||
document.body.innerHTML = '';
|
||||
});
|
||||
|
||||
it('draws a heading per non-empty state and places every tab in its group', () => {
|
||||
makeApp()._fullRenderSessionTabs();
|
||||
expect(container().classList.contains('tabs-triage')).toBe(true);
|
||||
expect(heads()).toEqual(['Needs you:1', 'Waiting:1', 'Working:2', 'idle:quiet']);
|
||||
// The idle row keeps its heading as an anchor, with nothing drawn in it.
|
||||
expect(container().querySelector('.tab-triage-head[data-triage-group="idle"]')!.textContent).toBe('');
|
||||
expect(visual()).toEqual([
|
||||
'head:needs',
|
||||
's3',
|
||||
'head:waiting',
|
||||
's1',
|
||||
'head:working',
|
||||
's2',
|
||||
's4',
|
||||
'head:idle',
|
||||
'web:w1',
|
||||
]);
|
||||
// One row break per heading; headings and breaks stay out of the tablist.
|
||||
expect(container().querySelectorAll(':scope > .tab-triage-break')).toHaveLength(4);
|
||||
for (const el of container().querySelectorAll(':scope > .tab-triage-head, :scope > .tab-triage-break')) {
|
||||
expect(el.getAttribute('aria-hidden')).toBe('true');
|
||||
}
|
||||
});
|
||||
|
||||
it("puts needs you in the bottom row with tabStateOrder 'urgent-last'", () => {
|
||||
const app = makeApp();
|
||||
document.documentElement.dataset.tabStateOrder = 'urgent-last';
|
||||
app._fullRenderSessionTabs();
|
||||
expect(heads()).toEqual(['idle:quiet', 'Working:2', 'Waiting:1', 'Needs you:1']);
|
||||
expect(visual()).toEqual([
|
||||
'head:idle',
|
||||
'web:w1',
|
||||
'head:working',
|
||||
's2',
|
||||
's4',
|
||||
'head:waiting',
|
||||
's1',
|
||||
'head:needs',
|
||||
's3',
|
||||
]);
|
||||
});
|
||||
|
||||
it('marks the first row heading as the lead, in either state order', () => {
|
||||
const app = makeApp();
|
||||
app._fullRenderSessionTabs();
|
||||
const lead = () =>
|
||||
[...container().querySelectorAll<HTMLElement>(':scope > .tab-triage-head--lead')].map((h) => h.dataset.triageGroup);
|
||||
expect(lead()).toEqual(['needs']);
|
||||
document.documentElement.dataset.tabStateOrder = 'urgent-last';
|
||||
app._renderSessionTabsImmediate();
|
||||
expect(lead()).toEqual(['idle']);
|
||||
});
|
||||
|
||||
it('keeps the DOM, and with it the Alt+N badges, in tab order', () => {
|
||||
makeApp()._fullRenderSessionTabs();
|
||||
const domOrder = [...container().querySelectorAll<HTMLElement>('.session-tab[data-id]')].map((t) => t.dataset.id);
|
||||
expect(domOrder).toEqual(['s1', 's2', 's3', 's4']);
|
||||
expect(tab('s3').querySelector('.tab-number')?.textContent).toBe('3');
|
||||
});
|
||||
|
||||
it('moves a tab between groups on an incremental pass, without rebuilding it', () => {
|
||||
const app = makeApp();
|
||||
app._fullRenderSessionTabs();
|
||||
const s2 = tab('s2');
|
||||
const fullRender = vi.spyOn(app, '_fullRenderSessionTabs');
|
||||
// s2 finishes its turn; the dialog on s3 is answered.
|
||||
app.sessions.get('s2').status = 'idle';
|
||||
app.pendingHooks.delete('s3');
|
||||
app._renderSessionTabsImmediate();
|
||||
expect(fullRender).not.toHaveBeenCalled();
|
||||
expect(tab('s2')).toBe(s2);
|
||||
expect(heads()).toEqual(['Waiting:1', 'Working:1', 'idle:quiet']);
|
||||
expect(container().querySelector('.tab-triage-head[data-triage-group="needs"]')).toBeNull();
|
||||
expect(container().querySelector('.tab-triage-break[data-triage-group="needs"]')).toBeNull();
|
||||
expect(visual()).toEqual(['head:waiting', 's1', 'head:working', 's4', 'head:idle', 's2', 's3', 'web:w1']);
|
||||
});
|
||||
|
||||
it('reconciles headings in place: an unchanged pass rewrites nothing', () => {
|
||||
const app = makeApp();
|
||||
app._fullRenderSessionTabs();
|
||||
app._renderSessionTabsImmediate();
|
||||
const head = container().querySelector('.tab-triage-head[data-triage-group="working"]');
|
||||
app._renderSessionTabsImmediate();
|
||||
expect(container().querySelector('.tab-triage-head[data-triage-group="working"]')).toBe(head);
|
||||
expect(container().querySelectorAll(':scope > .tab-triage-head')).toHaveLength(4);
|
||||
});
|
||||
|
||||
it('files an agent that exited in a live pane under idle, a failed session under needs you', () => {
|
||||
const app = makeApp();
|
||||
app.pendingHooks = new Map();
|
||||
app.sessions.get('s2').paneExit = { at: 1, status: 0 };
|
||||
app.sessions.get('s1').status = 'error';
|
||||
app._fullRenderSessionTabs();
|
||||
expect(visual()).toEqual(['head:needs', 's1', 'head:working', 's4', 'head:idle', 's2', 's3', 'web:w1']);
|
||||
});
|
||||
|
||||
it("leaves no trace with tabArrangement 'classic'", () => {
|
||||
makeApp('classic')._fullRenderSessionTabs();
|
||||
expect(container().classList.contains('tabs-triage')).toBe(false);
|
||||
expect(container().querySelector('.tab-triage-head, .tab-triage-break')).toBeNull();
|
||||
expect(container().innerHTML).not.toContain('order:');
|
||||
expect(container().querySelector<HTMLElement>('.session-tab[data-webview-id]')!.style.order).toBe('');
|
||||
});
|
||||
|
||||
it('switching off removes the headings and every inline order it wrote', () => {
|
||||
const app = makeApp();
|
||||
app._fullRenderSessionTabs();
|
||||
document.documentElement.dataset.tabArrangement = 'classic';
|
||||
app._renderSessionTabsImmediate();
|
||||
expect(container().querySelector('.tab-triage-head, .tab-triage-break')).toBeNull();
|
||||
for (const el of container().querySelectorAll<HTMLElement>('.session-tab')) expect(el.style.order).toBe('');
|
||||
expect(container().classList.contains('tabs-triage')).toBe(false);
|
||||
});
|
||||
|
||||
it('ranks rows inside each section by the activity sort on a sorted rail', () => {
|
||||
const app = makeApp();
|
||||
document.documentElement.setAttribute('data-tab-orientation', 'vertical');
|
||||
app.pendingHooks = new Map();
|
||||
// Both working; s4 has been running longer, so the activity sort puts it first.
|
||||
app.sessions.get('s2').lastSubmitAt = 2_000;
|
||||
app.sessions.get('s4').lastSubmitAt = 1_000;
|
||||
app._fullRenderSessionTabs();
|
||||
expect(visual()).toEqual(['head:working', 's4', 's2', 'head:idle', 's1', 's3', 'web:w1']);
|
||||
});
|
||||
|
||||
it('keeps tab order inside each row on the header strip', () => {
|
||||
const app = makeApp();
|
||||
app.pendingHooks = new Map();
|
||||
app.sessions.get('s2').lastSubmitAt = 2_000;
|
||||
app.sessions.get('s4').lastSubmitAt = 1_000;
|
||||
app._fullRenderSessionTabs();
|
||||
expect(visual()).toEqual(['head:working', 's2', 's4', 'head:idle', 's1', 's3', 'web:w1']);
|
||||
});
|
||||
|
||||
it('lets named groups in the vertical rail win', () => {
|
||||
const app = makeApp();
|
||||
document.documentElement.setAttribute('data-tab-orientation', 'vertical');
|
||||
app.tabLayout = {
|
||||
version: 1,
|
||||
groups: [{ id: 'g1', name: 'Core', refs: [{ kind: 'session', id: 's1' }] }],
|
||||
ungrouped: [],
|
||||
};
|
||||
app._fullRenderSessionTabs();
|
||||
expect(container().classList.contains('session-tabs--grouped')).toBe(true);
|
||||
expect(container().classList.contains('tabs-triage')).toBe(false);
|
||||
expect(container().querySelector('.tab-triage-head')).toBeNull();
|
||||
});
|
||||
|
||||
it('refuses a drop into another group and allows one inside the same group', () => {
|
||||
const app = makeApp();
|
||||
app._fullRenderSessionTabs();
|
||||
app.draggedTabId = 's2';
|
||||
expect(app._isTabDropAcrossGroups(tab('s4'))).toBe(false);
|
||||
expect(app._isTabDropAcrossGroups(tab('s1'))).toBe(true);
|
||||
expect(app._isTabDropAcrossGroups(tab('s3'))).toBe(true);
|
||||
document.documentElement.dataset.tabArrangement = 'classic';
|
||||
app._fullRenderSessionTabs();
|
||||
expect(app._isTabDropAcrossGroups(tab('s1'))).toBe(false);
|
||||
});
|
||||
|
||||
it('degrades to the flat strip when mobile-overview.js is stale or missing', () => {
|
||||
const app = makeApp();
|
||||
app._mobileOverviewState = undefined;
|
||||
app._fullRenderSessionTabs();
|
||||
expect(container().querySelector('.tab-triage-head')).toBeNull();
|
||||
expect(container().classList.contains('tabs-triage')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('tab grouping wiring (static)', () => {
|
||||
const html = read('index.html');
|
||||
const css = read('styles.css');
|
||||
const mobileCss = read('mobile.css');
|
||||
|
||||
it('stamps data-tab-arrangement and data-tab-state-order before first paint', () => {
|
||||
expect(html).toContain(
|
||||
"dataset.tabArrangement=(T==='case'||T==='ledger'||T==='classic')?T:'state'"
|
||||
);
|
||||
expect(html).toContain("dataset.tabStateOrder=(A.tabStateOrder==='urgent-last')?'urgent-last':'urgent-first'");
|
||||
// The catch branch (localStorage threw) must set both too.
|
||||
expect(html).toContain(
|
||||
"document.documentElement.dataset.tabArrangement='state';document.documentElement.dataset.tabStateOrder='urgent-first';"
|
||||
);
|
||||
});
|
||||
|
||||
it('offers the four layouts with "By state" as the default, and the state order', () => {
|
||||
expect(html).toMatch(
|
||||
/<select id="appSettingsTabArrangement"[^>]*>\s*<option value="state">By state \(default\)<\/option>\s*<option value="case">[^<]+<\/option>\s*<option value="ledger">[^<]+<\/option>\s*<option value="classic">Classic \(as before\)<\/option>/
|
||||
);
|
||||
expect(html).toMatch(
|
||||
/<select id="appSettingsTabStateOrder"[^>]*>\s*<option value="urgent-first">Needs you on top \(default\)<\/option>\s*<option value="urgent-last">/
|
||||
);
|
||||
});
|
||||
|
||||
it('only shows row breaks in a wrapping header strip', () => {
|
||||
expect(css).toMatch(/\.tab-triage-break \{\s*display: none;/);
|
||||
expect(css).toContain(
|
||||
'.session-tabs-host > .session-tabs.tabs-triage:is(.tabs-auto-wrap, .tabs-two-rows) > .tab-triage-break'
|
||||
);
|
||||
});
|
||||
|
||||
it('lets the rows after the first start under the brand, labels left-aligned', () => {
|
||||
expect(css).toMatch(
|
||||
/\.header:has\(> \.session-tabs-host > \.session-tabs\.tabs-triage:is\(\.tabs-auto-wrap, \.tabs-two-rows\)\) > \.header-brand \{\s*position: absolute;/
|
||||
);
|
||||
expect(css).toMatch(/> \.tab-triage-head \{\s*justify-content: flex-start;/);
|
||||
expect(css).toMatch(
|
||||
/> \.tab-triage-head--lead \{\s*width: auto;\s*margin-left: calc\(var\(--tab-triage-brand, 100px\) - var\(--tab-triage-gutter, 92px\)\);/
|
||||
);
|
||||
});
|
||||
|
||||
it('hides the headings on phones, whose strip stays one scrolling row', () => {
|
||||
expect(mobileCss).toMatch(/:where\(\.header\) \.tab-triage-head \{\s*display: none;/);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user