diff --git a/CLAUDE.md b/CLAUDE.md index e203d413..00c4e77c 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/`. @@ -256,7 +256,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. **Welcome "Resume Conversation" list** (terminal-ui.js): `loadHistorySessions()` fetches once and caches the corpus on `_historyAll`/`_historyCases`; every subsequent view (filter box, sort select, expand, the periodic refresh in panels-ui.js) goes through `_renderHistoryList()`, so never append rows to `#historyList` directly or re-fetch to re-sort. โš ๏ธ The box height is **class-driven**: expanding the list without `.history-list.expanded` leaves the collapsed `max-height` in place and just deepens a scroll well, which is the bug #260 reported (35 sessions in a ~4-row box). โš ๏ธ The Aโ€“Z sort keys off `_historyRowLabel()`, the SAME string the row renders (`name || firstPrompt || path`), most rows are transcript-backed and have no session name, so sorting on `name` alone silently does nothing. โš ๏ธ A filter implies expansion, and `_renderSearch()` hides `#historyHeader` (title + controls) as one unit while a search is active. Tests: `test/history-list-controls.test.ts`. 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/home-sessions.js b/src/web/public/home-sessions.js index fdeddbd2..740331d3 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/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 b29bb91d..6bc54df3 100644 --- a/src/web/public/index.html +++ b/src/web/public/index.html @@ -323,9 +323,9 @@
-
@@ -2892,6 +2892,7 @@
+
diff --git a/src/web/public/keyboard-accessory.js b/src/web/public/keyboard-accessory.js index 676598a9..0b814669 100644 --- a/src/web/public/keyboard-accessory.js +++ b/src/web/public/keyboard-accessory.js @@ -494,6 +494,7 @@ const KeyboardAccessoryBar = { + + @@ -594,6 +596,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) => { @@ -784,6 +789,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; @@ -829,6 +839,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 9d261a70..222b02ac 100644 --- a/src/web/public/mobile.css +++ b/src/web/public/mobile.css @@ -534,8 +534,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; } @@ -1323,6 +1324,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 f7856959..2c5f1357 100644 --- a/src/web/public/settings-ui.js +++ b/src/web/public/settings-ui.js @@ -2427,11 +2427,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 b7fad42e..47c199ad 100644 --- a/src/web/public/styles.css +++ b/src/web/public/styles.css @@ -10865,6 +10865,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; @@ -14136,15 +14186,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. @@ -14152,14 +14211,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; } @@ -14181,12 +14246,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; @@ -14197,25 +14263,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 { @@ -14230,15 +14300,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); @@ -14258,14 +14329,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; } @@ -14273,8 +14344,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); } @@ -14288,8 +14359,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 { @@ -14311,7 +14387,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%; @@ -14321,7 +14397,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; } @@ -14329,7 +14405,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; @@ -14342,21 +14418,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; @@ -14364,12 +14485,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; @@ -14388,13 +14509,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. */ 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/); + }); +});