mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-06 15:39:41 +02:00
feat(tabs): Tab Layout setting with by case (A) and ledger (B), reversible state order, cleaner tiles (#426)
Tab Grouping becomes Tab Layout (tabArrangement: 'state' | 'case' | 'ledger' | 'classic', default 'state'), the first row of App Settings, Appearance, Tabs, so the old and the new strip are one choice apart. - By case (option A): each case's tabs sit in one .tab-cluster box in first-appearance order, labelled with the case and its count, coloured by a stable hash into the session palette. Membership is _mobileOverviewCaseFor(), the home screens' own match. Inside a box with company a generated w75-api-gateway reads w75; the -<case> stays in the DOM in a .tab-name-case span only .tabs-clusters hides. The incremental render path rebuilds only when the cluster structure key changes. The rail and the sidebar get a labelled section per case; phones dissolve the boxes into the chip row. Drag stays inside one box. - Ledger (option B): CSS only on .tabs-ledger, desktop header strip: an auto-fill column grid of equal cells in mono type with a 3px status bar. Its markup is identical to classic's. - State Order (tabStateOrder: 'urgent-first' | 'urgent-last'): flips the by-state groups so needs you can be the bottom row. - By state: the label column is measured to the widest label on screen and the labels are right-aligned in it, instead of a fixed 92px gutter that left short labels far from their tabs. - Tiles: a three-row grid (label, value, bar) with pixel line-heights in the bundled JetBrains Mono, 36px like the header. The bar used to lie over a fixed 28px tile, and a taller system mono (SF Mono) pushed the value into it. The WS tile's grid moved onto an inner .connection-tile span because JS writes the indicator's display inline. Compact uses the same font and a matched WS size. Tests: test/tab-clusters.test.ts (new), plus the rename and the reversed order in test/tab-triage.test.ts. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -333,9 +333,9 @@ 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 grouping by state** (`tabGrouping`, per-device, default `state`; Discussion #426 option C): the tab list splits into needs you / waiting / working / idle, most urgent on top: rows in the desktop header strip, sections in the flat rail and the sidebar, inline dividers on the tablet strip, group order with no headings on phones. Classification is `_mobileOverviewState()` + `_mobileOverviewExit()`; the fold and the order bands are pure in `CodemanTabTriage` (constants.js). ⚠️ It is the flex `order` property plus `aria-hidden` heading/break elements reconciled in place by `_syncTabTriageChrome()` after BOTH render paths, never a DOM reorder (Alt+N, drag and the keyboard walk keep reading tab order). ⚠️ Drag only reorders within a group (`_isTabDropAcrossTriageGroups()`). ⚠️ Named groups in the vertical rail win: triage is off whenever `_projectTabGroups()` is non-null. ⚠️ `'none'` must leave no trace (no headings, no inline order, no `tabs-triage` class). Tests: `test/tab-triage.test.ts`. → [architecture-invariants#tab-grouping-by-state](docs/architecture-invariants.md#tab-grouping-by-state)
|
||||
**Tab layouts** (`tabArrangement`, per-device, default `state`; Discussion #426): `state` (option C) splits the list into needs you / waiting / working / idle (`tabStateOrder: 'urgent-last'` flips it, needs you at the bottom): rows in the desktop header strip with right-aligned labels in a measured gutter, 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). Tests: `test/header-stats-style.test.ts`. → [architecture-invariants#header-stats-styles](docs/architecture-invariants.md#header-stats-styles)
|
||||
**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)
|
||||
|
||||
|
||||
@@ -867,15 +867,21 @@ 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 grouping by state
|
||||
### Tab layouts (by state, by case, ledger)
|
||||
|
||||
**Tab grouping by state** (`tabGrouping`, per-device, default `state`; Discussion #426 option C): the tab list is split into four groups, most urgent first: **needs you** (a permission or question dialog; a failed session joins it), **waiting** (idle prompt pending), **working**, **idle** (also ended sessions, an agent that exited inside a live pane, and web tabs). The desktop header strip draws a row per group with its label and count in a left gutter; the flat vertical rail and the sidebar draw a section per group; the tablet strip (600-767px) keeps its scrolling row with the headings as inline dividers; phones keep their chip row in group order with the headings hidden (mobile.css). Classification is `_mobileOverviewState()` + `_mobileOverviewExit()`, the home screens' own, and the fold into four groups is pure in `CodemanTabTriage` (constants.js, `computeTabTriageLayout()`).
|
||||
**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.
|
||||
|
||||
⚠️ **It is the flex `order` property, never a DOM reorder**, exactly like the sorted rail: `#sessionTabs` stays in `sessionOrder`, so the Alt+N badges, drag, the keyboard walk (which already sorts by COMPUTED order) and the sidebar filter keep reading the list they always read. Each group owns a band of `TAB_TRIAGE_STRIDE` order values: its heading, its rows, its web tabs, then the row break that ends the header line. The headings and breaks are `aria-hidden` direct children of `#sessionTabs`, reconciled in place by `_syncTabTriageChrome()` after BOTH render paths, because a state change is an incremental pass (no tab added or removed) and can still empty a group or fill a new one. An unchanged pass writes nothing.
|
||||
**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). `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 right-aligned in a left gutter 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 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()`).
|
||||
|
||||
⚠️ Inside a group a header row keeps TAB order, so the strip only moves a tab when its state changes; a sorted rail ranks each section the way it ranks the flat rail (`_tabRailSortOrder()` feeds `pos`). Drag stays on in the strip, but `_isTabDropAcrossTriageGroups()` refuses a drop on a tab in another band: the dragged tab would stay in its own group and land where the user did not put it.
|
||||
⚠️ **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.
|
||||
|
||||
⚠️ **Named groups win**: in the vertical rail with owner tab layouts (`_projectTabGroups()` non-null) triage is off and the grouped tree renders as before. With `tabGrouping: 'none'` nothing is left behind: no headings, no breaks, no inline order, no `tabs-triage` class. A stale cached mobile-overview.js degrades to the flat strip. The header strip only wraps into rows on desktop, where `updateTabOverflowMode()` forces `tabs-auto-wrap` while `tabs-triage` is on; the breaks only display in a wrapping strip, since a `flex-basis: 100%` break in a nowrap scroller would steal width. Tests: `test/tab-triage.test.ts`.
|
||||
**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
|
||||
|
||||
@@ -883,7 +889,7 @@ Tests: `test/terminal-touch-tap.test.ts`.
|
||||
|
||||
⚠️ The clustered styles need the three elements contiguous, and the template keeps the classic order, so `applyHeaderStatsStyle()` MOVES `#connectionIndicator` into `#headerSystemStats` (first child) and `#planUsageChip` right after it, and `classic` moves them back to comment anchors it left at the template positions. Ids are unchanged, so every writer still finds them. ⚠️ The indicator joins the pill only while System Stats is shown: the pill is hidden with `display: none`, and the WS readout must not disappear with it.
|
||||
|
||||
⚠️ The extra parts (`.stat-spark`, `.connection-tile-label`/`-value`, `.pu-ring`, `.pu-meter`) are always rendered and hidden by default in styles.css, which is what keeps `classic` looking exactly as before. The tile words come from `_connectionTileWords()`, DERIVED from the connection descriptor rather than added to it, because `test/connection-indicator.test.ts` pins the descriptor's exact shape. Plan rings and meters clamp their fill to 0-100 while the label keeps the real number. Tests: `test/header-stats-style.test.ts`.
|
||||
⚠️ 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
|
||||
|
||||
|
||||
@@ -89,8 +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. |
|
||||
| Tab Grouping | *By state* (default) splits the tabs into needs you, waiting, working and idle: rows in the header, sections in the rail and sidebar. *None* keeps one list in tab order. See [The Dashboard](The-Dashboard#tab-grouping-by-state). |
|
||||
| Vertical Rail Order | *By activity* (default) sorts the rail the way the home screens are sorted; *Manual* keeps your tab order and drag-reordering. With grouping by state it orders the rows inside each section. |
|
||||
| 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. |
|
||||
|
||||
+22
-14
@@ -27,7 +27,7 @@ Session List Layout** can move it into a vertical sidebar on the left instead, a
|
||||
|
||||
| Layout | Behaviour |
|
||||
| -------------------- | --------------------------------------------------------------------------------- |
|
||||
| **Header tab strip** | The default. On desktop it is one row per state (see [Tab grouping by state](#tab-grouping-by-state)); it 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,9 +35,18 @@ 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 grouping by state
|
||||
## Tab layouts
|
||||
|
||||
By default the tabs are grouped by what each session needs from you, most urgent on top:
|
||||
**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 |
|
||||
| ------------- | ----------------------------------------------------------------------------------- |
|
||||
@@ -47,18 +56,17 @@ By default the tabs are grouped by what each session needs from you, most urgent
|
||||
| **Idle** | Everything quiet, including ended sessions, agents that exited inside their pane, and web tabs. |
|
||||
|
||||
In the header each group is a row with its name and count on the left; a group with more
|
||||
tabs than fit on one line continues under its own tabs. The vertical rail and the left
|
||||
sidebar show the same groups as sections. Empty groups are not shown. These are the same
|
||||
states the phone overview and the desktop home rail use.
|
||||
tabs than fit on one line continues under its own tabs. **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.
|
||||
|
||||
Tabs move between groups on their own as their state changes. Inside a group they keep your
|
||||
tab order (on a rail sorted *By activity*, the activity order), and the `Alt+1` to `Alt+9`
|
||||
numbers never change. Dragging reorders tabs within a group. On a phone the strip stays a
|
||||
single scrolling row: the tabs come in group order, without the headings. If you have named
|
||||
tab groups in the vertical rail, those take precedence there.
|
||||
|
||||
Turn it off with **App Settings → Appearance → Tabs → Tab Grouping → None** to get one list
|
||||
in tab order. Per device.
|
||||
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
|
||||
|
||||
|
||||
+210
-23
@@ -4797,7 +4797,7 @@ class CodemanApp {
|
||||
}
|
||||
|
||||
/**
|
||||
* True when the tab list groups by state (`tabGrouping: 'state'`, the default;
|
||||
* 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.
|
||||
*
|
||||
@@ -4807,7 +4807,7 @@ class CodemanApp {
|
||||
* grouped projection is on, and the grouped tree renders as it always did.
|
||||
*/
|
||||
isTabTriage() {
|
||||
return document.documentElement.dataset.tabGrouping === 'state';
|
||||
return document.documentElement.dataset.tabArrangement === 'state';
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -4840,7 +4840,8 @@ class CodemanApp {
|
||||
});
|
||||
}
|
||||
const webviewIds = (this.webviewOrder || []).filter((wid) => this.webviews?.has(wid));
|
||||
return window.CodemanTabTriage.layout(rows, webviewIds);
|
||||
const reverse = document.documentElement.dataset.tabStateOrder === 'urgent-last';
|
||||
return window.CodemanTabTriage.layout(rows, webviewIds, { reverse });
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -4860,6 +4861,7 @@ class CodemanApp {
|
||||
*/
|
||||
_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')]) {
|
||||
@@ -4899,25 +4901,184 @@ class CodemanApp {
|
||||
const value = triage?.webOrder.has(wid) ? String(triage.webOrder.get(wid)) : '';
|
||||
if (web.style.order !== value) web.style.order = value;
|
||||
}
|
||||
this._sizeTabTriageGutter(container, triage);
|
||||
}
|
||||
|
||||
/**
|
||||
* A drag in a state-grouped strip may only reorder WITHIN a group. Inside a
|
||||
* group the rows sit in tab order, so a drop there moves the tab exactly where
|
||||
* it was dropped; across groups the dragged tab would stay in its own group
|
||||
* (its state did not change) and land somewhere the user did not put it.
|
||||
* Groups are bands of `order` values, so comparing bands is enough.
|
||||
* Size the header strip's label column to the widest label on screen, so no
|
||||
* row carries a fixed gutter's worth of empty space before its tabs (labels
|
||||
* are right-aligned in it, styles.css). Measured only when the label text
|
||||
* changes (a group appears, goes, or its count gains a digit), and once more
|
||||
* when the web fonts finish loading, since that changes every width. The
|
||||
* vertical lists do not use the gutter, so they are never measured.
|
||||
*/
|
||||
_isTabDropAcrossTriageGroups(targetTab) {
|
||||
_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');
|
||||
this._tabTriageGutterKey = null;
|
||||
return;
|
||||
}
|
||||
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);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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?.classList.contains('tabs-triage') || !this.draggedTabId || !targetTab) return false;
|
||||
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.
|
||||
@@ -5581,10 +5742,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
|
||||
@@ -5707,11 +5874,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;
|
||||
}
|
||||
@@ -5787,6 +5960,7 @@ class CodemanApp {
|
||||
this._syncTabGroupHeaderAlerts(container, groupProjection);
|
||||
}
|
||||
this._syncTabTriageChrome(container, triage);
|
||||
this._syncTabArrangementClasses(container, { clusters: !!clusterLayout, groupProjection });
|
||||
} else {
|
||||
// Full rebuild needed (sessions added/removed)
|
||||
this._fullRenderSessionTabs();
|
||||
@@ -5856,10 +6030,11 @@ class CodemanApp {
|
||||
}
|
||||
|
||||
// Grouped by state, the header strip IS rows (one per state), so it always
|
||||
// wraps: the row breaks only take effect in a wrapping flex line. Narrower
|
||||
// screens keep the single scrolling row above, where the headings read as
|
||||
// inline dividers instead.
|
||||
if (container.classList.contains('tabs-triage')) {
|
||||
// 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;
|
||||
}
|
||||
@@ -5958,12 +6133,15 @@ class CodemanApp {
|
||||
// strip's markup is byte-identical to before.
|
||||
const liveIds = tabOrder.filter((id) => this.sessions.has(id));
|
||||
const railSortOrder = this._tabRailSortOrder(liveIds);
|
||||
// Grouped by state (tabGrouping, the default): the same `order` mechanism,
|
||||
// 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).
|
||||
@@ -6005,9 +6183,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 || '');
|
||||
@@ -6054,7 +6230,12 @@ class CodemanApp {
|
||||
_tabIdx++;
|
||||
}
|
||||
|
||||
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(
|
||||
@@ -6080,6 +6261,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);
|
||||
@@ -6089,6 +6271,7 @@ class CodemanApp {
|
||||
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
|
||||
@@ -6165,7 +6348,11 @@ class CodemanApp {
|
||||
// sort is stable, so equal orders keep DOM order, which is the unsorted case.
|
||||
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);
|
||||
|
||||
@@ -7366,7 +7553,7 @@ 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._isTabDropAcrossTriageGroups(tab)) return;
|
||||
if (this._isTabDropAcrossGroups(tab)) return;
|
||||
e.preventDefault();
|
||||
if (!this.draggedTabId || this.draggedTabId === tab.dataset.id) return;
|
||||
|
||||
@@ -7396,7 +7583,7 @@ class CodemanApp {
|
||||
tab.classList.remove('drag-over-left', 'drag-over-right');
|
||||
|
||||
if (!this.draggedTabId || this.draggedTabId === tab.dataset.id) return;
|
||||
if (this._isTabDropAcrossTriageGroups(tab)) return;
|
||||
if (this._isTabDropAcrossGroups(tab)) return;
|
||||
|
||||
const targetId = tab.dataset.id;
|
||||
const draggedId = this.draggedTabId;
|
||||
|
||||
@@ -658,7 +658,7 @@ function sortSessionsByActivity(rows) {
|
||||
return (Array.isArray(rows) ? rows.slice() : []).sort(compareSessionActivity);
|
||||
}
|
||||
|
||||
// Tab grouping by state (`tabGrouping: 'state'`, Discussion #426 option C).
|
||||
// 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
|
||||
@@ -723,18 +723,23 @@ function tabTriageGroupFor(state, exited) {
|
||||
* 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, count: number, headOrder: number, breakOrder: number}>
|
||||
* }} `groups` lists only the non-empty groups, most urgent first.
|
||||
* }} `groups` lists only the non-empty groups, in display order (most urgent
|
||||
* first, or last with `reverse`).
|
||||
*/
|
||||
function computeTabTriageLayout(rows, webviewIds) {
|
||||
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 = {};
|
||||
TAB_TRIAGE_GROUPS.forEach((group, i) => {
|
||||
sequence.forEach((group, i) => {
|
||||
baseOf[group.key] = (i + 1) * TAB_TRIAGE_STRIDE;
|
||||
counts[group.key] = 0;
|
||||
});
|
||||
@@ -764,7 +769,7 @@ function computeTabTriageLayout(rows, webviewIds) {
|
||||
});
|
||||
counts.idle += webOrder.size;
|
||||
|
||||
const groups = TAB_TRIAGE_GROUPS.filter((group) => counts[group.key] > 0).map((group) => ({
|
||||
const groups = sequence.filter((group) => counts[group.key] > 0).map((group) => ({
|
||||
key: group.key,
|
||||
label: group.label,
|
||||
count: counts[group.key],
|
||||
@@ -775,6 +780,68 @@ function computeTabTriageLayout(rows, webviewIds) {
|
||||
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/ +
|
||||
@@ -1100,6 +1167,12 @@ 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,
|
||||
|
||||
+25
-14
@@ -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';document.documentElement.dataset.tabGrouping=(A.tabGrouping==='none')?'none':'state';var H=A.headerStatsStyle;document.documentElement.dataset.headerStats=(window.innerWidth<768||solo)?'classic':(H==='classic'||H==='compact')?H:'tiles';var W=Number(A.tabRailWidth);if(V){if(Number.isInteger(W)&&W>=208&&W<=360)document.documentElement.style.setProperty('--tab-rail-width',W+'px');else if(document.documentElement.dataset.tabRailDetail!=='simple')document.documentElement.style.setProperty('--tab-rail-width','320px');}if(Number.isInteger(F)&&F>=11&&F<=18)document.documentElement.style.setProperty('--session-sidebar-name-font-size',F+'px');}catch(e){document.documentElement.dataset.sessionList='header';document.documentElement.dataset.sidebarDetail='simple';document.documentElement.dataset.sidebar='expanded';document.documentElement.dataset.tabOrientation='horizontal';document.documentElement.dataset.tabRailDetail='rich';document.documentElement.dataset.tabRailSort='activity';document.documentElement.dataset.tabGrouping='state';document.documentElement.dataset.headerStats='classic';}</script>
|
||||
<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,8 +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-label" id="connectionTileLabel"></span>
|
||||
<span class="connection-tile-value" id="connectionTileValue"></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>
|
||||
@@ -2085,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>
|
||||
@@ -2095,16 +2116,6 @@
|
||||
<option value="vertical">Vertical (side rail)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="set-row has-field" data-search="tab grouping group by state triage needs you waiting working idle rows sections">
|
||||
<div class="set-row-text">
|
||||
<span class="set-row-label">Tab Grouping</span>
|
||||
<span class="set-row-desc">By state puts what needs you on top: a row each for needs you (red), waiting (yellow), working and idle in the header, and the same sections in the side rail and sidebar. Tabs move between groups as their state changes; Alt+1..9 keeps the tab order. Named tab groups in the side rail take precedence.</span>
|
||||
</div>
|
||||
<select id="appSettingsTabGrouping" class="set-select">
|
||||
<option value="state">By state (default)</option>
|
||||
<option value="none">None (tab order)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="set-row has-field" data-search="tab rail detail rows created working idle status pill simple">
|
||||
<div class="set-row-text">
|
||||
<span class="set-row-label">Vertical Rail Rows</span>
|
||||
@@ -2118,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. With Tab Grouping by state it orders the rows inside each section. Alt+1..9 always follows the tab order either way.</span>
|
||||
<span class="set-row-desc">By activity uses the home screen's order: blocked on you first, then whatever has been running longest, then the most recently quiet. Manual keeps your tab order and is the only mode you can drag rows in. 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,7 +755,7 @@ html.mobile-init .file-browser-panel {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* Grouped by state (tabGrouping), the phone strip keeps its one scrolling
|
||||
/* 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. */
|
||||
@@ -763,6 +763,17 @@ html.mobile-init .file-browser-panel {
|
||||
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. */
|
||||
|
||||
@@ -501,8 +501,8 @@ Object.assign(CodemanApp.prototype, {
|
||||
settings.tabRailDetail ?? defaults.tabRailDetail ?? 'rich';
|
||||
document.getElementById('appSettingsTabRailSort').value =
|
||||
settings.tabRailSort ?? defaults.tabRailSort ?? 'activity';
|
||||
document.getElementById('appSettingsTabGrouping').value =
|
||||
(settings.tabGrouping ?? defaults.tabGrouping ?? 'state') === 'none' ? 'none' : 'state';
|
||||
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';
|
||||
@@ -2528,7 +2528,8 @@ Object.assign(CodemanApp.prototype, {
|
||||
tabRailWidth: this.readTabRailWidthSetting?.() ?? 256,
|
||||
tabRailDetail: document.getElementById('appSettingsTabRailDetail').value,
|
||||
tabRailSort: document.getElementById('appSettingsTabRailSort').value,
|
||||
tabGrouping: document.getElementById('appSettingsTabGrouping').value,
|
||||
tabArrangement: document.getElementById('appSettingsTabArrangement').value,
|
||||
tabStateOrder: document.getElementById('appSettingsTabStateOrder').value,
|
||||
showTabDetachButton: document.getElementById('appSettingsShowTabDetachButton').checked,
|
||||
sessionListLayout: document.getElementById('appSettingsSessionListLayout').value,
|
||||
sessionSidebarFontSize: this.resolveSessionSidebarFontSize(
|
||||
@@ -3453,7 +3454,8 @@ Object.assign(CodemanApp.prototype, {
|
||||
tabRailWidth: 256,
|
||||
tabRailDetail: 'rich',
|
||||
tabRailSort: 'activity',
|
||||
tabGrouping: 'state',
|
||||
tabArrangement: 'state',
|
||||
tabStateOrder: 'urgent-first',
|
||||
sessionListLayout: 'header',
|
||||
sessionSidebarFontSize: 12,
|
||||
cjkInputEnabled: false,
|
||||
@@ -3555,6 +3557,22 @@ 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',
|
||||
@@ -3815,14 +3833,18 @@ Object.assign(CodemanApp.prototype, {
|
||||
const sort = (settings.tabRailSort ?? defaults.tabRailSort ?? 'activity') === 'manual' ? 'manual' : 'activity';
|
||||
root.dataset.tabRailSort = sort;
|
||||
|
||||
// Grouping rides on a fourth attribute, for the same reason: it is applied
|
||||
// as inline `order` plus heading elements the render paths emit, so a flip
|
||||
// has to re-render, and `isTabTriage()` (app.js) reads one attribute per
|
||||
// pass instead of re-parsing localStorage. Anything but an explicit 'none'
|
||||
// is 'state', the default (Discussion #426, option C).
|
||||
const previousGrouping = root.dataset.tabGrouping || 'state';
|
||||
const grouping = (settings.tabGrouping ?? defaults.tabGrouping ?? 'state') === 'none' ? 'none' : 'state';
|
||||
root.dataset.tabGrouping = grouping;
|
||||
// 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');
|
||||
@@ -3847,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 || previousGrouping !== grouping;
|
||||
const changed = orientationChanged || previousDetail !== detail || previousSort !== sort || previousArrangement !== arrangement || previousStateOrder !== stateOrder;
|
||||
if (orientationChanged) {
|
||||
this.updateTabOverflowMode?.();
|
||||
if (!settleRailWidth) this.syncTerminalGeometry?.();
|
||||
@@ -4120,7 +4142,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
'showFontControls', 'showSystemStats', 'headerStatsStyle', 'showTokenCount', 'showCost',
|
||||
'showLifecycleLog', 'showResponseViewer', 'showRedrawButton',
|
||||
'showMonitor', 'showProjectInsights', 'showFileBrowser', 'showSubagents',
|
||||
'subagentActiveTabOnly', 'tabTwoRows', 'tabOrientation', 'tabRailWidth', 'tabRailDetail', 'tabRailSort', 'tabGrouping', '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',
|
||||
|
||||
+244
-34
@@ -19790,7 +19790,7 @@ html .toolbar .btn-git-status[aria-expanded='true'] {
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
Tab grouping by state (tabGrouping: 'state', the default;
|
||||
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
|
||||
@@ -19848,14 +19848,19 @@ html .toolbar .btn-git-status[aria-expanded='true'] {
|
||||
/* 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 gutter 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 every label is right-aligned in it, so each one sits right
|
||||
next to its own tabs while the rows still start on one line. */
|
||||
.session-tabs-host > .session-tabs.tabs-triage:is(.tabs-auto-wrap, .tabs-two-rows) {
|
||||
--tab-triage-gutter: 92px;
|
||||
padding-left: calc(0.25rem + var(--tab-triage-gutter));
|
||||
padding-left: calc(0.25rem + var(--tab-triage-gutter, 92px));
|
||||
}
|
||||
|
||||
.session-tabs-host > .session-tabs.tabs-triage:is(.tabs-auto-wrap, .tabs-two-rows) > .tab-triage-head {
|
||||
width: var(--tab-triage-gutter);
|
||||
margin-left: calc(-1 * var(--tab-triage-gutter));
|
||||
justify-content: flex-end;
|
||||
width: var(--tab-triage-gutter, 92px);
|
||||
margin-left: calc(-1 * var(--tab-triage-gutter, 92px));
|
||||
padding: 0 8px 0 0;
|
||||
}
|
||||
|
||||
.session-tabs-host > .session-tabs.tabs-triage:is(.tabs-auto-wrap, .tabs-two-rows) > .tab-triage-break {
|
||||
@@ -19877,6 +19882,183 @@ html[data-session-list='sidebar'][data-sidebar='collapsed'] .session-sidebar .ta
|
||||
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;
|
||||
gap: 4px 6px;
|
||||
}
|
||||
|
||||
.session-tabs-host > .session-tabs.tabs-ledger > .session-tab {
|
||||
--ledger-bar: var(--green);
|
||||
min-width: 0;
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
Header stats styles (headerStatsStyle; Discussion #426 option G)
|
||||
============================================================
|
||||
@@ -19890,8 +20072,7 @@ html[data-session-list='sidebar'][data-sidebar='collapsed'] .session-sidebar .ta
|
||||
|
||||
/* Parts only the two new styles draw. */
|
||||
.stat-spark,
|
||||
.connection-tile-label,
|
||||
.connection-tile-value,
|
||||
.connection-tile,
|
||||
.header-plan-usage .pu-ring,
|
||||
.header-plan-usage .pu-meter {
|
||||
display: none;
|
||||
@@ -19912,9 +20093,21 @@ html[data-header-stats='compact'] .header-system-stats {
|
||||
}
|
||||
|
||||
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: '·';
|
||||
@@ -20009,31 +20202,37 @@ html[data-header-stats='compact'] .header-plan-usage .pu-ring {
|
||||
}
|
||||
|
||||
/* --- 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 > .connection-indicator,
|
||||
html[data-header-stats='tiles'] .header-system-stats > .stat-item,
|
||||
html[data-header-stats='tiles'] .header-plan-usage .pu-win {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
justify-content: center;
|
||||
gap: 1px;
|
||||
min-width: 44px;
|
||||
height: 28px;
|
||||
padding: 0 8px 3px;
|
||||
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: 6px;
|
||||
border-radius: 7px;
|
||||
background: var(--control-bg);
|
||||
border: 1px solid var(--control-border);
|
||||
overflow: hidden;
|
||||
font-family: 'SF Mono', Monaco, monospace;
|
||||
line-height: 1.05;
|
||||
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,
|
||||
@@ -20043,10 +20242,12 @@ 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: 0.55rem;
|
||||
font-size: 9px;
|
||||
line-height: 9px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.06em;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-muted);
|
||||
opacity: 1;
|
||||
@@ -20056,10 +20257,13 @@ 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: 0.72rem;
|
||||
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
|
||||
@@ -20069,6 +20273,13 @@ 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;
|
||||
@@ -20088,14 +20299,13 @@ html[data-header-stats='tiles'] .connection-tile-value.offline {
|
||||
color: var(--red);
|
||||
}
|
||||
|
||||
/* The bar runs along the bottom edge of the tile. */
|
||||
/* 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;
|
||||
position: absolute;
|
||||
left: 6px;
|
||||
right: 6px;
|
||||
bottom: 3px;
|
||||
grid-row: 3;
|
||||
justify-self: stretch;
|
||||
width: auto;
|
||||
height: 2px;
|
||||
border-radius: 1px;
|
||||
@@ -20149,11 +20359,11 @@ html[data-header-stats='tiles'] .header-plan-usage .pu-win-idle {
|
||||
of its own so it does not read as a stray mark between the tiles. */
|
||||
html[data-header-stats='tiles'] .header-plan-usage:not(:has(.pu-row)) {
|
||||
justify-content: center;
|
||||
min-width: 44px;
|
||||
height: 28px;
|
||||
padding: 0 8px;
|
||||
min-width: 54px;
|
||||
height: 36px;
|
||||
padding: 0 10px;
|
||||
box-sizing: border-box;
|
||||
border-radius: 6px;
|
||||
border-radius: 7px;
|
||||
background: var(--control-bg);
|
||||
border: 1px solid var(--control-border);
|
||||
color: var(--text-muted);
|
||||
|
||||
+17
-6
@@ -1404,13 +1404,24 @@ export const SettingsUpdateSchema = z
|
||||
*/
|
||||
tabRailSort: z.enum(['activity', 'manual']).optional(),
|
||||
/**
|
||||
* Tab grouping. Display key (per-device).
|
||||
* 'state' = the strip is split into state rows (needs you, waiting,
|
||||
* working, idle; Discussion #426 option C), and the flat side
|
||||
* rail and the sidebar get the same sections. The default.
|
||||
* 'none' = one flat list in tab order, as before.
|
||||
* 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.
|
||||
*/
|
||||
tabGrouping: z.enum(['state', 'none']).optional(),
|
||||
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
|
||||
|
||||
@@ -201,7 +201,7 @@ describe('header stats wiring (static)', () => {
|
||||
|
||||
it('hides the new-style parts by default, so classic looks exactly as before', () => {
|
||||
expect(css).toMatch(
|
||||
/\.stat-spark,\s*\.connection-tile-label,\s*\.connection-tile-value,\s*\.header-plan-usage \.pu-ring,\s*\.header-plan-usage \.pu-meter \{\s*display: none;/
|
||||
/\.stat-spark,\s*\.connection-tile,\s*\.header-plan-usage \.pu-ring,\s*\.header-plan-usage \.pu-meter \{\s*display: none;/
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,296 @@
|
||||
/**
|
||||
* @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('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;/);
|
||||
});
|
||||
});
|
||||
@@ -281,9 +281,9 @@ describe('vertical tab rail sort setting', () => {
|
||||
expect(settingsUi).toContain('root.dataset.tabRailSort = sort;');
|
||||
// A sort flip leaves orientation on 'vertical' both times, so it has to
|
||||
// count as a change of its own or nothing re-renders. The same holds for a
|
||||
// grouping flip (tabGrouping, test/tab-triage.test.ts).
|
||||
// tab layout or state order flip (test/tab-triage.test.ts).
|
||||
expect(settingsUi).toContain(
|
||||
'const changed = orientationChanged || previousDetail !== detail || previousSort !== sort || previousGrouping !== grouping;'
|
||||
'const changed = orientationChanged || previousDetail !== detail || previousSort !== sort || previousArrangement !== arrangement || previousStateOrder !== stateOrder;'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
+65
-18
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* @fileoverview Tab grouping by state (`tabGrouping: 'state'`, Discussion #426
|
||||
* @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:
|
||||
@@ -12,7 +12,7 @@
|
||||
* - A state change is an INCREMENTAL pass: the tab element survives, only its
|
||||
* `order` and the heading counts move, and a group that empties loses its
|
||||
* heading.
|
||||
* - `tabGrouping: 'none'` leaves no trace (no headings, no inline order, no
|
||||
* - `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
|
||||
@@ -116,6 +116,24 @@ describe('CodemanTabTriage (pure)', () => {
|
||||
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([]);
|
||||
@@ -158,12 +176,13 @@ describe('tab grouping in the render paths (app.js)', () => {
|
||||
CodemanApp = window.__TriageCodemanApp;
|
||||
});
|
||||
|
||||
function makeApp(grouping: 'state' | 'none' = 'state') {
|
||||
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.tabGrouping = grouping;
|
||||
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([
|
||||
@@ -246,6 +265,24 @@ describe('tab grouping in the render paths (app.js)', () => {
|
||||
}
|
||||
});
|
||||
|
||||
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:1', '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('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);
|
||||
@@ -289,8 +326,8 @@ describe('tab grouping in the render paths (app.js)', () => {
|
||||
expect(visual()).toEqual(['head:needs', 's1', 'head:working', 's4', 'head:idle', 's2', 's3', 'web:w1']);
|
||||
});
|
||||
|
||||
it("leaves no trace with tabGrouping 'none'", () => {
|
||||
makeApp('none')._fullRenderSessionTabs();
|
||||
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:');
|
||||
@@ -300,7 +337,7 @@ describe('tab grouping in the render paths (app.js)', () => {
|
||||
it('switching off removes the headings and every inline order it wrote', () => {
|
||||
const app = makeApp();
|
||||
app._fullRenderSessionTabs();
|
||||
document.documentElement.dataset.tabGrouping = 'none';
|
||||
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('');
|
||||
@@ -345,12 +382,12 @@ describe('tab grouping in the render paths (app.js)', () => {
|
||||
const app = makeApp();
|
||||
app._fullRenderSessionTabs();
|
||||
app.draggedTabId = 's2';
|
||||
expect(app._isTabDropAcrossTriageGroups(tab('s4'))).toBe(false);
|
||||
expect(app._isTabDropAcrossTriageGroups(tab('s1'))).toBe(true);
|
||||
expect(app._isTabDropAcrossTriageGroups(tab('s3'))).toBe(true);
|
||||
document.documentElement.dataset.tabGrouping = 'none';
|
||||
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._isTabDropAcrossTriageGroups(tab('s1'))).toBe(false);
|
||||
expect(app._isTabDropAcrossGroups(tab('s1'))).toBe(false);
|
||||
});
|
||||
|
||||
it('degrades to the flat strip when mobile-overview.js is stale or missing', () => {
|
||||
@@ -367,14 +404,24 @@ describe('tab grouping wiring (static)', () => {
|
||||
const css = read('styles.css');
|
||||
const mobileCss = read('mobile.css');
|
||||
|
||||
it('stamps data-tab-grouping before first paint, defaulting to state', () => {
|
||||
expect(html).toContain("dataset.tabGrouping=(A.tabGrouping==='none')?'none':'state'");
|
||||
// The catch branch (localStorage threw) must set it too.
|
||||
expect(html).toContain("document.documentElement.dataset.tabGrouping='state';");
|
||||
it('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 setting with "By state" as the default', () => {
|
||||
expect(html).toMatch(/<select id="appSettingsTabGrouping"[^>]*>\s*<option value="state">By state \(default\)<\/option>\s*<option value="none">/);
|
||||
it('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', () => {
|
||||
|
||||
Reference in New Issue
Block a user