mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-09-30 12:39:42 +02:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1cc7f68c15 |
@@ -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, 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.<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, desktop; phones get the keyboard-accessory 🧠 key (`refreshReadMyMind()` in keyboard-accessory.js, re-derived after every innerHTML rebuild + settings apply + the tab-render tail; `.accessory-btn[hidden]` must stay re-asserted, inline-flex beats the UA hidden rule) plus a `🧠 Suggest` strip on the overview's YELLOW waiting rows ONLY (on red rows a dialog is on screen and `POST /input` text would land in its menu; answer routing via the approvals endpoint is phase-3 PR 2). Up to 3 kind-diverse suggestions: alternates swap into the editable field, Rethink records everything displayed as rejected and carries the optional steer note (resets each open, like the rejections); 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/`.
|
||||
|
||||
|
||||
@@ -122,14 +122,15 @@ Agent use cases this unlocks: a lead session records intentions as the user stat
|
||||
|
||||
## Phases
|
||||
|
||||
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.**
|
||||
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.
|
||||
1. **Intent store + capture + intent endpoints + skill docs.** Immediately useful to agents even before any UI exists. Shipped 1.16.1 (PR #253).
|
||||
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. Shipped 1.16.2 (PR #256).
|
||||
3. **Phase 3, PR 1: alternates row, rethink steering, phone surfaces.** Alternate suggestions as tappable rows that swap into the field; Rethink records everything displayed as rejected and carries the optional steer note; keyboard-accessory 🧠 key (`refreshReadMyMind()`, re-derived after every innerHTML rebuild, settings apply, and session switch); `🧠 Suggest` strip on the phone overview's YELLOW waiting rows only. Red rows deliberately get no shortcut yet: a dialog is on screen there and text sent via `POST /input` would land in its menu.
|
||||
4. **Phase 3, PR 2: the approvals fusion** (the Cloudflare OS learnings tie-in, items 1-2 of `cloudflare-os-learnings-plan.md`). `GET /api/sessions/:id/recap`: a deterministic "what was done, simplified" catch-up (last assistant tail + recent tool one-liners + git state) reusing the phase-2 collectors verbatim; no model call, effectively an observation-ledger v0 whose data source can later swap to a real ledger. Surfaces: collapsible "What happened" on approval cards (lazy-fetched), recap in the modal's loading phase (read while opus thinks), 🧠 on approval cards and red overview rows. Answer-aware Send: the predictor output gains an optional `answer` option number (validated against the pending dialog's parsed options, dropped when invalid) and the modal routes dialog answers through `POST /api/approvals/:id/answer` (option digits / idle text), keeping `POST /input` only for dialog-free sessions.
|
||||
5. 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, a model-written prose recap (deterministic-only in v1).
|
||||
|
||||
## Open questions
|
||||
|
||||
- Should Rethink's rejected-suggestion memory persist across modal closes, or reset each open?
|
||||
- ~~Should Rethink's rejected-suggestion memory persist across modal closes, or reset each open?~~ Decided in phase 2 and kept: reset each open (a fresh open is a fresh question); the steer note resets with it.
|
||||
- Is a composer-adjacent placement (next to the toolbar Run controls) better than the header for discoverability?
|
||||
- Pending-dialog input (source #1) consumes the approvals-inbox store (PR #245, merged): the phase-2 collector reads pending items directly from `src/approval-inbox.ts`.
|
||||
|
||||
|
||||
+5
-4
@@ -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). On phones the same modal opens from the 🧠 key on the keyboard accessory bar, or from the `🧠 Suggest` strip under a yellow waiting row on the phone overview home screen (steer a session without opening its tab). 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 kind-diverse suggestions (continue / verify / redirect); the top one lands in an editable field with its rationale, and the others render as tappable alternate rows that swap into the field.
|
||||
|
||||
- **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 currently displayed recorded as rejected. The optional steer field above the buttons ("no, I meant the mobile bug") tells the re-run what you actually meant; it is your own words and outranks everything the predictor observed.
|
||||
- **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,13 +87,14 @@ The `codeman` agent skill documents the same verbs (SKILL.md §3 plus `reference
|
||||
|
||||
## What comes next (phase 3+)
|
||||
|
||||
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).
|
||||
The approvals fusion: a per-session catch-up recap ("what was done, simplified", derived from the same collectors the predictor uses), shown on Approvals Inbox cards next to a 🧠 button, plus answer-aware Send that routes dialog answers through the approvals endpoint instead of typing into a menu. 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 (the header button is desktop-only; phones use the keyboard-accessory 🧠 key and the overview waiting rows), or the active session is not claude-mode |
|
||||
| No 🧠 key on the phone keyboard bar | `readMyMindEnabled` is OFF, or the active session is not claude-mode (the key hides itself for codex/gemini/opencode/antigravity/shell sessions) |
|
||||
| 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 |
|
||||
|
||||
@@ -3688,6 +3688,10 @@ class CodemanApp {
|
||||
this._refreshMobileOverviewIfVisible?.();
|
||||
// Same deal for the desktop home screen's tab column.
|
||||
this._refreshHomeSessionsIfVisible?.();
|
||||
// The keyboard accessory bar's 🧠 key depends on the active session's mode
|
||||
// (claude only) and the readMyMindEnabled setting; session switches and
|
||||
// mode changes all funnel through this render.
|
||||
if (typeof KeyboardAccessoryBar !== 'undefined') KeyboardAccessoryBar.refreshReadMyMind?.();
|
||||
}
|
||||
|
||||
// Auto-wrap desktop session tabs to a second row when they overflow one row,
|
||||
|
||||
@@ -257,6 +257,8 @@
|
||||
'Reading your mind…': '正在读取您的想法…',
|
||||
'No suggestion this time. Rethink to try again.': '这次没有建议。点击「重想」再试一次。',
|
||||
Rethink: '重想',
|
||||
'Steer the rethink (optional): tell it what you actually meant': '引导重想(可选):告诉它您真正的意思',
|
||||
'Optional steer note for Rethink': '重想的可选引导说明',
|
||||
Insert: '插入',
|
||||
"Put the text on the session's composer without submitting it": '将文本放入会话输入框但不提交',
|
||||
'Predicted prompt, editable': '预测的提示,可编辑',
|
||||
|
||||
@@ -2802,7 +2802,7 @@
|
||||
<div class="modal-backdrop" onclick="app.closeReadMyMind()"></div>
|
||||
<div class="modal-content readmymind-modal">
|
||||
<div class="modal-header">
|
||||
<h3><span aria-hidden="true">🧠</span> Read My Mind</h3>
|
||||
<h3><span aria-hidden="true">🧠</span> Read My Mind<span class="readmymind-session" id="readMyMindSession" data-i18n-skip></span></h3>
|
||||
<button class="modal-close" onclick="app.closeReadMyMind()" aria-label="Close">×</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
@@ -2813,14 +2813,18 @@
|
||||
<input type="text" id="readMyMindPrompt" class="readmymind-prompt-input" data-i18n-skip aria-label="Predicted prompt, editable" onkeydown="if(event.key==='Enter')app.sendReadMyMind(true)">
|
||||
</div>
|
||||
<div class="readmymind-why" id="readMyMindWhy" data-i18n-skip></div>
|
||||
<div class="readmymind-alternates" id="readMyMindAlternates" data-i18n-skip></div>
|
||||
</div>
|
||||
<div class="readmymind-error" style="display:none">No suggestion this time. Rethink to try again.</div>
|
||||
<!-- Outside the result div on purpose: steering matters in the error
|
||||
phase too (Rethink retries with the note); hidden only while loading. -->
|
||||
<input type="text" id="readMyMindSteer" class="readmymind-steer-input" style="display:none" placeholder="Steer the rethink (optional): tell it what you actually meant" aria-label="Optional steer note for Rethink" onkeydown="if(event.key==='Enter')app.rethinkReadMyMind()">
|
||||
</div>
|
||||
<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 btn-secondary" id="readMyMindInsert" onclick="app.sendReadMyMind(false)" title="Put the text on the session's composer without submitting it">Insert</button>
|
||||
<button class="btn btn-primary" id="readMyMindSend" onclick="app.sendReadMyMind(true)">Send</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -441,6 +441,7 @@ const KeyboardAccessoryBar = {
|
||||
<rect x="8" y="2" width="8" height="4" rx="1" ry="1"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button class="accessory-btn accessory-btn-readmymind" data-action="readmymind" title="Read My Mind: predict your next prompt" hidden>🧠</button>
|
||||
<button class="accessory-btn" data-action="esc" title="Escape">Esc</button>
|
||||
<button class="accessory-btn accessory-btn-dismiss" data-action="dismiss" title="Dismiss keyboard">
|
||||
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3">
|
||||
@@ -477,6 +478,7 @@ const KeyboardAccessoryBar = {
|
||||
</svg>
|
||||
</button>
|
||||
<button class="accessory-btn" data-action="pick-path" title="Insert a file or folder path">📁 Path</button>
|
||||
<button class="accessory-btn accessory-btn-readmymind" data-action="readmymind" title="Read My Mind: predict your next prompt" hidden>🧠</button>
|
||||
<button class="accessory-btn" data-action="clear-input" title="Clear the current unsent input">⌫ All</button>
|
||||
<button class="accessory-btn" data-action="tab" title="Tab">Tab</button>
|
||||
<button class="accessory-btn" data-action="shift-tab" title="Shift+Tab">⇧Tab</button>
|
||||
@@ -528,6 +530,8 @@ const KeyboardAccessoryBar = {
|
||||
if (toolbar && toolbar.parentNode) {
|
||||
toolbar.parentNode.insertBefore(this.element, toolbar);
|
||||
}
|
||||
|
||||
this.refreshReadMyMind();
|
||||
},
|
||||
|
||||
/** Switch between 'simple' and 'extended' button layouts */
|
||||
@@ -536,6 +540,27 @@ const KeyboardAccessoryBar = {
|
||||
this._mode = mode;
|
||||
this.clearConfirm();
|
||||
this.element.innerHTML = mode === 'extended' ? this._extendedButtons : this._simpleButtons;
|
||||
// innerHTML rebuilds ship the 🧠 key with its default `hidden` attribute;
|
||||
// re-derive its visibility for the fresh element.
|
||||
this.refreshReadMyMind();
|
||||
},
|
||||
|
||||
/**
|
||||
* Show the 🧠 key only when Read My Mind is opted in (`readMyMindEnabled`,
|
||||
* synced) AND the active session is claude mode (the predictor is a 400 on
|
||||
* every other CLI). Called after every innerHTML rebuild, from
|
||||
* applyHeaderVisibilitySettings (settings load/save), and from the tab
|
||||
* renderer tail (session switches funnel through it).
|
||||
*/
|
||||
refreshReadMyMind() {
|
||||
if (!this.element) return;
|
||||
const btn = this.element.querySelector('[data-action="readmymind"]');
|
||||
if (!btn) return;
|
||||
const hasApp = typeof app !== 'undefined' && app;
|
||||
const session = hasApp && app.activeSessionId ? app.sessions?.get(app.activeSessionId) : null;
|
||||
const enabled = hasApp && app.readMyMindEnabled?.() === true;
|
||||
const claudeMode = !!session && (!session.mode || session.mode === 'claude');
|
||||
btn.hidden = !(enabled && claudeMode);
|
||||
},
|
||||
|
||||
_confirmTimer: null,
|
||||
@@ -613,6 +638,11 @@ const KeyboardAccessoryBar = {
|
||||
case 'pick-path':
|
||||
this.pickPath();
|
||||
break;
|
||||
case 'readmymind':
|
||||
// Opens the shared prediction modal (readmymind-ui.js). Deliberately
|
||||
// not in refocusActions: the modal takes over and the keyboard closes.
|
||||
app.openReadMyMind?.();
|
||||
break;
|
||||
case 'clear-input':
|
||||
app.clearTerminalInput?.();
|
||||
break;
|
||||
|
||||
@@ -642,12 +642,16 @@ Object.assign(CodemanApp.prototype, {
|
||||
// Approvals Inbox: a pending dialog for this session gets an answer strip
|
||||
// BELOW the row (the row itself is a <button>, so actions cannot nest
|
||||
// inside it). Tapping the row still opens the session, unchanged.
|
||||
// Waiting rows get the Read My Mind shortcut strip the same way.
|
||||
const approval = this._pendingApprovalForSession(row.id);
|
||||
if (approval) {
|
||||
const rmmShortcut = !approval && this._readMyMindRowShortcut(row);
|
||||
if (approval || rmmShortcut) {
|
||||
const wrap = document.createElement('div');
|
||||
wrap.className = 'mobile-overview-row-wrap';
|
||||
wrap.appendChild(item);
|
||||
wrap.appendChild(this._buildMobileOverviewApprovalStrip(approval));
|
||||
wrap.appendChild(
|
||||
approval ? this._buildMobileOverviewApprovalStrip(approval) : this._buildMobileOverviewRmmStrip(row.id)
|
||||
);
|
||||
return wrap;
|
||||
}
|
||||
|
||||
@@ -691,6 +695,39 @@ Object.assign(CodemanApp.prototype, {
|
||||
return strip;
|
||||
},
|
||||
|
||||
/**
|
||||
* Whether a NEEDS YOU row gets the Read My Mind shortcut: opt-in setting,
|
||||
* live claude-mode session, and only the `waiting` state. A yellow row is a
|
||||
* bare composer waiting for a prompt; on red rows a dialog is on screen and
|
||||
* sent text would land in its menu, so those keep the approval strip (or the
|
||||
* open-the-session path) until the predictor can answer dialogs through the
|
||||
* approvals endpoint (phase 3 PR 2, docs/readmymind-plan.md).
|
||||
*/
|
||||
_readMyMindRowShortcut(row) {
|
||||
if (row.state !== 'waiting') return false;
|
||||
if (!this.readMyMindEnabled || !this.readMyMindEnabled()) return false;
|
||||
const session = this.sessions.get(row.id);
|
||||
return !!session && (!session.mode || session.mode === 'claude');
|
||||
},
|
||||
|
||||
/** A one-button strip opening the prediction modal for this row's session. */
|
||||
_buildMobileOverviewRmmStrip(sessionId) {
|
||||
const strip = document.createElement('div');
|
||||
strip.className = 'mobile-overview-approval-strip';
|
||||
strip.setAttribute('data-i18n-skip', '');
|
||||
const btn = document.createElement('button');
|
||||
btn.type = 'button';
|
||||
btn.className = 'mobile-overview-approval-btn rmm';
|
||||
btn.textContent = '🧠 Suggest';
|
||||
btn.setAttribute('aria-label', 'Predict my next prompt');
|
||||
btn.addEventListener('click', (ev) => {
|
||||
ev.stopPropagation();
|
||||
this.openReadMyMind(sessionId);
|
||||
});
|
||||
strip.appendChild(btn);
|
||||
return strip;
|
||||
},
|
||||
|
||||
/** A past conversation. Tapping it resumes, which creates a fresh session. */
|
||||
_buildMobileOverviewPastRow(row) {
|
||||
const item = document.createElement('button');
|
||||
|
||||
@@ -530,8 +530,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 🧠 button: desktop header only. The phone surfaces are the
|
||||
keyboard-accessory 🧠 key and the overview's waiting-row Suggest strip
|
||||
(docs/readmymind-plan.md phase 3), both opening the same modal. */
|
||||
.btn-icon-header.btn-readmymind {
|
||||
display: none !important;
|
||||
}
|
||||
@@ -1290,8 +1291,11 @@ html.mobile-init .file-browser-panel {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* Small modals (confirmations, dialogs) — auto-height, not full-screen */
|
||||
.modal-content.modal-sm {
|
||||
/* Small modals (confirmations, dialogs) — auto-height, not full-screen.
|
||||
Read My Mind is a decision dialog, not a workspace, so it gets the same
|
||||
compact-sheet treatment instead of the full-screen default. */
|
||||
.modal-content.modal-sm,
|
||||
.modal-content.readmymind-modal {
|
||||
height: auto;
|
||||
max-height: 85vh;
|
||||
border-radius: 12px;
|
||||
@@ -1299,6 +1303,16 @@ html.mobile-init .file-browser-panel {
|
||||
width: calc(100% - 2rem);
|
||||
}
|
||||
|
||||
/* Read My Mind phone polish: iOS zooms the page when focusing sub-16px
|
||||
inputs, and alternate rows are tap targets. */
|
||||
.readmymind-prompt-input,
|
||||
.readmymind-steer-input {
|
||||
font-size: 16px;
|
||||
}
|
||||
.readmymind-alt {
|
||||
min-height: 44px;
|
||||
}
|
||||
|
||||
/* Modal safe area padding - all sides for full-screen modals */
|
||||
.ios-device .modal-content {
|
||||
padding-top: var(--safe-area-top);
|
||||
@@ -2600,6 +2614,11 @@ html.mobile-init .file-browser-panel {
|
||||
border-color: var(--error);
|
||||
color: var(--error);
|
||||
}
|
||||
/* Read My Mind shortcut on waiting rows (mobile-overview.js). */
|
||||
.mobile-overview-approval-btn.rmm {
|
||||
border-color: var(--accent);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
/* Attention states mirror the session tabs exactly: red blink when the agent
|
||||
asked something (permission / question), yellow blink when it is waiting for
|
||||
|
||||
+113
-19
@@ -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';
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
@@ -2117,6 +2117,9 @@ Object.assign(CodemanApp.prototype, {
|
||||
if (readMyMindBtn) {
|
||||
readMyMindBtn.classList.toggle('btn-readmymind--hidden', settings.readMyMindEnabled !== true);
|
||||
}
|
||||
// The phone accessory bar's 🧠 key follows the same setting (plus the
|
||||
// active session's mode); keep it in step on every settings apply.
|
||||
if (typeof KeyboardAccessoryBar !== 'undefined') KeyboardAccessoryBar.refreshReadMyMind?.();
|
||||
|
||||
// Plan-usage chip — shown by default on desktop, OFF on handhelds (App
|
||||
// Settings → Display → "Plan Usage Limits"). The template always ships it
|
||||
|
||||
@@ -10697,6 +10697,17 @@ kbd {
|
||||
font-size: 13px;
|
||||
line-height: 1;
|
||||
}
|
||||
/* Which session the prediction targets: overview rows and the accessory key
|
||||
can open the modal for a session that is not the active tab. */
|
||||
.readmymind-session {
|
||||
margin-left: 8px;
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
.readmymind-session:empty {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Read My Mind modal: top suggestion in an editable single-line field with the
|
||||
kind badge beside it and the rationale below. */
|
||||
@@ -10753,6 +10764,75 @@ kbd {
|
||||
font-size: 12px;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
/* Alternates: the predictor's other kind-diverse suggestions (verify /
|
||||
redirect) as tappable rows; tapping swaps one into the editable field.
|
||||
Text is set via textContent only (predictor output is observed/injectable
|
||||
content, same rule as the field above). */
|
||||
.readmymind-alternates {
|
||||
margin-top: 10px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
.readmymind-alternates:empty {
|
||||
display: none;
|
||||
}
|
||||
.readmymind-alt {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
padding: 7px 9px;
|
||||
background: var(--bg-dark);
|
||||
border: 1px solid var(--control-border);
|
||||
border-radius: 8px;
|
||||
color: var(--text);
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
}
|
||||
.readmymind-alt:hover {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
.readmymind-alt-col {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
.readmymind-alt-text {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-size: 12px;
|
||||
}
|
||||
/* Rationale as a visible second line: phones have no hover for a title tip,
|
||||
and the why is what separates two plausible-looking prompts. */
|
||||
.readmymind-alt-why {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-size: 11px;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
/* Steer note: optional free-text guidance for Rethink ("no, I meant the
|
||||
mobile bug"); sent as the highest-authority tier of the re-run's context. */
|
||||
.readmymind-steer-input {
|
||||
margin-top: 10px;
|
||||
width: 100%;
|
||||
font-size: 12px;
|
||||
padding: 7px 9px;
|
||||
background: transparent;
|
||||
color: var(--text);
|
||||
border: 1px dashed var(--control-border);
|
||||
border-radius: 8px;
|
||||
}
|
||||
.readmymind-steer-input:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent);
|
||||
border-style: solid;
|
||||
}
|
||||
.readmymind-error {
|
||||
padding: 12px 4px;
|
||||
color: var(--text-dim);
|
||||
@@ -12075,6 +12155,13 @@ body.touch-device.cjk-input-visible .main {
|
||||
transition: background 0.15s, border-color 0.15s;
|
||||
}
|
||||
|
||||
/* The 🧠 key ships `hidden` until refreshReadMyMind() reveals it; the
|
||||
inline-flex rules above (and mobile.css's) beat the UA [hidden] rule, so
|
||||
hiding must be re-asserted, same trap as the home-sessions column. */
|
||||
.accessory-btn[hidden] {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.accessory-btn.confirming {
|
||||
background: color-mix(in srgb, var(--yellow) 18%, var(--bg-input));
|
||||
border-color: var(--yellow);
|
||||
|
||||
@@ -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