From c891a8045da71e4902c08f3b238464d4e9f5b2d2 Mon Sep 17 00:00:00 2001 From: Codeman maintainer Date: Mon, 10 Aug 2026 02:56:35 +0200 Subject: [PATCH 1/2] feat(home): dock the desktop tab list as a left rail with age stamps The open-tabs list on the welcome screen was a fixed 256px card floating vertically centered in the left gutter, which read as debris rather than chrome and left 12px type stranded on a wide display. - Dock it: left/top/bottom 0, full height, hairline right border and a soft background fade. The centered welcome content still does not move. - Scale it off one knob: width clamp(250px, 19vw, 430px) plus a fluid font-size on .home-sessions, every child sized in em. Measured 250px/12.2px at the 1180px gate, 380px/15px at 2000px, 430px/17px at 2938px; the gap to the centered content never goes negative. - Show when each session was first created and last active, on a full-width footer line so it does not fight the status pill, exact dates in the title. Both stamps refresh in place on a 20s clock (disarmed when the home screen goes away) rather than by re-rendering, which would restart every row's blink animation and working ring twice a minute. - Mute idle green: dot and pill mix toward --text-muted, so idle reads as greyed-out next to the vivid green of a working session. Mixed rather than hardcoded, so every skin keeps its own green. Verified in a browser at 1180/2000/2938px and on a light skin, plus test/home-sessions.test.ts, frontend-syntax, public-assets, prettier and a PostCSS parse of styles.css. Co-Authored-By: Claude Opus 5 (1M context) --- CLAUDE.md | 2 +- src/web/public/home-sessions.js | 127 +++++++++++++++++++++++-- src/web/public/index.html | 4 +- src/web/public/styles.css | 158 ++++++++++++++++++++++++-------- 4 files changed, 240 insertions(+), 51 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index e6792be6..24bb3669 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -254,7 +254,7 @@ Frontend JS modules have `@fileoverview` with `@dependency`/`@loadorder` tags. L **Phone overview home screen** (`mobile-overview.js`, phones only, per-device `mobileOverviewEnabled`, default ON): under 430px the "C" logo shows a session overview (NEEDS YOU / CURRENT SESSIONS / PAST SESSIONS) instead of the welcome overlay; tablet and desktop are unchanged. The branch lives in `showWelcome()`/`hideWelcome()` (terminal-ui.js) behind `shouldUseMobileOverview()`, which is **width-driven** (`getDeviceType() === 'mobile'`) because this is a layout decision, unlike the settings namespace which stays handheld-based. ⚠️ The container ships with the `hidden` attribute and only this module removes it: never give `.mobile-overview` a bare `display` rule, since desktop does not load `mobile.css` (`media="(max-width: 1023px)"`) and would then render it unstyled. Live re-renders ride on the tail of `_renderSessionTabsImmediate()` (every state change it needs already funnels there); PAST rows come from one `_fetchUnifiedSessions(60)` per home-screen visit and resume through the shared `resumeHistorySession()`, so they behave exactly like the welcome screen's Resume list. ⚠️ Two things must stay in lockstep with surfaces outside this module, because divergence reads as a bug rather than a style: the split Run button carries the **toolbar's own classes** (`btn-toolbar btn-run mode-` / `btn-run-gear`) so the per-backend gradient and the light-skin overrides apply unchanged (mobile.css must therefore set no `background`/`color` on it), and row status uses the **session-tab language** (green dot when fine, `pulse` while working, yellow blinking row when waiting for input, red blinking row when a question is pending, mirroring `tab-alert-idle`/`tab-alert-action`). The picker mirrors the toolbar run-mode menu (`setRunMode()` + `run()`, `openWebviewFromMenu()` for saved dashboards) and deliberately omits its Recent-Sessions block, since PAST SESSIONS is that. Status pills carry `data-i18n-skip` (generic words like "idle" collide with state strings elsewhere). -**Desktop home tab column** (`home-sessions.js`, desktop only): the welcome overlay centers ~560px of content in a ~1400px window, so its left gutter is dead space; it now carries the open tabs as a vertical list. Rows are in **tab order**, not sorted by urgency like the phone overview, because the row badges are the Alt+1..9 indices. State classification is REUSED from mobile-overview.js (`_mobileOverviewState`/`_mobileOverviewCaseFor`), which is why the module loads after it. ⚠️ The column is `position: absolute` so the centered content never moves, which is exactly why it needs a **width gate in two places** — `HOME_SESSIONS_MIN_WIDTH` (1180) in the JS plus a `max-width: 1179px` media query as the backstop for a resize that outruns the matchMedia listener; drift between them means a column overlapping the search panel, and `test/home-sessions.test.ts` pins them equal. ⚠️ `.home-sessions` is `display: flex`, so `[hidden]` must be re-asserted as `display: none` or the module's only visibility lever does nothing. Working state is deliberately byte-identical to the phone's: pulsing green dot + the `tab-load-spin` ring reused from the tab strip + the same green halo (added to `.mobile-overview-dot--working` at the same time), so "working" reads the same on every surface. Live re-renders ride the tail of `_renderSessionTabsImmediate()` alongside the phone overview. +**Desktop home tab rail** (`home-sessions.js`, desktop only): the welcome overlay centers ~560px of content in a ~1400px window, so its left gutter is dead space; it carries the open tabs as a rail **docked flush to the left edge, full height** (a vertically centered card floating mid-gutter read as debris). Rows are in **tab order**, not sorted by urgency like the phone overview, because the row badges are the Alt+1..9 indices, and each carries **created / last-active** stamps. State classification is REUSED from mobile-overview.js (`_mobileOverviewState`/`_mobileOverviewCaseFor`), which is why the module loads after it. ⚠️ The rail is `position: absolute` so the centered content never moves, which is exactly why it needs a **width gate in two places** — `HOME_SESSIONS_MIN_WIDTH` (1180) in the JS plus a `max-width: 1179px` media query as the backstop for a resize that outruns the matchMedia listener; drift between them means a rail overlapping the search panel, and `test/home-sessions.test.ts` pins them equal. ⚠️ `.home-sessions` is `display: flex`, so `[hidden]` must be re-asserted as `display: none` or the module's only visibility lever does nothing. ⚠️ Size scales with the viewport off **one knob**: `width: clamp(250px, 19vw, 430px)` plus a fluid `font-size` on `.home-sessions`, with every child sized in `em` — reintroducing `rem`/px type inside the block silently breaks the scaling, and widening the clamp past the gutter reintroduces the overlap the gate exists to prevent. The age stamps are refreshed **in place** by a 20s clock (`_tickHomeSessionsTimes()`, disarmed in `hideHomeSessions()`), never by re-rendering, which would restart every row's blink and working ring. Working state is deliberately byte-identical to the phone's: pulsing green dot + the `tab-load-spin` ring reused from the tab strip + the same green halo (added to `.mobile-overview-dot--working` at the same time), so "working" reads the same on every surface; **idle** is deliberately NOT that green — dot and pill mix toward `--text-muted` so a glance separates running from sitting. Live re-renders ride the tail of `_renderSessionTabsImmediate()` alongside the phone overview. **Command palette + shortcut registry**: `Ctrl/Cmd/Alt+K` opens the session palette; shortcuts live in a rebindable registry (`DEFAULT_SHORTCUTS`/`getShortcutRegistry()`/`matchesShortcutEvent()` in app.js, overrides in `settings.shortcutOverrides`). ⚠️ Palette-chord keys must ALSO be swallowed in `attachCustomKeyEventHandler` (terminal-ui.js) or xterm writes the control byte (0x0B) into the PTY. ⚠️ `saveAppSettings()` rebuilds settings from the DOM, so keys edited elsewhere (`shortcutOverrides`, `showTokenCount`, `showCost`) need explicit `_prev` carry-over. ⚠️ **Smart copy (`Ctrl+C`)** lives in that same handler: with a selection it copies, with none it must `return true` **without** `preventDefault()` or the interrupt is lost. `copyTerminalSelection` is deliberately absent from `SHORTCUT_ACTIONS` because the generic capture loop preventDefaults every match it dispatches. → [architecture-invariants#command-palette-and-shortcut-registry](docs/architecture-invariants.md#command-palette-and-shortcut-registry) diff --git a/src/web/public/home-sessions.js b/src/web/public/home-sessions.js index 032596da..38473f3f 100644 --- a/src/web/public/home-sessions.js +++ b/src/web/public/home-sessions.js @@ -1,6 +1,6 @@ /** - * @fileoverview Desktop home screen session list: the open tabs as a vertical - * column down the left of the welcome overlay. + * @fileoverview Desktop home screen session list: the open tabs as a rail docked + * down the left edge of the welcome overlay. * * The welcome screen centers ~560px of content in a window that is usually * 1400px+, so the two gutters are dead space. The left one now carries the same @@ -8,11 +8,19 @@ * one row per live tab, in TAB ORDER (not sorted by state) so it reads as the * tab strip rotated, and so Alt+1..9 still matches what you see. * - * DESKTOP ONLY, and only in a wide enough window: the column is absolutely + * DESKTOP ONLY, and only in a wide enough window: the rail is absolutely * positioned so the centered welcome content never moves, which means it can - * only exist where the gutter is genuinely wider than the column. Below + * only exist where the gutter is genuinely wider than the rail. Below * `HOME_SESSIONS_MIN_WIDTH` nothing renders; on a phone the mobile overview owns - * the home screen entirely and this surface stays out of its way. + * the home screen entirely and this surface stays out of its way. Width and type + * both scale with the viewport (see the `.home-sessions` block in styles.css) — + * a fixed 256px card looks abandoned on a 2560px display. + * + * Each row carries when the session was FIRST CREATED and when it was LAST + * ACTIVE, both relative. Those two stamps go stale on their own (a sitting + * session emits no event), so a slow clock refreshes them IN PLACE from the + * epoch-ms values parked on the elements, rather than re-rendering: a re-render + * would restart every row's blink animation and its working ring. * * The working state is deliberately identical to the phone's: a pulsing green * dot ringed by the spinner a tab shows while it loads (`tab-load-spin`, reused @@ -27,19 +35,23 @@ * @mixin Extends CodemanApp.prototype via Object.assign * @dependency app.js (this.sessions, this.cases, this.pendingHooks, selectSession) * @dependency mobile-overview.js (_mobileOverviewState, _mobileOverviewCaseFor, shouldUseMobileOverview) + * @dependency ralph-panel.js (formatRelativeTime — the app's one relative-time formatter) * @dependency webview-tabs.js (this.webviews, this.webviewOrder, openWebview) * @dependency mobile-handlers.js (MobileDetection) * @loadorder 12.56 of 16, after mobile-overview.js, before entrance-animations.js */ /** - * Narrowest window that gets the column. The welcome content is 560px wide and - * centered, so at 1180px each gutter is 310px — enough for the 256px column plus - * its 20px offset and still a visible gap. Anything narrower would overlap the + * Narrowest window that gets the rail. The welcome content is 560px wide and + * centered, so at 1180px each gutter is 310px — enough for the rail at its + * 250px floor and still a visible gap. Anything narrower would overlap the * search panel, which is why this is a width gate and not a device-type gate. */ const HOME_SESSIONS_MIN_WIDTH = 1180; +/** How often the relative stamps are rewritten while the home screen is up. */ +const HOME_SESSIONS_CLOCK_MS = 20000; + /** Pill copy per state. Same words as the phone overview, same reasons. */ const HOME_SESSIONS_PILL_LABEL = { needs: 'needs you', @@ -87,6 +99,7 @@ Object.assign(CodemanApp.prototype, { this._wireHomeSessions(el); if (!this.shouldShowHomeSessions()) { el.hidden = true; + this._stopHomeSessionsClock(); return; } el.hidden = false; @@ -96,6 +109,7 @@ Object.assign(CodemanApp.prototype, { hideHomeSessions() { const el = document.getElementById('homeSessions'); if (el) el.hidden = true; + this._stopHomeSessionsClock(); }, /** Re-render only when showing (called from the tab renderer's tail). */ @@ -173,6 +187,10 @@ Object.assign(CodemanApp.prototype, { dir: this._shortenHomePath ? this._shortenHomePath(session.workingDir) : session.workingDir || '', state, pill: HOME_SESSIONS_PILL_LABEL[state] || state, + // Epoch ms, straight off the session payload; formatting happens at + // render time so the clock below can redo it without a re-render. + createdAt: Number(session.createdAt) || 0, + lastActivityAt: Number(session.lastActivityAt) || 0, }; }); }, @@ -193,6 +211,7 @@ Object.assign(CodemanApp.prototype, { if (!rows.length && !webviews.length) { el.hidden = true; el.replaceChildren(); + this._stopHomeSessionsClock(); return; } el.hidden = false; @@ -205,6 +224,95 @@ Object.assign(CodemanApp.prototype, { for (const row of rows) list.appendChild(this._buildHomeSessionRow(row)); for (const webview of webviews) list.appendChild(this._buildHomeSessionsWebviewRow(webview)); el.appendChild(list); + + this._startHomeSessionsClock(); + }, + + // ═══════════════════════════════════════════════════════════════ + // Age stamps: created / last active + // ═══════════════════════════════════════════════════════════════ + + /** + * The "created 2h ago · active 3m ago" footer line. Both stamps keep their raw + * epoch-ms on the element (`data-hs-ts`) so `_tickHomeSessionsTimes()` can + * rewrite the text without rebuilding the row. + */ + _buildHomeSessionsMeta(row) { + const meta = document.createElement('span'); + meta.className = 'home-sessions-row-meta'; + // Relative times are generated text, and "created"/"active" here are the + // same generic words that mean something else on other surfaces. + meta.setAttribute('data-i18n-skip', ''); + + meta.appendChild(this._buildHomeSessionsStamp('created', row.createdAt, 'home-sessions-meta-created')); + + const sep = document.createElement('span'); + sep.className = 'home-sessions-meta-sep'; + sep.setAttribute('aria-hidden', 'true'); + sep.textContent = '·'; + meta.appendChild(sep); + + meta.appendChild(this._buildHomeSessionsStamp('active', row.lastActivityAt, 'home-sessions-meta-active')); + + return meta; + }, + + /** One labelled stamp: a dim key, the relative value, full date in the title. */ + _buildHomeSessionsStamp(key, timestamp, className) { + const wrap = document.createElement('span'); + wrap.className = `home-sessions-meta-item ${className}`; + + const label = document.createElement('span'); + label.className = 'home-sessions-meta-key'; + label.textContent = key; + wrap.appendChild(label); + + const value = document.createElement('span'); + value.dataset.hsTs = String(timestamp || 0); + value.textContent = this._homeSessionsAgo(timestamp); + wrap.appendChild(value); + + if (timestamp) + wrap.title = `${key === 'created' ? 'First created' : 'Last active'}: ${new Date(timestamp).toLocaleString()}`; + return wrap; + }, + + /** Relative label for a stamp. `formatRelativeTime` is the app's one formatter. */ + _homeSessionsAgo(timestamp) { + if (!timestamp) return '—'; + return this.formatRelativeTime(timestamp) || '—'; + }, + + /** + * Rewrites the stamps in place every `HOME_SESSIONS_CLOCK_MS`. In place, not a + * re-render: replacing the rows would restart the blink animation on every + * waiting row and the ring on every working one, twice a minute, for nothing. + */ + _startHomeSessionsClock() { + if (this._homeSessionsClock) return; + this._homeSessionsClock = setInterval(() => { + if (!this.isHomeSessionsVisible()) { + this._stopHomeSessionsClock(); + return; + } + this._tickHomeSessionsTimes(); + }, HOME_SESSIONS_CLOCK_MS); + }, + + _stopHomeSessionsClock() { + if (!this._homeSessionsClock) return; + clearInterval(this._homeSessionsClock); + this._homeSessionsClock = null; + }, + + _tickHomeSessionsTimes() { + const el = document.getElementById('homeSessions'); + if (!el) return; + for (const node of el.querySelectorAll('[data-hs-ts]')) { + const ts = Number(node.dataset.hsTs) || 0; + const text = this._homeSessionsAgo(ts); + if (node.textContent !== text) node.textContent = text; + } }, _buildHomeSessionsHeader(count) { @@ -287,6 +395,9 @@ Object.assign(CodemanApp.prototype, { pill.textContent = row.pill; item.appendChild(pill); + // Wraps onto its own line (the row is flex-wrap) so it gets the full width. + item.appendChild(this._buildHomeSessionsMeta(row)); + return item; }, diff --git a/src/web/public/index.html b/src/web/public/index.html index 6ec66057..a7bde739 100644 --- a/src/web/public/index.html +++ b/src/web/public/index.html @@ -323,9 +323,9 @@
-
diff --git a/src/web/public/styles.css b/src/web/public/styles.css index 46f890e9..20bff7c7 100644 --- a/src/web/public/styles.css +++ b/src/web/public/styles.css @@ -14014,15 +14014,24 @@ html[data-skin="daylight-blue"] .welcome-btn-tunnel.active:hover { } /* ══════════════════════════════════════════════════════════════════════════ - Home screen: open tabs in the left gutter (home-sessions.js) + Home screen: open tabs docked down the left edge (home-sessions.js) - The welcome content is 560px wide and centered, so this column lives in dead + The welcome content is 560px wide and centered, so this rail lives in dead space. It is `position: absolute` precisely so that stays true: the centered - content does not move by a pixel whether the column renders or not. That in + content does not move by a pixel whether the rail renders or not. That in turn is why the width gate below has to exist — in a narrow window there is no gutter to sit in, and an absolute box would simply overlap the search panel. JS gates on the same 1180px so the two can never disagree. + It is FLUSH to the left edge and FULL HEIGHT on purpose: a floating, vertically + centered card in the middle of the gutter reads as debris, a docked rail with + an edge to hang off reads as part of the chrome. + + Everything inside scales off ONE knob — the `font-size` below — because every + child sizes in `em`. Widen the window and the whole rail grows with it instead + of leaving 12px type stranded on a 2560px display. Do not reintroduce `rem` + or fixed px sizes for type/spacing here; that is what breaks the scaling. + The working dot is the phone's, exactly: pulsing green ringed by the very same `tab-load-spin` a tab shows while it loads (reused from above, never re-declared), plus a green halo. One signal, one motion, both home screens. @@ -14030,14 +14039,20 @@ html[data-skin="daylight-blue"] .welcome-btn-tunnel.active:hover { .home-sessions { position: absolute; - left: 20px; - top: 50%; - transform: translateY(-50%); + left: 0; + top: 0; + bottom: 0; display: flex; flex-direction: column; - gap: 8px; - width: 256px; - max-height: calc(100% - 3rem); + gap: 0.9em; + /* Ramps with the viewport, floored so it still clears the centered content at + the 1180px gate and capped so it never becomes the main event. */ + width: clamp(250px, 19vw, 430px); + padding: 1.5em 1.1em; + background: linear-gradient(90deg, color-mix(in srgb, var(--bg-card) 65%, transparent) 0%, transparent 100%); + border-right: 1px solid color-mix(in srgb, var(--border) 65%, transparent); + /* The single scale knob: ~12px at the gate, ~17px on a 2560px display. */ + font-size: clamp(12.2px, calc(8px + 0.35vw), 17px); text-align: left; z-index: 1; } @@ -14059,12 +14074,13 @@ html[data-skin="daylight-blue"] .welcome-btn-tunnel.active:hover { .home-sessions-header { display: flex; align-items: center; - gap: 8px; - padding: 0 6px; + gap: 0.66em; + flex-shrink: 0; + padding: 0 0.5em; } .home-sessions-title { - font-size: 0.66rem; + font-size: 0.87em; font-weight: 700; letter-spacing: 0.12em; text-transform: uppercase; @@ -14075,25 +14091,29 @@ html[data-skin="daylight-blue"] .welcome-btn-tunnel.active:hover { display: inline-flex; align-items: center; justify-content: center; - min-width: 18px; - height: 16px; - padding: 0 5px; + min-width: 1.5em; + height: 1.35em; + padding: 0 0.42em; border-radius: 999px; background: var(--bg-input); border: 1px solid var(--border); color: var(--text-dim); - font-size: 0.6rem; + font-size: 0.79em; font-weight: 700; font-family: monospace; } +/* `min-height: 0` is what lets this scroll inside the full-height rail: without + it the flex item's auto minimum keeps growing and the overflow never engages. */ .home-sessions-list { display: flex; flex-direction: column; - gap: 4px; + gap: 0.35em; + flex: 1 1 auto; + min-height: 0; overflow-y: auto; overflow-x: hidden; - padding: 2px 2px 6px; + padding: 0.16em 0.16em 0.5em; } .home-sessions-list::-webkit-scrollbar { @@ -14108,15 +14128,16 @@ html[data-skin="daylight-blue"] .welcome-btn-tunnel.active:hover { .home-sessions-row { display: flex; align-items: center; - gap: 8px; + flex-wrap: wrap; + gap: 0.66em; width: 100%; - padding: 7px 9px; - border-radius: 9px; + padding: 0.6em 0.75em; + border-radius: 0.75em; background: var(--bg-card); border: 1px solid var(--border); color: var(--text-dim); font-family: inherit; - font-size: 0.76rem; + font-size: 1em; text-align: left; cursor: pointer; transition: background var(--transition-smooth), border-color var(--transition-smooth), color var(--transition-smooth); @@ -14136,14 +14157,14 @@ html[data-skin="daylight-blue"] .welcome-btn-tunnel.active:hover { display: inline-flex; align-items: center; justify-content: center; - width: 15px; - height: 15px; + width: 1.3em; + height: 1.3em; flex-shrink: 0; - border-radius: 3px; + border-radius: 0.25em; background: var(--bg-input); border: 1px solid var(--border); color: var(--text-muted); - font-size: 0.58rem; + font-size: 0.76em; font-weight: 700; font-family: monospace; } @@ -14151,8 +14172,8 @@ html[data-skin="daylight-blue"] .welcome-btn-tunnel.active:hover { .home-sessions-dot { position: relative; flex-shrink: 0; - width: 9px; - height: 9px; + width: 0.75em; + height: 0.75em; border-radius: 50%; background: var(--text-muted); } @@ -14166,8 +14187,13 @@ html[data-skin="daylight-blue"] .welcome-btn-tunnel.active:hover { background: var(--yellow); } +/* Idle is green, but a MUTED green: still unmistakably "fine", visibly greyer + and darker than the vivid green a working session gets, so a glance down the + rail separates "running right now" from "sitting there" without reading a + word. Mixed toward --text-muted rather than hardcoded, so every skin keeps + its own green. */ .home-sessions-dot--idle { - background: var(--green); + background: color-mix(in srgb, var(--green) 42%, var(--text-muted)); } .home-sessions-dot--done { @@ -14189,7 +14215,7 @@ html[data-skin="daylight-blue"] .welcome-btn-tunnel.active:hover { .home-sessions-dot--working::after { content: ''; position: absolute; - inset: -4px; + inset: -0.34em; border: 2px solid color-mix(in srgb, var(--green) 25%, transparent); border-top-color: var(--green); border-radius: 50%; @@ -14199,7 +14225,7 @@ html[data-skin="daylight-blue"] .welcome-btn-tunnel.active:hover { .home-sessions-row-body { display: flex; flex-direction: column; - gap: 1px; + gap: 0.12em; min-width: 0; flex: 1; } @@ -14207,7 +14233,7 @@ html[data-skin="daylight-blue"] .welcome-btn-tunnel.active:hover { .home-sessions-row-title { display: flex; align-items: center; - gap: 5px; + gap: 0.42em; min-width: 0; color: var(--text); font-weight: 600; @@ -14220,21 +14246,66 @@ html[data-skin="daylight-blue"] .welcome-btn-tunnel.active:hover { } .home-sessions-row-sub { - font-size: 0.66rem; + font-size: 0.87em; color: var(--text-muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +/* Age line: when the session was first created and when it last did anything. + Both are relative and refreshed in place on a timer (home-sessions.js), never + by a re-render — a re-render would restart the row's blink animation. */ +.home-sessions-row-meta { + display: flex; + align-items: center; + gap: 0.4em; + /* Wraps to its own full-width line under the row (the row is `flex-wrap`), + so the stamps get the whole width instead of the sliver left beside the + status pill and ellipsize away on the narrow end of the rail. */ + flex: 0 0 100%; + min-width: 0; + margin-top: -0.2em; + font-size: 0.78em; + font-family: monospace; + color: var(--text-muted); + opacity: 0.75; + white-space: nowrap; + overflow: hidden; +} + +.home-sessions-meta-item { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; +} + +.home-sessions-meta-key { + margin-right: 0.35em; + opacity: 0.7; + text-transform: uppercase; + letter-spacing: 0.06em; +} + +.home-sessions-meta-sep { + opacity: 0.45; +} + +/* The freshest signal on the row: while a session is actually doing something, + its "active" stamp is the one the eye should land on. */ +.home-sessions-row--working .home-sessions-meta-active { + color: var(--green); + opacity: 0.95; +} + .home-sessions-mode { flex-shrink: 0; - padding: 0 4px; - border-radius: 3px; + padding: 0 0.35em; + border-radius: 0.25em; background: var(--bg-input); border: 1px solid var(--border); color: var(--text-muted); - font-size: 0.55rem; + font-size: 0.72em; font-weight: 700; font-family: monospace; text-transform: uppercase; @@ -14242,12 +14313,12 @@ html[data-skin="daylight-blue"] .welcome-btn-tunnel.active:hover { .home-sessions-pill { flex-shrink: 0; - padding: 2px 6px; + padding: 0.2em 0.55em; border-radius: 999px; background: var(--bg-input); border: 1px solid var(--border); color: var(--text-muted); - font-size: 0.58rem; + font-size: 0.76em; font-weight: 700; letter-spacing: 0.02em; white-space: nowrap; @@ -14266,13 +14337,20 @@ html[data-skin="daylight-blue"] .welcome-btn-tunnel.active:hover { color: var(--yellow); } -.home-sessions-pill--working, -.home-sessions-pill--idle { +.home-sessions-pill--working { background: color-mix(in srgb, var(--green) 15%, transparent); border-color: color-mix(in srgb, var(--green) 40%, transparent); color: var(--green); } +/* Same muting as the idle dot, one step further: the pill is a block of color, + so it reads louder than a 9px dot at the same mix. */ +.home-sessions-pill--idle { + background: color-mix(in srgb, var(--green) 7%, transparent); + border-color: color-mix(in srgb, var(--green) 18%, var(--border)); + color: color-mix(in srgb, var(--green) 45%, var(--text-muted)); +} + /* Row accents: same language as the session tabs and the phone overview — red means a question is pending, yellow means it wants input, green means work is happening. Nothing else on this screen may reuse these colors. */ From a070fc43ea79a4ecdcdde16a924b8cd14d0501b3 Mon Sep 17 00:00:00 2001 From: Codeman maintainer Date: Mon, 10 Aug 2026 03:57:06 +0200 Subject: [PATCH 2/2] feat(readmymind): alternates row, phone accessory key, phone-sized modal (phase 3 part 1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Read My Mind modal grows up and reaches phones: - Alternate suggestions (the predictor's verify/redirect kinds) now render as tappable rows below the main field. Tapping one swaps it into the editable field; the edit you were making folds back into the row you leave, so toggling between alternates never loses typing. Rethink now records the WHOLE shown set (main + alternates) as rejected. - Phones get a 🧠 key on the keyboard accessory bar (both simple and extended layouts), gated on the same synced readMyMindEnabled setting via an rmm-enabled marker class on the BAR element: setMode() rebuilds the buttons' innerHTML, so per-key state would be wiped. Synced at init and re-synced by applyHeaderVisibilitySettings() on every settings apply, so a live toggle needs no reload. The header button stays off phones. - On phones the modal renders as a small dialog (mirrors modal-sm) instead of the full-screen default, with wrap-friendly finger-sized footer buttons. Not modal-sm itself: that caps desktop width at 340px and this modal wants 560px there. - On touch devices the ready/swap paths no longer focus the field, so the OS keyboard does not pop over the alternates that just rendered. - New static guard test/readmymind-phone-key.test.ts pins the dual-template key, the marker-class gating, the phone-hidden header button, the small-dialog phone modal, and the no-innerHTML discipline. Part 2 of phase 3 (rethink steering, the free-text steer note) is next; the API already accepts steer. Co-Authored-By: Claude Fable 5 --- CLAUDE.md | 2 +- docs/readmymind-plan.md | 2 +- docs/readmymind.md | 10 +-- src/web/public/i18n.js | 1 + src/web/public/index.html | 1 + src/web/public/keyboard-accessory.js | 21 ++++++ src/web/public/mobile.css | 32 +++++++++- src/web/public/readmymind-ui.js | 96 +++++++++++++++++++++++----- src/web/public/settings-ui.js | 7 +- src/web/public/styles.css | 50 +++++++++++++++ test/readmymind-phone-key.test.ts | 76 ++++++++++++++++++++++ 11 files changed, 272 insertions(+), 26 deletions(-) create mode 100644 test/readmymind-phone-key.test.ts diff --git a/CLAUDE.md b/CLAUDE.md index 24bb3669..3d59a613 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, desktop-only (mobile.css hides it; phone key is phase 3); 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. 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 d263bf07..101bffd2 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.** +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`). 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 fee5d477..fc9aef8e 100644 --- a/docs/readmymind.md +++ b/docs/readmymind.md @@ -23,11 +23,11 @@ Add `-u user:password` if your install has `CODEMAN_PASSWORD` set, and drop `-k` ## The 🧠 button -On a Claude session, press the brain button in the header (desktop; the phone surface is a planned keyboard-accessory key). Codeman assembles everything it already knows: your goals, your recent prompts (with your voice: length, tone, shorthand), the tail of the last assistant reply, recent tool activity, git state (branch, dirty files, pending changesets), how long you have been away and what happened meanwhile, sibling sessions in the same case, and any dialog the session is currently waiting on. A one-shot model call (opus by default, `readMyMindModel` to override) turns that into 1-3 suggestions; the top one lands in an editable field with its rationale. +On a Claude session, press the brain button in the header (desktop) or the 🧠 key on the keyboard accessory bar (phones and tablets; it appears when the setting is on). Codeman assembles everything it already knows: your goals, your recent prompts (with your voice: length, tone, shorthand), the tail of the last assistant reply, recent tool activity, git state (branch, dirty files, pending changesets), how long you have been away and what happened meanwhile, sibling sessions in the same case, and any dialog the session is currently waiting on. A one-shot model call (opus by default, `readMyMindModel` to override) turns that into 1-3 suggestions; the top one lands in an editable field with its rationale, and the others render as tappable alternate rows: tap one to swap it into the field (edits you already made are kept on the row you leave). - **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 the shown suggestion recorded as rejected. +- **Rethink** re-runs with everything shown (the field and the alternates) recorded as rejected. - **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. @@ -85,15 +85,15 @@ A case with nothing recorded answers an empty profile with `updatedAt: 0`; reads The `codeman` agent skill documents the same verbs (SKILL.md §3 plus `reference/endpoints.md`), with the ground rules: read the profile to understand what the user wants, record goals the user actually stated, merge instead of blind-writing (PUT replaces), never delete a profile unprompted, and never send a predicted suggestion into a session unless the user asked. It is the user's memory, not the agent's. -## What comes next (phase 3+) +## What comes next -Phone keyboard-accessory 🧠 key, a steer-note input on Rethink, and tappable alternate suggestions. 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). +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). ## Troubleshooting | Symptom | Cause / fix | | ------- | ----------- | -| No 🧠 button in the header | `readMyMindEnabled` is OFF (App Settings → Panels), you are on a phone (desktop-only in this phase), or the active session is not claude-mode | +| No 🧠 button in the header | `readMyMindEnabled` is OFF (App Settings → Panels), you are on a phone (there it is a key on the keyboard accessory bar instead, visible while typing), or the active session is not claude-mode | | Prediction feels generic | The profile is thin: record goals (PUT or ask your agent to), and let capture accumulate a few real prompts first | | "A prediction is already running" (409) | One per session at a time; wait for the current one (up to 90 s) | | Prediction fails (502) | The model returned no usable JSON, or the CLI could not start; retry. Check `readMyMindModel` if you overrode it | diff --git a/src/web/public/i18n.js b/src/web/public/i18n.js index a73c4c16..a9fd9849 100644 --- a/src/web/public/i18n.js +++ b/src/web/public/i18n.js @@ -260,6 +260,7 @@ Insert: '插入', "Put the text on the session's composer without submitting it": '将文本放入会话输入框但不提交', 'Predicted prompt, editable': '预测的提示,可编辑', + 'Use this suggestion instead': '改用此建议', '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 a7bde739..8afb9531 100644 --- a/src/web/public/index.html +++ b/src/web/public/index.html @@ -2813,6 +2813,7 @@
+
diff --git a/src/web/public/keyboard-accessory.js b/src/web/public/keyboard-accessory.js index a254b96c..1c0bbc01 100644 --- a/src/web/public/keyboard-accessory.js +++ b/src/web/public/keyboard-accessory.js @@ -441,6 +441,7 @@ const KeyboardAccessoryBar = { + + @@ -502,6 +504,9 @@ const KeyboardAccessoryBar = { this.element = document.createElement('div'); this.element.className = 'keyboard-accessory-bar'; this.element.innerHTML = this._simpleButtons; + // The 🧠 key is opt-in (`readMyMindEnabled`, synced): it ships in both + // templates but stays display:none until the bar carries the marker class. + this.syncReadMyMind(); // Add click handlers — preventDefault stops event from reaching terminal this.element.addEventListener('click', (e) => { @@ -607,6 +612,11 @@ const KeyboardAccessoryBar = { } break; } + case 'readmymind': + // Opens the shared Read My Mind modal (readmymind-ui.js); the modal + // takes focus, so deliberately NOT in the terminal-refocus set. + app.openReadMyMind?.(); + break; case 'paste': this.pasteFromClipboard(); break; @@ -652,6 +662,17 @@ const KeyboardAccessoryBar = { this._confirmAction = null; }, + /** Reveal/hide the 🧠 key from the synced `readMyMindEnabled` setting. + * The marker class lives on the BAR because setMode() rebuilds the buttons' + * innerHTML on every layout switch (per-key state would be wiped). Called at + * init and re-synced by applyHeaderVisibilitySettings() on every settings + * apply, so a live toggle needs no reload. */ + syncReadMyMind() { + if (!this.element) return; + const enabled = typeof app !== 'undefined' && typeof app.readMyMindEnabled === 'function' && app.readMyMindEnabled(); + this.element.classList.toggle('rmm-enabled', enabled === true); + }, + /** Send a slash command to the active session. * Sends text and Enter separately so Ink processes them as distinct events. */ sendCommand(command) { diff --git a/src/web/public/mobile.css b/src/web/public/mobile.css index c3cf7087..0d2766c3 100644 --- a/src/web/public/mobile.css +++ b/src/web/public/mobile.css @@ -530,8 +530,9 @@ html.mobile-init .file-browser-panel { display: none !important; } - /* Read My Mind 🧠 button: desktop header only in phase 2; the phone surface - is a planned keyboard-accessory key (docs/readmymind-plan.md phase 3). */ + /* Read My Mind 🧠 header button: never in the phone header; the phone + surface is the keyboard-accessory 🧠 key (same `readMyMindEnabled` gate, + see keyboard-accessory.js + the rmm-enabled rules in styles.css). */ .btn-icon-header.btn-readmymind { display: none !important; } @@ -1299,6 +1300,33 @@ html.mobile-init .file-browser-panel { width: calc(100% - 2rem); } + /* Read My Mind: a small dialog (mirrors modal-sm), not a full-screen + takeover — it opens over the keyboard from the accessory 🧠 key and + should read as a quick suggestion sheet. Not modal-sm itself because + that caps desktop width at 340px; this modal wants 560px there. */ + .modal-content.readmymind-modal { + height: auto; + max-height: 85vh; + border-radius: 12px; + margin: 1rem; + 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. */ + .readmymind-modal .modal-footer { + display: flex; + flex-wrap: wrap; + justify-content: flex-end; + gap: 0.5rem; + } + .readmymind-modal .modal-footer .btn { + flex: 1 1 auto; + min-height: 38px; + } + .readmymind-alt { + min-height: 38px; + } + /* Modal safe area padding - all sides for full-screen modals */ .ios-device .modal-content { padding-top: var(--safe-area-top); diff --git a/src/web/public/readmymind-ui.js b/src/web/public/readmymind-ui.js index 0e8c4c51..bde315a3 100644 --- a/src/web/public/readmymind-ui.js +++ b/src/web/public/readmymind-ui.js @@ -2,12 +2,15 @@ * @fileoverview Read My Mind UI: predict the prompt you were about to type. * * A 🧠 header button (marker-hidden until the synced opt-in `readMyMindEnabled` - * setting is ON) opens a modal that asks the server for the user's most likely + * setting is ON; phones get a keyboard-accessory 🧠 key gated on the same + * setting) opens a modal that asks the server for the user's most likely * next prompt (`POST /api/sessions/:id/readmymind`, one-shot predictor over the * case's intent profile + live session signals). The top suggestion lands in an - * editable single-line field with its rationale below; buttons are Send (with - * Enter), Insert (drop on the CLI composer WITHOUT Enter, for editing), Rethink - * (re-run with the shown suggestion recorded as rejected), Dismiss. + * editable single-line field with its rationale below; the predictor's other + * suggestions render as tappable alternate rows that swap into the field + * without losing edits. Buttons are Send (with Enter), Insert (drop on the CLI + * composer WITHOUT Enter, for editing), Rethink (re-run with the whole shown + * set, main + alternates, recorded as rejected), Dismiss. * * Suggestions are NEVER auto-sent: the explicit click here is the security * boundary for observed/injectable predictor inputs, so suggestion text is @@ -20,6 +23,7 @@ * * @mixin Extends CodemanApp.prototype via Object.assign * @dependency app.js (CodemanApp class, this.sessions, this.activeSessionId, showToast) + * @dependency mobile-handlers.js (MobileDetection.isTouchDevice, focus policy) * @dependency settings-ui.js (loadAppSettingsFromStorage) * @dependency api-client.js at runtime (this._apiJson; loads later but is only called after init) * @loadorder 11.3, after panels-ui.js, before ultracode-panel.js @@ -44,7 +48,7 @@ Object.assign(CodemanApp.prototype, { return; } // Rethink memory resets on each open (a fresh open is a fresh question). - this._rmm = { sessionId, shown: null, rejected: [], busy: false }; + this._rmm = { sessionId, suggestions: [], selected: 0, rejected: [], busy: false }; document.getElementById('readMyMindModal')?.classList.add('active'); this._readMyMindPredict(); }, @@ -54,7 +58,7 @@ Object.assign(CodemanApp.prototype, { this._rmm = null; }, - /** Run (or re-run) the prediction and render the top suggestion. */ + /** Run (or re-run) the prediction and render the suggestion set. */ async _readMyMindPredict() { const state = this._rmm; if (!state || state.busy) return; @@ -69,26 +73,83 @@ Object.assign(CodemanApp.prototype, { if (this._rmm !== state) return; state.busy = false; - const suggestion = data && data.suggestions && data.suggestions[0]; - if (!suggestion) { + const suggestions = (data && Array.isArray(data.suggestions) ? data.suggestions : []).filter( + (s) => s && typeof s.prompt === 'string' && s.prompt.trim() + ); + if (suggestions.length === 0) { this._rmmSetPhase('error'); return; } - state.shown = suggestion; + state.suggestions = suggestions.slice(0, 3); + state.selected = 0; this._rmmSetPhase('ready'); + this._rmmRender(); + this._rmmFocusPrompt(); + }, + + /** Paint the selected suggestion into the editable field, the rest as alternates. */ + _rmmRender() { + const state = this._rmm; + const current = state && state.suggestions[state.selected]; + if (!current) return; const input = document.getElementById('readMyMindPrompt'); const why = document.getElementById('readMyMindWhy'); const kind = document.getElementById('readMyMindKind'); // Predictor output is derived from observable (injectable) content: // value/textContent only, never innerHTML. - if (input) input.value = suggestion.prompt; - if (why) why.textContent = suggestion.why || ''; + if (input) input.value = current.prompt; + if (why) why.textContent = current.why || ''; if (kind) { - kind.textContent = suggestion.kind || 'continue'; - kind.className = `readmymind-kind readmymind-kind-${suggestion.kind || 'continue'}`; + kind.textContent = current.kind || 'continue'; + kind.className = `readmymind-kind readmymind-kind-${current.kind || 'continue'}`; } - input?.focus(); + + const alternates = document.getElementById('readMyMindAlternates'); + if (!alternates) return; + alternates.replaceChildren(); + // The container is data-i18n-skip (suggestion text must never be mistaken + // for app copy), so the one piece of app copy inside it is pre-translated. + const translate = window.codemanT || ((s) => s); + state.suggestions.forEach((suggestion, index) => { + if (index === state.selected) return; + const row = document.createElement('button'); + row.type = 'button'; + row.className = 'readmymind-alt'; + row.title = suggestion.why || ''; + row.setAttribute('aria-label', translate('Use this suggestion instead')); + const badge = document.createElement('span'); + badge.className = `readmymind-kind readmymind-kind-${suggestion.kind || 'continue'}`; + badge.textContent = suggestion.kind || 'continue'; + const text = document.createElement('span'); + text.className = 'readmymind-alt-text'; + text.textContent = suggestion.prompt; + row.append(badge, text); + row.addEventListener('click', () => this._rmmSelect(index)); + alternates.appendChild(row); + }); + alternates.style.display = alternates.childElementCount > 0 ? '' : 'none'; + }, + + /** Swap an alternate into the field, folding the current edit back first. */ + _rmmSelect(index) { + const state = this._rmm; + if (!state || state.busy || !state.suggestions[index]) return; + const input = document.getElementById('readMyMindPrompt'); + const current = state.suggestions[state.selected]; + // Keep edits: fold the field text back into the suggestion it belongs to, + // so toggling between alternates never loses typing. + if (input && current) current.prompt = input.value; + state.selected = index; + this._rmmRender(); + this._rmmFocusPrompt(); + }, + + /** Focus the editable field on desktop. On touch devices leave it blurred so + * the OS keyboard doesn't pop over the alternates that just rendered. */ + _rmmFocusPrompt() { + if (typeof MobileDetection !== 'undefined' && MobileDetection.isTouchDevice()) return; + document.getElementById('readMyMindPrompt')?.focus(); }, /** @@ -114,11 +175,14 @@ Object.assign(CodemanApp.prototype, { this.showToast(withEnter ? 'Prompt sent' : 'Inserted, press Enter in the terminal to send', 'success'); }, - /** Re-run with the shown suggestion recorded as a rejection. */ + /** Re-run with the whole shown set (main + alternates) recorded as rejected: + * the user saw every row and asked for something else. */ rethinkReadMyMind() { const state = this._rmm; if (!state || state.busy) return; - if (state.shown && state.shown.prompt) state.rejected.push(state.shown.prompt); + for (const suggestion of state.suggestions) { + if (suggestion.prompt && suggestion.prompt.trim()) state.rejected.push(suggestion.prompt); + } this._readMyMindPredict(); }, diff --git a/src/web/public/settings-ui.js b/src/web/public/settings-ui.js index 07527870..c7d097d5 100644 --- a/src/web/public/settings-ui.js +++ b/src/web/public/settings-ui.js @@ -2112,11 +2112,16 @@ Object.assign(CodemanApp.prototype, { // Read My Mind 🧠 — hidden unless the synced opt-in `readMyMindEnabled` is // ON (only an explicit true enables, mirroring the Approvals bell). Marker // class (base is display:inline-flex !important); phones hide it in - // mobile.css regardless (the phase-3 surface there is an accessory key). + // mobile.css regardless (their surface is the keyboard-accessory 🧠 key, + // re-synced right below). const readMyMindBtn = document.querySelector('.btn-readmymind'); if (readMyMindBtn) { readMyMindBtn.classList.toggle('btn-readmymind--hidden', settings.readMyMindEnabled !== true); } + // The accessory-bar 🧠 key shares the setting; its marker class lives on + // the bar element (keyboard-accessory.js), so a live toggle from a + // settings save reveals/hides it without a reload. + if (typeof KeyboardAccessoryBar !== 'undefined') KeyboardAccessoryBar.syncReadMyMind?.(); // Plan-usage chip — shown by default on desktop, OFF on handhelds (App // Settings → Display → "Plan Usage Limits"). The template always ships it diff --git a/src/web/public/styles.css b/src/web/public/styles.css index 20bff7c7..ff32510f 100644 --- a/src/web/public/styles.css +++ b/src/web/public/styles.css @@ -10757,6 +10757,56 @@ kbd { padding: 12px 4px; color: var(--text-dim); } +/* Alternate suggestions (the predictor's verify/redirect kinds): tappable rows + that swap into the editable field (readmymind-ui.js _rmmSelect). */ +.readmymind-alternates { + margin-top: 10px; + display: flex; + flex-direction: column; + gap: 6px; +} +.readmymind-alt { + display: flex; + align-items: center; + gap: 8px; + width: 100%; + min-width: 0; + text-align: left; + padding: 7px 10px; + background: transparent; + border: 1px solid var(--control-border); + border-radius: 8px; + color: var(--text-dim); + cursor: pointer; + font-size: 12px; +} +.readmymind-alt:hover, +.readmymind-alt:focus-visible { + border-color: var(--accent); + color: var(--text); +} +.readmymind-alt-text { + flex: 1 1 auto; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-family: var(--mono-font, monospace); +} + +/* 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 + BOTH bar templates (keyboard-accessory.js) but stays hidden until the bar + carries the marker class: setMode() rebuilds the bar's innerHTML on every + layout switch, so the gate must live on the bar element, not on the key. + Lives here rather than mobile.css because large touch tablets (>1023px) + never load mobile.css but do render the accessory bar. */ +.keyboard-accessory-bar .accessory-btn-rmm { + display: none; +} +.keyboard-accessory-bar.rmm-enabled .accessory-btn-rmm { + display: inline-flex; +} .approvals-badge { position: absolute; diff --git a/test/readmymind-phone-key.test.ts b/test/readmymind-phone-key.test.ts new file mode 100644 index 00000000..a4b38b99 --- /dev/null +++ b/test/readmymind-phone-key.test.ts @@ -0,0 +1,76 @@ +// Port: none (pure static analysis — runs in CI, no browser/server). +// +// Read My Mind phase 3 part 1 guards: the modal's alternates row and the phone +// keyboard-accessory 🧠 key. The mobile Playwright suite is excluded from CI, +// so like test/mobile-header-buttons-policy.test.ts this parses the frontend +// assets directly to pin the wiring that only a phone would exercise: +// +// 1. the 🧠 key ships in BOTH accessory-bar templates (setMode() swaps the +// bar's innerHTML between them, so a key present in only one layout would +// silently vanish when the user toggles `extendedKeyboardBar`) and routes +// to the shared modal; +// 2. the key is hidden unless the bar carries the `rmm-enabled` marker class +// — gating must live on the BAR element because setMode() rebuilds the +// buttons — synced at init and re-synced by settings-ui.js on every +// settings apply (a live toggle needs no reload); +// 3. the header 🧠 button STAYS off phones (the key is the phone surface); +// 4. the modal renders on phones as a small dialog, not the full-screen +// default that phone `.modal-content` rules would impose; +// 5. readmymind-ui.js keeps the no-innerHTML discipline (predictor output is +// injectable content) and renders alternates into the i18n-skipped +// container declared in index.html. +import { describe, it, expect } from 'vitest'; +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { join } from 'node:path'; + +const HERE = fileURLToPath(new URL('.', import.meta.url)); +const PUBLIC = join(HERE, '../src/web/public'); +const read = (name: string) => readFileSync(join(PUBLIC, name), 'utf-8'); + +describe('read my mind phone key + alternates (static guards)', () => { + const accessory = read('keyboard-accessory.js'); + const styles = read('styles.css'); + const mobile = read('mobile.css'); + const html = read('index.html'); + const ui = read('readmymind-ui.js'); + const settingsUi = read('settings-ui.js'); + // Everything phone-specific lives in the max-width 430px block of mobile.css. + const phoneBlock = mobile.slice(mobile.indexOf('@media (max-width: 430px)')); + + it('ships the 🧠 key in BOTH accessory bar templates and routes it to the modal', () => { + const simple = accessory.match(/_simpleButtons\s*:\s*`([\s\S]*?)`/)?.[1] ?? ''; + const extended = accessory.match(/_extendedButtons\s*:\s*`([\s\S]*?)`/)?.[1] ?? ''; + expect(simple).toMatch(/accessory-btn-rmm[^>]*data-action="readmymind"/); + expect(extended).toMatch(/accessory-btn-rmm[^>]*data-action="readmymind"/); + expect(accessory).toMatch(/case 'readmymind':/); + expect(accessory).toMatch(/openReadMyMind/); + }); + + it('hides the key until the bar carries rmm-enabled, synced at init and on settings apply', () => { + expect(styles).toMatch(/\.keyboard-accessory-bar \.accessory-btn-rmm \{\s*display: none;/); + expect(styles).toMatch(/\.keyboard-accessory-bar\.rmm-enabled \.accessory-btn-rmm \{\s*display: inline-flex;/); + // init() applies the gate as soon as the bar exists… + expect(accessory).toMatch(/this\.syncReadMyMind\(\);/); + // …and settings-ui re-syncs it on every settings apply (live toggle). + expect(settingsUi).toMatch(/KeyboardAccessoryBar\.syncReadMyMind/); + }); + + it('keeps the header 🧠 button off phones (the accessory key is the phone surface)', () => { + expect(phoneBlock).toMatch(/\.btn-icon-header\.btn-readmymind\s*\{\s*display: none !important;/); + }); + + it('renders the modal as a small dialog on phones, not the full-screen default', () => { + expect(phoneBlock).toMatch(/\.modal-content\.readmymind-modal\s*\{[^}]*height: auto;/); + }); + + it('declares the i18n-skipped alternates container and keeps the no-innerHTML discipline', () => { + expect(html).toMatch(/id="readMyMindAlternates"[^>]*data-i18n-skip/); + // Predictor output is injectable content: value/textContent only, ever. + // (`.innerHTML`: property ACCESS — the fileoverview's "never innerHTML" + // prose is allowed to say the word.) + expect(ui).not.toMatch(/\.innerHTML/); + expect(ui).toContain('readMyMindAlternates'); + expect(ui).toMatch(/\.textContent = suggestion\.prompt/); + }); +});