From 831af88579bb9db90ffefbbf2ecd65ab102dd1b7 Mon Sep 17 00:00:00 2001 From: Codeman maintainer Date: Mon, 10 Aug 2026 10:41:39 +0200 Subject: [PATCH 01/11] feat(readmymind): rethink steer note (phase 3 part 2) Adds the optional free-text steer note to the Read My Mind modal: a dashed input under the suggestions ("no, I meant the mobile bug") that rides along as `steer` on every Rethink. The API already accepted it; this wires the frontend end of the contract. - Shown whenever Rethink is live (ready AND empty-result phases), hidden only while a prediction runs; typed text survives re-runs. - Enter in the field triggers Rethink, mirroring the prompt field's Enter-to-send; a fresh open clears it with the rethink memory. - Trimmed and capped to the schema's 2000 chars on the way out; a plain open still sends an empty body (neither steer nor rejected). - zh-CN strings for the placeholder and aria-label, phone-sized touch target in mobile.css, static guards in the phase-3 test. Verified with a browser E2E against a live dev server (stubbed predict endpoint): payload contents, phase visibility, Enter wiring, and reset-on-reopen all asserted with real keystrokes. Co-Authored-By: Claude Fable 5 --- CLAUDE.md | 2 +- docs/readmymind-plan.md | 2 +- docs/readmymind.md | 4 ++-- src/web/public/i18n.js | 2 ++ src/web/public/index.html | 7 +++++++ src/web/public/mobile.css | 3 +++ src/web/public/readmymind-ui.js | 24 ++++++++++++++++++++---- src/web/public/styles.css | 26 ++++++++++++++++++++++++++ test/readmymind-phone-key.test.ts | 16 ++++++++++++++++ 9 files changed, 78 insertions(+), 8 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 8803833e..6351c239 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -210,7 +210,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph **Approvals Inbox** (cross-session queue of prompts waiting on a human; `approvalsInboxEnabled`, SYNCED, default OFF: every surface is opt-in; only the store and answer endpoints run regardless, so flipping it ON shows anything already pending): `web/approval-inbox.ts` is a `sessionWaits`-style singleton fed by `/api/hook-event`, holding at most ONE item per session (a new prompt supersedes), claude-mode only, in-memory. Cards are answered via `POST /api/approvals/:id/answer`, which sends a digit / Esc / idle-prompt text through `writeViaMux` (menu answers never carry `\r`). โš ๏ธ `option` digits are accepted ONLY when they match options parsed from the captured pane frame, and the answer path RE-CAPTURES the pane first (a dialog that no longer parses on screen means the keystroke would land in the composer, so refuse with 409). โš ๏ธ Resolution on the heuristic `working` signal is restricted to `idle` items; permission/question items clear only on definitive signals (`stop`, `elicitation_complete`/`elicitation_response`, exit/delete, answer, supersede, 12h TTL). The frontend seeds from `GET /api/approvals` in `handleInit` (which is what makes tab alerts survive reloads), but only with the setting ON; push Approve/Deny buttons are also gated on it (`sendPushNotifications` strips `actions`/`approvalId` when OFF) and are answered from `sw.js` directly so they work with no tab open. Surfaces (all gated on the setting): header bell (marker-hidden until count > 0, phones never show it) + drawer (`approvals-ui.js`), phone overview NEEDS YOU answer strips (`mobile-overview.js`). Design: `docs/approvals-inbox-plan.md`. -**Read My Mind intent profiles** (phase 1 of `docs/readmymind-plan.md`; `readMyMindEnabled`, SYNCED, default OFF): per-CASE profiles (user-stated `goals` + the user's recent real prompts), keyed by owner + realpath(workingDir) so they survive `/clear`/respawns and multi-user scoping is structural. Capture rides the transcript (`transcript:user_prompt` from `transcript-watcher.ts`), NOT the input paths: `POST /input` sees only programmatic prompts and the WS channel is raw keystrokes. The listener lives inside `startTranscriptWatcher()`'s `if (!watcher)` block (outside it would duplicate per hook event) and is claude-only + gated on the setting per event. Store: `src/intent-store.ts` singleton, `intents.json` written 0600 tmp+rename (prompts can contain secrets; never fed to `/api/search`). Endpoints: GET/PUT/DELETE `/api/sessions/:id/intent` + POST `/api/sessions/:id/readmymind` (`readmymind-routes.ts`, ownership via `findSessionOrFail` WITH `req`; registrations stay the bare `app.('path')` shape, the endpoints.md drift scanner cannot see generics). **Phase 2 (predictor + ๐Ÿง  button)**: `readmymind-context.ts` is the PURE budgeted assembler (9 ranked sources, drop order siblingsโ†’awayโ†’workspaceโ†’tools, sections 1-4 truncate only); IO lives in `readmymind-collectors.ts` (transcript TAIL read โ€” the live watcher keeps only a 500-char snippet โ€” + git signals, skipped for remote-SSH cases) and the route; `readmymind-predictor.ts` reuses the AiCheckerBase spawn mechanics standalone (verdict-shaped base vs freeform JSON) as a mutable singleton routes call and tests stub. Claude-mode only (400), one in flight per session (409 CONFLICT), model = `readMyMindModel` setting defaulting to `AI_CHECK_MODEL` (opus, decided). Frontend `readmymind-ui.js`: header ๐Ÿง  marker-hidden (`btn-readmymind--hidden`) until the setting is ON; phones hide it in mobile.css and get a keyboard-accessory ๐Ÿง  key instead (ships in BOTH bar templates, revealed by the `rmm-enabled` class on the BAR element โ€” setMode() rebuilds button innerHTML, so per-key state would be wiped; synced at init + every `applyHeaderVisibilitySettings()`). Alternate suggestions render as tappable rows that swap into the editable field without losing edits; Rethink rejects the whole shown set. Suggestions render via value/`textContent` ONLY and Send/Insert go through `POST /input` (server-side, so the sendEnterKey/local-echo trap does not apply) โ€” nothing auto-sends, ever. User guide: `docs/readmymind.md`. +**Read My Mind intent profiles** (phase 1 of `docs/readmymind-plan.md`; `readMyMindEnabled`, SYNCED, default OFF): per-CASE profiles (user-stated `goals` + the user's recent real prompts), keyed by owner + realpath(workingDir) so they survive `/clear`/respawns and multi-user scoping is structural. Capture rides the transcript (`transcript:user_prompt` from `transcript-watcher.ts`), NOT the input paths: `POST /input` sees only programmatic prompts and the WS channel is raw keystrokes. The listener lives inside `startTranscriptWatcher()`'s `if (!watcher)` block (outside it would duplicate per hook event) and is claude-only + gated on the setting per event. Store: `src/intent-store.ts` singleton, `intents.json` written 0600 tmp+rename (prompts can contain secrets; never fed to `/api/search`). Endpoints: GET/PUT/DELETE `/api/sessions/:id/intent` + POST `/api/sessions/:id/readmymind` (`readmymind-routes.ts`, ownership via `findSessionOrFail` WITH `req`; registrations stay the bare `app.('path')` shape, the endpoints.md drift scanner cannot see generics). **Phase 2 (predictor + ๐Ÿง  button)**: `readmymind-context.ts` is the PURE budgeted assembler (9 ranked sources, drop order siblingsโ†’awayโ†’workspaceโ†’tools, sections 1-4 truncate only); IO lives in `readmymind-collectors.ts` (transcript TAIL read โ€” the live watcher keeps only a 500-char snippet โ€” + git signals, skipped for remote-SSH cases) and the route; `readmymind-predictor.ts` reuses the AiCheckerBase spawn mechanics standalone (verdict-shaped base vs freeform JSON) as a mutable singleton routes call and tests stub. Claude-mode only (400), one in flight per session (409 CONFLICT), model = `readMyMindModel` setting defaulting to `AI_CHECK_MODEL` (opus, decided). Frontend `readmymind-ui.js`: header ๐Ÿง  marker-hidden (`btn-readmymind--hidden`) until the setting is ON; phones hide it in mobile.css and get a keyboard-accessory ๐Ÿง  key instead (ships in BOTH bar templates, revealed by the `rmm-enabled` class on the BAR element โ€” setMode() rebuilds button innerHTML, so per-key state would be wiped; synced at init + every `applyHeaderVisibilitySettings()`). Alternate suggestions render as tappable rows that swap into the editable field without losing edits; Rethink rejects the whole shown set and carries the optional steer note (`#readMyMindSteer`, sent as `steer`, shown in ready + empty-result phases, cleared on each open). Suggestions render via value/`textContent` ONLY and Send/Insert go through `POST /input` (server-side, so the sendEnterKey/local-echo trap does not apply) โ€” nothing auto-sends, ever. User guide: `docs/readmymind.md`. **Agent Teams**: `TeamWatcher` polls `~/.claude/teams/`, matches to sessions via `leadSessionId`. Teammates are in-process threads appearing as subagents. Enable: `CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS=1`. See `docs/agent-teams/`. diff --git a/docs/readmymind-plan.md b/docs/readmymind-plan.md index 101bffd2..5d664f88 100644 --- a/docs/readmymind-plan.md +++ b/docs/readmymind-plan.md @@ -124,7 +124,7 @@ Agent use cases this unlocks: a lead session records intentions as the user stat 1. **Intent store + capture + intent endpoints + skill docs.** Immediately useful to agents even before any UI exists. 2. **Context assembler + predictor + predict endpoint + desktop button/modal.** The feature as pitched. The assembler ships with all collectors it can serve from day one (transcript, intent, git, run-summary, siblings); the approvals collector activates when PR #245 lands. -3. **Phone accessory key, rethink steering, alternates row.** Part 1 (shipped): the alternates row (tappable, swap into the field without losing edits; Rethink rejects the whole shown set), the phone ๐Ÿง  keyboard-accessory key (both bar templates, `rmm-enabled` marker class on the bar), and a phone-sized modal (small dialog, not full-screen). Part 2: rethink steering (the free-text steer note; the API already accepts `steer`). +3. **Phone accessory key, rethink steering, alternates row.** Part 1 (shipped): the alternates row (tappable, swap into the field without losing edits; Rethink rejects the whole shown set), the phone ๐Ÿง  keyboard-accessory key (both bar templates, `rmm-enabled` marker class on the bar), and a phone-sized modal (small dialog, not full-screen). Part 2 (shipped): rethink steering, the free-text steer note under the suggestions, sent as `steer`, visible whenever Rethink is live (ready and empty-result phases), cleared on each open. 4. Explicitly later: proactive predict-on-idle (ghost suggestion chip), auto-compaction of `recentPrompts` into `goals` via a cheap model, codex/gemini capture, cross-case "global" intent. ## Open questions diff --git a/docs/readmymind.md b/docs/readmymind.md index fc9aef8e..bb2d95ca 100644 --- a/docs/readmymind.md +++ b/docs/readmymind.md @@ -27,7 +27,7 @@ On a Claude session, press the brain button in the header (desktop) or the ๐Ÿง  - **Send** submits it to the session (with Enter). - **Insert** drops it on the CLI composer *without* Enter, so you can edit it in the terminal before sending. -- **Rethink** re-runs with everything shown (the field and the alternates) recorded as rejected. +- **Rethink** re-runs with everything shown (the field and the alternates) recorded as rejected. An optional steer note below the suggestions ("no, I meant the mobile bug") rides along as your own words, the highest-authority signal the predictor gets; it stays in the field across re-runs until you clear it or reopen the modal. - **Dismiss** closes; nothing happens. A prediction takes 5-90 seconds and costs real tokens; one runs per session at a time. If the session is sitting on a permission/question dialog, the suggestion is usually an answer to that dialog: that is intentional. @@ -87,7 +87,7 @@ The `codeman` agent skill documents the same verbs (SKILL.md ยง3 plus `reference ## What comes next -A steer-note input on Rethink ("no, I meant the mobile bug"; the API already accepts `steer`). Explicitly later: proactive predict-on-idle, auto-compaction of the prompt history into goals, non-Claude capture. See the phases section of [`readmymind-plan.md`](readmymind-plan.md). +Explicitly later: proactive predict-on-idle, auto-compaction of the prompt history into goals, non-Claude capture. See the phases section of [`readmymind-plan.md`](readmymind-plan.md). ## Troubleshooting diff --git a/src/web/public/i18n.js b/src/web/public/i18n.js index a9fd9849..47e681f4 100644 --- a/src/web/public/i18n.js +++ b/src/web/public/i18n.js @@ -261,6 +261,8 @@ "Put the text on the session's composer without submitting it": 'ๅฐ†ๆ–‡ๆœฌๆ”พๅ…ฅไผš่ฏ่พ“ๅ…ฅๆก†ไฝ†ไธๆไบค', 'Predicted prompt, editable': '้ข„ๆต‹็š„ๆ็คบ๏ผŒๅฏ็ผ–่พ‘', 'Use this suggestion instead': 'ๆ”น็”จๆญคๅปบ่ฎฎ', + "Steer the rethink, e.g. 'no, I meant the mobile bug'": 'ๅผ•ๅฏผ้‡ๆƒณ๏ผŒไพ‹ๅฆ‚๏ผš"ไธ๏ผŒๆˆ‘ๆ˜ฏๆŒ‡็งปๅŠจ็ซฏ็š„้—ฎ้ข˜"', + 'Steer note for Rethink': '้‡ๆƒณ็š„ๅผ•ๅฏผๅค‡ๆณจ', 'Select a session first': '่ฏทๅ…ˆ้€‰ๆ‹ฉไธ€ไธชไผš่ฏ', 'Read My Mind works on Claude sessions only': '่ฏปๅฟƒๆœฏไป…้€‚็”จไบŽ Claude ไผš่ฏ', 'Prompt sent': 'ๆ็คบๅทฒๅ‘้€', diff --git a/src/web/public/index.html b/src/web/public/index.html index 6bc54df3..85038bd8 100644 --- a/src/web/public/index.html +++ b/src/web/public/index.html @@ -2895,6 +2895,13 @@ + +
- + - + -

Settings marked device stay on this browser. The rest sync to every device.

@@ -1364,6 +1364,96 @@ + +
+
+ +

System

+
+

What this install is running, plus paths, automation and remote access.

+ +
+

Updates

+
+
+
Current version
+ — +
+
+
+ Check for updates + Looks at GitHub for a newer Codeman release. +
+ +
+ + + + +
+
+ +
+

Paths

synced
+
+
+
+ CLAUDE.md template + Used when creating new cases. Leave empty for the built-in template. +
+ +
+
+
+ Default working directory + Starting directory for new sessions. +
+ +
+
+
+ +
+

Automation

synced
+
+
+
+ Image watcher + Detect and pop up new images found in session directories. +
+ +
+
+
+ +
+

Remote access

synced
+
+
+
+ Cloudflare Tunnel + Expose Codeman on a public URL. Set CODEMAN_PASSWORD first. +
+ +
+ + +
+
+ +
@@ -1427,6 +1517,121 @@
+ +
+
+ +

Header & Panels

+
+

Pick the buttons and panels you actually use. Everything else stays out of the way.

+ + +
+
+ Live preview + Updates as you pick, before you save. +
+
+
+ + w1-codeman + w2-docs + + + +
+
+
❯ npm run dev
+
+
+
+
+ Run + +
+
+ +
+ +
+

Header buttons

device
+

Tap to show a control in the header. Multi-monitor is the one entry here that syncs across devices.

+
+
+ + + + + + + + + + + +
+
+
+ +
+

Panels

device
+
+
+ + + + +
+
+
+ +
+

Cross-session features

synced
+

Approvals answers prompts from any session in one place. Read My Mind captures your prompts and predicts the next one on demand; both cost tokens and never auto-send.

+
+
+ + + + +
+
+
+ +
+

Scheduling

device
+
+
+ +
+
+
+ +
+

Subagent windows

+
+
+
+ Track background agents synced + Follow Claude Code subagents in real time. +
+ +
+
+
+ Active tab only device + Only show agent windows for the selected session. +
+ +
+
+
+
+
@@ -1525,81 +1730,6 @@
- -
-
- -

Header & Panels

-
-

Pick the buttons and panels you actually use. Everything else stays out of the way.

- -
-

Header buttons

device
-

Tap to show a control in the header. Multi-monitor is the one entry here that syncs across devices.

-
-
- - - - - - - - - - - - -
-
-
- -
-

Panels

device
-
-
- - - - -
-
-
- -
-

Cross-session features

synced
-

Approvals answers prompts from any session in one place. Read My Mind captures your prompts and predicts the next one on demand; both cost tokens and never auto-send.

-
-
- - - - -
-
-
- -
-

Subagent windows

-
-
-
- Track background agents synced - Follow Claude Code subagents in real time. -
- -
-
-
- Active tab only device - Only show agent windows for the selected session. -
- -
-
-
-
-
@@ -2032,95 +2162,6 @@
- -
-
- -

System

-
-

Paths, automation, remote access and updates.

- -
-

Paths

synced
-
-
-
- CLAUDE.md template - Used when creating new cases. Leave empty for the built-in template. -
- -
-
-
- Default working directory - Starting directory for new sessions. -
- -
-
-
- -
-

Automation

synced
-
-
-
- Image watcher - Detect and pop up new images found in session directories. -
- -
-
-
- -
-

Remote access

synced
-
-
-
- Cloudflare Tunnel - Expose Codeman on a public URL. Set CODEMAN_PASSWORD first. -
- -
- - -
-
- -
-

Updates

-
-
-
Current version
- — -
-
-
- Check for updates - Looks at GitHub for a newer Codeman release. -
- -
- - - - -
-
-
diff --git a/src/web/public/mobile.css b/src/web/public/mobile.css index 222b02ac..ae1a7e20 100644 --- a/src/web/public/mobile.css +++ b/src/web/public/mobile.css @@ -3378,6 +3378,15 @@ html:is([data-skin="paper-gray"], [data-skin="solarized-light"], [data-skin="cat display: none; } + /* โ”€โ”€ live layout preview โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ */ + #appSettingsModal .set-preview { + margin-bottom: 12px; + } + + #appSettingsModal .set-preview-stage { + min-height: 62px; + } + /* โ”€โ”€ inset grouped list โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ */ #appSettingsModal .set-group { margin-top: 14px; diff --git a/src/web/public/settings-ui.js b/src/web/public/settings-ui.js index 2c5f1357..24331c11 100644 --- a/src/web/public/settings-ui.js +++ b/src/web/public/settings-ui.js @@ -503,8 +503,10 @@ Object.assign(CodemanApp.prototype, { this._syncSettingsChips(); this._syncModelCards(); this._syncEffortSegment(); - // Back to the top of the document (one scroll, not a tab reset). - this.switchSettingsTab('settings-terminal'); + // Back to the top of the document (one scroll, not a tab reset). System is + // first now: the version this install is running, and whether an update is + // waiting, are the two things worth seeing before anything is configured. + this.switchSettingsTab('settings-system'); const modal = document.getElementById('appSettingsModal'); modal.classList.add('active'); @@ -692,6 +694,79 @@ Object.assign(CodemanApp.prototype, { document.querySelectorAll('#appSettingsModal .set-chip').forEach(chip => { chip.classList.toggle('is-on', !!chip.querySelector('input')?.checked); }); + this._syncLayoutPreview(); + }, + + /** + * Redraw the Header & Panels live preview from the chips above it. + * + * The preview is a scale model of the app, not a second list of settings, so + * every icon is CLONED from the chip that owns it (`.set-chip-ico`): each icon + * has exactly ONE copy in index.html and a chip can never drift from the button + * it previews. A chip joins the preview purely by carrying `data-preview` + * (which slot) and `data-preview-order` (where in that slot); nothing here + * needs to know the setting's name. + * + * `data-preview-text` replaces the icon with a text token for the header + * entries that are readouts rather than buttons (plan usage, CPU, font size). + */ + _syncLayoutPreview() { + const modal = document.getElementById('appSettingsModal'); + if (!modal || typeof modal.querySelectorAll !== 'function') return; + const slots = { + header: document.getElementById('appSettingsPreviewHeader'), + panel: document.getElementById('appSettingsPreviewPanels'), + toolbar: document.getElementById('appSettingsPreviewToolbar'), + float: document.getElementById('appSettingsPreviewFloats'), + }; + if (!slots.header) return; + Object.values(slots).forEach(el => { + if (el) el.innerHTML = ''; + }); + + const chips = [...modal.querySelectorAll('.set-chip[data-preview]')] + .filter(chip => chip.querySelector('input')?.checked) + .sort((a, b) => (Number(a.dataset.previewOrder) || 0) - (Number(b.dataset.previewOrder) || 0)); + + let shown = 0; + for (const chip of chips) { + const kind = chip.dataset.preview; + const slot = slots[kind]; + if (!slot) continue; + // The label is the chip's own text; the icon span (if any) is skipped by + // taking the LAST span, which is always the label. + const spans = chip.querySelectorAll('span'); + const label = (spans[spans.length - 1]?.textContent || '').trim(); + const el = document.createElement('span'); + el.title = label; + if (kind === 'header') { + const text = chip.dataset.previewText; + el.className = text ? 'set-preview-chip' : 'set-preview-btn'; + if (text) el.textContent = text; + else this._appendPreviewIcon(el, chip); + } else { + el.className = `set-preview-${kind}`; + this._appendPreviewIcon(el, chip); + const name = document.createElement('span'); + name.textContent = label; + el.appendChild(name); + } + slot.appendChild(el); + shown++; + } + + const empty = document.getElementById('appSettingsPreviewEmpty'); + if (empty) empty.hidden = shown > 0; + }, + + /** Clone a chip's icon into a preview element (see _syncLayoutPreview). */ + _appendPreviewIcon(target, chip) { + const icon = chip.querySelector('.set-chip-ico'); + if (!icon) return; + const clone = icon.cloneNode(true); + clone.classList.remove('set-chip-ico'); + clone.classList.add('set-preview-ico'); + target.appendChild(clone); }, /** @@ -860,6 +935,10 @@ Object.assign(CodemanApp.prototype, { section.classList.toggle('set-hit-hidden', !!q && !hasVisible); }); + // The live preview sits outside any group, so it survives the sweep above; + // a search is asking for one row, not for the scale model around it. + doc.querySelectorAll('.set-preview').forEach(pv => pv.classList.toggle('set-hit-hidden', !!q)); + const empty = document.getElementById('appSettingsSearchEmpty'); if (empty) empty.hidden = !q || anyVisible; if (!q) doc.querySelectorAll('.set-group-advanced').forEach(g => g.classList.remove('open')); diff --git a/src/web/public/styles.css b/src/web/public/styles.css index 47c199ad..5dbbfdeb 100644 --- a/src/web/public/styles.css +++ b/src/web/public/styles.css @@ -14572,9 +14572,12 @@ html[data-skin="daylight-blue"] .welcome-btn-tunnel.active:hover { introduces its own `set-*` classes rather than restyling those. โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• */ +/* Deliberately narrower than the other .modal-lg users: the document is a + single column of rows whose control sits at the far right, so extra width + buys nothing but a gap between a row's text and its switch. */ #appSettingsModal .modal-content.modal-lg { - max-width: min(960px, 96vw); - width: min(960px, 96vw); + max-width: min(840px, 96vw); + width: min(840px, 96vw); height: min(700px, 88vh); max-height: 88vh; } @@ -14609,7 +14612,7 @@ html[data-skin="daylight-blue"] .welcome-btn-tunnel.active:hover { #appSettingsModal .set-body { display: grid; - grid-template-columns: 212px minmax(0, 1fr); + grid-template-columns: 196px minmax(0, 1fr); flex: 1; min-height: 0; } @@ -14729,19 +14732,19 @@ html[data-skin="daylight-blue"] .welcome-btn-tunnel.active:hover { /* โ”€โ”€ document โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ */ #appSettingsModal .set-doc { overflow-y: auto; - padding: 0 22px 40px; + padding: 0 18px 32px; min-width: 0; min-height: 0; position: relative; } #appSettingsModal .set-section { - padding-top: 20px; + padding-top: 16px; } #appSettingsModal .set-section + .set-section { border-top: 1px solid var(--border); - margin-top: 26px; + margin-top: 20px; } #appSettingsModal .set-section-head { @@ -14770,7 +14773,7 @@ html[data-skin="daylight-blue"] .welcome-btn-tunnel.active:hover { } #appSettingsModal .set-section-blurb { - margin: 0 0 14px 36px; + margin: 0 0 11px 34px; font-size: 0.69rem; color: var(--text-muted); } @@ -14784,7 +14787,7 @@ html[data-skin="daylight-blue"] .welcome-btn-tunnel.active:hover { /* โ”€โ”€ groups โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ */ #appSettingsModal .set-group + .set-group { - margin-top: 18px; + margin-top: 14px; } #appSettingsModal .set-group-head { @@ -14849,15 +14852,15 @@ html[data-skin="daylight-blue"] .welcome-btn-tunnel.active:hover { #appSettingsModal .set-group-body { display: flex; flex-direction: column; - gap: 5px; + gap: 4px; } /* โ”€โ”€ rows โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ */ #appSettingsModal .set-row { display: flex; align-items: center; - gap: 14px; - padding: 9px 12px; + gap: 10px; + padding: 7px 11px; border-radius: 9px; background: rgba(255, 255, 255, 0.028); border: 1px solid rgba(255, 255, 255, 0.055); @@ -14893,11 +14896,13 @@ html[data-skin="daylight-blue"] .welcome-btn-tunnel.active:hover { letter-spacing: -0.005em; } +/* Wide enough that a description runs most of the way to its switch: the + complaint about this panel was the dead gap a 62ch cap left behind. */ #appSettingsModal .set-row-desc { font-size: 0.66rem; line-height: 1.45; color: var(--text-muted); - max-width: 62ch; + max-width: 78ch; } #appSettingsModal .set-row-actions { @@ -14980,6 +14985,14 @@ html[data-skin="daylight-blue"] .welcome-btn-tunnel.active:hover { color: var(--text-dim); } +/* The version is the first value in the first section now, so it reads as an + answer to "what am I running", not as one more right-aligned field. */ +#appSettingsModal .set-static-strong { + font-size: 0.82rem; + font-weight: 620; + color: var(--text); +} + #appSettingsModal .mono { font-family: var(--font-mono); } @@ -15054,6 +15067,24 @@ html[data-skin="daylight-blue"] .welcome-btn-tunnel.active:hover { pointer-events: none; } +/* The chip carries the icon of the button it switches on, so the list reads as + the header rather than as a list of names. It is also the ONLY copy of that + icon: the live preview clones it (settings-ui.js: _syncLayoutPreview). */ +#appSettingsModal .set-chip-ico { + flex-shrink: 0; + opacity: 0.75; +} + +#appSettingsModal .set-chip-emoji { + font-size: 0.85em; + line-height: 1; +} + +#appSettingsModal .set-chip.is-on .set-chip-ico, +#appSettingsModal .set-chip:has(input:checked) .set-chip-ico { + opacity: 1; +} + #appSettingsModal .set-chip::before { content: '\2713'; font-size: 0.8em; @@ -15089,6 +15120,219 @@ html[data-skin="daylight-blue"] .welcome-btn-tunnel.active:hover { outline-offset: 1px; } +/* โ”€โ”€ live layout preview โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + A scale model of the app at the top of Header & Panels, rebuilt from the + chips on every change (settings-ui.js: _syncLayoutPreview). Everything here + is decoration around cloned chip icons, so it carries no icon of its own. + โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ */ +#appSettingsModal .set-preview { + margin: 0 0 14px; +} + +#appSettingsModal .set-preview-head { + display: flex; + align-items: baseline; + gap: 8px; + margin-bottom: 6px; +} + +#appSettingsModal .set-preview-title { + font-size: 0.63rem; + font-weight: 640; + letter-spacing: 0.09em; + text-transform: uppercase; + color: var(--text-muted); +} + +#appSettingsModal .set-preview-hint { + font-size: 0.62rem; + color: var(--text-muted); + opacity: 0.72; +} + +/* Skin tokens only, no black/white alphas: on the four light skins a + hardcoded rgba(0,0,0,โ€ฆ) turned the whole model into a grey slab. */ +#appSettingsModal .set-preview-frame { + border: 1px solid var(--border); + border-radius: 10px; + overflow: hidden; + background: var(--bg-dark); +} + +#appSettingsModal .set-preview-bar { + display: flex; + align-items: center; + gap: 6px; + padding: 6px 8px; + border-bottom: 1px solid var(--border); + background: var(--bg-card); +} + +#appSettingsModal .set-preview-logo { + display: grid; + place-items: center; + width: 16px; + height: 16px; + border-radius: 5px; + font-size: 0.55rem; + font-weight: 700; + color: var(--accent-ink); + background: linear-gradient(180deg, var(--accent-grad-a), var(--accent-grad-b)); + flex-shrink: 0; +} + +#appSettingsModal .set-preview-tab { + font-size: 0.56rem; + padding: 2px 7px; + border-radius: 5px; + background: var(--control-bg); + color: var(--text-dim); + white-space: nowrap; +} + +#appSettingsModal .set-preview-tab-dim { + opacity: 0.5; +} + +#appSettingsModal .set-preview-gap { + flex: 1; + min-width: 6px; +} + +/* Wraps rather than overflows: every button switched on has to stay visible, + and the frame is narrower than the real header it stands in for. */ +#appSettingsModal .set-preview-btns { + display: flex; + align-items: center; + justify-content: flex-end; + flex-wrap: wrap; + gap: 3px; +} + +#appSettingsModal .set-preview-btn { + display: inline-grid; + place-items: center; + width: 19px; + height: 19px; + border-radius: 5px; + color: var(--text-dim); + background: var(--control-bg); +} + +#appSettingsModal .set-preview-btn-fixed { + color: var(--text-muted); + background: transparent; +} + +#appSettingsModal .set-preview-chip { + font-size: 0.55rem; + padding: 3px 6px; + border-radius: 5px; + color: var(--text-dim); + background: var(--control-bg); + white-space: nowrap; +} + +#appSettingsModal svg.set-preview-ico { + width: 11px; + height: 11px; + flex-shrink: 0; +} + +#appSettingsModal span.set-preview-ico { + font-size: 9px; + line-height: 1; +} + +#appSettingsModal .set-preview-stage { + position: relative; + display: flex; + min-height: 76px; + padding: 9px 10px; +} + +#appSettingsModal .set-preview-term { + flex: 1; + font-family: var(--font-mono, monospace); + font-size: 0.58rem; + color: var(--text-muted); +} + +#appSettingsModal .set-preview-caret { + color: var(--accent); +} + +/* Right-docked, like every real panel in the app (monitor, subagents, file + browser and project insights are all `position: fixed; right: โ€ฆ`). */ +#appSettingsModal .set-preview-panels { + position: absolute; + right: 9px; + top: 9px; + bottom: 9px; + display: flex; + flex-direction: column; + align-items: flex-end; + gap: 4px; +} + +#appSettingsModal .set-preview-panel, +#appSettingsModal .set-preview-float, +#appSettingsModal .set-preview-toolbar { + display: inline-flex; + align-items: center; + gap: 4px; + font-size: 0.55rem; + padding: 3px 7px; + border-radius: 6px; + color: var(--text-dim); + background: var(--control-bg); + border: 1px solid var(--border); + white-space: nowrap; +} + +#appSettingsModal .set-preview-floats { + position: absolute; + left: 12px; + bottom: 10px; + display: flex; + gap: 5px; +} + +#appSettingsModal .set-preview-float { + border-style: dashed; + color: var(--text-muted); +} + +#appSettingsModal .set-preview-tray { + display: flex; + align-items: center; + gap: 5px; + padding: 5px 8px; + border-top: 1px solid var(--border); + background: var(--bg-card); +} + +#appSettingsModal .set-preview-run { + font-size: 0.55rem; + font-weight: 640; + padding: 3px 9px; + border-radius: 5px; + color: var(--accent-ink); + background: linear-gradient(180deg, var(--accent-grad-a), var(--accent-grad-b)); +} + +#appSettingsModal .set-preview-tools { + display: flex; + align-items: center; + gap: 4px; +} + +#appSettingsModal .set-preview-empty { + margin: 6px 0 0; + font-size: 0.62rem; + color: var(--text-muted); +} + /* โ”€โ”€ model cards โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ */ #appSettingsModal .set-modelgrid { display: grid; diff --git a/test/app-settings-structure.test.ts b/test/app-settings-structure.test.ts index b71c8096..5a69a25e 100644 --- a/test/app-settings-structure.test.ts +++ b/test/app-settings-structure.test.ts @@ -59,8 +59,23 @@ describe('App Settings modal structure', () => { } }); - it('opens on Terminal & Input, so Local Echo is the first thing in reach', () => { - expect(settingsUi).toContain("this.switchSettingsTab('settings-terminal')"); + it('opens on System, with the version and the updater above everything else', () => { + expect(settingsUi).toContain("this.switchSettingsTab('settings-system')"); + const modal = settingsModal(); + const order = [...modal.matchAll(/
m[1]); + // Rail and document must agree, or scroll-spy paints the wrong entry. + const rail = [...modal.matchAll(/data-section="([a-z-]+)"/g)].map((m) => m[1]); + expect(rail.slice(0, 3)).toEqual(['settings-system', 'settings-terminal', 'settings-layout']); + expect(order.slice(0, 3)).toEqual(['settings-system', 'settings-terminal', 'settings-layout']); + // Updates leads the System section: version first, then the update action. + const system = modal.match(/id="settings-system"([\s\S]*?)<\/section>/)?.[1] ?? ''; + const version = system.indexOf('id="updateCurrentVersion"'); + expect(version).toBeGreaterThan(-1); + expect(version).toBeLessThan(system.indexOf('id="appSettingsClaudeMdPath"')); + expect(system.indexOf('id="updateCheckBtn"')).toBeLessThan(system.indexOf('id="appSettingsClaudeMdPath"')); + }); + + it('keeps Local Echo the first row of the second section', () => { const terminal = settingsModal().match(/id="settings-terminal"([\s\S]*?)<\/section>/); const localEcho = terminal?.[1].indexOf('appSettingsLocalEcho') ?? -1; const cjk = terminal?.[1].indexOf('appSettingsCjkInput') ?? -1; @@ -68,6 +83,33 @@ describe('App Settings modal structure', () => { expect(localEcho).toBeLessThan(cjk); }); + it('gives every previewed chip an icon to clone, and a slot that exists', () => { + // _syncLayoutPreview clones `.set-chip-ico` out of the chip, so a chip that + // opts into the preview without an icon renders as an empty button, and one + // pointing at a slot id that does not exist renders as nothing at all. + const layout = settingsModal().match(/id="settings-layout"([\s\S]*?)<\/section>/)?.[1] ?? ''; + const chips = [...layout.matchAll(/
- + diff --git a/src/web/public/mobile.css b/src/web/public/mobile.css index 1ff443a4..981c3e98 100644 --- a/src/web/public/mobile.css +++ b/src/web/public/mobile.css @@ -1336,15 +1336,14 @@ html.mobile-init .file-browser-panel { width: calc(100% - 2rem); } /* Four footer buttons on a narrow phone: let them wrap instead of clipping, - and give buttons + alternate rows finger-sized targets. */ + and give buttons + alternate rows finger-sized targets. The flex row + itself comes from the base rule in styles.css. */ .readmymind-modal .modal-footer { - display: flex; flex-wrap: wrap; - justify-content: flex-end; - gap: 0.5rem; } - .readmymind-modal .modal-footer .btn { + .readmymind-modal .modal-footer .btn-toolbar { flex: 1 1 auto; + justify-content: center; min-height: 38px; } .readmymind-alt { diff --git a/src/web/public/styles.css b/src/web/public/styles.css index 9d56bcc4..de29e37e 100644 --- a/src/web/public/styles.css +++ b/src/web/public/styles.css @@ -10927,6 +10927,32 @@ kbd { .readmymind-steer-input::placeholder { color: var(--text-dim); } +/* Footer row: the buttons are btn-toolbar (display: flex, block-level), so + without this rule the four of them stack vertically. Mirrors the + runSummaryModal footer; the โ‰ค430px block in mobile.css adds wrapping. */ +.readmymind-modal .modal-footer { + display: flex; + justify-content: flex-end; + gap: 0.5rem; + padding: 0.75rem 1rem; + border-top: 1px solid rgba(255, 255, 255, 0.06); +} +/* Send keeps the primary accent: the skin block's bare .btn-toolbar rule is + (0,2,1) under html:not([data-skin="og"]) and outranks the base + .btn-toolbar.btn-primary (0,2,0) โ€” the specificity trap CLAUDE.md documents + for mobile.css โ€” so the accent is re-asserted here at (0,4,0). Scoped to + this modal on purpose; un-greying every btn-primary on the new skins is a + design call, not this feature's. */ +.readmymind-modal .modal-footer .btn-toolbar.btn-primary { + background: var(--accent); + border-color: var(--accent); + color: #fff; +} +.readmymind-modal .modal-footer .btn-toolbar.btn-primary:hover { + background: var(--accent-hover); + border-color: var(--accent-hover); + color: #fff; +} /* Keyboard-accessory ๐Ÿง  key: the phone surface for the same opt-in setting (the header ๐Ÿง  button stays phone-hidden in mobile.css). The key ships in diff --git a/test/readmymind-phone-key.test.ts b/test/readmymind-phone-key.test.ts index dc31e2a2..c3f938e5 100644 --- a/test/readmymind-phone-key.test.ts +++ b/test/readmymind-phone-key.test.ts @@ -89,4 +89,20 @@ describe('read my mind phone key + alternates (static guards)', () => { // A fresh open resets the note along with the rethink memory. expect(ui).toMatch(/steer\.value = ''/); }); + + it('styles the footer with btn-toolbar (bare "btn btn-*" matches no CSS in this codebase)', () => { + const modal = html.slice(html.indexOf('id="readMyMindModal"'), html.indexOf('id="approvalsDrawer"')); + // The unstyled classes the footer originally shipped with must not return. + expect(modal).not.toMatch(/class="btn /); + expect(modal.match(/class="btn-toolbar/g)?.length).toBe(4); + expect(modal).toMatch(/class="btn-toolbar btn-primary"[^>]*sendReadMyMind\(true\)/); + // btn-toolbar is display:flex (block-level): without the desktop footer + // row rule the four buttons would stack vertically. + expect(styles).toMatch(/\.readmymind-modal \.modal-footer \{[^}]*display: flex/); + // The skin block's bare .btn-toolbar (0,2,1) greys out .btn-primary + // (0,2,0), so Send's accent must be re-asserted at higher specificity. + expect(styles).toMatch(/\.readmymind-modal \.modal-footer \.btn-toolbar\.btn-primary \{[^}]*var\(--accent\)/); + // The phone block sizes the same class for finger targets. + expect(phoneBlock).toMatch(/\.readmymind-modal \.modal-footer \.btn-toolbar/); + }); }); From aaad0315106b22fa486ae4d9daa22a2e026bbfcf Mon Sep 17 00:00:00 2001 From: Codeman maintainer Date: Mon, 10 Aug 2026 11:16:58 +0200 Subject: [PATCH 04/11] fix(paths): one home-prefix helper, so labels abbreviate on both platforms The rule "show ~/project rather than /home//project" had three implementations in the frontend, two of them platform-specific in opposite directions, so each looked correct to whoever wrote it. - The Run menu's Recent Sessions rows matched /home// only. On macOS nothing was stripped, so every row spent its first ~19 characters on an identical /Users// prefix and the left-to-right ellipsis removed the tail that identifies the row. That is #273, reported by @jordan8037310, who also traced why the menu's 250px cap made it worse: the width was chosen on the assumption the abbreviation had run. - The case-manage list matched /Users/ only, the mirror image, so on a Linux host no case path was ever abbreviated there. Unreported. Both now call _shortenHomePath(), which was already correct for both layouts and already used by the Resume list, Cmd+K, the desktop home rail and the phone overview. Its regex collapses to one alternation with a lookahead, so a path that is exactly $HOME renders "~" instead of being left raw, matching what the case-manage list used to do on macOS. test/home-path-abbreviation.test.ts pins the helper on both layouts and the rendered case-manage label, and fails if a fourth copy of the pattern appears in src/web/public. The Run-menu guard counts helper calls rather than pinning a source line, so it survives the row restructure in #274. test/run-mode-ui.test.ts gains a _shortenHomePath stub: its harness loads session-ui.js without terminal-ui.js, which the real app never does. Verified against an isolated instance with 27 real cases and 50 history rows: 27 of 27 case paths and 17 of 20 Run menu rows abbreviate, the other 3 are /tmp paths that correctly stay raw, tooltips keep the full path, no page errors. Co-Authored-By: Claude Opus 5 (1M context) --- src/web/public/session-ui.js | 10 +- src/web/public/terminal-ui.js | 18 ++- test/home-path-abbreviation.test.ts | 166 ++++++++++++++++++++++++++++ test/run-mode-ui.test.ts | 5 + 4 files changed, 193 insertions(+), 6 deletions(-) create mode 100644 test/home-path-abbreviation.test.ts diff --git a/src/web/public/session-ui.js b/src/web/public/session-ui.js index 99ad73cd..7818de39 100644 --- a/src/web/public/session-ui.js +++ b/src/web/public/session-ui.js @@ -489,7 +489,11 @@ Object.assign(CodemanApp.prototype, { const date = new Date(s.lastModified); const timeStr = date.toLocaleDateString('en', { month: 'short', day: 'numeric' }) + ' ' + date.toLocaleTimeString('en', { hour: '2-digit', minute: '2-digit', hour12: false }); - const shortDir = s.workingDir.replace(/^\/home\/[^/]+\//, '~/'); + // Shared helper, not a local regex: the copy that used to live here + // matched `/home//` only, so on macOS every row rendered the same + // unabbreviated `/Users//โ€ฆ` prefix and ellipsized away the tail + // that identifies it (#273). + const shortDir = this._shortenHomePath(s.workingDir); const btn = document.createElement('button'); btn.className = 'run-mode-option'; @@ -2676,7 +2680,9 @@ Object.assign(CodemanApp.prototype, { cases.forEach((c, idx) => { const isFirst = idx === 0; const isLast = idx === cases.length - 1; - const pathDisplay = c.path ? c.path.replace(/^\/Users\/[^/]+/, '~') : ''; + // Was `/Users/` only, the mirror image of the Run menu's bug: every + // case path on a Linux host rendered in full, unabbreviated. + const pathDisplay = c.path ? this._shortenHomePath(c.path) : ''; html += `
diff --git a/src/web/public/terminal-ui.js b/src/web/public/terminal-ui.js index 83c2282d..aaaf1f4f 100644 --- a/src/web/public/terminal-ui.js +++ b/src/web/public/terminal-ui.js @@ -1610,11 +1610,21 @@ Object.assign(CodemanApp.prototype, { return workingDir.split('/').pop() || workingDir; }, - /** Normalize home prefixes to "~/" on both Linux and macOS */ + /** + * Normalize a home prefix to "~" on both Linux (`/home/`) and macOS + * (`/Users/`). The lookahead lets the home directory ITSELF match, so a + * path that is exactly `$HOME` renders "~" instead of being left raw. + * + * This is the only place that pattern belongs. Two hand-rolled copies had + * drifted, each broken on the platform its author was not using: the Run + * menu's matched `/home/` only, so on macOS nothing was stripped and every + * Recent Sessions row spent its first ~19 characters on an identical + * `/Users//` prefix (#273); the case-manage list's matched `/Users/` + * only, so no Linux path was ever abbreviated there. Route new path labels + * through here rather than writing a third copy. + */ _shortenHomePath(p) { - return (p || '') - .replace(/^\/home\/[^/]+\//, '~/') - .replace(/^\/Users\/[^/]+\//, '~/'); + return (p || '').replace(/^\/(?:home|Users)\/[^/]+(?=\/|$)/, '~'); }, /** diff --git a/test/home-path-abbreviation.test.ts b/test/home-path-abbreviation.test.ts new file mode 100644 index 00000000..f7d4ba77 --- /dev/null +++ b/test/home-path-abbreviation.test.ts @@ -0,0 +1,166 @@ +/** + * @fileoverview Issue #273 and its mirror image: abbreviating `$HOME` in path labels. + * + * The rule ("show `~/project` rather than `/home//project`") had three + * implementations in the frontend, and two of them were platform-specific in + * opposite directions, so each looked correct to whoever wrote it: + * + * - the Run menu's Recent Sessions rows matched `/home//` only, so on + * macOS nothing was stripped, every row spent its first ~19 characters on an + * identical `/Users//` prefix, and the left-to-right ellipsis removed + * the tail that identifies the row (#273), + * - the case-manage list matched `/Users/` only, so on a Linux host no + * case path was ever abbreviated at all. + * + * Both now call `_shortenHomePath()`, which is pinned here for both layouts, and + * a static guard fails if a fourth copy of the pattern appears. + * + * Loaded via `vm` against a stub CodemanApp with a fake DOM, same harness as + * history-list-controls.test.ts. Port: none (no browser, no server). + */ + +import { readdirSync, readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import vm from 'node:vm'; +import { describe, expect, it, vi } from 'vitest'; + +/* eslint-disable @typescript-eslint/no-explicit-any */ + +const PUBLIC = resolve(import.meta.dirname, '../src/web/public'); + +/** + * The container the vm's `document.getElementById` resolves for the case list. + * Swapped per test: the closure lives in THIS realm, so the shipping code inside + * the vm reads whatever the current test installed. + */ +let currentCaseList: { innerHTML: string } | null = null; + +function loadTerminalUiPrototype(): Record { + const source = readFileSync(resolve(PUBLIC, 'terminal-ui.js'), 'utf8'); + const context = vm.createContext({ + console, + CodemanApp: class CodemanApp {}, + setInterval: vi.fn(), + clearInterval: vi.fn(), + setTimeout, + clearTimeout, + requestAnimationFrame: vi.fn(), + document: { addEventListener: vi.fn(), getElementById: () => null, createElement: () => ({}) }, + window: { addEventListener: vi.fn(), removeEventListener: vi.fn() }, + }); + vm.runInContext(`${source}\nglobalThis.__proto = CodemanApp.prototype;`, context); + return (context as unknown as { __proto: Record }).__proto; +} + +function loadSessionUiPrototype(): Record { + const source = readFileSync(resolve(PUBLIC, 'session-ui.js'), 'utf8'); + const context = vm.createContext({ + console, + CodemanApp: class CodemanApp {}, + VoiceInput: {}, + escapeHtml: (t: unknown) => String(t ?? ''), + setTimeout, + clearTimeout, + localStorage: { getItem: () => null, setItem: () => {} }, + document: { getElementById: (id: string) => (id === 'caseManageList' ? currentCaseList : null) }, + window: { addEventListener: vi.fn() }, + }); + vm.runInContext(`${source}\nglobalThis.__proto = CodemanApp.prototype;`, context); + return (context as unknown as { __proto: Record }).__proto; +} + +const terminalProto = loadTerminalUiPrototype(); +const sessionProto = loadSessionUiPrototype(); +const shorten = (p: unknown) => terminalProto._shortenHomePath.call(terminalProto, p); + +describe('_shortenHomePath', () => { + it('abbreviates the Linux home prefix', () => { + expect(shorten('/home/arkon/default/claudeman')).toBe('~/default/claudeman'); + }); + + it('abbreviates the macOS home prefix, which the Run menu never did (#273)', () => { + expect(shorten('/Users/jordanryan/code/facet/facet-agency-ops')).toBe('~/code/facet/facet-agency-ops'); + }); + + it('abbreviates the home directory itself, not only paths below it', () => { + // The case-manage list's old regex had no trailing slash and did collapse + // this to "~"; keep that, or a case whose path IS $HOME would regress. + expect(shorten('/home/arkon')).toBe('~'); + expect(shorten('/Users/jordanryan')).toBe('~'); + }); + + it('leaves paths that only look like a home prefix alone', () => { + expect(shorten('/homer/bob/x')).toBe('/homer/bob/x'); + expect(shorten('/Userspace/bob/x')).toBe('/Userspace/bob/x'); + expect(shorten('/home')).toBe('/home'); + expect(shorten('/mnt/d/work')).toBe('/mnt/d/work'); + expect(shorten('/opt/codeman')).toBe('/opt/codeman'); + }); + + it('replaces only the leading occurrence', () => { + expect(shorten('/home/arkon/home/bob/x')).toBe('~/home/bob/x'); + }); + + it('tolerates empty and missing input', () => { + expect(shorten('')).toBe(''); + expect(shorten(undefined)).toBe(''); + expect(shorten(null)).toBe(''); + }); +}); + +describe('renderCaseManageList path labels', () => { + function render(cases: Array<{ name: string; path: string; location?: string }>): string { + currentCaseList = { innerHTML: '' }; + const app: any = { + cases, + _shortenHomePath: terminalProto._shortenHomePath, + renderCaseManageList: sessionProto.renderCaseManageList, + }; + app.renderCaseManageList(); + const html = currentCaseList.innerHTML; + currentCaseList = null; + return html; + } + + it('abbreviates a Linux case path (the mirror of #273)', () => { + const html = render([{ name: 'demo', path: '/home/arkon/codeman-cases/demo' }]); + expect(html).toContain('~/codeman-cases/demo'); + expect(html).not.toContain('/home/arkon/codeman-cases/demo'); + }); + + it('still abbreviates a macOS case path', () => { + const html = render([{ name: 'demo', path: '/Users/jordanryan/codeman-cases/demo' }]); + expect(html).toContain('~/codeman-cases/demo'); + expect(html).not.toContain('/Users/jordanryan/codeman-cases/demo'); + }); + + it('renders the row when a case has no path at all', () => { + const html = render([{ name: 'demo', path: '' }]); + expect(html).toContain('demo'); + expect(html).toContain('class="case-manage-path"'); + }); +}); + +describe('single implementation of the home-prefix rule', () => { + /** Every top-level frontend module (vendor/ and subdirs are not ours). */ + const sources = readdirSync(PUBLIC) + .filter((name) => name.endsWith('.js')) + .map((name) => ({ name, text: readFileSync(resolve(PUBLIC, name), 'utf8') })); + + it('has exactly one home-prefix regex, in terminal-ui.js', () => { + // Any regex literal anchored at a home root. Three of these had drifted + // apart; a fourth would drift the same way. + const pattern = /\/\^\\\/(?:\(\?:home\|Users\)|home|Users)\\\//g; + const hits = sources.flatMap(({ name, text }) => (text.match(pattern) ?? []).map(() => name)); + expect(hits).toEqual(['terminal-ui.js']); + }); + + it('routes both session-ui path labels through the helper', () => { + // Deliberately counts calls rather than pinning source lines: the Run menu + // row is being restructured in #274, and this guard should survive that as + // long as the label still goes through the helper. + const sessionUi = sources.find((s) => s.name === 'session-ui.js')!.text; + const calls = sessionUi.match(/this\._shortenHomePath\(/g) ?? []; + expect(calls.length).toBeGreaterThanOrEqual(2); + }); +}); diff --git a/test/run-mode-ui.test.ts b/test/run-mode-ui.test.ts index 7961c388..16defaac 100644 --- a/test/run-mode-ui.test.ts +++ b/test/run-mode-ui.test.ts @@ -760,6 +760,11 @@ describe('case selector refresh', () => { ]; app.showToast = vi.fn(); + // deleteCase re-renders the case-manage list, whose path label goes through + // _shortenHomePath. That method lives in terminal-ui.js, which this harness + // does not load (the real app always has it: load order 7 before 12). + app._shortenHomePath = (p: string) => p; + await app.deleteCase('deleted-case'); expect(quickStartCase.blur).toHaveBeenCalled(); From 6ccab925b1e3702cb7f9a87dfc9059716a6a97c8 Mon Sep 17 00:00:00 2001 From: Codeman maintainer Date: Mon, 10 Aug 2026 11:19:56 +0200 Subject: [PATCH 05/11] feat(settings): put Session Options on the same surface as App Settings Session Options was the last modal still wearing the old chrome: a strip of top tabs over `.form-row` stacks, sitting next to a settings modal that had just been rebuilt around a rail and grouped row cards. It now uses the same surface. The `set-*` rules move from `#appSettingsModal` to `:is(#appSettingsModal, #sessionOptionsModal)`. An `:is()` list takes the specificity of its most specific argument, and both arguments are ids, so every rule keeps exactly the weight it had - nothing downstream shifts in the cascade. What the two modals do NOT share is what the rail means: - App Settings stays a table of contents over one scrolling document. - Session Options switches: one `.set-section` visible, `.hidden` on the rest. Summary owns its own scroller and Respawn is long, so stacking them into a single document would bury both. `switchOptionsTab` now queries `.set-rail-item` (it read `.modal-tab-btn` before) and resets the document scroll, so a switched-to section starts at its own top. Phones get a horizontal, scrollable rail strip rather than App Settings' sticky jump pill, which Session Options has no equivalent of. That is close to the tab bar it replaces, so the phone gesture is unchanged. Content is regrouped into the row language - label, description, control pinned right - across all four sections: usage limits / respawn loop / cycle steps / loop control, identity / token management / this session, tracker / limits, and the summary timeline. The three cycle-step checkboxes became chips, which is why `_syncSettingsChips` now covers both modals and Session Options registers one delegated change listener per page for them. Every id and handler the JS reads is preserved, and the component classes it queries (`.duration-preset-btn`, `.duration-custom-input`, `.color-swatch`, `.respawn-status-text`, `.run-summary-filters .filter-btn`) are untouched. `data-claude-only` moved onto the rail entries, so external-CLI sessions still lose Respawn and Ralph and land on Context. `.modal-tabs`/`.modal-tab-btn`/`.modal-tab-content` now belong to #createCaseModal alone. test/session-options-structure.test.ts pins the rail to section pairing, the ids openSessionOptions reads, the one-visible-section invariant and the Claude-only entries. --- CLAUDE.md | 2 +- src/web/public/index.html | 612 ++++++++++++++----------- src/web/public/mobile.css | 165 ++++--- src/web/public/session-ui.js | 36 +- src/web/public/settings-ui.js | 9 +- src/web/public/styles.css | 379 +++++++++------ test/session-options-structure.test.ts | 97 ++++ 7 files changed, 828 insertions(+), 472 deletions(-) create mode 100644 test/session-options-structure.test.ts diff --git a/CLAUDE.md b/CLAUDE.md index 8803833e..fd351eaf 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -264,7 +264,7 @@ Frontend JS modules have `@fileoverview` with `@dependency`/`@loadorder` tags. L **Per-device vs synced settings**: the `displayKeys` set in settings-ui.js is a **client-side merge policy**, not a wire filter. A display key seeds from the server only when localStorage has no value for it, which is what prevents one device overwriting another; `showPlanUsageLimits` is additionally `delete`d from the incoming payload outright. Separately, `SettingsUpdateSchema` is `.strict()` and simply **does not declare** `skin`, `showFileViewerButton`, `showCronButton`, `webglRendererEnabled`, `localEchoEnabled`, `cjkInputEnabled`, or `extendedKeyboardBar`, so sending one of those is a validation error. The rest (`showResponseViewer`, `showPlanUsageLimits`, `language`, and most `show*` keys) ARE in the schema and do persist server-side; they are per-device by client policy only. โš ๏ธ Adding a new per-device setting means deciding **both** questions: membership in `displayKeys`, and presence in the schema. -**App Settings modal** (`#appSettingsModal`): a left rail that is a **table of contents over ONE scrolling document**, not a tab switcher. Every section stays mounted (`.set-section`, ids `settings-terminal|appearance|layout|models|clis|notifications|voice|shortcuts|system`), the rail follows the scroll, and `switchSettingsTab(id)` keeps its historical name but SCROLLS instead of hiding. Phones swap the rail for the sticky `#appSettingsJump` pill (compact layout at โ‰ค860px in mobile.css). โš ๏ธ **The load/save contract is `getElementById` by id**: `openAppSettings()`/`saveAppSettings()` read every control by a fixed id, so moving a control between sections is free but renaming or dropping one silently stops it loading or saving. `test/app-settings-structure.test.ts` is the static guard. โš ๏ธ Model cards (`#appSettingsModelCards`) and the effort segment are **views over hidden ``s** that remain the source of truth; the cards hold the BASE model and the "1M context window" switch composes `base + [1m]` back into `claudeModel`, which is what retires the old "takes precedence over the toggle below" trap. โš ๏ธ `.modal-tabs`/`.modal-tab-btn`/`.modal-tab-content` now belong to `#createCaseModal` alone; the settings surface uses its own `set-*` classes and must not restyle them. โš ๏ธ The **Header & Panels live preview** is a scale model rebuilt from the chips (`_syncLayoutPreview`); it owns NO icons, it CLONES `.set-chip-ico` out of the chip, so each icon has exactly one copy in index.html. A chip joins it via `data-preview` (slot) + `data-preview-order`, or `data-preview-text` for readouts that are not buttons. Its frame is painted from skin tokens only (hardcoded black alphas turned it into a grey slab on the light skins) and is `data-i18n-skip`. โš ๏ธ `admin-ui.js` injects the multi-user Users entry into `.set-rail-items` + `.set-doc`, so those hooks must survive any restructure. **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`) 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 + 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) diff --git a/src/web/public/index.html b/src/web/public/index.html index 97069654..33a274db 100644 --- a/src/web/public/index.html +++ b/src/web/public/index.html @@ -983,282 +983,378 @@
+