mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-05 15:09:42 +02:00
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:
@@ -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"');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user