test(mobile): the gate checks the File Viewer button's phone default again (#809)

Until #809 a display:none rule in mobile.css and its KNOWN_PHONE_HIDDEN
entry kept the File Viewer button off phones, and the gate checked both.
With that rule gone, the handheld default in getDefaultSettings() and the
html.mobile-init startup rule are the whole lock, and only the browser
test saw them, which npm test does not run.

The policy test now loads the real getDefaultSettings() on a handheld and
on a desktop, checks that every reader of the setting goes through the
device default, and that mobile.css keeps the startup hide.

Docs: architecture-invariants, src/web/CLAUDE.md and the wiki say the
button is off on a phone until it is switched on there, and that a window
narrower than 600px on a device that is not a handheld now shows it.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
This commit is contained in:
Codeman maintainer committed 2026-11-11 23:07:46 +01:00
1 parent b587ac71f4
commit d192eb6825
8 files changed
+86 -11

No files matched your search

+1 -1
View File
@@ -1039,7 +1039,7 @@ Further detail: in the Settings section order, the version and the updater lead
⚠️ **`Session.lastSubmitAt` is persisted state, not a runtime counter.** `start()` reassigns `_claudeSessionId = resumeSessionId || id` on every launch — including the re-attach path for a mux session that survived the restart — so a recovered pane always points the viewer at its *launch* conversation, even when the CLI moved on via `/clear` hours earlier. The submit anchor is the only thing that can correct that without user input, so it round-trips through `SessionState.lastSubmitAt` and is restored in `restoreMuxSessions()`. Drop it from `toState()` and recovered panes silently show the pre-`/clear` transcript until the user types again. Restoring a *stale* anchor is safe: the resolver's staleness guard rejects any candidate transcript older than the one the pane is currently on, which is exactly the shape of a respawn into a fresh conversation.
⚠️ **The Claude viewer emits one message per model message and groups them with `turn`; it never concatenates them.** A Claude transcript is an append-only event log, so one logical exchange spans many rows: tool-result rows, meta/image/skill rows, compact summaries, task/team notifications, sidechains, replayed assistant snapshots. Rendering a card per *row* was the original bug (#169) — but the fix overshot to one card per *human turn*, which fused up to 74 distinct model messages into a single card and reported it as one message. One assistant row IS one whole model message: measured across a real `~/.claude/projects` (CLI 2.1.220-2.1.251) no assistant row carries more than one content block and no `message.id` carries more than one text block, so there was never anything to reassemble, and no adjacent pair of assistant rows continues a table, a list, or an open code fence. Each row is therefore its own message carrying `{kind, label, role, text, timestamp, turn}`; the frontend renders a same-role run inside one `turn` as badge-less continuation segments (`.rv-msg-cont`), which is what keeps a p90 of 11 messages per turn from reading as card spam. ⚠️ **A prompt typed while Claude is working is recorded ONLY as an `attachment/queued_command` row** — the CLI never re-emits it as a `user` row — so reading only `user` rows lost 162 of 353 user cards on that corpus AND lost the turn boundary each one carries, which is what let an assistant run fuse in the first place. Take it only when `attachment.origin.kind === 'human'` and `commandMode === 'prompt'`; the CLI's own queue entries (`commandMode: 'task-notification'`) carry no `origin` key at all. The shape is not a documented CLI contract, so every field check must fail closed. ⚠️ **`data.text` (no `?context=full`) is frozen on the last assistant row and must never be derived from `messages.at(-1)`** — agent pollers hash it (`skills/codeman/preamble.sh`), and the last message can be the user's own queued prompt. The BRIEF view therefore does not render `text`: it asks for `?context=turn`, which returns the assistant messages of the last ANSWERED turn (`selectLastAnsweredTurn()` in `src/web/response-viewer-transcript.ts`: the highest `turn` that has an assistant row, so a prompt queued after the answer does not blank the view), and renders them with the same numeric-`turn` continuation gate as the full view. `text` alone was the final row of a median-3-row turn — a "Done." tail with the substance in the rows before it. Readers that emit no `turn` (Codex, the pane parser, an older server) return `text` only, and the brief view falls back to one card for them. Replayed assistant snapshots are still deduped, and the tool/task/skill/compact/team metadata filtering is unchanged. Related: a recovered `restored-<uuid8>` tmux placeholder carries a **stale cwd**, so transcript lookup by working directory finds nothing; it rebinds to the matching top-level Claude transcript UUID instead when that match is unambiguous. Tests: `test/routes/session-routes-claude-last-response.test.ts`, `test/response-viewer-turn-segments.test.ts`. Purely client-side (no `renderIndexHtml` step): the template ships with `btn-response-viewer-header--hidden` and `applyHeaderVisibilitySettings()` (settings-ui.js) toggles it after settings load. Hiding must go through that marker class — the base rule is `display:inline-flex !important`, so an inline style can't override it. `showResponseViewer` is in the `displayKeys` per-device set (settings-ui.js), so it does NOT sync across devices.
**File Viewer button** (header, 1.4.1) is **shown by default on desktop** since `211f3c0` (post-1.8.0): toggle under App Settings → Header & Panels → Header buttons → File Viewer (`showFileViewerButton`, in the per-device `displayKeys` set, fallback default `true`). Purely client-side like the response viewer: the template now ships the button VISIBLE (no `--hidden` class) and `applyHeaderVisibilitySettings()` toggles the `btn-file-viewer--hidden` marker class after settings load; phones still hide it via mobile.css. The button toggles the file-browser panel open/closed without opening the settings modal (`panels-ui.js`). The same commit set the **default desktop header** to WS/CPU/MEM + File Viewer + gear: the token-count chip (`showTokenCount`, no settings-UI toggle) and the lifecycle-log button (`showLifecycleLog`) both default **OFF** now (templates ship them hidden; stored prefs still honored). The plan-usage chip default is unchanged (opt-in, see Plan-usage chip). The **Cron toolbar button** joined the same opt-in pattern in 1.6.0: template ships `btn-cron--hidden`, `applyHeaderVisibilitySettings()` toggles it via the per-device `showCronButton` setting (default OFF, App Settings → Header & Panels → Scheduling); cron jobs themselves are unaffected.
**File Viewer button** (header, 1.4.1) is **shown by default on desktop** since `211f3c0` (post-1.8.0): toggle under App Settings → Header & Panels → Header buttons → File Viewer (`showFileViewerButton`, in the per-device `displayKeys` set, fallback default `true`). Purely client-side like the response viewer: the template now ships the button VISIBLE (no `--hidden` class) and `applyHeaderVisibilitySettings()` toggles the `btn-file-viewer--hidden` marker class after settings load. **On a phone it is opt-in (#759):** the handheld branch of `getDefaultSettings()` defaults it OFF, `html.mobile-init` in `mobile.css` hides it until the settings have been applied (the template ships it visible, so it would flash otherwise), and switching it on in App Settings on that phone shows it there with a 44px touch target. `mobile.css` no longer hides it at phone width, so that default and the startup rule are the whole lock; `test/mobile-header-buttons-policy.test.ts` pins both in the gate, and `test/mobile/file-browser.test.ts` drives the real page at 390px. The setting never reaches the server (stripped from the PUT, absent from the schema), so a desktop cannot switch it on for a phone. ⚠️ A device that is not a handheld takes the desktop default at any width, so a desktop window or an iPad split view narrower than 600px now shows the button too. The button toggles the file-browser panel open/closed without opening the settings modal (`panels-ui.js`). The same commit set the **default desktop header** to WS/CPU/MEM + File Viewer + gear: the token-count chip (`showTokenCount`, no settings-UI toggle) and the lifecycle-log button (`showLifecycleLog`) both default **OFF** now (templates ship them hidden; stored prefs still honored). The plan-usage chip default is unchanged (opt-in, see Plan-usage chip). The **Cron toolbar button** joined the same opt-in pattern in 1.6.0: template ships `btn-cron--hidden`, `applyHeaderVisibilitySettings()` toggles it via the per-device `showCronButton` setting (default OFF, App Settings → Header & Panels → Scheduling); cron jobs themselves are unaffected.
### Session list layout (header strip vs. left sidebar)
+3 -2
View File
@@ -45,8 +45,9 @@ target escapes the workspace is already rejected. Ownership is `findSessionOrFai
- CSS: `src/web/public/styles.css:9320-9430`. Overlay `z-index: 2000`, window `80vw/80vh`, capped
`900x700`. There are **no `.file-preview-*` rules in `mobile.css` at all**.
**Reachability on phones.** The header File Viewer button is hidden below 430px
(`mobile.css:482`, locked by `KNOWN_PHONE_HIDDEN` in `test/mobile-header-buttons-policy.test.ts`), so on a
**Reachability on phones.** When this plan was written the header File Viewer button was hidden below 430px
(`mobile.css:482`, locked by `KNOWN_PHONE_HIDDEN` in `test/mobile-header-buttons-policy.test.ts`); since
#759 it shows on a phone once switched on there, and stays off by default. Without it, on a
phone the preview overlay is reached through:
1. an attachment card's **Preview** button (`panels-ui.js:3451`), which is exactly the "agent just wrote a
+4 -2
View File
@@ -191,12 +191,14 @@ last answer as scrollable text instead, with a **More** button for additional co
Persian, Arabic and Hebrew answers read right to left there.
The [File Viewer](Working-With-Files) works on phones too, including edit mode, which is
enough to fix a typo an agent introduced while you are away from your desk.
enough to fix a typo an agent introduced while you are away from your desk. Its header button
is off on a phone until you turn on **File Viewer** under **App Settings → Header & Panels**
on that phone.
## What is deliberately not on phones
- Extra header buttons. New header controls are kept off phones by policy, with a test that
enforces it.
enforces it. The one exception is File Viewer, which you can switch on (see above).
- The Approvals bell. Phones get the NEEDS YOU strips on the home screen instead.
- The desktop home tab rail, which needs a wide window.
- Lineage lines, which are a desktop overlay.
+1 -1
View File
@@ -154,7 +154,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 a compact pill by default; see Header Stats Style below. |
| File Viewer | On | Toggles the file browser panel. |
| File Viewer | On, off on phones | Toggles the file browser panel. |
| Settings gear | Always on | App Settings. |
| Plan usage chip | On, desktop only | Live subscription usage: Claude (needs its telemetry exporter, which the same setting installs), Codex limits and the Copilot monthly quota. |
| Session Manager | Off | The full session list, live and historical. |
+2 -1
View File
@@ -8,7 +8,8 @@ until you get home.
A panel that browses the active session's working directory, or the git worktree its agent
moved into ([Git Worktrees](Git-Worktrees#when-an-agent-moves-into-a-worktree)). Its header
button is on by default; if it is missing, re-enable it in **App Settings → Header & Panels**.
button is on by default on a desktop and off on a phone. The setting is per device: switch it
in **App Settings → Header & Panels** on the device you want it on.
The panel and its search list up to 5,000 files and folders per load, shallow levels first and
taking turns between folders, so one huge folder cannot use up that limit before the ones after it,
+1 -1
View File
@@ -60,7 +60,7 @@ Frontend JS modules have `@fileoverview` with `@dependency`/`@loadorder` tags. L
**Settings surface** (`#appSettingsModal` + `#sessionOptionsModal` + `#createCaseModal`): one `set-*` language shared through a single `:is(...)` id scope in styles.css. App Settings' rail is a table of contents over ONE scrolling document (`switchSettingsTab` scrolls); Session Options and Add Case really switch (`switchOptionsTab` / `switchCaseModalTab`), and their larger per-modal size blocks are the design, not drift. ⚠️ **The load/save contract is `getElementById` by id**: renaming or dropping a control id silently stops it loading or saving. ⚠️ The Session Options "Session" entry still keys off `context` (label-only rename). ⚠️ Add Case keeps its legacy `.form-row` markup via an adapter; every `<details>` there needs `.set-adv-chev` plus both marker suppressions. ⚠️ Model cards and the effort segment are views over hidden `<select>`s, which stay the source of truth. ⚠️ `.modal-tabs*` classes are retired; `admin-ui.js` needs `.set-rail-items` + `.set-doc` to survive any restructure. Guard: `test/app-settings-structure.test.ts`. → [architecture-invariants#settings-surface-app-settings-session-options-add-case](../../docs/architecture-invariants.md#settings-surface-app-settings-session-options-add-case)
**Header button visibility**: most header controls are opt-in and hidden by a marker class (`btn-multimonitor--hidden`, `btn-response-viewer-header--hidden`, `btn-file-viewer--hidden`, `btn-cron--hidden`, and the desktop-gated `btn-split--hidden` / `btn-tile-grid--hidden`, which also need a `@media (max-width: 1179px)` backstop) that `applyHeaderVisibilitySettings()` (settings-ui.js) toggles after settings load; the multi-monitor button is instead stripped at render by `renderIndexHtml`. ⚠️ Hiding must go through the marker class: the base rules are `display:inline-flex !important`, so an inline style cannot override them. Current desktop default is WS/CPU/MEM + File Viewer + Tiles (1180px and wider) + gear, with the token chip and lifecycle-log button OFF. ⚠️ New header controls must not leak onto phones; `test/mobile-header-buttons-policy.test.ts` is the static guard. → [architecture-invariants#header-button-visibility-multi-monitor-response-viewer-file-viewer-cron](../../docs/architecture-invariants.md#header-button-visibility-multi-monitor-response-viewer-file-viewer-cron)
**Header button visibility**: most header controls are opt-in and hidden by a marker class (`btn-multimonitor--hidden`, `btn-response-viewer-header--hidden`, `btn-file-viewer--hidden`, `btn-cron--hidden`, and the desktop-gated `btn-split--hidden` / `btn-tile-grid--hidden`, which also need a `@media (max-width: 1179px)` backstop) that `applyHeaderVisibilitySettings()` (settings-ui.js) toggles after settings load; the multi-monitor button is instead stripped at render by `renderIndexHtml`. ⚠️ Hiding must go through the marker class: the base rules are `display:inline-flex !important`, so an inline style cannot override them. Current desktop default is WS/CPU/MEM + File Viewer + Tiles (1180px and wider) + gear, with the token chip and lifecycle-log button OFF. ⚠️ New header controls must not leak onto phones; `test/mobile-header-buttons-policy.test.ts` is the static guard. The one opt-in exception is File Viewer (#759): `mobile.css` no longer hides it at phone width, so it stays off a phone only through the handheld default in `getDefaultSettings()` and the `html.mobile-init` startup rule (both pinned by that test), and shows once the setting is switched on on that phone. → [architecture-invariants#header-button-visibility-multi-monitor-response-viewer-file-viewer-cron](../../docs/architecture-invariants.md#header-button-visibility-multi-monitor-response-viewer-file-viewer-cron)
**Terminal font weight** (`terminalFontWeight` / `terminalFontWeightBold`, per-device, default = xterm's own `normal`/`bold`): Claude Code's markdown bold is a bare `ESC[1m`, so the weight step is its only cue. `CodemanTerminalFont.resolveWeights()` (constants.js, pure) resolves each slot against **its own** xterm default. ⚠️ The `@font-face` for `fonts/jetbrains-mono-variable.woff2` must stay declared `100 800` (the browser synthesizes from the descriptor, not the file); narrowing it silently makes the setting a no-op. ⚠️ A live save must reach both echo overlays (`refreshFont()`) and open Agent Teams panes. ⚠️ Leave `_awaitTerminalFont()` untouched. Test: `test/terminal-font-weight.test.ts`. → [architecture-invariants#terminal-font-weight](../../docs/architecture-invariants.md#terminal-font-weight)
+2 -2
View File
@@ -14427,8 +14427,8 @@ html:is([data-skin="paper-gray"], [data-skin="solarized-light"], [data-skin="cat
display: none !important;
}
/* "File Viewer" header button — opt-in (App Settings → Header Displays), hidden
by default. Clicking it toggles the file browser panel. Same marker pattern as
/* "File Viewer" header button: on by default on a desktop, opt-in on a phone
(App Settings → Header & Panels). Clicking it toggles the file browser panel. Same marker pattern as
the response viewer: a base inline-flex !important so an inline style can't
override it, and a more-specific marker rule to hide. */
.btn-file-viewer {
+72 -1
View File
@@ -20,6 +20,7 @@ import { describe, it, expect } from 'vitest';
import { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { join } from 'node:path';
import vm from 'node:vm';
import postcss from 'postcss';
const HERE = fileURLToPath(new URL('.', import.meta.url));
@@ -30,7 +31,9 @@ const PUBLIC = join(HERE, '../src/web/public');
const PHONE_WIDTH = 393;
// Issue #759: the maintainer approved File Viewer on phones when explicitly enabled.
// Its per-device setting still defaults off; the browser test covers that default.
// Its per-device setting still defaults off on a handheld. With the blanket phone
// hide gone, that default and the startup hide are the whole lock, so both are
// pinned below ("File Viewer on a phone: off until it is switched on").
const MOBILE_VISIBLE_ALLOWLIST = new Set<string>(['btn-file-viewer']);
// Buttons we expect to STAY hidden on phones — an explicit lock so a future edit
@@ -152,6 +155,74 @@ describe('Mobile header button policy (static guard)', () => {
});
});
// Until #759 the gate kept the File Viewer button off phones with a blanket
// display:none rule and its KNOWN_PHONE_HIDDEN entry. That rule is gone, so what
// keeps the button off a phone nobody switched it on for is now two things, and
// the real-page test that exercises them (test/mobile/file-browser.test.ts) is
// not part of `npm test`:
// 1. the handheld branch of getDefaultSettings() says false, and the header
// reads the setting through that default (a bare `?? true` would show it);
// 2. `html.mobile-init` hides the button until the settings have been applied,
// or it would flash on every phone page load (the template ships it visible).
describe('File Viewer on a phone: off until it is switched on (#759)', () => {
const settingsUi = readFileSync(join(PUBLIC, 'settings-ui.js'), 'utf-8');
/** The real getDefaultSettings() of settings-ui.js on one kind of device. */
function defaultsOn(handheld: boolean): Record<string, unknown> {
const CodemanApp = function CodemanApp(this: unknown) {};
const context = vm.createContext({
CodemanApp,
VoiceInput: {},
localStorage: { getItem: () => null, setItem: () => {} },
document: { getElementById: () => null },
console,
MobileDetection: {
isHandheldDevice: () => handheld,
isTouchDevice: () => handheld,
getDeviceType: () => (handheld ? 'mobile' : 'desktop'),
},
window: { matchMedia: () => ({ matches: false }) },
});
vm.runInContext(settingsUi, context, { filename: 'settings-ui.js' });
const app = Object.create(CodemanApp.prototype) as { getDefaultSettings(): Record<string, unknown> };
return app.getDefaultSettings();
}
it('a handheld defaults the button off', () => {
expect(defaultsOn(true).showFileViewerButton).toBe(false);
});
it('a desktop leaves the key unset, which every reader resolves to on', () => {
expect(defaultsOn(false)).not.toHaveProperty('showFileViewerButton');
});
it('the header and the App Settings chip both read the setting through the device default', () => {
const read = 'settings.showFileViewerButton ?? defaults.showFileViewerButton ?? true;';
expect(settingsUi).toContain(`const showFileViewerButton = ${read}`);
expect(settingsUi).toContain(`document.getElementById('appSettingsShowFileViewerButton').checked = ${read}`);
// Every reader goes through it: one that skipped the device default would resolve to on.
const reads = [...settingsUi.matchAll(/\bsettings\.showFileViewerButton\b(.{0,40})/g)];
expect(reads.length).toBeGreaterThanOrEqual(2);
for (const [line, rest] of reads) {
expect(rest.startsWith(' ?? defaults.showFileViewerButton'), `"${line}" skips the device default`).toBe(true);
}
});
it('html.mobile-init hides the button until the settings have been applied', () => {
const css = readFileSync(join(PUBLIC, 'mobile.css'), 'utf-8');
let hidesAtStartup = false;
postcss.parse(css).each((node) => {
// Top level only: the rule must apply at every width mobile.css is loaded for.
if (node.type !== 'rule') return;
if (!node.selectors.includes('html.mobile-init .btn-file-viewer')) return;
node.walkDecls('display', (decl) => {
if (decl.value.trim() === 'none' && decl.important) hidesAtStartup = true;
});
});
expect(hidesAtStartup, 'mobile.css must keep .btn-file-viewer in its html.mobile-init display:none rule').toBe(true);
});
});
// The flip side of the policy above: the ONE header control phones do keep has
// to be pressable. The brand "C" is the way back to the home screen and was a
// 0.85rem inline span — roughly a 12x13px target, well under the 44px minimum.