mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-02 13:39:41 +02:00
Compare commits
1
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/`.
|
||||
|
||||
@@ -252,8 +252,6 @@ Frontend JS modules have `@fileoverview` with `@dependency`/`@loadorder` tags. L
|
||||
|
||||
**Entrance animations** (`entrance-animations.js`, all OFF by default): opt-in animations for the four things that appear when work starts, chosen per surface via `data-tab-anim` / `data-term-anim` / `data-win-anim` / `data-line-anim` on `<html>`. Defaults are the `legacy` theme, so an untouched install behaves exactly as before and every hook short-circuits on its first line. ⚠️ Tabs and connection lines are **destroyed mid-animation** on every re-render (`_fullRenderSessionTabs()` replaces the strip's innerHTML; `_updateConnectionLinesImmediate()` does `svg.innerHTML = ''`), so both are tracked by id and re-applied to the fresh element with a **negative `animation-delay`** to resume rather than restart. ⚠️ The terminal-pane styles may animate **transform / opacity / clip-path only**, xterm's FitAddon derives rows+cols from `getComputedStyle(parent).width/height`, so animating width/height/padding there would resize the PTY. ⚠️ Window styles other than `beam` transform the window, which moves the rect its connection line is aimed at; `beam` deliberately animates opacity/filter only so its line can draw toward a stable target. Persisted to its own `codeman:*Anim` localStorage keys (per-device, deliberately NOT in the `.strict()` `SettingsUpdateSchema`); picker in App Settings → Appearance, full per-surface lab at `?animlab=1`.
|
||||
|
||||
**Mobile tab strip scrolling** (issue #257): under 768px the tab strip is a horizontal scroller (desktop wraps to a second row instead), so the active tab can sit off-screen. Three rules keep it reachable and they only work together: `_updateActiveTabImmediate()` scrolls the selected tab into view via `computeTabScrollLeft()` (pure, in constants.js) using **rect math on the strip's own `scrollLeft`**, never `scrollIntoView()`, which would also scroll the document under a fixed header; `_fullRenderSessionTabs()` **restores `scrollLeft`** across the `innerHTML` rebuild, since ambient rebuilds (a task badge appearing, a session created elsewhere) otherwise snap a mid-swipe strip back to 0; and it re-reveals the active tab **only when it changed** (`_lastRenderedActiveTabId`), so browsing the far end of the strip is not undone by background renders. ⚠️ Mobile no longer hoists the active session to the front of the strip: that reordering ran on full renders only, so tab order flipped depending on which render path fired, and it renumbered the Alt+N badges. Scroll-into-view replaces it; do not reintroduce it.
|
||||
|
||||
**Phone overview home screen** (`mobile-overview.js`, phones only, per-device `mobileOverviewEnabled`, default ON): under 430px the "C" logo shows a session overview (NEEDS YOU / CURRENT SESSIONS / PAST SESSIONS) instead of the welcome overlay; tablet and desktop are unchanged. The branch lives in `showWelcome()`/`hideWelcome()` (terminal-ui.js) behind `shouldUseMobileOverview()`, which is **width-driven** (`getDeviceType() === 'mobile'`) because this is a layout decision, unlike the settings namespace which stays handheld-based. ⚠️ The container ships with the `hidden` attribute and only this module removes it: never give `.mobile-overview` a bare `display` rule, since desktop does not load `mobile.css` (`media="(max-width: 1023px)"`) and would then render it unstyled. Live re-renders ride on the tail of `_renderSessionTabsImmediate()` (every state change it needs already funnels there); PAST rows come from one `_fetchUnifiedSessions(60)` per home-screen visit and resume through the shared `resumeHistorySession()`, so they behave exactly like the welcome screen's Resume list. ⚠️ Two things must stay in lockstep with surfaces outside this module, because divergence reads as a bug rather than a style: the split Run button carries the **toolbar's own classes** (`btn-toolbar btn-run mode-<backend>` / `btn-run-gear`) so the per-backend gradient and the light-skin overrides apply unchanged (mobile.css must therefore set no `background`/`color` on it), and row status uses the **session-tab language** (green dot when fine, `pulse` while working, yellow blinking row when waiting for input, red blinking row when a question is pending, mirroring `tab-alert-idle`/`tab-alert-action`). The picker mirrors the toolbar run-mode menu (`setRunMode()` + `run()`, `openWebviewFromMenu()` for saved dashboards) and deliberately omits its Recent-Sessions block, since PAST SESSIONS is that. Status pills carry `data-i18n-skip` (generic words like "idle" collide with state strings elsewhere).
|
||||
|
||||
**Desktop home tab column** (`home-sessions.js`, desktop only): the welcome overlay centers ~560px of content in a ~1400px window, so its left gutter is dead space; it now carries the open tabs as a vertical list. Rows are in **tab order**, not sorted by urgency like the phone overview, because the row badges are the Alt+1..9 indices. State classification is REUSED from mobile-overview.js (`_mobileOverviewState`/`_mobileOverviewCaseFor`), which is why the module loads after it. ⚠️ The column is `position: absolute` so the centered content never moves, which is exactly why it needs a **width gate in two places** — `HOME_SESSIONS_MIN_WIDTH` (1180) in the JS plus a `max-width: 1179px` media query as the backstop for a resize that outruns the matchMedia listener; drift between them means a column overlapping the search panel, and `test/home-sessions.test.ts` pins them equal. ⚠️ `.home-sessions` is `display: flex`, so `[hidden]` must be re-asserted as `display: none` or the module's only visibility lever does nothing. Working state is deliberately byte-identical to the phone's: pulsing green dot + the `tab-load-spin` ring reused from the tab strip + the same green halo (added to `.mobile-overview-dot--working` at the same time), so "working" reads the same on every surface. Live re-renders ride the tail of `_renderSessionTabsImmediate()` alongside the phone overview.
|
||||
|
||||
@@ -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 |
|
||||
|
||||
+12
-82
@@ -3461,54 +3461,6 @@ class CodemanApp {
|
||||
tab.classList.remove('active');
|
||||
}
|
||||
}
|
||||
// #257: selection used to stop at the class toggle. On phones/tablets the
|
||||
// strip scrolls horizontally, so a tab selected from the palette, a swipe,
|
||||
// Alt+N or a push notification could stay parked off-screen.
|
||||
this._scrollActiveTabIntoView(sessionId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Scroll the tab strip so the given (default: active) tab is visible.
|
||||
*
|
||||
* Only phones/tablets scroll the strip (desktop wraps to a second row), and
|
||||
* the pure policy no-ops whenever there is nothing to scroll, so this is a
|
||||
* cheap call on every device.
|
||||
*
|
||||
* Deliberately NOT scrollIntoView(): that also scrolls every scrollable
|
||||
* ANCESTOR, which on a phone is the document itself. With the header fixed
|
||||
* and the keyboard possibly open, a vertical nudge there shifts the whole
|
||||
* app. Rect math + scrollLeft touches exactly one scroller.
|
||||
*/
|
||||
_scrollActiveTabIntoView(sessionId, behavior = 'smooth') {
|
||||
const container = this.$('sessionTabs');
|
||||
if (!container) return;
|
||||
const tab =
|
||||
(sessionId && container.querySelector(`.session-tab[data-id="${sessionId}"]`)) ||
|
||||
container.querySelector('.session-tab.active');
|
||||
if (!tab) return;
|
||||
|
||||
const policy = window.CodemanTabOverflow?.computeTabScrollLeft;
|
||||
if (!policy) return;
|
||||
const containerRect = container.getBoundingClientRect();
|
||||
const tabRect = tab.getBoundingClientRect();
|
||||
const target = policy({
|
||||
scrollLeft: container.scrollLeft,
|
||||
clientWidth: container.clientWidth,
|
||||
scrollWidth: container.scrollWidth,
|
||||
// Offsets are relative to the SCROLL CONTENT, not the offsetParent: the
|
||||
// tabs' offsetParent is the positioned header, so offsetLeft would carry
|
||||
// the brand column's width into the math.
|
||||
tabLeft: tabRect.left - containerRect.left + container.scrollLeft,
|
||||
tabWidth: tabRect.width,
|
||||
});
|
||||
if (Math.abs(target - container.scrollLeft) < 1) return;
|
||||
|
||||
const reduceMotion = window.matchMedia?.('(prefers-reduced-motion: reduce)')?.matches;
|
||||
if (typeof container.scrollTo === 'function') {
|
||||
container.scrollTo({ left: target, behavior: reduceMotion ? 'auto' : behavior });
|
||||
} else {
|
||||
container.scrollLeft = target;
|
||||
}
|
||||
}
|
||||
|
||||
_setTerminalLoadState(sessionId, selectGen, phase) {
|
||||
@@ -3726,11 +3678,6 @@ class CodemanApp {
|
||||
this._fullRenderSessionTabs();
|
||||
}
|
||||
|
||||
// Keep the reveal-on-change bookkeeping honest when only the incremental
|
||||
// branch ran: _updateActiveTabImmediate has already scrolled the new active
|
||||
// tab into view, so the next full rebuild must not treat it as a change.
|
||||
this._lastRenderedActiveTabId = this.activeSessionId;
|
||||
|
||||
this.updateTabOverflowMode();
|
||||
// After the wrap measurement: the `unroll` style starts tabs at max-width 0,
|
||||
// so measuring mid-animation would decide the wrap on collapsed widths.
|
||||
@@ -3741,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,
|
||||
@@ -3802,25 +3753,15 @@ class CodemanApp {
|
||||
document.querySelectorAll('body > .subagent-dropdown').forEach(d => d.remove());
|
||||
this.cancelHideSubagentDropdown();
|
||||
|
||||
// #257: replacing innerHTML below resets scrollLeft to 0. On phones the
|
||||
// strip scrolls, and ambient rebuilds (a task badge appearing, a session
|
||||
// created elsewhere) fire often enough that a user swiping toward the
|
||||
// right-hand tabs kept getting yanked back to the first one. Remember
|
||||
// where the strip was; the browser clamps the restore to the new content.
|
||||
const prevScrollLeft = container.scrollLeft;
|
||||
const prevActiveTabId = this._lastRenderedActiveTabId;
|
||||
const isFirstRender = !container.querySelector('.session-tab');
|
||||
|
||||
// Build tabs HTML using array for better string concatenation performance.
|
||||
// Iterate in sessionOrder to respect the user's custom tab arrangement, on
|
||||
// EVERY device: mobile used to hoist the active session to the front, from
|
||||
// when only one tab fit on screen. With five tabs it made the strip jump
|
||||
// under the user's finger (and renumbered the Alt+N badges) on every full
|
||||
// rebuild, while the incremental path left the order alone, so the order
|
||||
// depended on which render path happened to run. Scrolling the active tab
|
||||
// into view replaces it.
|
||||
// Build tabs HTML using array for better string concatenation performance
|
||||
// Iterate in sessionOrder to respect user's custom tab arrangement
|
||||
// On mobile: put active session first (only one tab visible anyway)
|
||||
const parts = [];
|
||||
const tabOrder = this.sessionOrder;
|
||||
let tabOrder = this.sessionOrder;
|
||||
if (MobileDetection.getDeviceType() === 'mobile' && this.activeSessionId) {
|
||||
// Reorder to put active tab first
|
||||
tabOrder = [this.activeSessionId, ...this.sessionOrder.filter(id => id !== this.activeSessionId)];
|
||||
}
|
||||
let _tabIdx = 0;
|
||||
for (const id of tabOrder) {
|
||||
const session = this.sessions.get(id);
|
||||
@@ -3889,17 +3830,6 @@ class CodemanApp {
|
||||
|
||||
container.innerHTML = parts.join('');
|
||||
|
||||
// Put the strip back where the user left it, then reveal the active tab
|
||||
// only when it CHANGED (or on the first paint). Restoring unconditionally
|
||||
// and revealing conditionally is what lets someone browse the far end of
|
||||
// the strip while a background rebuild fires, without the active tab ever
|
||||
// being stranded off-screen after a switch.
|
||||
container.scrollLeft = prevScrollLeft;
|
||||
this._lastRenderedActiveTabId = this.activeSessionId;
|
||||
if (isFirstRender || prevActiveTabId !== this.activeSessionId) {
|
||||
this._scrollActiveTabIntoView(this.activeSessionId, isFirstRender ? 'auto' : 'smooth');
|
||||
}
|
||||
|
||||
// Set up drag-and-drop handlers for tab reordering
|
||||
this.setupTabDragHandlers();
|
||||
|
||||
|
||||
@@ -156,47 +156,6 @@ function shouldAutoWrapTabs(input) {
|
||||
return scrollWidth > clientWidth + 1;
|
||||
}
|
||||
|
||||
// Sliver of the neighbouring tab left visible when the strip scrolls a tab into
|
||||
// view. Landing a tab flush against the edge reads as "this is the last one";
|
||||
// the gap is what tells the user there is more strip to swipe to.
|
||||
const TAB_SCROLL_REVEAL_PX = 16;
|
||||
|
||||
// Phone/tablet tab-strip scroll policy (issue #257). Those breakpoints scroll
|
||||
// the strip horizontally (desktop wraps to a second row instead and never
|
||||
// scrolls), so the active tab can sit entirely outside the visible slice with
|
||||
// no way back except a swipe the user may not know is possible.
|
||||
//
|
||||
// Returns the scrollLeft that puts the tab inside the window, clamped to the
|
||||
// scrollable range, and returns the CURRENT scrollLeft when the tab is already
|
||||
// visible: callers compare and skip the write, so an already-correct strip is
|
||||
// never nudged. Pure: the caller measures, this decides.
|
||||
function computeTabScrollLeft(input) {
|
||||
const scrollWidth = Number(input?.scrollWidth) || 0;
|
||||
const clientWidth = Number(input?.clientWidth) || 0;
|
||||
const maxScroll = Math.max(0, scrollWidth - clientWidth);
|
||||
if (maxScroll === 0 || clientWidth <= 0) return 0;
|
||||
|
||||
const pad = input?.padding == null ? TAB_SCROLL_REVEAL_PX : Number(input.padding) || 0;
|
||||
const tabLeft = Number(input?.tabLeft) || 0;
|
||||
const tabWidth = Number(input?.tabWidth) || 0;
|
||||
const tabRight = tabLeft + tabWidth;
|
||||
const viewLeft = Math.min(Math.max(Number(input?.scrollLeft) || 0, 0), maxScroll);
|
||||
const viewRight = viewLeft + clientWidth;
|
||||
|
||||
let target = viewLeft;
|
||||
if (tabWidth + pad >= clientWidth) {
|
||||
// Tab is as wide as the window (long session name on a narrow phone):
|
||||
// there is no position that shows all of it plus padding, so align its
|
||||
// start, since the name matters more than the trailing badges.
|
||||
target = tabLeft;
|
||||
} else if (tabLeft - pad < viewLeft) {
|
||||
target = tabLeft - pad;
|
||||
} else if (tabRight + pad > viewRight) {
|
||||
target = tabRight + pad - clientWidth;
|
||||
}
|
||||
return Math.min(Math.max(Math.round(target), 0), maxScroll);
|
||||
}
|
||||
|
||||
// COD-134 — Terminal WebSocket reconnect policy.
|
||||
//
|
||||
// Decide what to do after a terminal WebSocket closes, given the close `code`
|
||||
@@ -302,8 +261,6 @@ if (typeof window !== 'undefined') {
|
||||
window.shouldSkipWebGL = shouldSkipWebGL;
|
||||
window.CodemanTabOverflow = {
|
||||
shouldAutoWrapTabs,
|
||||
computeTabScrollLeft,
|
||||
TAB_SCROLL_REVEAL_PX,
|
||||
};
|
||||
window.CodemanWsReconnect = {
|
||||
plan: planWsReconnect,
|
||||
|
||||
@@ -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');
|
||||
|
||||
+24
-10
@@ -115,17 +115,13 @@ html.mobile-init .file-browser-panel {
|
||||
}
|
||||
|
||||
/* Compact session tabs — .tabs-two-rows override needed to match
|
||||
specificity of .session-tabs.tabs-two-rows in styles.css (0,2,0).
|
||||
overscroll-behavior-x keeps a swipe that runs past the last tab inside the
|
||||
strip: chained to the page it becomes the browser's back gesture, which is
|
||||
exactly the swipe someone makes reaching for the rightmost tabs (#257). */
|
||||
specificity of .session-tabs.tabs-two-rows in styles.css (0,2,0) */
|
||||
.session-tabs,
|
||||
.session-tabs.tabs-two-rows {
|
||||
flex-wrap: nowrap;
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
overscroll-behavior-x: contain;
|
||||
scrollbar-width: none;
|
||||
max-height: 52px;
|
||||
gap: 3px;
|
||||
@@ -534,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;
|
||||
}
|
||||
@@ -647,7 +644,6 @@ html.mobile-init .file-browser-panel {
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
overscroll-behavior-x: contain;
|
||||
scrollbar-width: none;
|
||||
max-height: 36px;
|
||||
gap: 2px;
|
||||
@@ -1295,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;
|
||||
@@ -1304,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);
|
||||
@@ -2605,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);
|
||||
|
||||
@@ -183,171 +183,6 @@ describe('Tab Navigation', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Tab Strip Scrolling (issue #257) ────────────────────────────────────
|
||||
|
||||
describe('Tab Strip Scrolling', () => {
|
||||
/**
|
||||
* Seed `count` real sessions and render the strip through the production
|
||||
* code path (_fullRenderSessionTabs), so the tabs carry the real markup,
|
||||
* widths and CSS rather than hand-built stand-ins.
|
||||
*/
|
||||
async function seedTabs(page: Page, count: number, activeIndex = 0): Promise<void> {
|
||||
await page.evaluate(`(function (n, activeIndex) {
|
||||
app.sessions.clear();
|
||||
app.sessionOrder = [];
|
||||
for (let i = 1; i <= n; i++) {
|
||||
const id = 'scroll-sess-' + i;
|
||||
app.sessions.set(id, { id, name: 'w' + i + '-project', status: 'idle', mode: 'claude', workingDir: '/tmp/p' + i });
|
||||
app.sessionOrder.push(id);
|
||||
}
|
||||
app.activeSessionId = app.sessionOrder[activeIndex];
|
||||
app._lastRenderedActiveTabId = null;
|
||||
app._fullRenderSessionTabs();
|
||||
})(${count}, ${activeIndex})`);
|
||||
await page.waitForTimeout(200);
|
||||
}
|
||||
|
||||
async function stripState(page: Page, sessionId: string) {
|
||||
return page.evaluate(`(function (id) {
|
||||
const c = document.getElementById('sessionTabs');
|
||||
const tab = c.querySelector('.session-tab[data-id="' + id + '"]');
|
||||
const cRect = c.getBoundingClientRect();
|
||||
const tRect = tab ? tab.getBoundingClientRect() : null;
|
||||
return {
|
||||
scrollLeft: Math.round(c.scrollLeft),
|
||||
maxScroll: Math.round(c.scrollWidth - c.clientWidth),
|
||||
order: [...c.querySelectorAll('.session-tab[data-id]')].map((t) => t.dataset.id),
|
||||
visible: tRect ? tRect.left >= cRect.left - 1 && tRect.right <= cRect.right + 1 : false,
|
||||
};
|
||||
})('${sessionId}')`) as Promise<{ scrollLeft: number; maxScroll: number; order: string[]; visible: boolean }>;
|
||||
}
|
||||
|
||||
it('reveals a rightmost tab that selection would otherwise leave off-screen', async () => {
|
||||
const { context, page } = await createDevicePage(standardPhone, BASE_URL, 'chromium');
|
||||
try {
|
||||
await page.waitForTimeout(WAIT.PAGE_SETTLE);
|
||||
await seedTabs(page, 5);
|
||||
|
||||
const before = await stripState(page, 'scroll-sess-5');
|
||||
// Precondition: the strip really does overflow and the last tab is hidden.
|
||||
expect(before.maxScroll).toBeGreaterThan(0);
|
||||
expect(before.visible).toBe(false);
|
||||
|
||||
// The selection path selectSession() uses (class toggle, no rebuild).
|
||||
await page.evaluate(`(function () {
|
||||
app.activeSessionId = 'scroll-sess-5';
|
||||
app._updateActiveTabImmediate('scroll-sess-5');
|
||||
})()`);
|
||||
await page.waitForTimeout(600); // smooth scroll
|
||||
|
||||
const after = await stripState(page, 'scroll-sess-5');
|
||||
expect(after.visible).toBe(true);
|
||||
expect(after.scrollLeft).toBeGreaterThan(before.scrollLeft);
|
||||
} finally {
|
||||
await context.close();
|
||||
}
|
||||
});
|
||||
|
||||
it('scrolls back to reveal a leftmost tab', async () => {
|
||||
const { context, page } = await createDevicePage(standardPhone, BASE_URL, 'chromium');
|
||||
try {
|
||||
await page.waitForTimeout(WAIT.PAGE_SETTLE);
|
||||
await seedTabs(page, 5);
|
||||
await page.evaluate(`document.getElementById('sessionTabs').scrollLeft = 9999`);
|
||||
|
||||
await page.evaluate(`(function () {
|
||||
app.activeSessionId = 'scroll-sess-1';
|
||||
app._updateActiveTabImmediate('scroll-sess-1');
|
||||
})()`);
|
||||
await page.waitForTimeout(600);
|
||||
|
||||
const after = await stripState(page, 'scroll-sess-1');
|
||||
expect(after.visible).toBe(true);
|
||||
expect(after.scrollLeft).toBe(0);
|
||||
} finally {
|
||||
await context.close();
|
||||
}
|
||||
});
|
||||
|
||||
it('keeps the scroll position across an ambient full re-render', async () => {
|
||||
const { context, page } = await createDevicePage(standardPhone, BASE_URL, 'chromium');
|
||||
try {
|
||||
await page.waitForTimeout(WAIT.PAGE_SETTLE);
|
||||
await seedTabs(page, 5);
|
||||
|
||||
// User swipes to the end of the strip, then a background rebuild fires
|
||||
// (a task badge appearing forces the full-render path).
|
||||
await page.evaluate(`document.getElementById('sessionTabs').scrollLeft = 9999`);
|
||||
const scrolled = await stripState(page, 'scroll-sess-5');
|
||||
expect(scrolled.scrollLeft).toBeGreaterThan(0);
|
||||
|
||||
await page.evaluate(`(function () {
|
||||
app.sessions.get('scroll-sess-2').taskStats = { running: 2, total: 3 };
|
||||
app._fullRenderSessionTabs();
|
||||
})()`);
|
||||
await page.waitForTimeout(200);
|
||||
|
||||
const after = await stripState(page, 'scroll-sess-5');
|
||||
expect(after.scrollLeft).toBe(scrolled.scrollLeft);
|
||||
} finally {
|
||||
await context.close();
|
||||
}
|
||||
});
|
||||
|
||||
it('renders tabs in sessionOrder on phones instead of hoisting the active one', async () => {
|
||||
const { context, page } = await createDevicePage(standardPhone, BASE_URL, 'chromium');
|
||||
try {
|
||||
await page.waitForTimeout(WAIT.PAGE_SETTLE);
|
||||
await seedTabs(page, 5, 3); // 4th tab active
|
||||
|
||||
const state = await stripState(page, 'scroll-sess-4');
|
||||
expect(state.order).toEqual([
|
||||
'scroll-sess-1',
|
||||
'scroll-sess-2',
|
||||
'scroll-sess-3',
|
||||
'scroll-sess-4',
|
||||
'scroll-sess-5',
|
||||
]);
|
||||
// ...and the active tab is still brought into view by the render.
|
||||
expect(state.visible).toBe(true);
|
||||
} finally {
|
||||
await context.close();
|
||||
}
|
||||
});
|
||||
|
||||
it('reaches the last tab with a horizontal touch drag', async () => {
|
||||
const { context, page } = await createDevicePage(standardPhone, BASE_URL, 'chromium');
|
||||
try {
|
||||
await page.waitForTimeout(WAIT.PAGE_SETTLE);
|
||||
await seedTabs(page, 5);
|
||||
|
||||
const cdp = await context.newCDPSession(page);
|
||||
const box = await page.locator(SELECTORS.TABS_CONTAINER).boundingBox();
|
||||
if (!box) throw new Error('tab strip not found');
|
||||
const y = box.y + box.height / 2;
|
||||
const startX = box.x + box.width * 0.85;
|
||||
const endX = box.x + box.width * 0.1;
|
||||
|
||||
await cdp.send('Input.dispatchTouchEvent', { type: 'touchStart', touchPoints: [{ x: startX, y }] });
|
||||
for (let i = 1; i <= 10; i++) {
|
||||
await cdp.send('Input.dispatchTouchEvent', {
|
||||
type: 'touchMove',
|
||||
touchPoints: [{ x: startX + ((endX - startX) * i) / 10, y }],
|
||||
});
|
||||
await page.waitForTimeout(16);
|
||||
}
|
||||
await cdp.send('Input.dispatchTouchEvent', { type: 'touchEnd', touchPoints: [] });
|
||||
await page.waitForTimeout(400);
|
||||
|
||||
const after = await stripState(page, 'scroll-sess-5');
|
||||
expect(after.scrollLeft).toBeGreaterThan(0);
|
||||
expect(after.visible).toBe(true);
|
||||
} finally {
|
||||
await context.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Swipe Navigation (CDP - Chromium) ───────────────────────────────────
|
||||
|
||||
describe('Swipe Navigation (CDP - Chromium)', () => {
|
||||
|
||||
@@ -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"');
|
||||
});
|
||||
});
|
||||
@@ -3,28 +3,12 @@ import { resolve } from 'node:path';
|
||||
import vm from 'node:vm';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
type ScrollInput = {
|
||||
scrollLeft?: number;
|
||||
clientWidth?: number;
|
||||
scrollWidth?: number;
|
||||
tabLeft?: number;
|
||||
tabWidth?: number;
|
||||
padding?: number;
|
||||
};
|
||||
|
||||
function loadTabOverflowHelper() {
|
||||
const context = vm.createContext({ window: {}, globalThis: {} });
|
||||
const source = readFileSync(resolve(import.meta.dirname, '../src/web/public/constants.js'), 'utf8');
|
||||
vm.runInContext(source, context, { filename: 'constants.js' });
|
||||
return (
|
||||
context.window as {
|
||||
CodemanTabOverflow: {
|
||||
shouldAutoWrapTabs: (input: unknown) => boolean;
|
||||
computeTabScrollLeft: (input: ScrollInput) => number;
|
||||
TAB_SCROLL_REVEAL_PX: number;
|
||||
};
|
||||
}
|
||||
).CodemanTabOverflow;
|
||||
return (context.window as { CodemanTabOverflow: { shouldAutoWrapTabs: (input: unknown) => boolean } })
|
||||
.CodemanTabOverflow;
|
||||
}
|
||||
|
||||
describe('tab overflow layout policy', () => {
|
||||
@@ -79,64 +63,3 @@ describe('tab overflow layout policy', () => {
|
||||
expect(helper.shouldAutoWrapTabs({ ...base, tabCount: 1, scrollWidth: 1400, clientWidth: 760 })).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// Issue #257: the phone tab strip scrolls horizontally, so the active tab can
|
||||
// sit entirely outside the visible slice. These pin the scroll target math that
|
||||
// _scrollActiveTabIntoView() feeds with measured rects.
|
||||
describe('mobile tab strip scroll-into-view policy', () => {
|
||||
// A 5-tab phone strip: 335px visible of 558px of tabs.
|
||||
const strip = { clientWidth: 335, scrollWidth: 558 };
|
||||
const pad = 16;
|
||||
|
||||
it('scrolls right to reveal a tab past the right edge, leaving the reveal sliver', () => {
|
||||
const helper = loadTabOverflowHelper();
|
||||
// Last tab: 458..558, strip parked at 0.
|
||||
const target = helper.computeTabScrollLeft({ ...strip, scrollLeft: 0, tabLeft: 458, tabWidth: 100 });
|
||||
// 558 + 16 - 335 = 239, clamped to the 223px maximum.
|
||||
expect(target).toBe(223);
|
||||
// The revealed tab is now inside the window.
|
||||
expect(458).toBeGreaterThanOrEqual(target);
|
||||
expect(558).toBeLessThanOrEqual(target + strip.clientWidth);
|
||||
});
|
||||
|
||||
it('scrolls left to reveal a tab before the left edge', () => {
|
||||
const helper = loadTabOverflowHelper();
|
||||
// First tab: 0..150, strip scrolled to the end.
|
||||
expect(helper.computeTabScrollLeft({ ...strip, scrollLeft: 223, tabLeft: 0, tabWidth: 150 })).toBe(0);
|
||||
// A middle tab partially cut off on the left: reveal it with the sliver.
|
||||
expect(helper.computeTabScrollLeft({ ...strip, scrollLeft: 223, tabLeft: 200, tabWidth: 100 })).toBe(200 - pad);
|
||||
});
|
||||
|
||||
it('leaves an already-visible tab alone (callers skip the write)', () => {
|
||||
const helper = loadTabOverflowHelper();
|
||||
expect(helper.computeTabScrollLeft({ ...strip, scrollLeft: 100, tabLeft: 152, tabWidth: 100 })).toBe(100);
|
||||
});
|
||||
|
||||
it('never scrolls a strip that fits, and never leaves the scrollable range', () => {
|
||||
const helper = loadTabOverflowHelper();
|
||||
// Everything fits: nothing to scroll, whatever the tab geometry says.
|
||||
expect(
|
||||
helper.computeTabScrollLeft({ clientWidth: 900, scrollWidth: 400, scrollLeft: 0, tabLeft: 300, tabWidth: 100 })
|
||||
).toBe(0);
|
||||
// Clamped at both ends.
|
||||
const low = helper.computeTabScrollLeft({ ...strip, scrollLeft: 40, tabLeft: 4, tabWidth: 100 });
|
||||
expect(low).toBe(0);
|
||||
const high = helper.computeTabScrollLeft({ ...strip, scrollLeft: 0, tabLeft: 500, tabWidth: 58 });
|
||||
expect(high).toBeLessThanOrEqual(strip.scrollWidth - strip.clientWidth);
|
||||
});
|
||||
|
||||
it('aligns the start of a tab too wide to fit the window', () => {
|
||||
const helper = loadTabOverflowHelper();
|
||||
// 330px tab in a 335px window: no position shows it plus padding.
|
||||
expect(
|
||||
helper.computeTabScrollLeft({ clientWidth: 335, scrollWidth: 900, scrollLeft: 0, tabLeft: 400, tabWidth: 330 })
|
||||
).toBe(400);
|
||||
});
|
||||
|
||||
it('tolerates missing measurements instead of producing NaN', () => {
|
||||
const helper = loadTabOverflowHelper();
|
||||
expect(helper.computeTabScrollLeft({})).toBe(0);
|
||||
expect(helper.computeTabScrollLeft(undefined as unknown as ScrollInput)).toBe(0);
|
||||
expect(helper.TAB_SCROLL_REVEAL_PX).toBe(pad);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user