feat(defaults): Compact header, Tiles button on, Classic tab layout

The owner's picks after testing the 1.36.0 beta:

- Header Stats Style defaults to Compact (two pills with rings) instead of
  Tiles. The resolver, the pre-paint stamp and the App Settings option all
  agree; an unknown value now reads as compact.
- The Tiles header button is ON by default everywhere but handhelds (their
  defaults object keeps it off, and the button still needs a 1180px window).
  The Ctrl+Shift+G gate in tileShortcutFor() now resolves an absent key
  through the device defaults too, so the chord and the button cannot
  disagree; before, it required a stored true.
- Tab Layout defaults to Classic (the single strip, as before). By state, By
  case and Ledger stay available as opt-ins. The tile-grid beta the owner used
  last had only this layout, and it is the one they wanted back.

zh-CN option labels follow the new "(default)" markers; docs and wiki updated.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Codeman maintainer
2026-10-08 22:17:27 +02:00
parent 1568e489eb
commit 6644962d70
17 changed files with 121 additions and 71 deletions
+3 -3
View File
File diff suppressed because one or more lines are too long
+3 -3
View File
@@ -803,7 +803,7 @@ Further detail: with many sessions the horizontal strip stops being scannable, w
### Tile grid
**Tile grid** (`showTileGridButton`, header Tiles button + `Ctrl+Shift+G`, default OFF, per-device: in `displayKeys`, stripped from the settings PUT, not in `SettingsUpdateSchema`; desktop-only at 1180px by a JS width check with a live media listener plus a `@media (max-width: 1179px)` backstop, never in a solo window). 1 to 6 live sessions side by side in one window, each a `TerminalTile` (terminal-tile.js), orchestrated by tile-grid.js (load order 7.6, `CodemanApp.prototype` methods like the split's). Pure helpers live in constants.js as `window.CodemanTileGrid`: `computeTileLayout` (1x1, 2x1, 3x1 on a grid area at least 1800px wide else 2x2, 2x2, 3x2, and 3x3 up to `TILE_LAYOUT_MAX` (9), unreachable today; `fits` against a 480x240 minimum tile), `tileGridCapacity` (never more than `TILE_GRID_MAX`), `sanitizeTileGridState` (truncates a stored grid to the cap, keeps focus only if it survives), `buildTilePickerSessions`, `dragTrackFractions`, `tileNeighbor`, `tileInDirection`, `cycleTile`, `TILE_SCROLLBACK` (10,000), and `TILE_GRID_MAX` (6), the ONE cap (owner decision 7: six tested smooth on a real desktop, nine missed the headless frame bar). ⚠️ Every limit reads the cap through `_tileGridLimit()` (tile-grid.js: the window's capacity, at most the cap), never a literal, and its texts say which binds ("at most 6 tiles" vs "what this window fits"). The grid is a `<section class="tile-grid">` SIBLING of `.terminal-wrap`, swapped in by `.main.tiles-active` (no reparenting); `.main.webview-active .tile-grid` hides it like the single view.
**Tile grid** (`showTileGridButton`, header Tiles button + `Ctrl+Shift+G`, default ON on desktop and OFF on handhelds, per-device: in `displayKeys`, stripped from the settings PUT, not in `SettingsUpdateSchema`; desktop-only at 1180px by a JS width check with a live media listener plus a `@media (max-width: 1179px)` backstop, never in a solo window). 1 to 6 live sessions side by side in one window, each a `TerminalTile` (terminal-tile.js), orchestrated by tile-grid.js (load order 7.6, `CodemanApp.prototype` methods like the split's). Pure helpers live in constants.js as `window.CodemanTileGrid`: `computeTileLayout` (1x1, 2x1, 3x1 on a grid area at least 1800px wide else 2x2, 2x2, 3x2, and 3x3 up to `TILE_LAYOUT_MAX` (9), unreachable today; `fits` against a 480x240 minimum tile), `tileGridCapacity` (never more than `TILE_GRID_MAX`), `sanitizeTileGridState` (truncates a stored grid to the cap, keeps focus only if it survives), `buildTilePickerSessions`, `dragTrackFractions`, `tileNeighbor`, `tileInDirection`, `cycleTile`, `TILE_SCROLLBACK` (10,000), and `TILE_GRID_MAX` (6), the ONE cap (owner decision 7: six tested smooth on a real desktop, nine missed the headless frame bar). ⚠️ Every limit reads the cap through `_tileGridLimit()` (tile-grid.js: the window's capacity, at most the cap), never a literal, and its texts say which binds ("at most 6 tiles" vs "what this window fits"). The grid is a `<section class="tile-grid">` SIBLING of `.terminal-wrap`, swapped in by `.main.tiles-active` (no reparenting); `.main.webview-active .tile-grid` hides it like the single view.
⚠️ **The main terminal is parked while the grid is open.** Opening runs `_cleanupPreviousSession()` ONCE (its snapshot is right at that moment, and it closes the main socket), and `activeSessionId` is always the FOCUSED tile's session, so everything keyed on it (files panel, git status, respawn and Ralph panels, subagent windows, voice, image paste, the tab highlight) follows focus. With the main socket closed `_wsReady` is false, so every SSE terminal handler would write the focused tile's output into the hidden xterm: `_tilesOwnTerminal()` turns `_onSessionTerminal`, `_onSessionClearTerminal`, `_onSessionNeedsRefresh` (returns `false`, which the drop recovery reads), `_scheduleDroppedOutputRecovery`, the `writeln` in `_onSessionCompletion`/`_onSessionError`, `sendResize`, `throttledResize`'s fit and `_maybeRefetchFullHistory` into no-ops; `retryConnection` and `handleInit` re-arm the TILES' sockets instead of the main one (`handleInit` keeps live tiles, drops dead ones through `_reconcileTileGrid`, and re-selects only when the focused tile is gone, so an SSE blip never hides an open web tab). ⚠️ The page's SSE filter names `TILE_GRID_SSE_FILTER` (constants.js, an id no session takes) instead of the focused session while tiles own the terminal: the server's filter gates only `session:terminal` batches, which the parked terminal could only parse and drop. Both places that set the filter ask `_sseFilterSessionId()`: the live re-subscribe every tile focus runs (`_updateSseSubscription`) and the connect URL an SSE reconnect rebuilds (`connectSSE`); leaving the grid re-subscribes the shown session through `selectSession`. `test/sse-tile-grid-filter.test.ts` pins the server's side in multi-user mode (the id is accepted on connect and on re-subscribe, and session/hook events still reach their owner through it). ⚠️ The WebGL long-task observer watches the WHOLE page: it counts nothing while tiles own the terminal, or tile renders would write the sticky 7-day WebGL disable. The header connection dot reads the tile sockets (`_tileGridSocketState`; a tile stopped for good does not count). `_focusedPane()` answers with the focused tile even when DOM focus left every terminal, and `_forEachTile` reaches every grid tile (`{ grid: false }` skips them where tiles keep their own font size). Leaving the grid destroys every tile, resets `_lastResizeDims`, invalidates the main terminal's cached content for EVERY tiled id (`_xtermSnapshots`, `codeman-xs-<id>`, `terminalBufferCache`: written before the grid opened, and selectSession paints a snapshot as its first frame) and replays the focused session through `selectSession(id, { forceReload: true, auto: true })`.
@@ -901,7 +901,7 @@ Tests: `test/terminal-touch-tap.test.ts`.
### Tab layouts (by state, by case, ledger)
**Tab layouts** (`tabArrangement`, per-device, default `state`; Discussion #426): App Settings → Appearance → Tabs → **Tab Layout**. Four values: `state` (option C), `case` (option A), `ledger` (option B) and `classic`, the strip as before. `<html data-tab-arrangement>` is stamped pre-paint and by `applyTabOrientation()`; the render paths read it through `isTabTriage()`, `isTabClusters()` and `isTabLedger()`. Named groups in the vertical rail (owner tab layouts, `_projectTabGroups()` non-null) win over both groupings, and the grouped tree renders as before.
**Tab layouts** (`tabArrangement`, per-device, default `classic`, the others opt-in; Discussion #426): App Settings → Appearance → Tabs → **Tab Layout**. Four values: `state` (option C), `case` (option A), `ledger` (option B) and `classic`, the strip as before. `<html data-tab-arrangement>` is stamped pre-paint and by `applyTabOrientation()`; the render paths read it through `isTabTriage()`, `isTabClusters()` and `isTabLedger()`. Named groups in the vertical rail (owner tab layouts, `_projectTabGroups()` non-null) win over both groupings, and the grouped tree renders as before.
**By state.** The list is split into four groups: **needs you** (a permission or question dialog; a failed session joins it), **waiting** (idle prompt pending), **working**, **idle** (also ended sessions, an agent that exited inside a live pane, and web tabs). Idle is `quiet` (`TAB_TRIAGE_GROUPS`): its heading element still exists, because it anchors the row's `order` band and, as the lead, holds the row's place beside the brand, but it draws no label or count (`.tab-triage-head--quiet`). `tabStateOrder` (`urgent-first` default, `urgent-last`) decides which end they start from; only the group order flips, never the rows inside a group. The desktop header strip draws a row per group with its label left-aligned in a column that `_sizeTabTriageGutter()` measures to the widest label on screen (re-measured only when the label text changes, when the strip starts wrapping, and once on `document.fonts.ready`), so no row carries a fixed gutter's worth of empty space. The brand leaves the flow and sits over the strip's top-left corner (`.header:has(...)`), so the first row's heading (`.tab-triage-head--lead`, natural width) is pushed past it by `--tab-triage-brand` (a ResizeObserver keeps that width, so no render pass reads layout for it) and every later row starts at the edge, under "Codeman"; the flat rail and the sidebar draw a section per group; the tablet strip (600-767px) keeps its scrolling row with the headings as inline dividers; phones keep their chip row in group order with the headings hidden (mobile.css). ⚠️ Only the wrapping strip reads the label column, so `_sizeTabTriageGutter()` measures nothing (and forgets its key) while the strip does not wrap, skips parts that are not laid out (a hidden heading measures 0 per part, and the 5px gap between label and count used to turn that into a 15px column cached as if measured), and `updateTabOverflowMode()` calls it right after deciding to wrap, because a phone turned to landscape or a foldable opened crosses 768px with no tab render behind it (the resize handler only calls `updateTabOverflowMode()`). Classification is `_mobileOverviewState()` + `_mobileOverviewExit()`, the home screens' own; the fold into four groups and the order bands are pure in `CodemanTabTriage` (constants.js, `computeTabTriageLayout()`).
@@ -917,7 +917,7 @@ With `classic` nothing is left behind: no headings, no breaks, no inline order,
### Header stats styles
**Header stats styles** (`headerStatsStyle`, per-device, desktop only, default `tiles`; Discussion #426 option G): how the WS readout, CPU, MEM and the plan-usage windows are drawn. `tiles` gives each its own label-over-value tile with a bar along the bottom edge; `compact` is two pills, WS / CPU / MEM and the plan windows, with a ring beside every value (stat rings accent, red past 80%; plan rings green, yellow or red); `classic` is the bars and `5H · 7D` chip as before. `data-header-stats` on `<html>` (pre-paint, then `applyHeaderStatsStyle()` from `applyHeaderVisibilitySettings()`) drives every rule; narrower than 768px and in a solo window it resolves to `classic`, because the stats are hidden there anyway and the moves below would otherwise reach the phone header.
**Header stats styles** (`headerStatsStyle`, per-device, desktop only, default `compact`; Discussion #426 option G): how the WS readout, CPU, MEM and the plan-usage windows are drawn. `tiles` gives each its own label-over-value tile with a bar along the bottom edge; `compact` is two pills, WS / CPU / MEM and the plan windows, with a ring beside every value (stat rings accent, red past 80%; plan rings green, yellow or red); `classic` is the bars and `5H · 7D` chip as before. `data-header-stats` on `<html>` (pre-paint, then `applyHeaderStatsStyle()` from `applyHeaderVisibilitySettings()`) drives every rule; narrower than 768px and in a solo window it resolves to `classic`, because the stats are hidden there anyway and the moves below would otherwise reach the phone header.
⚠️ The clustered styles need the three elements contiguous, and the template keeps the classic order, so `applyHeaderStatsStyle()` MOVES `#connectionIndicator` into `#headerSystemStats` (first child) and `#planUsageChip` right after it, and `classic` moves them back to comment anchors it left at the template positions. Ids are unchanged, so every writer still finds them. ⚠️ The indicator joins the pill only while System Stats is shown: the pill is hidden with `display: none`, and the WS readout must not disappear with it.
+4
View File
@@ -20,6 +20,10 @@ or settled a question the spec left open. The invariants as built are in
- **`Ctrl+Shift+G` follows `showTileGridButton`** (decided by the owner, decision 6): with
the setting off the toggle chord is inert. A grid opened another
way (Ctrl/Cmd+click, a dropped tab, "Open group as tiles") keeps all its chords.
- **The Tiles button ships ON** (owner, 1.36.0 beta): `showTileGridButton` defaults to on
everywhere but handhelds (their defaults object keeps it off), so the chord is live by
default too. An absent key resolves through the device defaults in both the button
(settings-ui.js) and the chord (`tileShortcutFor`), so they cannot disagree.
- **Dividers are grid tracks.** Each gap between columns and rows is its own 6px track (the
grid gap is 0) and every tile and every empty slot is placed explicitly in its cell
(`grid.cells`, see "Tiles move"). Fractions reset when the column or row count changes.
+4 -4
View File
@@ -68,9 +68,9 @@ Git window; see [Working With Files](Working-With-Files#git-changes). **Git stat
by folder** (per device, on by default) shows changed files under collapsed folders in that
window; off lists every file by its full path.
Most default to off. The stock desktop header is system stats, File Viewer, and the gear.
**Header Stats Style** picks how the system stats and plan usage are drawn: *Tiles*
(default; label over value with a bar underneath), *Compact* (two pills with a ring beside every value) or *As before* (the bars and the `5H · 7D` chip). Desktop only, per device.
Most default to off. The stock desktop header is system stats, File Viewer, Tiles, and the gear.
**Header Stats Style** picks how the system stats and plan usage are drawn: *Compact*
(default; two pills with a ring beside every value), *Tiles* (label over value with a bar underneath) or *As before* (the bars and the `5H · 7D` chip). Desktop only, per device.
New header controls never appear on phones. Split is desktop-only regardless of this
setting — the button and the feature both stay off below a ~1180px viewport, where two
resizable panes plus their divider have nowhere to go. **Tiles** is desktop-only the same
@@ -90,7 +90,7 @@ every session or only the active tab.
| Interface Language | English or Simplified Chinese. Per device. |
| Session List Layout | Header tab strip (default), a collapsible left sidebar, or the sidebar with detailed rows. See [The Dashboard](The-Dashboard#session-list-layout). |
| Tab Orientation | Keeps the header list but turns the strip vertical beside the terminal, resizable, with detailed rows by default. Desktop and tablet only. |
| Tab Layout | *By state* (default): a row each for needs you, waiting, working and idle, sections in the rail and sidebar. *By case*: one box per case. *Ledger*: an aligned column grid. *Classic*: the single list as before. See [The Dashboard](The-Dashboard#tab-layouts). |
| Tab Layout | *Classic* (default): the single list as before. *By state*: 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. 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. |
+7 -7
View File
@@ -27,7 +27,7 @@ Session List Layout** can move it into a vertical sidebar on the left instead, a
| Layout | Behaviour |
| -------------------- | --------------------------------------------------------------------------------- |
| **Header tab strip** | The default. On desktop it is one row per state by default (see [Tab layouts](#tab-layouts)); it scrolls sideways on a phone. |
| **Header tab strip** | The default. One list in tab order unless you pick another [Tab layout](#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. |
@@ -41,10 +41,10 @@ per device, so a sidebar on your desktop does not force one onto your phone.
| Layout | What it does |
| ---------------------- | ------------------------------------------------------------------------------------ |
| **By state** (default) | Groups the tabs by what each session needs from you (below). |
| **By state** | 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. |
| **Classic** (default) | The single list in tab order, as before. |
**By state** groups the tabs like this, most urgent on top:
@@ -141,7 +141,7 @@ The right side of the header. Almost all of these are off until you enable them
| ---------------------- | ------------------ | ------------------------------------------------------------------------------- |
| Connection dot | Always on | SSE connection health. Green is connected. |
| Font size `-` / `+` | Always on | `Ctrl +` / `Ctrl -` do the same. |
| CPU / MEM | On | Server resource use. Drawn as tiles by default; see Header Stats Style below. |
| CPU / MEM | On | Server resource use. Drawn as a compact pill by default; see Header Stats Style below. |
| File Viewer | On | Toggles the file browser panel. |
| Settings gear | Always on | App Settings. |
| Plan usage chip | On, desktop only | Live Claude subscription usage. Claude-only, and needs its telemetry exporter, which the same setting installs. |
@@ -157,7 +157,7 @@ The right side of the header. Almost all of these are off until you enable them
| Cron ⏰ | Off | Scheduled jobs. |
| Multi-monitor | Off, macOS | Opens a window spanning every display. |
| Split | Off, desktop only | View a second session beside the active one, with a draggable divider. |
| Tiles | Off, desktop only | Up to six live sessions side by side. See [Tile Grid](Tile-Grid). |
| Tiles | On, desktop only | Up to six live sessions side by side. See [Tile Grid](Tile-Grid). |
| Tunnel indicator | When a tunnel runs | Cloudflare tunnel status. |
| Admin panel | Multi-user only | User administration. |
@@ -168,8 +168,8 @@ The connection readout, CPU, MEM and the plan usage windows can be drawn three w
| Style | Look |
| -------------- | -------------------------------------------------------------------------------------- |
| **Tiles** | The default. One small tile each (`WS live`, `CPU 22%`, `MEM 14.4G`, `5H 28%`, `7D 35%`): label over value, a thin bar underneath, no icons. |
| **Compact** | Two slim pills, `WS · CPU · MEM` and the plan windows, with a small ring beside every value. Hands the tabs back the most room. |
| **Tiles** | One small tile each (`WS live`, `CPU 22%`, `MEM 14.4G`, `5H 28%`, `7D 35%`): label over value, a thin bar underneath, no icons. |
| **Compact** | The default. Two slim pills, `WS · CPU · MEM` and the plan windows, with a small ring beside every value. Hands the tabs back the most room. |
| **As before** | The bars and the `5H · 7D` chip, exactly as they were. |
Hiding System Stats or Plan Usage still hides them in every style.
+3 -3
View File
@@ -9,9 +9,9 @@ never offered in a popped-out session window.
## Turning it on
**App Settings → Header & Panels → Tiles.** This is a per-device setting, off by default,
so turning it on at your desk never puts the button on your phone. It shows a **Tiles**
button in the header, beside Split, and enables `Ctrl+Shift+G`.
**App Settings → Header & Panels → Tiles.** This is a per-device setting, on by default
everywhere except phones, and the button only appears in a window at least 1180px wide.
It shows a **Tiles** button in the header, beside Split, and enables `Ctrl+Shift+G`.
## Opening a grid
+2 -2
View File
@@ -5052,7 +5052,7 @@ class CodemanApp {
}
/**
* True when the tab list groups by state (`tabArrangement: 'state'`, the default;
* True when the tab list groups by state (`tabArrangement: 'state'`, opt-in;
* 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.
*
@@ -6543,7 +6543,7 @@ 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 (tabArrangement 'state', the default): the same `order` mechanism,
// Grouped by state (tabArrangement 'state', opt-in): 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();
+5 -5
View File
@@ -348,14 +348,14 @@
'Pop-out Button on Tabs': '标签页弹出窗口按钮',
// Tab Layout and Header Stats Style (Discussion #426). The header style's
// "Tiles" is 磁贴, never 平铺: that is the tile grid's word (the Tiles
// button), and "Tiles (default)" must not read as the grid.
// button), and "Tiles (label over value)" must not read as the grid.
'Tab Layout': '标签页布局',
'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.':
'按状态:需要你、等待中、工作中和空闲各占一行。按案例:每个案例一个框。台账:对齐的列网格。经典:与以前相同的单一列表。按状态和按案例也会为侧边标签栏和侧边栏分组。Alt+1..9 仍按标签页顺序切换。',
'By state (default)': '按状态(默认)',
'By state (rows per state)': '按状态(每种状态一行)',
'By case (clusters)': '按案例(分组框)',
'Ledger (aligned columns)': '台账(对齐的列)',
'Classic (as before)': '经典(与以前相同)',
'Classic (default)': '经典(默认)',
'State Order': '状态顺序',
'For Tab Layout by state. At the bottom flips the rows, so needs you sits right above the terminal.':
'用于按状态的标签页布局。选择在底部会倒转各行,让“需要你”紧挨在终端上方。',
@@ -365,8 +365,8 @@
'How WS, CPU, MEM and the plan-usage windows are drawn. Compact puts a ring beside each value in two pills; Tiles put each label over its value with a bar underneath.':
'WS、CPU、MEM 和套餐用量窗口的显示方式。紧凑:在两个胶囊中每个数值旁显示一个圆环;磁贴:每个标签位于数值上方,下方带一条进度条。',
'As before (bars)': '与以前相同(进度条)',
'Compact (pill + rings)': '紧凑(胶囊 + 圆环)',
'Tiles (default)': '磁贴(默认)',
'Compact (default)': '紧凑(默认)',
'Tiles (label over value)': '磁贴(标签在数值上方)',
Panels: '面板',
Monitor: '监视器',
'Project Insights': '项目洞察',
+5 -5
View File
@@ -65,7 +65,7 @@
app.js, NOT the handheld storage-key test `m`. Use a different predicate
here and boot will contradict this value, animating the drawer open by
itself on every load between 768 and 1023px. -->
<script>try{var m=window.innerWidth<768||(('ontouchstart' in window||navigator.maxTouchPoints>0)&&window.innerWidth<1024);var k=m?'codeman-app-settings-mobile':'codeman-app-settings';var A=JSON.parse(localStorage.getItem(k)||'{}');var L=A.sessionListLayout;var F=Number(A.sessionSidebarFontSize);var solo=/^\/session\//.test(location.pathname);var C=localStorage.getItem('codeman-sidebar-collapsed');var S=(L==='sidebar'||L==='sidebar-rich')&&!solo;document.documentElement.dataset.sessionList=S?'sidebar':'header';document.documentElement.dataset.sidebarDetail=(S&&L==='sidebar-rich')?'rich':'simple';document.documentElement.dataset.sidebar=(C===null?window.innerWidth<1024:C==='1')?'collapsed':'expanded';var V=A.tabOrientation==='vertical'&&!S&&!solo&&window.innerWidth>=768;document.documentElement.dataset.tabOrientation=V?'vertical':'horizontal';document.documentElement.dataset.tabRailDetail=(A.tabRailDetail==='simple')?'simple':'rich';document.documentElement.dataset.tabRailSort=(A.tabRailSort==='manual')?'manual':'activity';var T=A.tabArrangement;document.documentElement.dataset.tabArrangement=(T==='case'||T==='ledger'||T==='classic')?T:'state';document.documentElement.dataset.tabStateOrder=(A.tabStateOrder==='urgent-last')?'urgent-last':'urgent-first';var H=A.headerStatsStyle;document.documentElement.dataset.headerStats=(window.innerWidth<768||solo)?'classic':(H==='classic'||H==='compact')?H:'tiles';var W=Number(A.tabRailWidth);if(V){if(Number.isInteger(W)&&W>=208&&W<=360)document.documentElement.style.setProperty('--tab-rail-width',W+'px');else if(document.documentElement.dataset.tabRailDetail!=='simple')document.documentElement.style.setProperty('--tab-rail-width','320px');}if(Number.isInteger(F)&&F>=11&&F<=18)document.documentElement.style.setProperty('--session-sidebar-name-font-size',F+'px');}catch(e){document.documentElement.dataset.sessionList='header';document.documentElement.dataset.sidebarDetail='simple';document.documentElement.dataset.sidebar='expanded';document.documentElement.dataset.tabOrientation='horizontal';document.documentElement.dataset.tabRailDetail='rich';document.documentElement.dataset.tabRailSort='activity';document.documentElement.dataset.tabArrangement='state';document.documentElement.dataset.tabStateOrder='urgent-first';document.documentElement.dataset.headerStats='classic';}</script>
<script>try{var m=window.innerWidth<768||(('ontouchstart' in window||navigator.maxTouchPoints>0)&&window.innerWidth<1024);var k=m?'codeman-app-settings-mobile':'codeman-app-settings';var A=JSON.parse(localStorage.getItem(k)||'{}');var L=A.sessionListLayout;var F=Number(A.sessionSidebarFontSize);var solo=/^\/session\//.test(location.pathname);var C=localStorage.getItem('codeman-sidebar-collapsed');var S=(L==='sidebar'||L==='sidebar-rich')&&!solo;document.documentElement.dataset.sessionList=S?'sidebar':'header';document.documentElement.dataset.sidebarDetail=(S&&L==='sidebar-rich')?'rich':'simple';document.documentElement.dataset.sidebar=(C===null?window.innerWidth<1024:C==='1')?'collapsed':'expanded';var V=A.tabOrientation==='vertical'&&!S&&!solo&&window.innerWidth>=768;document.documentElement.dataset.tabOrientation=V?'vertical':'horizontal';document.documentElement.dataset.tabRailDetail=(A.tabRailDetail==='simple')?'simple':'rich';document.documentElement.dataset.tabRailSort=(A.tabRailSort==='manual')?'manual':'activity';var T=A.tabArrangement;document.documentElement.dataset.tabArrangement=(T==='state'||T==='case'||T==='ledger')?T:'classic';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==='tiles')?H:'compact';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='classic';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)}
@@ -1945,8 +1945,8 @@
</div>
<select id="appSettingsHeaderStatsStyle" class="set-select">
<option value="classic">As before (bars)</option>
<option value="compact">Compact (pill + rings)</option>
<option value="tiles">Tiles (default)</option>
<option value="compact">Compact (default)</option>
<option value="tiles">Tiles (label over value)</option>
</select>
</div>
</div>
@@ -2107,10 +2107,10 @@
<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="state">By state (rows per state)</option>
<option value="case">By case (clusters)</option>
<option value="ledger">Ledger (aligned columns)</option>
<option value="classic">Classic (as before)</option>
<option value="classic">Classic (default)</option>
</select>
</div>
<div class="set-row has-field" data-search="state order reverse needs you bottom top rows sections">
+11 -9
View File
@@ -431,7 +431,7 @@ Object.assign(CodemanApp.prototype, {
settings.ultracodeFloatingWindows ?? defaults.ultracodeFloatingWindows ?? false;
document.getElementById('appSettingsShowMultiMonitorButton').checked = settings.showMultiMonitorButton ?? defaults.showMultiMonitorButton ?? false;
document.getElementById('appSettingsShowSplitButton').checked = settings.showSplitButton ?? defaults.showSplitButton ?? false;
document.getElementById('appSettingsShowTileGridButton').checked = settings.showTileGridButton ?? defaults.showTileGridButton ?? false;
document.getElementById('appSettingsShowTileGridButton').checked = settings.showTileGridButton ?? defaults.showTileGridButton ?? true;
document.getElementById('appSettingsShowPlanUsageLimits').checked = this.planUsageChipEnabled(settings);
document.getElementById('appSettingsShowRedrawButton').checked = settings.showRedrawButton ?? defaults.showRedrawButton ?? false;
// Phone overview home screen: only meaningful under 600px, so the row is
@@ -3467,7 +3467,7 @@ Object.assign(CodemanApp.prototype, {
tabRailWidth: 256,
tabRailDetail: 'rich',
tabRailSort: 'activity',
tabArrangement: 'state',
tabArrangement: 'classic',
tabStateOrder: 'urgent-first',
sessionListLayout: 'header',
sessionSidebarFontSize: 12,
@@ -3572,12 +3572,13 @@ Object.assign(CodemanApp.prototype, {
/**
* 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).
* (an absent key, a value from a newer build) reads as 'classic', the default:
* the single strip as before, the owner's pick on the 1.36.0 beta. 'state'
* (Discussion #426, option C), 'case' and 'ledger' are opt-in.
*/
resolveTabArrangement(settings) {
const value = settings?.tabArrangement ?? this.getDefaultSettings().tabArrangement;
return value === 'case' || value === 'ledger' || value === 'classic' ? value : 'state';
return value === 'state' || value === 'case' || value === 'ledger' ? value : 'classic';
},
/** The stored state-group order: 'urgent-last' only when chosen, else 'urgent-first'. */
@@ -3588,12 +3589,13 @@ Object.assign(CodemanApp.prototype, {
/**
* The stored header-stats style, or the default. Anything but the three
* known values (an absent key, a value from a newer build) reads as 'tiles',
* the default (the tile variant of Discussion #426's option G).
* known values (an absent key, a value from a newer build) reads as
* 'compact', the default (the two-pill ring variant of Discussion #426's
* option G, picked by the owner on the 1.36.0 beta over 'tiles').
*/
resolveHeaderStatsStyle(settings) {
const value = settings?.headerStatsStyle ?? this.getDefaultSettings().headerStatsStyle;
return value === 'classic' || value === 'compact' ? value : 'tiles';
return value === 'classic' || value === 'tiles' ? value : 'compact';
},
/**
@@ -3733,7 +3735,7 @@ Object.assign(CodemanApp.prototype, {
this._applySplitButtonVisibility?.(showSplitButton);
// Tiles button: same gate and backstop as Split (tile-grid.js).
const showTileGridButton = settings.showTileGridButton ?? defaults.showTileGridButton ?? false;
const showTileGridButton = settings.showTileGridButton ?? defaults.showTileGridButton ?? true;
this._applyTileGridButtonVisibility?.(showTileGridButton);
// Ultracode/Workflow agents launcher — hidden by default; reveal when enabled.
+1 -1
View File
@@ -20653,7 +20653,7 @@ html .toolbar .btn-git-status[aria-expanded='true'] {
}
/* ============================================================
Tab grouping by state (tabArrangement: 'state', the default;
Tab grouping by state (tabArrangement: 'state', opt-in;
Discussion #426 option C)
============================================================
app.js writes a flex `order` per tab plus one heading and one row break per
+7 -4
View File
@@ -1014,9 +1014,11 @@ Object.assign(CodemanApp.prototype, {
/**
* The tile chord `e` asks for, if it applies right now, else null. The
* toggle applies while the grid is open, or where one could open AND the
* per-device `showTileGridButton` setting is on: with it off (the default)
* the chord is inert and reaches the terminal like any unbound key (owner
* decision 6 in docs/tile-grid-plan.md). The focus, move,
* per-device `showTileGridButton` setting is on (the desktop default; OFF on
* handhelds): with it off the chord is inert and reaches the terminal like
* any unbound key (owner decision 6 in docs/tile-grid-plan.md). An absent key
* resolves through the device defaults exactly as the header button does
* (settings-ui.js), so the chord and the button can never disagree. The focus, move,
* zoom and remove chords apply only while the grid is open, however it was
* opened (a move chord also while a tile is zoomed, as a no-op, so its keys
* never reach the CLI). The arrow chords never apply in a text field, whose
@@ -1036,7 +1038,8 @@ Object.assign(CodemanApp.prototype, {
if (!spec || shortcut.disabled || !this.matchesShortcutEvent(e, shortcut)) continue;
if ((spec.direction || spec.move) && isTextFieldTarget(e.target)) continue;
if (spec.needsOpen) return open ? shortcut.id : null;
const enabled = this.loadAppSettingsFromStorage?.()?.showTileGridButton === true;
const stored = this.loadAppSettingsFromStorage?.()?.showTileGridButton;
const enabled = (stored ?? this.getDefaultSettings?.()?.showTileGridButton ?? true) === true;
return open || (enabled && this.canOpenTileGrid()) ? shortcut.id : null;
}
return null;
+26 -13
View File
@@ -1,11 +1,11 @@
/**
* @fileoverview The header-stats styles (`headerStatsStyle`, Discussion #426
* option G): 'classic' (as before), 'compact' (one system pill with rings
* plus a plan-ring pill) and 'tiles' (label over value, bar underneath; the
* default).
* plus a plan-ring pill; the default, the owner's pick on the 1.36.0 beta) and
* 'tiles' (label over value, bar underneath).
*
* Pinned here:
* - The default is 'tiles', and only the three known values are honoured.
* - The default is 'compact', and only the three known values are honoured.
* - The two clustered styles move the connection indicator INTO the system
* stats pill and the plan chip right after it, on the REAL header markup
* from index.html, and 'classic' puts both back exactly where the template
@@ -69,8 +69,7 @@ function makeApp() {
}
/** Element children of the header's right side, by id (or first class). */
const rightSide = () =>
[...document.getElementById('headerRight')!.children].map((el) => el.id || el.classList[0]);
const rightSide = () => [...document.getElementById('headerRight')!.children].map((el) => el.id || el.classList[0]);
const statsChildren = () =>
[...document.getElementById('headerSystemStats')!.children].map((el) => el.id || el.classList[0]);
@@ -80,14 +79,27 @@ beforeEach(() => {
});
describe('resolveHeaderStatsStyle', () => {
it("defaults to 'tiles' and honours only the three known styles", () => {
it("defaults to 'compact' and honours only the three known styles", () => {
const app = makeApp();
expect(app.resolveHeaderStatsStyle({})).toBe('tiles');
expect(app.resolveHeaderStatsStyle(undefined)).toBe('tiles');
expect(app.resolveHeaderStatsStyle({})).toBe('compact');
expect(app.resolveHeaderStatsStyle(undefined)).toBe('compact');
expect(app.resolveHeaderStatsStyle({ headerStatsStyle: 'classic' })).toBe('classic');
expect(app.resolveHeaderStatsStyle({ headerStatsStyle: 'compact' })).toBe('compact');
expect(app.resolveHeaderStatsStyle({ headerStatsStyle: 'tiles' })).toBe('tiles');
expect(app.resolveHeaderStatsStyle({ headerStatsStyle: 'neon' })).toBe('tiles');
expect(app.resolveHeaderStatsStyle({ headerStatsStyle: 'neon' })).toBe('compact');
});
});
describe('resolveTabArrangement', () => {
it("defaults to 'classic' and honours only the four known layouts", () => {
// Lives here because this file already loads settings-ui.js's resolvers.
const app = makeApp();
expect(app.resolveTabArrangement({})).toBe('classic');
expect(app.resolveTabArrangement(undefined)).toBe('classic');
for (const value of ['state', 'case', 'ledger', 'classic']) {
expect(app.resolveTabArrangement({ tabArrangement: value })).toBe(value);
}
expect(app.resolveTabArrangement({ tabArrangement: 'neon' })).toBe('classic');
});
});
@@ -260,15 +272,16 @@ describe('header stats wiring (static)', () => {
expect([...found.keys()].filter((s) => /(tiles|split)-open/.test(s) && s.includes(':is('))).toEqual([]);
});
it('stamps data-header-stats before first paint, tiles by default and classic on narrow screens', () => {
it('stamps data-header-stats before first paint, compact by default and classic on narrow screens', () => {
expect(INDEX).toContain(
"dataset.headerStats=(window.innerWidth<768||solo)?'classic':(H==='classic'||H==='compact')?H:'tiles'"
"dataset.headerStats=(window.innerWidth<768||solo)?'classic':(H==='classic'||H==='tiles')?H:'compact'"
);
});
it('offers the three styles with tiles marked as the default', () => {
it('offers the three styles with compact marked as the default', () => {
expect(INDEX).toMatch(
/<select id="appSettingsHeaderStatsStyle"[^>]*>\s*<option value="classic">[^<]+<\/option>\s*<option value="compact">[^<]+<\/option>\s*<option value="tiles">Tiles \(default\)<\/option>/
/<select id="appSettingsHeaderStatsStyle"[^>]*>\s*<option value="classic">[^<]+<\/option>\s*<option value="compact">Compact \(default\)<\/option>\s*<option value="tiles">[^<]+<\/option>/
);
expect(INDEX).not.toContain('Tiles (default)');
});
});
+2 -1
View File
@@ -71,7 +71,8 @@ afterAll(() => {
describe('the Tab Layout settings rows in zh-CN', () => {
it('finds the three rows and all their options (the check is not vacuous)', () => {
expect(english.size).toBe(15);
expect(english.get('appSettingsHeaderStatsStyle option tiles')).toBe('Tiles (default)');
expect(english.get('appSettingsHeaderStatsStyle option compact')).toBe('Compact (default)');
expect(english.get('appSettingsHeaderStatsStyle option tiles')).toBe('Tiles (label over value)');
expect(english.get('appSettingsTabStateOrder option urgent-last')).toBe('Needs you at the bottom');
});
+6 -4
View File
@@ -615,17 +615,19 @@ describe('tab grouping wiring (static)', () => {
const mobileCss = read('mobile.css');
it('stamps data-tab-arrangement and data-tab-state-order before first paint', () => {
expect(html).toContain("dataset.tabArrangement=(T==='case'||T==='ledger'||T==='classic')?T:'state'");
expect(html).toContain("dataset.tabArrangement=(T==='state'||T==='case'||T==='ledger')?T:'classic'");
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';"
"document.documentElement.dataset.tabArrangement='classic';document.documentElement.dataset.tabStateOrder='urgent-first';"
);
});
it('offers the four layouts with "By state" as the default, and the state order', () => {
it('offers the four layouts with "Classic" as the default, and the state order', () => {
// Classic (the single strip, as before) is the default: the owner's pick on
// the 1.36.0 beta. By state, By case and Ledger are opt-in.
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>/
/<select id="appSettingsTabArrangement"[^>]*>\s*<option value="state">By state \(rows per state\)<\/option>\s*<option value="case">[^<]+<\/option>\s*<option value="ledger">[^<]+<\/option>\s*<option value="classic">Classic \(default\)<\/option>/
);
expect(html).toMatch(
/<select id="appSettingsTabStateOrder"[^>]*>\s*<option value="urgent-first">Needs you on top \(default\)<\/option>\s*<option value="urgent-last">/
+9 -3
View File
@@ -2,7 +2,7 @@
// Port: none (pure static analysis, runs in CI, no browser/server).
//
// `showTileGridButton` (the header Tiles button, and the Ctrl+Shift+G chord) is
// a PER-DEVICE setting, default OFF, with the same three-way rule as
// a PER-DEVICE setting (default ON on desktop, OFF on handhelds) with the same three-way rule as
// showSplitButton (test/split-pane-per-device-setting.test.ts, read it for the
// history): in settings-ui.js's displayKeys merge policy, stripped out of the
// object saveAppSettings() PUTs (SettingsUpdateSchema is .strict(), so sending
@@ -38,10 +38,16 @@ describe('showTileGridButton stays per-device: display key, stripped from the PU
expect(schemas).not.toContain('showTileGridButton');
});
it('defaults OFF on handhelds (and reads as OFF when absent everywhere)', () => {
it('defaults ON on desktop (an absent key reads as ON) and OFF on handhelds', () => {
// Owner's pick on the 1.36.0 beta: the Tiles button ships visible on desktop.
// Handhelds keep it OFF in their defaults object, and the button never shows
// below 1180px wide anyway.
expect(settingsUi).toMatch(/showSplitButton: false,\s*showTileGridButton: false,/);
expect(settingsUi).toContain(
"document.getElementById('appSettingsShowTileGridButton').checked = settings.showTileGridButton ?? defaults.showTileGridButton ?? false;"
"document.getElementById('appSettingsShowTileGridButton').checked = settings.showTileGridButton ?? defaults.showTileGridButton ?? true;"
);
expect(settingsUi).toContain(
'const showTileGridButton = settings.showTileGridButton ?? defaults.showTileGridButton ?? true;'
);
});
+23 -4
View File
@@ -51,6 +51,11 @@ const withTilesSetting = (app: GridApp) => {
app.loadAppSettingsFromStorage = () => ({ showTileGridButton: true });
return app;
};
/** The per-device Tiles setting explicitly off on this device. */
const withTilesSettingOff = (app: GridApp) => {
app.loadAppSettingsFromStorage = () => ({ showTileGridButton: false });
return app;
};
const RIGHT = { key: 'ArrowRight', code: 'ArrowRight', altKey: true, shiftKey: true };
beforeEach(() => {
@@ -88,13 +93,27 @@ describe('when a chord applies', () => {
expect(app.tileShortcutFor(chord(TOGGLE))).toBe('toggle-tile-grid');
});
it('with the Tiles setting off (the default) the toggle is inert, like an unbound key', () => {
const app = makeGridApp(IDS);
it('with the Tiles setting off the toggle is inert, like an unbound key', () => {
const app = withTilesSettingOff(makeGridApp(IDS));
expect(app.tileShortcutFor(chord(TOGGLE))).toBeNull();
});
it('with no stored value the device default decides: ON on desktop, OFF on a handheld', () => {
// Desktop: nothing stored and no device default, so the chord follows the
// button's own `?? true` (settings-ui.js) and applies.
const desktop = makeGridApp(IDS);
desktop.loadAppSettingsFromStorage = () => ({});
desktop.getDefaultSettings = () => ({});
expect(desktop.tileShortcutFor(chord(TOGGLE))).toBe('toggle-tile-grid');
// Handheld: the device defaults say OFF, so the chord stays inert.
const handheld = makeGridApp(IDS);
handheld.loadAppSettingsFromStorage = () => ({});
handheld.getDefaultSettings = () => ({ showTileGridButton: false });
expect(handheld.tileShortcutFor(chord(TOGGLE))).toBeNull();
});
it('with the setting off, a grid opened another way (Ctrl+click, a drop) still has its chords, toggle included', () => {
const app = makeGridApp(IDS);
const app = withTilesSettingOff(makeGridApp(IDS));
app.openTileGrid(IDS);
expect(app.tileShortcutFor(chord(RIGHT))).toBe('focus-tile-right');
expect(app.tileShortcutFor(chord(TOGGLE))).toBe('toggle-tile-grid');
@@ -176,7 +195,7 @@ describe('the capture-phase handler', () => {
}
it('setting off: Ctrl+Shift+G is left alone (no preventDefault), the grid stays closed', () => {
const app = makeGridApp(IDS);
const app = withTilesSettingOff(makeGridApp(IDS));
const onKeydown = handlerFor(app);
const e = chord(TOGGLE);
onKeydown(e);