Compare commits

...
Author SHA1 Message Date
Codeman maintainer 29efd0e970 fix(readmymind): style the modal footer, point the empty-result copy at the steer note
The footer buttons shipped with class="btn btn-secondary/primary", but no
.btn or .btn-secondary rule exists in this codebase, so all four rendered
as unstyled UA buttons. Moved them to the btn-toolbar convention every
other modal footer uses, with a scoped flex-row footer rule (btn-toolbar
is display:flex, block-level) mirroring the runSummaryModal footer.

Send's accent needs a (0,4,0) re-assert: the skin block's bare
.btn-toolbar rule is (0,2,1) under html:not([data-skin="og"]) and beats
.btn-toolbar.btn-primary (0,2,0), the same specificity trap CLAUDE.md
documents for mobile.css. Scoped to this modal; the repo-wide greying of
btn-primary on non-OG skins is pre-existing and left as a design call.

The empty-result copy now points at the steer note sitting right below
it ("Add a steer note and Rethink to try again"), zh-CN updated.

Verified with the steer E2E (still green) plus desktop, phone (390px),
and error-phase screenshots; static guards extended to pin the footer
convention and the accent re-assert.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-10 11:15:47 +02:00
Codeman maintainer 831af88579 feat(readmymind): rethink steer note (phase 3 part 2)
Adds the optional free-text steer note to the Read My Mind modal: a
dashed input under the suggestions ("no, I meant the mobile bug") that
rides along as `steer` on every Rethink. The API already accepted it;
this wires the frontend end of the contract.

- Shown whenever Rethink is live (ready AND empty-result phases),
  hidden only while a prediction runs; typed text survives re-runs.
- Enter in the field triggers Rethink, mirroring the prompt field's
  Enter-to-send; a fresh open clears it with the rethink memory.
- Trimmed and capped to the schema's 2000 chars on the way out; a
  plain open still sends an empty body (neither steer nor rejected).
- zh-CN strings for the placeholder and aria-label, phone-sized
  touch target in mobile.css, static guards in the phase-3 test.

Verified with a browser E2E against a live dev server (stubbed predict
endpoint): payload contents, phase visibility, Enter wiring, and
reset-on-reopen all asserted with real keystrokes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-10 10:41:39 +02:00
9 changed files with 134 additions and 19 deletions
+1 -1
View File
@@ -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.<method>('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`.
**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.<method>('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 and carries the optional steer note (`#readMyMindSteer`, sent as `steer`, shown in ready + empty-result phases, cleared on each open). 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/`.
+1 -1
View File
@@ -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.** 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`).
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 (shipped): rethink steering, the free-text steer note under the suggestions, sent as `steer`, visible whenever Rethink is live (ready and empty-result phases), cleared on each open; the empty-result copy points at the note, and the footer buttons moved to the styled `btn-toolbar` convention (the bare `btn btn-*` classes they shipped with match no CSS in this codebase and rendered as unstyled UA buttons).
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
+2 -2
View File
@@ -27,7 +27,7 @@ On a Claude session, press the brain button in the header (desktop) or the 🧠
- **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 everything shown (the field and the alternates) recorded as rejected.
- **Rethink** re-runs with everything shown (the field and the alternates) recorded as rejected. An optional steer note below the suggestions ("no, I meant the mobile bug") rides along as your own words, the highest-authority signal the predictor gets; it stays in the field across re-runs until you clear it or reopen the modal.
- **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.
@@ -87,7 +87,7 @@ The `codeman` agent skill documents the same verbs (SKILL.md §3 plus `reference
## What comes next
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).
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
+4 -1
View File
@@ -255,12 +255,15 @@
'Read My Mind: predict your next prompt': '读心术:预测您的下一条提示',
'Predict my next prompt': '预测我的下一条提示',
'Reading your mind…': '正在读取您的想法…',
'No suggestion this time. Rethink to try again.': '这次没有建议。点击「重想」再试一次。',
'No suggestion this time. Add a steer note and Rethink to try again.':
'这次没有建议。可添加引导备注后点击「重想」再试一次。',
Rethink: '重想',
Insert: '插入',
"Put the text on the session's composer without submitting it": '将文本放入会话输入框但不提交',
'Predicted prompt, editable': '预测的提示,可编辑',
'Use this suggestion instead': '改用此建议',
"Steer the rethink, e.g. 'no, I meant the mobile bug'": '引导重想,例如:"不,我是指移动端的问题"',
'Steer note for Rethink': '重想的引导备注',
'Select a session first': '请先选择一个会话',
'Read My Mind works on Claude sessions only': '读心术仅适用于 Claude 会话',
'Prompt sent': '提示已发送',
+15 -5
View File
@@ -2894,13 +2894,23 @@
<div class="readmymind-why" id="readMyMindWhy" data-i18n-skip></div>
<div class="readmymind-alternates" id="readMyMindAlternates" data-i18n-skip style="display:none"></div>
</div>
<div class="readmymind-error" style="display:none">No suggestion this time. Rethink to try again.</div>
<div class="readmymind-error" style="display:none">No suggestion this time. Add a steer note and Rethink to try again.</div>
<!-- Rethink steer note: the user's own words about what they actually
meant, sent as `steer` with the next Rethink. Shown whenever
Rethink is live (ready AND empty-result phases), hidden while a
prediction runs. -->
<div class="readmymind-steer-row" id="readMyMindSteerRow" style="display:none">
<input type="text" id="readMyMindSteer" class="readmymind-steer-input" maxlength="2000" placeholder="Steer the rethink, e.g. 'no, I meant the mobile bug'" aria-label="Steer note for Rethink" onkeydown="if(event.key==='Enter')app.rethinkReadMyMind()">
</div>
</div>
<!-- btn-toolbar, not bare "btn btn-*": no .btn/.btn-secondary rule exists
in this codebase, so the bare classes render as unstyled UA buttons.
btn-toolbar is the convention every other modal footer uses. -->
<div class="modal-footer">
<button class="btn btn-secondary" onclick="app.closeReadMyMind()">Dismiss</button>
<button class="btn btn-secondary" id="readMyMindRethink" onclick="app.rethinkReadMyMind()">Rethink</button>
<button class="btn btn-secondary" onclick="app.sendReadMyMind(false)" title="Put the text on the session's composer without submitting it">Insert</button>
<button class="btn btn-primary" onclick="app.sendReadMyMind(true)">Send</button>
<button class="btn-toolbar" onclick="app.closeReadMyMind()">Dismiss</button>
<button class="btn-toolbar" id="readMyMindRethink" onclick="app.rethinkReadMyMind()">Rethink</button>
<button class="btn-toolbar" onclick="app.sendReadMyMind(false)" title="Put the text on the session's composer without submitting it">Insert</button>
<button class="btn-toolbar btn-primary" onclick="app.sendReadMyMind(true)">Send</button>
</div>
</div>
</div>
+7 -5
View File
@@ -1336,20 +1336,22 @@ html.mobile-init .file-browser-panel {
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. */
and give buttons + alternate rows finger-sized targets. The flex row
itself comes from the base rule in styles.css. */
.readmymind-modal .modal-footer {
display: flex;
flex-wrap: wrap;
justify-content: flex-end;
gap: 0.5rem;
}
.readmymind-modal .modal-footer .btn {
.readmymind-modal .modal-footer .btn-toolbar {
flex: 1 1 auto;
justify-content: center;
min-height: 38px;
}
.readmymind-alt {
min-height: 38px;
}
.readmymind-steer-input {
min-height: 38px;
}
/* Modal safe area padding - all sides for full-screen modals */
.ios-device .modal-content {
+20 -4
View File
@@ -10,7 +10,8 @@
* 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.
* set, main + alternates, recorded as rejected, plus the optional free-text
* steer note, e.g. "no, I meant the mobile bug", sent as `steer`), Dismiss.
*
* Suggestions are NEVER auto-sent: the explicit click here is the security
* boundary for observed/injectable predictor inputs, so suggestion text is
@@ -47,8 +48,11 @@ 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).
// Rethink memory resets on each open (a fresh open is a fresh question),
// and the steer note resets with it.
this._rmm = { sessionId, suggestions: [], selected: 0, rejected: [], busy: false };
const steer = document.getElementById('readMyMindSteer');
if (steer) steer.value = '';
document.getElementById('readMyMindModal')?.classList.add('active');
this._readMyMindPredict();
},
@@ -65,7 +69,13 @@ Object.assign(CodemanApp.prototype, {
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 rides every re-run while it stays in the field: what the
// user sees in the box is what the predictor gets. Empty on first open
// (openReadMyMind clears it), so a plain predict sends neither key.
const steer = document.getElementById('readMyMindSteer')?.value.trim() ?? '';
if (steer) body.steer = steer.slice(0, 2000);
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
@@ -176,7 +186,8 @@ Object.assign(CodemanApp.prototype, {
},
/** Re-run with the whole shown set (main + alternates) recorded as rejected:
* the user saw every row and asked for something else. */
* the user saw every row and asked for something else. The steer note (if
* any) is read from the field by _readMyMindPredict itself. */
rethinkReadMyMind() {
const state = this._rmm;
if (!state || state.busy) return;
@@ -193,6 +204,11 @@ 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';
// The steer note belongs to Rethink, so it shows wherever Rethink is live:
// the ready phase AND the empty-result phase (typed text survives the
// loading round-trip, only the row's visibility toggles).
const steerRow = document.getElementById('readMyMindSteerRow');
if (steerRow) steerRow.style.display = phase === 'loading' ? 'none' : '';
const rethinkBtn = document.getElementById('readMyMindRethink');
if (rethinkBtn) rethinkBtn.disabled = phase === 'loading';
},
+52
View File
@@ -10901,6 +10901,58 @@ kbd {
white-space: nowrap;
font-family: var(--mono-font, monospace);
}
/* Rethink steer note: the user's own words about what they actually meant,
sent as `steer` on the next re-run (readmymind-ui.js _readMyMindPredict).
Dashed border marks it as the optional side channel, distinct from the
primary suggestion field above; it solidifies on focus. */
.readmymind-steer-row {
margin-top: 10px;
}
.readmymind-steer-input {
width: 100%;
min-width: 0;
font-size: 12px;
padding: 7px 10px;
background: transparent;
color: var(--text);
border: 1px dashed var(--control-border);
border-radius: 8px;
}
.readmymind-steer-input:focus {
outline: none;
border-style: solid;
border-color: var(--accent);
background: var(--bg-dark);
}
.readmymind-steer-input::placeholder {
color: var(--text-dim);
}
/* Footer row: the buttons are btn-toolbar (display: flex, block-level), so
without this rule the four of them stack vertically. Mirrors the
runSummaryModal footer; the ≤430px block in mobile.css adds wrapping. */
.readmymind-modal .modal-footer {
display: flex;
justify-content: flex-end;
gap: 0.5rem;
padding: 0.75rem 1rem;
border-top: 1px solid rgba(255, 255, 255, 0.06);
}
/* Send keeps the primary accent: the skin block's bare .btn-toolbar rule is
(0,2,1) under html:not([data-skin="og"]) and outranks the base
.btn-toolbar.btn-primary (0,2,0) — the specificity trap CLAUDE.md documents
for mobile.css — so the accent is re-asserted here at (0,4,0). Scoped to
this modal on purpose; un-greying every btn-primary on the new skins is a
design call, not this feature's. */
.readmymind-modal .modal-footer .btn-toolbar.btn-primary {
background: var(--accent);
border-color: var(--accent);
color: #fff;
}
.readmymind-modal .modal-footer .btn-toolbar.btn-primary:hover {
background: var(--accent-hover);
border-color: var(--accent-hover);
color: #fff;
}
/* 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
+32
View File
@@ -73,4 +73,36 @@ describe('read my mind phone key + alternates (static guards)', () => {
expect(ui).toContain('readMyMindAlternates');
expect(ui).toMatch(/\.textContent = suggestion\.prompt/);
});
// Phase 3 part 2: the Rethink steer note (docs/readmymind-plan.md phase 3).
it('wires the rethink steer note end to end: field, payload, phase visibility, reset', () => {
// The field lives in the modal, capped to the schema's 2000-char limit,
// and Enter in it triggers a rethink (mirroring the prompt field's
// Enter-to-send).
expect(html).toMatch(/id="readMyMindSteer"[^>]*maxlength="2000"/);
expect(html).toMatch(/id="readMyMindSteer"[^>]*onkeydown="[^"]*rethinkReadMyMind\(\)"/);
// Predict sends the trimmed note as `steer`, bounded to the schema cap.
expect(ui).toMatch(/body\.steer = steer\.slice\(0, 2000\)/);
// The row hides ONLY during loading: Rethink is live in both the ready
// and the empty-result phases, so the note must be reachable in both.
expect(ui).toMatch(/steerRow\.style\.display = phase === 'loading' \? 'none' : ''/);
// A fresh open resets the note along with the rethink memory.
expect(ui).toMatch(/steer\.value = ''/);
});
it('styles the footer with btn-toolbar (bare "btn btn-*" matches no CSS in this codebase)', () => {
const modal = html.slice(html.indexOf('id="readMyMindModal"'), html.indexOf('id="approvalsDrawer"'));
// The unstyled classes the footer originally shipped with must not return.
expect(modal).not.toMatch(/class="btn /);
expect(modal.match(/class="btn-toolbar/g)?.length).toBe(4);
expect(modal).toMatch(/class="btn-toolbar btn-primary"[^>]*sendReadMyMind\(true\)/);
// btn-toolbar is display:flex (block-level): without the desktop footer
// row rule the four buttons would stack vertically.
expect(styles).toMatch(/\.readmymind-modal \.modal-footer \{[^}]*display: flex/);
// The skin block's bare .btn-toolbar (0,2,1) greys out .btn-primary
// (0,2,0), so Send's accent must be re-asserted at higher specificity.
expect(styles).toMatch(/\.readmymind-modal \.modal-footer \.btn-toolbar\.btn-primary \{[^}]*var\(--accent\)/);
// The phone block sizes the same class for finger targets.
expect(phoneBlock).toMatch(/\.readmymind-modal \.modal-footer \.btn-toolbar/);
});
});