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
+113 -19
View File
@@ -5,9 +5,16 @@
* setting is ON) 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 returns up
* to 3 kind-diverse suggestions (continue / verify / redirect) and the rest
* render as tappable alternate rows that swap into the field. Buttons are Send
* (with Enter), Insert (drop on the CLI composer WITHOUT Enter, for editing),
* Rethink (re-run with every displayed suggestion recorded as rejected, plus an
* optional free-text steer note from the field above the buttons), Dismiss.
*
* Phone surfaces reuse this same modal: the keyboard-accessory 🧠 key
* (keyboard-accessory.js) and the phone overview's waiting-row shortcut
* (mobile-overview.js) both call openReadMyMind(sessionId).
*
* Suggestions are NEVER auto-sent: the explicit click here is the security
* boundary for observed/injectable predictor inputs, so suggestion text is
@@ -31,10 +38,14 @@ Object.assign(CodemanApp.prototype, {
return this.loadAppSettingsFromStorage().readMyMindEnabled === true;
},
/** Open the modal for the active session and start a prediction. */
openReadMyMind() {
const sessionId = this.activeSessionId;
const session = sessionId ? this.sessions.get(sessionId) : null;
/**
* Open the modal and start a prediction. Defaults to the active session;
* phone surfaces (accessory key, overview rows) pass an explicit id so a
* prediction can start without switching tabs first.
*/
openReadMyMind(sessionId) {
const id = sessionId || this.activeSessionId;
const session = id ? this.sessions.get(id) : null;
if (!session) {
this.showToast('Select a session first', 'warning');
return;
@@ -43,8 +54,15 @@ Object.assign(CodemanApp.prototype, {
this.showToast('Read My Mind works on Claude sessions only', 'warning');
return;
}
// Rethink memory resets on each open (a fresh open is a fresh question).
this._rmm = { sessionId, shown: null, rejected: [], busy: false };
// Rethink memory resets on each open (a fresh open is a fresh question),
// and so does the steer note (it belongs to the question it steered).
this._rmm = { sessionId: id, shown: null, alternates: [], rejected: [], busy: false };
const steer = document.getElementById('readMyMindSteer');
if (steer) steer.value = '';
// Name the target in the header: overview rows and the accessory key can
// open this for a session that is not the active tab.
const sessionLabel = document.getElementById('readMyMindSession');
if (sessionLabel) sessionLabel.textContent = this.getSessionName?.(session) || session.name || '';
document.getElementById('readMyMindModal')?.classList.add('active');
this._readMyMindPredict();
},
@@ -54,14 +72,25 @@ 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 suggestions. */
async _readMyMindPredict() {
const state = this._rmm;
if (!state || state.busy) return;
state.busy = true;
this._rmmSetPhase('loading');
const body = state.rejected.length > 0 ? { rejected: state.rejected.slice(-10) } : {};
const body = {};
if (state.rejected.length > 0) body.rejected = state.rejected.slice(-10);
// The steer note is the user's own words; server-side it rides in the
// highest-authority context tier. Bounds mirror ReadMyMindPredictSchema.
const steerEl = document.getElementById('readMyMindSteer');
const steer = steerEl
? steerEl.value
.replace(/[\r\n]+/g, ' ')
.trim()
.slice(0, 2000)
: '';
if (steer) body.steer = steer;
const data = await this._apiJson(`/api/sessions/${state.sessionId}/readmymind`, { method: 'POST', body });
// The modal may have been dismissed (or reopened for another session) while
@@ -69,14 +98,21 @@ 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 && data.suggestions) || [];
if (suggestions.length === 0) {
this._rmmSetPhase('error');
return;
}
state.shown = suggestion;
state.shown = suggestions[0];
state.alternates = suggestions.slice(1);
this._rmmSetPhase('ready');
this._rmmShowSuggestion(state.shown);
this._rmmRenderAlternates();
document.getElementById('readMyMindPrompt')?.focus();
},
/** Paint one suggestion into the editable field, kind chip, and rationale. */
_rmmShowSuggestion(suggestion) {
const input = document.getElementById('readMyMindPrompt');
const why = document.getElementById('readMyMindWhy');
const kind = document.getElementById('readMyMindKind');
@@ -88,7 +124,49 @@ Object.assign(CodemanApp.prototype, {
kind.textContent = suggestion.kind || 'continue';
kind.className = `readmymind-kind readmymind-kind-${suggestion.kind || 'continue'}`;
}
input?.focus();
},
/** The non-primary suggestions as tappable rows below the rationale. */
_rmmRenderAlternates() {
const state = this._rmm;
const box = document.getElementById('readMyMindAlternates');
if (!box) return;
box.textContent = '';
if (!state) return;
state.alternates.forEach((alt, i) => {
const row = document.createElement('button');
row.type = 'button';
row.className = 'readmymind-alt';
const kind = document.createElement('span');
kind.className = `readmymind-kind readmymind-kind-${alt.kind || 'continue'}`;
kind.textContent = alt.kind || 'continue';
const col = document.createElement('span');
col.className = 'readmymind-alt-col';
const text = document.createElement('span');
text.className = 'readmymind-alt-text';
text.textContent = alt.prompt;
col.appendChild(text);
// The rationale as a visible second line: phones have no hover, and the
// why is what separates two plausible-looking prompts.
if (alt.why) {
const why = document.createElement('span');
why.className = 'readmymind-alt-why';
why.textContent = alt.why;
col.appendChild(why);
}
row.appendChild(kind);
row.appendChild(col);
row.addEventListener('click', () => {
// Swap: the tapped alternate becomes the shown suggestion and the
// previously shown one takes its row, so browsing loses nothing.
const prev = state.shown;
state.shown = alt;
state.alternates[i] = prev;
this._rmmShowSuggestion(alt);
this._rmmRenderAlternates();
});
box.appendChild(row);
});
},
/**
@@ -114,11 +192,19 @@ 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 every displayed suggestion recorded as a rejection (the user
* saw them all and wanted none: strong negative signal) plus the optional
* steer note, read by _readMyMindPredict from its field.
*/
rethinkReadMyMind() {
const state = this._rmm;
if (!state || state.busy) return;
if (state.shown && state.shown.prompt) state.rejected.push(state.shown.prompt);
for (const s of [state.shown, ...state.alternates]) {
if (!s || !s.prompt) continue;
const prompt = s.prompt.slice(0, 1000);
if (!state.rejected.includes(prompt)) state.rejected.push(prompt);
}
this._readMyMindPredict();
},
@@ -129,7 +215,15 @@ Object.assign(CodemanApp.prototype, {
modal.querySelector('.readmymind-loading').style.display = phase === 'loading' ? '' : 'none';
modal.querySelector('.readmymind-result').style.display = phase === 'ready' ? '' : 'none';
modal.querySelector('.readmymind-error').style.display = phase === 'error' ? '' : 'none';
const rethinkBtn = document.getElementById('readMyMindRethink');
if (rethinkBtn) rethinkBtn.disabled = phase === 'loading';
// The steer field shows in ready AND error phases: steering a failed run's
// retry is exactly when a note helps. Hidden only while loading.
const steer = document.getElementById('readMyMindSteer');
if (steer) steer.style.display = phase === 'loading' ? 'none' : '';
// While the predictor runs the hidden field still holds the previous text;
// freeze every action so a stale suggestion cannot be sent mid-rethink.
for (const btnId of ['readMyMindRethink', 'readMyMindInsert', 'readMyMindSend']) {
const btn = document.getElementById(btnId);
if (btn) btn.disabled = phase === 'loading';
}
},
});