feat(readmymind): phase 3 PR 1: alternates, rethink steering, phone surfaces

- Alternate suggestions render as tappable rows that swap into the
  editable field, with their rationale as a visible second line (phones
  have no hover for a title tip). The predictor already returned up to 3
  kind-diverse suggestions; the modal showed only the first.
- Rethink now records every displayed suggestion as rejected and carries
  an optional free-text steer note (the API accepted steer since phase 2;
  the UI never collected it). Both reset on each open. The steer field
  stays available in the error phase: steering a failed run's retry is
  exactly when a note helps.
- The modal header names the target session: overview rows and the
  accessory key can open it for a session that is not the active tab.
- Phone surfaces: a keyboard-accessory 🧠 key (hidden unless
  readMyMindEnabled is ON and the active session is claude mode,
  re-derived after innerHTML rebuilds, settings applies, and session
  switches) and a Suggest strip on the phone overview's yellow waiting
  rows (waiting only: on red rows a dialog is on screen and input text
  would land in its menu; answer routing is phase 3 PR 2)
- The modal renders as a compact sheet on phones with 16px inputs (iOS
  zoom guard) and 44px tap targets
- Send/Insert/Rethink freeze during the loading phase so a stale
  suggestion cannot be sent mid-rethink
- test/readmymind-phase3-surfaces.test.ts pins the load-bearing facts in
  CI: key in BOTH bar layouts, [hidden] re-assertion over inline-flex,
  header button off phones, the waiting-only row gate, no terminal
  refocus from the readmymind action, steer outside the result div

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Codeman maintainer
2026-08-10 03:30:07 +02:00
parent c942bb5dfb
commit 1cc7f68c15
13 changed files with 424 additions and 38 deletions
+104
View File
@@ -0,0 +1,104 @@
// Port: none (pure static analysis, runs in CI, no browser/server).
//
// Regression guard for the Read My Mind phase-3 surfaces (alternates, steer,
// phone accessory key, overview waiting-row shortcut). The browser E2E that
// exercised these lives outside CI, so this test pins the load-bearing facts
// statically, in the style of mobile-header-buttons-policy.test.ts:
//
// 1. The accessory-bar 🧠 key exists in BOTH layouts and ships `hidden`
// (setMode() swaps innerHTML; a key present in only one layout silently
// vanishes when the user toggles extendedKeyboardBar).
// 2. `.accessory-btn[hidden]` is re-asserted as display:none !important:
// the base rule is display:inline-flex, which beats the UA [hidden] rule,
// so without this the key can never hide (same trap as .home-sessions).
// 3. The header 🧠 button stays OFF phones (the accessory key + overview strip
// are the phone surfaces).
// 4. The overview shortcut is gated to `waiting` rows only: on red rows a
// dialog is on screen and text sent via POST /input would land in its menu.
// Answer-aware routing through the approvals endpoint is phase-3 PR 2;
// loosening this gate before that lands is a real misdelivery bug.
// 5. The 'readmymind' action must NOT refocus the terminal (the modal takes
// over; a refocus would pop the phone keyboard back up over it).
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 accessoryJs = readFileSync(join(PUBLIC, 'keyboard-accessory.js'), 'utf8');
const overviewJs = readFileSync(join(PUBLIC, 'mobile-overview.js'), 'utf8');
const stylesCss = readFileSync(join(PUBLIC, 'styles.css'), 'utf8');
const mobileCss = readFileSync(join(PUBLIC, 'mobile.css'), 'utf8');
const indexHtml = readFileSync(join(PUBLIC, 'index.html'), 'utf8');
/** Extract a template-literal property body, e.g. `_simpleButtons: \`...\``. */
function templateBody(source: string, prop: string): string {
const m = source.match(new RegExp(`${prop}:\\s*\``));
if (!m || m.index === undefined) return '';
const start = m.index + m[0].length;
const end = source.indexOf('`', start);
return end === -1 ? '' : source.slice(start, end);
}
describe('read my mind phase-3 surfaces (static policy)', () => {
it('accessory 🧠 key exists in BOTH layouts and ships hidden', () => {
for (const layout of ['_simpleButtons', '_extendedButtons']) {
const body = templateBody(accessoryJs, layout);
expect(body, `${layout} template found`).not.toBe('');
const keys = body.match(/data-action="readmymind"/g) || [];
expect(keys.length, `${layout} has exactly one readmymind key`).toBe(1);
const tag = body.match(/<button[^>]*data-action="readmymind"[^>]*>/);
expect(tag, `${layout} readmymind button tag parses`).toBeTruthy();
expect(tag![0], `${layout} key ships hidden (refreshReadMyMind reveals it)`).toContain('hidden');
}
});
it('refreshReadMyMind is re-applied after every innerHTML rebuild', () => {
// init() and setMode() both assign innerHTML, which resurrects the default
// `hidden` attribute; each must re-derive visibility afterwards.
const calls = accessoryJs.match(/this\.refreshReadMyMind\(\)/g) || [];
expect(calls.length, 'called from init() and setMode() at least').toBeGreaterThanOrEqual(2);
// The setMode rebuild specifically must be followed by a refresh.
expect(accessoryJs).toMatch(
/innerHTML = mode === 'extended' \? this\._extendedButtons : this\._simpleButtons;[\s\S]{0,300}?this\.refreshReadMyMind\(\)/
);
});
it('accessory [hidden] re-assertion exists (inline-flex beats the UA rule)', () => {
expect(stylesCss).toMatch(/\.accessory-btn\[hidden\]\s*\{\s*display:\s*none\s*!important;/);
});
it('the readmymind action never refocuses the terminal', () => {
const refocus = accessoryJs.match(/refocusActions\s*=\s*new Set\(\[([^\]]*)\]\)/);
expect(refocus, 'refocusActions set found').toBeTruthy();
expect(refocus![1]).not.toContain('readmymind');
});
it('header 🧠 button stays hidden on phones', () => {
expect(mobileCss).toMatch(/\.btn-icon-header\.btn-readmymind\s*\{\s*display:\s*none\s*!important;/);
});
it('overview shortcut is gated to waiting rows only (red rows have a dialog on screen)', () => {
const defIdx = overviewJs.indexOf('_readMyMindRowShortcut(row) {');
expect(defIdx, '_readMyMindRowShortcut definition found').toBeGreaterThan(-1);
const gate = overviewJs.slice(defIdx, overviewJs.indexOf('_buildMobileOverviewRmmStrip(sessionId) {'));
expect(gate, 'definition precedes its strip builder').not.toBe('');
expect(gate).toContain("row.state !== 'waiting'");
});
it('alternates container and steer input exist with i18n protection', () => {
// Suggestion content is observed/injectable text; the container-level skip
// covers the dynamically inserted rows (i18n skip checks ancestors).
expect(indexHtml).toMatch(/id="readMyMindAlternates"[^>]*data-i18n-skip/);
expect(indexHtml).toContain('id="readMyMindSteer"');
// The steer field lives OUTSIDE the result div so it stays available in
// the error phase (steering a failed run's retry).
const resultDiv = indexHtml.slice(
indexHtml.indexOf('class="readmymind-result"'),
indexHtml.indexOf('class="readmymind-error"')
);
expect(resultDiv).not.toContain('id="readMyMindSteer"');
});
});