mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-09-30 20:49:41 +02:00
Compare commits
37
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
13d069e1e5 | ||
|
|
fe2c03b2cc | ||
|
|
4e3f7ac36b | ||
|
|
089283e0b3 | ||
|
|
1513067a7f | ||
|
|
8d094b086c | ||
|
|
ecc6f30e24 | ||
|
|
7da9fb4d53 | ||
|
|
b025047cbf | ||
|
|
f11bee72f5 | ||
|
|
78356d7fd0 | ||
|
|
0da7f652b4 | ||
|
|
6ccab925b1 | ||
|
|
4b51ba306e | ||
|
|
aaad031510 | ||
|
|
29efd0e970 | ||
|
|
a6cf4c2b2a | ||
|
|
831af88579 | ||
|
|
752374abc7 | ||
|
|
adfc4fbb1c | ||
|
|
b0b058891c | ||
|
|
4a1ad8d194 | ||
|
|
193ce6348d | ||
|
|
8668b4b352 | ||
|
|
312ca541e6 | ||
|
|
40ce91f098 | ||
|
|
250a53125a | ||
|
|
14ea9f630f | ||
|
|
8fcfdb1e6e | ||
|
|
45ad9de89e | ||
|
|
a070fc43ea | ||
|
|
aa35c1a0c4 | ||
|
|
c13b3c55d3 | ||
|
|
053a6d238d | ||
|
|
a80eda8e4c | ||
|
|
5d42f64393 | ||
|
|
c891a8045d |
@@ -1,5 +1,72 @@
|
||||
# aicodeman
|
||||
|
||||
## 1.16.3
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Session rows that name their worktree, a shell keyboard bar for phones, App Settings as one scrolling document, and the Read My Mind modal on phones.
|
||||
- **#265 / #266**: a past session whose directory no longer exists used to report
|
||||
`$HOME` as its working directory, because history rows reconstructed a path by
|
||||
stat-walking the filesystem and fell back to `$HOME` when nothing resolved.
|
||||
Deleting a worktree is the normal end of its life, so every past worktree
|
||||
session collapsed onto the same indistinguishable row. History rows now read
|
||||
the literal `cwd` Claude Code stamps on its own records, out of buffers the
|
||||
scanner had already loaded, so it costs no extra file reads and survives the
|
||||
directory being removed. Sessions that ran in a worktree also carry a
|
||||
`⑂ name · branch` pill in the Resume list and the Cmd+K session manager, and
|
||||
both are searchable by worktree name and branch. Measured on a real install:
|
||||
the cwd was recoverable for 215 of 216 transcripts, 212 of them from the first
|
||||
16KB, and 28 rows that previously read `$HOME` now report their real path.
|
||||
Reported and implemented by @jordan8037310.
|
||||
- **#262**: a shell session now gets its own mobile accessory bar
|
||||
(`Ctrl · Esc · Tab · ↑ · ↓ · ← · → · Paste · ⌄`), with Ctrl as a one-shot
|
||||
modifier: tap it, and the next character goes out as its control byte. That
|
||||
puts Ctrl+C/D/Z/R/L/A/E/W/U/K on a nine-button bar without a button per chord.
|
||||
The modifier is applied on the CJK input path too, where the textarea owns the
|
||||
keyboard and an armed modifier could previously neither fire nor be spent, so
|
||||
it survived until a later keystroke and turned that one into a control byte.
|
||||
Agent sessions keep the existing bar unchanged. Proposed by @DodgyBadger.
|
||||
- **#257**: with several tabs open on a phone, the rightmost ones could not be
|
||||
reached. Selecting a tab never scrolled the strip, and every ambient rebuild
|
||||
reset `scrollLeft` to 0, so a strip the user had just swiped snapped back a
|
||||
moment later. Reported by @DodgyBadger.
|
||||
- **App Settings** is now a left rail acting as a table of contents over one
|
||||
scrolling document instead of 8 tabs that wrapped onto two rows. Nine sections,
|
||||
all mounted at once, so find-in-page works across the whole thing. The model
|
||||
controls stop contradicting each other: the base model lives on cards and "1M
|
||||
context window" is a switch that composes onto it, retiring the old pair of
|
||||
settings that each claimed precedence over the other.
|
||||
- **Read My Mind** suggestions beyond the first are no longer discarded. The
|
||||
alternates render as tappable rows with their kind badge, tapping one swaps it
|
||||
into the editable field without losing an in-progress edit, and Rethink now
|
||||
records the whole shown set as rejected. The modal is sized for phones and
|
||||
reachable from the phone keyboard bar.
|
||||
- The desktop welcome screen carries the open tabs as a rail docked to the left
|
||||
edge, with created and last-active stamps refreshed in place.
|
||||
- The README now documents cloning a GitHub repository straight into a case
|
||||
(**Add Case → Clone Repo**), which shipped in 1.16.2 but was only described in
|
||||
the architecture docs.
|
||||
|
||||
- 5d42f64: Home screen: make the past-conversation list usable, and let search find past sessions.
|
||||
- **#260**: "Resume Conversation" showed 4 rows and then dumped every remaining
|
||||
one into a fixed 240px box, with no ordering or filtering. The list now opens
|
||||
with 10 rows, "Show more"/"Show less" grows and shrinks the box itself (the
|
||||
height cap is class-driven instead of fixed), and the header carries a filter
|
||||
box (matches name, folder, `#case` label and the conversation's prompts), a
|
||||
sort control (recent / name A–Z / folder A–Z, pinned rows still first) and a
|
||||
shown-of-total count. Filtering implies expansion, so every match is visible.
|
||||
- **#261**: the search box could not match a past project by folder name: its
|
||||
session corpus was the live in-memory map, while past sessions come from
|
||||
`/api/sessions/unified`. Search now also harvests a bounded snapshot of that
|
||||
unified list, refreshed OUTSIDE the request path (published by
|
||||
`/api/sessions/unified`, plus a fire-and-forget rebuild when stale), so the
|
||||
search path keeps its no-filesystem-reads property. Results for a closed
|
||||
session resume the conversation instead of trying to select a tab that no
|
||||
longer exists, and are badged `RESUME`. In multi-user mode the snapshot is
|
||||
re-scoped per row on read, matching what `/api/sessions/unified` exposes.
|
||||
|
||||
Reported by @jordan8037310.
|
||||
|
||||
## 1.16.2
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -13,7 +13,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
|
||||
| Task | Command |
|
||||
| ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Dev server | `npm run dev` (or `npx tsx src/index.ts web`) |
|
||||
| Type check | `tsc --noEmit` |
|
||||
| Type check | `npm run typecheck` (= `tsc --noEmit`) |
|
||||
| Lint | `npm run lint` (fix: `npm run lint:fix`) |
|
||||
| Format | `npm run format` (check: `npm run format:check`) |
|
||||
| Single test | `npm test -- test/<file>.test.ts` (or `npx vitest run --config config/vitest.config.ts test/<file>.test.ts`) — ⚠ **never** run bare `npm test`, see Testing section |
|
||||
@@ -74,7 +74,7 @@ When user says "COM":
|
||||
|
||||
CI runs `npm run check:lockfile` on every push/PR, so lockfile drift fails the build even if the `version-packages` script is bypassed.
|
||||
|
||||
**Version**: 1.16.2 (must match `package.json`)
|
||||
**Version**: 1.16.3 (must match `package.json`)
|
||||
|
||||
## Project Overview
|
||||
|
||||
@@ -159,15 +159,15 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
|
||||
| **Search** | `src/search-service.ts` | Pure in-memory core for `GET /api/search` |
|
||||
| **Attachments** | `src/attachment-registry.ts`, `attachment-magic`, `generated-artifact-attachments`, `session-attachment-history`, `document-preview-cache`, `document-thumbnailer`, `document-conversion-limiter`, `config/attachment-guard` | See Key Patterns |
|
||||
| **Plan** | `src/plan-orchestrator.ts`, `src/prompts/*.ts`, `src/templates/` (`claude-md.ts` + `case-template.md`) | `templates/` holds the CLAUDE.md scaffold generated into new cases |
|
||||
| **Web** | `src/web/server.ts` ★, `sse-events.ts`, `routes/*.ts` (20 modules + barrel; `session-routes.ts` ★), `route-helpers.ts`, `ports/*.ts`, `middleware/auth.ts`, `schemas.ts`, `self-update.ts`, `plan-usage-latest.ts`, `ws-connection-registry.ts`, `heic-jpeg-converter.ts` + `heic-jpeg-worker.ts` | |
|
||||
| **Frontend** | `src/web/public/app.js` (~5K lines, core) + 28 modules + `sw.js` | See Frontend section for the load order, which is authoritative |
|
||||
| **Types** | `src/types/index.ts` (barrel) → 20 domain files; also `src/types.ts` root re-export | See `@fileoverview` in index.ts |
|
||||
| **Web** | `src/web/server.ts` ★, `sse-events.ts`, `routes/*.ts` (24 modules + barrel; `session-routes.ts` ★), `route-helpers.ts`, `ports/*.ts`, `middleware/auth.ts`, `schemas.ts`, `self-update.ts`, `plan-usage-latest.ts`, `ws-connection-registry.ts`, `heic-jpeg-converter.ts` + `heic-jpeg-worker.ts` | |
|
||||
| **Frontend** | `src/web/public/app.js` (~5K lines, core) + 29 modules + `sw.js` | See Frontend section for the load order, which is authoritative |
|
||||
| **Types** | `src/types/index.ts` (barrel) → 22 domain files; also `src/types.ts` root re-export | See `@fileoverview` in index.ts |
|
||||
|
||||
★ = Large, central file (>50KB) — read its `@fileoverview` first. All files have `@fileoverview` JSDoc — read that before diving in. Discovery aid: `grep -l '@fileoverview' src/web/routes/*.ts` lists all route modules; same grep works for `src/types/`, `src/web/public/*.js`.
|
||||
|
||||
**Local packages**: `packages/xterm-zerolag-input/` (local echo overlay, single-source, see Gotchas). `packages/gesture-control/` (`codeman-gesture-control`, hand-tracking overlay source, built via `npm run build:gesture`).
|
||||
|
||||
**Config**: `src/config/` — 17 files, no barrel (`index.ts`) exists; import from the specific file.
|
||||
**Config**: `src/config/` — 20 files, no barrel (`index.ts`) exists; import from the specific file.
|
||||
|
||||
**Utilities**: `src/utils/` — re-exported via index. Key: `CleanupManager`, `LRUMap` (⚠ NOT in the barrel — import from `./utils/lru-map.js` directly), `StaleExpirationMap`, `BufferAccumulator`, `stripAnsi`, `Debouncer`, `KeyedDebouncer`. Also: `claude-cli-resolver`/`opencode-cli-resolver`/`codex-cli-resolver`/`gemini-cli-resolver` (CLI path resolution), `string-similarity` (fuzzy matching), `regex-patterns` (ANSI/token/spinner patterns), `assertNever` (exhaustive checks), `token-validation` (auth tokens), `nice-wrapper` (process priority).
|
||||
|
||||
@@ -200,7 +200,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
|
||||
|
||||
**Docker cases**: a case can point at a **container**, with any of the five CLI backends running inside it. Like remote-SSH this is a **LOCATION OVERLAY on cases, never a sixth `SessionMode`**. Exactly one long-lived container **per case**, shared by all its sessions, so killing a session kills only that session's in-container tmux and **never** `docker stop` while siblings remain. The workspace is a real host dir bind-mounted at the **same absolute path**, which is what keeps file-routes/watchers on real host bytes and makes the in-container transcript projHash match the host. Credentials are **seeded** (RO mount, copied into the container once) rather than shared RW, so in-container CLIs never write refreshed tokens back to the host, and bind mounts are excluded from `docker commit` so exports stay secret-free. **NEVER a create-time `-e` for secrets, NEVER `--privileged`, NEVER the docker socket.** Config drift is detected via a label hash and a drifted launch is REFUSED rather than silently launched with stale config. ⚠️ On the loopback-only prod bind a container cannot reach 127.0.0.1, so in-container hooks need `CODEMAN_DOCKER_BRIDGE_HOOKS=1`; otherwise idle detection falls back to output-based. → [architecture-invariants#docker-cases](docs/architecture-invariants.md#docker-cases), `docs/docker-cases.md` (user guide), `docs/docker-cases-plan.md` (design)
|
||||
|
||||
**External CLI modes (OpenCode, Codex, Gemini, Antigravity)**: `isExternalCliMode()` in `session.ts` gates Claude-specific behavior off (Ralph tracker, BashToolParser, token/CLI-info parsing, ❯-prompt readiness); these CLIs render their own TUIs, so readiness is output stabilization instead. All four **require tmux with no direct PTY fallback**, because secrets are injected via socket-scoped `tmux setenv` and never on the spawn command line. ⚠️ `run*()` in `session-ui.js` MUST unwrap the `{success,data}` envelope; reading the raw shape silently breaks the run. ⚠️ **Codex sessions use PREDICTIVE WRITE-THROUGH echo, never the buffer overlay** (`_localEchoPolicy` in `_updateLocalEchoState`, terminal-ui.js): codex's composer reacts per keystroke ("/" pops a live-filtering picker, arrows edit server-side state, the composer grows as it wraps), so buffer-until-Enter starved it into issues #218/#219/#220/#222 and stays disabled (`_localEchoEnabled` remains false for codex). Instead, `PredictiveEchoAddon` (separate `vendor/xterm-predictive-echo.js` bundle) paints each keystroke at the predicted cell while the wire path stays BYTE-IDENTICAL: the onData hook (`_predictHookOnData`) is a plain statement with no `return`, so control always falls through into the untouched send path — pinned by vm and E2E byte-identity tests. Predictions reconcile against the parsed buffer and only while the cursor sits on the measured composer row (`isCodexComposerRow`, `/^› /`). Codex also **drops keystrokes that share a PTY read with a bracketed paste**, so flushed text and the paste sequence must go out as separate delayed writes (mirroring the Enter branch's delayed `\r`). Tests: `test/local-echo-codex-gating.test.ts`, `test/codex-predictive-echo.test.ts` (E2E vs real codex), `packages/xterm-zerolag-input/test/codex-replay.test.ts`. → [architecture-invariants#external-cli-modes-opencode-codex-gemini](docs/architecture-invariants.md#external-cli-modes-opencode-codex-gemini)
|
||||
**External CLI modes (OpenCode, Codex, Gemini, Antigravity)**: `isExternalCliMode()` in `session.ts` gates Claude-specific behavior off (Ralph tracker, BashToolParser, token/CLI-info parsing, ❯-prompt readiness); these CLIs render their own TUIs, so readiness is output stabilization instead. All four **require tmux with no direct PTY fallback**, because secrets are injected via socket-scoped `tmux setenv` and never on the spawn command line. ⚠️ `run*()` in `session-ui.js` MUST unwrap the `{success,data}` envelope; reading the raw shape silently breaks the run. ⚠️ **Codex sessions use PREDICTIVE WRITE-THROUGH echo, never the buffer overlay** (`_localEchoPolicy` in `_updateLocalEchoState`, terminal-ui.js): codex's composer reacts per keystroke ("/" pops a live-filtering picker, arrows edit server-side state, the composer grows as it wraps), so buffer-until-Enter starved it into issues #218/#219/#220/#222 and stays disabled (`_localEchoEnabled` remains false for codex). Instead, `PredictiveEchoAddon` (separate `vendor/xterm-predictive-echo.js` bundle) paints each keystroke at the predicted cell while the wire path stays BYTE-IDENTICAL: the onData hook (`_predictHookOnData`) is a plain statement with no `return`, so control always falls through into the untouched send path — pinned by vm and E2E byte-identity tests. Predictions reconcile against the parsed buffer and only while the cursor sits on the measured composer row (`isCodexComposerRow`, `/^› /`). Codex also **drops keystrokes that share a PTY read with a bracketed paste**, so flushed text and the paste sequence must go out as separate delayed writes (mirroring the Enter branch's delayed `\r`). Tests: `test/local-echo-codex-gating.test.ts`, `test/codex-predictive-echo.test.ts` (E2E vs real codex), `packages/xterm-zerolag-input/test/codex-replay.test.ts`. → [architecture-invariants#external-cli-modes-opencode-codex-gemini](docs/architecture-invariants.md#external-cli-modes-opencode-codex-gemini-antigravity)
|
||||
|
||||
**Run launch synchronization**: the Run entrypoint holds an in-flight lock and disables `#runBtn` for the whole launch (≥500ms), so a double click cannot create duplicate sessions with the same `w<n>-<case>` name. `_ensureCreatedSessionVisible()` runs before `selectSession()`, and `_onSessionCreated()` stays an idempotent upsert, so POST-first and SSE-first ordering both produce exactly one rendered tab. → [architecture-invariants#run-launch-synchronization](docs/architecture-invariants.md#run-launch-synchronization)
|
||||
|
||||
@@ -210,7 +210,9 @@ 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; phones hide it in mobile.css and get a keyboard-accessory 🧠 key instead (ships in BOTH bar templates, revealed by the `rmm-enabled` class on the BAR element — setMode() rebuilds button innerHTML, so per-key state would be wiped; synced at init + every `applyHeaderVisibilitySettings()`). Alternate suggestions render as tappable rows that swap into the editable field without losing edits; Rethink rejects the whole shown set and carries the optional steer note (`#readMyMindSteer`, sent as `steer`, shown in ready + empty-result phases, cleared on each open). Suggestions render via value/`textContent` ONLY and Send/Insert go through `POST /input` (server-side, so the sendEnterKey/local-echo trap does not apply) — nothing auto-sends, ever. User guide: `docs/readmymind.md`.
|
||||
|
||||
**Voice dictation via Claude** (`claudeVoiceEnabled`, SYNCED, default OFF): the mic button can transcribe through this machine's Claude Code login instead of a Deepgram key, using the same speech-to-text service the CLI's own `/voice` mode uses. ⚠️ **Claude Code's voice mode itself is unusable here**: it opens the HOST's microphone (`sox`/`arecord`), and the CLI runs in a headless tmux pane while the human is in a browser elsewhere. So Codeman captures in the browser and borrows only the backend. Audio goes browser → Codeman → Anthropic (`src/web/voice-stream.ts`): the OAuth token never reaches the page, and the browser only sends PCM and receives text. ⚠️ Credentials are **read-only** (`src/claude-credentials.ts`) and Codeman never refreshes them — a refresh rotates the refresh token and could sign the user out of their own CLI; an elapsed token reports `expired` instead. ⚠️ Capture MUST be linear16/16 kHz/mono, so it uses an **AudioWorklet**, not MediaRecorder (which cannot emit raw PCM); `voice-pcm-worklet.js` is fetched from JS, so it is invisible to `cacheBustAssets` and borrows voice-input.js's `?v=` token — **edit the two together**. ⚠️ Transcript frames carry the WHOLE running transcript, not deltas: the Claude path replaces where the Deepgram path appends. Provider choice is `voiceSettings.provider` (`auto` prefers Claude → Deepgram → Web Speech). → `docs/claude-voice-plan.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/`.
|
||||
|
||||
@@ -222,7 +224,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
|
||||
|
||||
**Detached start + service install** (issue #231): `codeman web -d` relaunches the SAME entry script with `detached:true` (setsid), so there is no controlling terminal and no shell job entry. ⚠️ `nohup` is NOT what makes this work: Node re-arms SIGHUP to its default disposition even when it inherits "ignore", and `cli.ts` handles SIGHUP with a graceful shutdown, so a delivered HUP still stops the server. ⚠️ Both `-d` and `service install` must REFUSE when a server is already up on this data dir (pidfile check + `/api/status` probe): a second instance on the shared tmux socket attaches PTYs to the first one's live sessions. ⚠️ Neither may report success it has not observed — the parent polls `/api/status` until the child answers or dies, since `launchctl load` and a clean spawn are both silent about a server that starts and immediately exits. `--stop` verifies the pid still LOOKS like a Codeman server (`ps -o command=`) before signalling, because pids get recycled. Unit/label names live in `config/service-names.ts` so install.sh, `detectSupervisor()` and `service install` cannot drift into supervising two copies; they are instance-scoped, and identical to the historical names for the default instance. `service install` bakes the installing shell's PATH into the unit (launchd gives a job `/usr/bin:/bin:/usr/sbin:/sbin`, which finds neither a Homebrew/nvm `node` nor `tmux`/`claude`) and never writes `CODEMAN_PASSWORD` into it. → [architecture-invariants#detached-start-and-service-install](docs/architecture-invariants.md#detached-start-and-service-install)
|
||||
|
||||
**Self-update** (App Settings → Updates): in-app updater for git-clone installs supervised by systemd/launchd (`systemd`, `launchd`, `launchd-daemon`, else `none` → "restart manually"). The update restarts the very process running it, so the real work runs in a DETACHED `scripts/self-update.sh` that outlives the restart and writes progress to `update-status.json`, which the browser polls across the connection drop. `src/web/self-update.ts` splits pure helpers (unit-tested) from IO wrappers. npm installs report as non-updatable. → [architecture-invariants#self-update](docs/architecture-invariants.md#self-update)
|
||||
**Self-update** (App Settings → System → Updates): in-app updater for git-clone installs supervised by systemd/launchd (`systemd`, `launchd`, `launchd-daemon`, else `none` → "restart manually"). The update restarts the very process running it, so the real work runs in a DETACHED `scripts/self-update.sh` that outlives the restart and writes progress to `update-status.json`, which the browser polls across the connection drop. `src/web/self-update.ts` splits pure helpers (unit-tested) from IO wrappers. npm installs report as non-updatable. → [architecture-invariants#self-update](docs/architecture-invariants.md#self-update)
|
||||
|
||||
**Attachments** (live external document references; all wiring in `file-routes.ts`): a **registry** maps a stable `attachmentId` to a realpath-resolved, extension-allowlisted absolute path, so browser requests never carry arbitrary absolute paths. ⚠️ The **magic-link scanner** (`codeman://attach?...` in terminal output) is **prompt-injectable**, so its scan path is force-confined to the session workspace; a hostile prompt could otherwise exfiltrate arbitrary host files over SSE. The security gate is an extension **allowlist**, not a blocklist. `document-conversion-limiter.ts` caps converter spawns globally: without it, N large docs detected at once fork N multi-minute processes, which is a resource-exhaustion vector. → [architecture-invariants#attachments](docs/architecture-invariants.md#attachments)
|
||||
|
||||
@@ -234,7 +236,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
|
||||
|
||||
**Clone a repository as a case** (issue #236, Add Case → **Clone Repo**): `POST /api/cases/clone` clones a public repo into the caller's case space synchronously (request held open, bounded by `GIT_CLONE_TIMEOUT_MS`, no job store); `POST /api/cases/clone-preflight` reports whether the URL can be cloned anonymously plus its real branches/tags. Core in `src/git-clone.ts`. ⚠️ **The URL is a code-execution surface**: `ext::sh -c <cmd>` (and ANY `<name>::<payload>` helper) makes git run a command, so every `::` form is refused, a leading `-` is refused, and every spawn is an argv array with `--` before the operands. ⚠️ **Non-interactive or the open request hangs** — `gitNonInteractiveEnv()` closes the terminal/askpass/ssh/GCM prompt paths; `HOME`/`PATH` stay inherited, so a user's OWN credential helper may authenticate (Codeman still never collects or stores credentials, and refuses a `user:password@` URL). ⚠️ Timeout kills the process GROUP (clone fans out into child processes), the destination is removed only if this attempt created it, and repository contents win over scaffolding (existing `CLAUDE.md` kept, hooks merged, repo-shipped `.claude/settings*` reported as a warning since its hooks run locally). The **Brain** picker sets the toolbar run mode on success. → [architecture-invariants#clone-a-repository-as-a-case](docs/architecture-invariants.md#clone-a-repository-as-a-case)
|
||||
|
||||
**Cross-session search**: `GET /api/search` federates an in-memory search over session metadata, run-summary events, and attachment-history entries. The pure core `searchSources()` does substring matching with hard per-type caps: **no regex (so no ReDoS) and no filesystem reads (so no traversal)**. The server-private `externalPath` is never read. → [architecture-invariants#cross-session-search](docs/architecture-invariants.md#cross-session-search)
|
||||
**Cross-session search**: `GET /api/search` federates an in-memory search over session metadata, run-summary events, and attachment-history entries. The pure core `searchSources()` does substring matching with hard per-type caps: **no regex (so no ReDoS) and no filesystem reads (so no traversal)**. The server-private `externalPath` is never read. PAST sessions (#261) come from `session-history-index.ts`, a capped snapshot of the unified list filled **outside** the request path (`/api/sessions/unified` publishes it; a stale one is rebuilt fire-and-forget), that indirection is what keeps the no-fs property. ⚠️ The snapshot is stored UNSCOPED with a per-row owner and MUST be re-filtered through `canAccessOwned()` on read; history rows carry `jumpTo.kind:'resume-session'`, since a closed session has no tab to select. → [architecture-invariants#cross-session-search](docs/architecture-invariants.md#cross-session-search)
|
||||
|
||||
**Web tabs** (dashboard URLs as tabs): a saved URL renders as a tab beside agent sessions. **NOT a sixth `SessionMode`** (no PTY, no tmux, no respawn), same reasoning that keeps Docker/remote-SSH as case overlays. Dashboards are **proxied through Codeman's own origin** by default, because a direct iframe fails three ways at once: prod is HTTPS so `http://` targets are blocked as mixed content, many dashboards send `X-Frame-Options: DENY`, and our own `default-src 'self'` CSP blocks cross-origin frames. Proxying leaves the prod CSP unchanged (`/webview/...` is `'self'`). ⚠️ The proxy is **NOT an API surface**: it authenticates on an in-memory capability in the path and is correspondingly exempt from the cookie + Origin checks; that exemption is fenced to safe methods and non-`/api` paths and is pinned by `test/webview-auth-exemption.test.ts`. ⚠️ Iframes omit `allow-same-origin` unless a dashboard is explicitly marked `trusted`, and `Authorization`/`codeman_session` are stripped upstream in **both** modes so `CODEMAN_PASSWORD` cannot leak. ⚠️ A sandboxed frame is **opaque-origin**, which breaks two things `curl` can never reproduce: its runtime-built root-absolute URLs escape `<base>` (fixed by an injected `runtimeUrlShim()`), and its same-host `fetch`/XHR are CORS-checked with `Origin: null` (fixed by `buildProxyCorsHeaders()` plus exempting the proxy from the global `OPTIONS`-204 short-circuit in `registerSecurityHeaders`). Both present as the dashboard's own "Failed to fetch" while the page renders fine. → [architecture-invariants#web-tabs](docs/architecture-invariants.md#web-tabs), `docs/web-tabs.md`
|
||||
|
||||
@@ -252,14 +254,20 @@ 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.
|
||||
**Desktop home tab rail** (`home-sessions.js`, desktop only): the welcome overlay centers ~560px of content in a ~1400px window, so its left gutter is dead space; it carries the open tabs as a rail **docked flush to the left edge, full height** (a vertically centered card floating mid-gutter read as debris). Rows are in **tab order**, not sorted by urgency like the phone overview, because the row badges are the Alt+1..9 indices, and each carries **created / last-active** stamps. State classification is REUSED from mobile-overview.js (`_mobileOverviewState`/`_mobileOverviewCaseFor`), which is why the module loads after it. ⚠️ The rail 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 rail 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. ⚠️ Size scales with the viewport off **one knob**: `width: clamp(250px, 19vw, 430px)` plus a fluid `font-size` on `.home-sessions`, with every child sized in `em` — reintroducing `rem`/px type inside the block silently breaks the scaling, and widening the clamp past the gutter reintroduces the overlap the gate exists to prevent. The age stamps are refreshed **in place** by a 20s clock (`_tickHomeSessionsTimes()`, disarmed in `hideHomeSessions()`), never by re-rendering, which would restart every row's blink and working ring. 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; **idle** is deliberately NOT that green — dot and pill mix toward `--text-muted` so a glance separates running from sitting. Live re-renders ride the tail of `_renderSessionTabsImmediate()` alongside the phone overview.
|
||||
|
||||
**Welcome "Resume Conversation" list** (terminal-ui.js): `loadHistorySessions()` fetches once and caches the corpus on `_historyAll`/`_historyCases`; every subsequent view (filter box, sort select, expand, the periodic refresh in panels-ui.js) goes through `_renderHistoryList()`, so never append rows to `#historyList` directly or re-fetch to re-sort. ⚠️ The box height is **class-driven**: expanding the list without `.history-list.expanded` leaves the collapsed `max-height` in place and just deepens a scroll well, which is the bug #260 reported (35 sessions in a ~4-row box). ⚠️ The A–Z sort keys off `_historyRowLabel()`, the SAME string the row renders (`name || firstPrompt || path`), most rows are transcript-backed and have no session name, so sorting on `name` alone silently does nothing. ⚠️ A filter implies expansion, and `_renderSearch()` hides `#historyHeader` (title + controls) as one unit while a search is active. Tests: `test/history-list-controls.test.ts`.
|
||||
|
||||
**Command palette + shortcut registry**: `Ctrl/Cmd/Alt+K` opens the session palette; shortcuts live in a rebindable registry (`DEFAULT_SHORTCUTS`/`getShortcutRegistry()`/`matchesShortcutEvent()` in app.js, overrides in `settings.shortcutOverrides`). ⚠️ Palette-chord keys must ALSO be swallowed in `attachCustomKeyEventHandler` (terminal-ui.js) or xterm writes the control byte (0x0B) into the PTY. ⚠️ `saveAppSettings()` rebuilds settings from the DOM, so keys edited elsewhere (`shortcutOverrides`, `showTokenCount`, `showCost`) need explicit `_prev` carry-over. ⚠️ **Smart copy (`Ctrl+C`)** lives in that same handler: with a selection it copies, with none it must `return true` **without** `preventDefault()` or the interrupt is lost. `copyTerminalSelection` is deliberately absent from `SHORTCUT_ACTIONS` because the generic capture loop preventDefaults every match it dispatches. → [architecture-invariants#command-palette-and-shortcut-registry](docs/architecture-invariants.md#command-palette-and-shortcut-registry)
|
||||
|
||||
**Per-device vs synced settings**: the `displayKeys` set in settings-ui.js is a **client-side merge policy**, not a wire filter. A display key seeds from the server only when localStorage has no value for it, which is what prevents one device overwriting another; `showPlanUsageLimits` is additionally `delete`d from the incoming payload outright. Separately, `SettingsUpdateSchema` is `.strict()` and simply **does not declare** `skin`, `showFileViewerButton`, `showCronButton`, `webglRendererEnabled`, `localEchoEnabled`, `cjkInputEnabled`, or `extendedKeyboardBar`, so sending one of those is a validation error. The rest (`showResponseViewer`, `showPlanUsageLimits`, `language`, and most `show*` keys) ARE in the schema and do persist server-side; they are per-device by client policy only. ⚠️ Adding a new per-device setting means deciding **both** questions: membership in `displayKeys`, and presence in the schema.
|
||||
|
||||
**Settings surface** (`#appSettingsModal` + `#sessionOptionsModal` + `#createCaseModal`): the `set-*` language (left rail, groups of rows, control pinned right) is shared by all three modals through ONE `:is(#appSettingsModal, #sessionOptionsModal, #createCaseModal)` scope in styles.css: an `:is()` list takes its most specific argument's specificity, so every rule keeps the id weight it had and nothing downstream shifts. **App Settings** is a rail that is a **table of contents over ONE scrolling document**, not a tab switcher: every section stays mounted (`.set-section`, ids `settings-updates|terminal|layout|appearance|models|clis|notifications|voice|shortcuts|system`, in that order, the version and the updater leading and the rest of the system settings tailing), and `switchSettingsTab(id)` keeps its historical name but SCROLLS instead of hiding. **Session Options** and **Add Case** use the same surface with a rail that really SWITCHES (`switchOptionsTab` / `switchCaseModalTab` show one `.set-section` and `.hidden` the rest, since Summary owns its own scroller, Respawn is long, and Add Case is six independent forms). ⚠️ They also take a deliberate **size-up** that App Settings does not (900px shell, 236px rail, `height:auto` between `min(560px,80vh)` and 88vh, vs App Settings' tight 760×620): they are short task panels, not a document you scan, and at scanning density they read as a few fields marooned in an empty frame. Those per-modal blocks are the design, not drift. Phones (≤860px) give App Settings the sticky `#appSettingsJump` pill and give the other two a horizontal rail strip, which neither has a pill for. ⚠️ The Session Options rail entry labelled **Session** still keys off `context` (`data-tab="context"`, `#context-tab`, `switchOptionsTab('context')`), the rename is label-only. Add Case keeps its legacy `.form-row` markup (six panels of it, every id read back by session-ui.js) and is mapped onto the look by an adapter block scoped to `#createCaseModal .set-doc`. Do not restructure those forms just to reach the row classes. ⚠️ That adapter's `summary { display:flex }` **kills the native disclosure triangle**, so every `<details>` there needs the explicit `.set-adv-chev` and both marker suppressions (`list-style` + `::-webkit-details-marker`); without it five collapsed blocks render as plain headings nobody clicks. ⚠️ **The load/save contract is `getElementById` by id**: `openAppSettings()`/`saveAppSettings()`/`openSessionOptions()` read every control by a fixed id, so moving a control between sections is free but renaming or dropping one silently stops it loading or saving. Static guards: `test/app-settings-structure.test.ts` + `test/session-options-structure.test.ts` (rail↔section pairing, one-visible-section, the `data-claude-only` entries external CLIs drop). ⚠️ Model cards (`#appSettingsModelCards`) and the effort segment are **views over hidden `<select>`s** that remain the source of truth; the cards hold the BASE model and the "1M context window" switch composes `base + [1m]` back into `claudeModel`, which is what retires the old "takes precedence over the toggle below" trap. ⚠️ `.modal-tabs`/`.modal-tab-btn`/`.modal-tab-content` are RETIRED: no modal uses them and their CSS is deleted, and a reappearance means a modal drifted off the shared surface. ⚠️ The **Header & Panels live preview** is a scale model rebuilt from the chips (`_syncLayoutPreview`); it owns NO icons, it CLONES `.set-chip-ico` out of the chip, so each icon has exactly one copy in index.html. A chip joins it via `data-preview` (slot) + `data-preview-order`, or `data-preview-text` for readouts that are not buttons. Its frame is painted from skin tokens only (hardcoded black alphas turned it into a grey slab on the light skins) and is `data-i18n-skip`. ⚠️ In Session Options → Respawn, auto-resume is a `.set-callout` whose `<label>` **wraps its own switch with no `for=`** (nesting associates them; the label+`for` pair has historically double-fired), and the cycle steps are real checkboxes (`.set-checks`), not chips. ⚠️ `admin-ui.js` injects the multi-user Users entry into `.set-rail-items` + `.set-doc`, so those hooks must survive any restructure. → [architecture-invariants#settings-surface-app-settings-session-options-add-case](docs/architecture-invariants.md#settings-surface-app-settings-session-options-add-case)
|
||||
|
||||
**Header button visibility**: most header controls are opt-in and hidden by a marker class (`btn-multimonitor--hidden`, `btn-response-viewer-header--hidden`, `btn-file-viewer--hidden`, `btn-cron--hidden`) that `applyHeaderVisibilitySettings()` (settings-ui.js) toggles after settings load; the multi-monitor button is instead stripped at render by `renderIndexHtml`. ⚠️ Hiding must go through the marker class: the base rules are `display:inline-flex !important`, so an inline style cannot override them. Current desktop default is WS/CPU/MEM + File Viewer + gear, with the token chip and lifecycle-log button OFF. ⚠️ New header controls must not leak onto phones; `test/mobile-header-buttons-policy.test.ts` is the static guard. → [architecture-invariants#header-button-visibility-multi-monitor-response-viewer-file-viewer-cron](docs/architecture-invariants.md#header-button-visibility-multi-monitor-response-viewer-file-viewer-cron)
|
||||
|
||||
**Gesture control** (camera hand-tracking overlay, opt-in, default OFF): `CODEMAN_GESTURE=1` makes the feature *available*; `gestureControlEnabled` turns it on. The bundle is injected by `renderIndexHtml` only when enabled, which is why that method is `async` and reads settings with `readSettings(true)` (a fresh read: a post-save reload lands inside the 2s cache TTL and would otherwise render the pre-toggle state). **Source lives in `packages/gesture-control/`; edit there, run `npm run build:gesture`, and commit the regenerated bundle** because dev serves the committed bundle with no runtime bundler. The MediaPipe wasm + model are fetched separately and gitignored. ⚠️ Keep `MP_VERSION` in `fetch-gesture-assets.mjs` in sync with `@mediapipe/tasks-vision`. → [architecture-invariants#gesture-control-the-source-package](docs/architecture-invariants.md#gesture-control-the-source-package)
|
||||
@@ -308,11 +316,11 @@ Frontend JS modules have `@fileoverview` with `@dependency`/`@loadorder` tags. L
|
||||
|
||||
### SSE Event Registry
|
||||
|
||||
154 event constants in `src/web/sse-events.ts` (backend) and `SSE_EVENTS` in `constants.js` (frontend). **Both must be kept in sync** — they are currently exactly in sync, and the backend file's `@fileoverview` carries the per-category breakdown.
|
||||
154 event constants in `src/web/sse-events.ts` (backend) and `SSE_EVENTS` in `constants.js` (frontend). **Both must be kept in sync**, and `test/sse-registry-parity.test.ts` is the guard that pins it (currently exactly in sync, 154 = 154, no drift either direction). The backend file's `@fileoverview` carries the per-category breakdown.
|
||||
|
||||
### API Routes
|
||||
|
||||
~200 handlers across 23 route files in `src/web/routes/`: system (45), sessions (34), cases (29), files (16), orchestrator (10), ralph (9), cron (9), admin (8), plan (8), respawn (7), webviews (6 + the `/webview/:cap/*` proxy), mux (5), push (4), scheduled (4, legacy `ScheduledRun`), approvals (3), readmymind (4), me (2), teams (2), search (1), hooks (1), clipboard (1), status-telemetry (1), ws (1 WebSocket). Each file has `@fileoverview` with endpoint details.
|
||||
~200 handlers across 24 route files in `src/web/routes/`: system (45), sessions (34), cases (29), files (16), orchestrator (10), ralph (9), cron (9), admin (8), plan (8), respawn (7), webviews (6 + the `/webview/:cap/*` proxy), mux (5), push (4), scheduled (4, legacy `ScheduledRun`), approvals (3), readmymind (4), me (2), teams (2), search (1), hooks (1), clipboard (1), status-telemetry (1), voice (1 + the `/ws/voice/stream` relay), ws (1 WebSocket). Each file has `@fileoverview` with endpoint details.
|
||||
|
||||
**HTTP contract** (stable since 0.9.x, see `docs/versioning-policy.md`; full envelope/status/error-code/SSE spec in `docs/api-reference.md`): responses use the `ApiResponse<T>` envelope — `{ success: true, data? }` or `{ success: false, error, errorCode }` (`src/types/api.ts`). `/api/v1/*` is a versioned alias of `/api/*` (URL rewrite in `server.ts`).
|
||||
|
||||
|
||||
@@ -252,9 +252,9 @@ Click **+ New Session** (or **Quick Start**). A session is one AI CLI running in
|
||||
|
||||
| Field | What it does |
|
||||
| ---------------------------- | ------------------------------------------------------------------------------------------------------------------- |
|
||||
| **Working directory / case** | The folder the agent operates in. A "case" is just a named working dir Codeman remembers. |
|
||||
| **Working directory / case** | The folder the agent operates in. A "case" is just a named working dir Codeman remembers. **Add Case** creates one from scratch, links an existing folder, or clones a GitHub repo straight into one (**Clone Repo**). |
|
||||
| **CLI / run mode** | `Claude` (default), `OpenCode`, `Codex`, `Antigravity`, `Gemini`, or `Terminal` (plain shell). |
|
||||
| **Model** | Per-session model (App Settings → Claude Model). A soft default — `/model` still works in-session. |
|
||||
| **Model** | Per-session model (App Settings → Models → New Claude sessions). A soft default — `/model` still works in-session. |
|
||||
| **Effort / Ultracode** | Reasoning effort (`low`–`max`) or `ultracode` for dynamic multi-agent workflows. Switchable anytime with `/effort`. |
|
||||
|
||||
Hit start — Codeman spawns the CLI via a real PTY and streams it to your browser over SSE.
|
||||
@@ -278,7 +278,7 @@ Hit start — Codeman spawns the CLI via a real PTY and streams it to your brows
|
||||
| ---------------- | --------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------- |
|
||||
| **Respawn** | Long unattended runs — auto-restarts the CLI on idle/limit, with adaptive timing. Presets: `solo-work`, `overnight-autonomous`, … | Respawn tab |
|
||||
| **Orchestrator** | Turn one goal into a phased plan and drive it to completion across agents. | Orchestrator panel |
|
||||
| **Cron** | Saved, named jobs on a schedule (`once`/`interval`/`daily`/`weekly`) that spawn a session and send a prompt when due. | ⏰ Cron button _(opt-in: App Settings → Display → Header Displays)_ |
|
||||
| **Cron** | Saved, named jobs on a schedule (`once`/`interval`/`daily`/`weekly`) that spawn a session and send a prompt when due. | ⏰ Cron button _(opt-in: App Settings → Header & Panels → Scheduling)_ |
|
||||
| **Auto-resume** | Automatically continue after a subscription rate-limit resets. | Respawn tab (top) |
|
||||
|
||||
### 6. Reach it from anywhere
|
||||
@@ -291,7 +291,7 @@ Hit start — Codeman spawns the CLI via a real PTY and streams it to your brows
|
||||
|
||||
- **App Settings** — model, effort, permission startup mode, theme/skin, notifications, display toggles, per-CLI options, a synced custom display name, and per-device English/Simplified Chinese UI language.
|
||||
- **Run it in the background** — `codeman web -d` detaches from your shell (`--status`, `--stop`); `codeman service install` makes it a systemd user unit / macOS LaunchAgent that survives reboots. Both verify the server actually answers before reporting success, and both refuse to start a second server on one data dir. See [Keep it running in the background](#quick-start---installation).
|
||||
- **Self-update** — git-clone installs update in place from **Settings → Updates**.
|
||||
- **Self-update** — git-clone installs update in place from **App Settings → System → Updates**.
|
||||
- **Deploy your own changes** — see [Development](#development).
|
||||
|
||||
> ⚠️ **Safety:** if you're working _inside_ a Codeman-managed session (`echo $CODEMAN_MUX` → `1`), never run `tmux kill-session` / `pkill claude` directly — use the web UI or `./scripts/tmux-manager.sh`.
|
||||
@@ -427,16 +427,17 @@ PTY Output → 16ms Server Batch → DEC 2026 Wrap → SSE → Client rAF → xt
|
||||
## More Features
|
||||
|
||||
- **Background daemon & service install** — `codeman web -d` runs the server detached with a pidfile, `~/.codeman/web.log`, and verified startup (it polls the server until it answers, so a port clash never reads as success); `codeman service install` writes a systemd user unit (Linux) or LaunchAgent (macOS) with your shell's PATH baked in, so an nvm or Homebrew `node`, `tmux` and `claude` are actually found. Secrets are never written into unit files
|
||||
- **Self-update** — git-clone installs under systemd/launchd update in place from **App Settings → Updates**: it detects the latest release, auto-stashes a dirty tree, and streams build progress across the service restart (npm installs report as non-updatable)
|
||||
- **Self-update** — git-clone installs under systemd/launchd update in place from **App Settings → System → Updates**: it detects the latest release, auto-stashes a dirty tree, and streams build progress across the service restart (npm installs report as non-updatable)
|
||||
- **Clone a GitHub repo as a case** — paste a repository URL into **Add Case → Clone Repo** and Codeman clones it into `~/codeman-cases/<name>` and registers it as a normal case, ready to run an agent in. It preflights the URL while you type (tells you whether it can be cloned anonymously and offers the repo's real branches and tags for the optional branch/tag field), fills the case name in from the URL, and lets you pick which CLI the Run button should use. Public repositories over `https://`; Codeman never collects or stores credentials
|
||||
- **Multi-CLI** — run **Claude Code**, **OpenCode**, **Codex**, **Antigravity**, or **Gemini** per session; env-var prefixes auto-gate (`CLAUDE_CODE_*` vs `OPENCODE_*` vs `CODEX_*` vs `ANTIGRAVITY_*` vs `GEMINI_*`/`GOOGLE_*`). See [`docs/opencode-integration.md`](docs/opencode-integration.md)
|
||||
- **Docker sessions** — run a case inside an isolated, hardened container. One checkbox on **Create New** spins up a container with sensible defaults and starts the agent inside it; multiple sessions share one per-case container; export a container + its workspace to a portable `.tar.gz` to move it to another machine. See [`docs/docker-cases.md`](docs/docker-cases.md)
|
||||
- **Remote SSH sessions** — point a case at another machine and run the agent there inside a durable remote tmux: survives SSH drops, auto-reconnects, and can discover + attach sessions already running on the host. See [`docs/remote-sessions.md`](docs/remote-sessions.md)
|
||||
- **Effort & Ultracode** — set a per-session default effort (`low`–`max`) or enable **ultracode** (dynamic multi-agent workflows). Soft defaults only — switchable anytime with `/effort` in-session. Extended-thinking budget is configurable too
|
||||
- **Voice input** — dictate prompts with Deepgram Nova-3 (Web Speech API fallback): toggle recording, auto-silence stop, live level meter (`Ctrl+Shift+V`)
|
||||
- **Image input** — paste or drag-and-drop images straight into a session
|
||||
- **Gesture control** _(opt-in)_ — a MediaPipe hand-tracking overlay to grab/drag session windows and pinch buttons, hands-free. Enable with `CODEMAN_GESTURE=1` + App Settings → Display
|
||||
- **Gesture control** _(opt-in)_ — a MediaPipe hand-tracking overlay to grab/drag session windows and pinch buttons, hands-free. Enable with `CODEMAN_GESTURE=1` + App Settings → Terminal & Input
|
||||
- **Multi-monitor span** _(macOS)_ — one click opens a browser window maximized across all displays, so floating agent/gesture panels can cross the physical seam
|
||||
- **File Viewer button** _(opt-in)_ — a header button that toggles the built-in file browser panel with one tap; enable under App Settings → Display → Header Displays
|
||||
- **File Viewer button** _(opt-in)_ — a header button that toggles the built-in file browser panel with one tap; enable under App Settings → Header & Panels → Header buttons
|
||||
- **CJK / IME input** — full composition support for Chinese / Japanese / Korean
|
||||
- **OS notifications & hostname-aware titles** — desktop alerts and tab titles are prefixed `codeman:<host>` so multi-host setups stay unambiguous
|
||||
|
||||
@@ -520,7 +521,7 @@ The script auto-installs a systemd user service on first run. The tunnel URL is
|
||||
systemctl --user enable codeman-tunnel
|
||||
loginctl enable-linger $USER
|
||||
|
||||
# Or via the Codeman web UI: Settings → Tunnel → Toggle On
|
||||
# Or via the Codeman web UI: App Settings → System → Remote access → Cloudflare Tunnel
|
||||
```
|
||||
|
||||
</details>
|
||||
@@ -622,7 +623,7 @@ By default Codeman launches sessions with `--dangerously-skip-permissions`, so t
|
||||
- **Loopback by default** — the server binary binds `127.0.0.1`, reachable only from the same machine, so the no-password default is safe out of the box (the guided installer asks about network access and configures the binding + password for you). Binding a non-loopback host without `CODEMAN_PASSWORD` _starts but prints a loud warning_ with three concrete fixes (set a password, loopback + an authenticated tunnel, or explicitly acknowledge with `--allow-unauthenticated-network`)
|
||||
- **Optional auth, real sessions** — HTTP Basic via `CODEMAN_USERNAME` (default `admin`) / `CODEMAN_PASSWORD`. Success issues an opaque 256-bit `codeman_session` cookie (`randomBytes(32)`) — validated server-side, not client-signed, so it can't be forged offline (24h TTL, auto-extend, device-context audit log)
|
||||
- **Per-IP rate limiting** — 10 failed attempts → `429` with `Retry-After` (15-min decay). A valid cookie or correct password recovers _immediately_ even while an attacker hammers the same IP — important because all tunnel traffic shares one loopback IP. QR auth has its own separate limiter
|
||||
- **Configurable permission mode** - `--dangerously-skip-permissions` is only the default. **App Settings → Claude CLI → Startup Mode** can switch new sessions to Anthropic's classifier-guarded `auto` mode (low-prompt, needs Claude Code 2.1.207+), `normal` prompting, or an explicit allowed-tools list. In multi-user mode, non-granted users are forced to `auto`, and shell sessions / skip-permissions require an explicit per-user grant
|
||||
- **Configurable permission mode** - `--dangerously-skip-permissions` is only the default. **App Settings → Agents & CLIs → Claude → Startup Mode** can switch new sessions to Anthropic's classifier-guarded `auto` mode (low-prompt, needs Claude Code 2.1.207+), `normal` prompting, or an explicit allowed-tools list. In multi-user mode, non-granted users are forced to `auto`, and shell sessions / skip-permissions require an explicit per-user grant
|
||||
|
||||
### Always-on browser hardening (v0.9.5)
|
||||
|
||||
@@ -695,7 +696,7 @@ For AI agents and automation that control Codeman without a browser: an agent th
|
||||
>
|
||||
> - `npx skills add Ark0N/Codeman --skill codeman -g`: global, works for any skills-aware agent
|
||||
> - `codeman skill install` (global) or `codeman skill install --case <name>`: for npm installs that never cloned the repo; `codeman skill uninstall` reverses it
|
||||
> - **App Settings → Agent Skill** (`agentSkillEnabled`, default off): Codeman then injects the skill into each case on Claude session create; a user-authored `skills/codeman` in the case is never overwritten
|
||||
> - **App Settings → Agents & CLIs → Claude → Agent Skill** (`agentSkillEnabled`, default off): Codeman then injects the skill into each case on Claude session create; a user-authored `skills/codeman` in the case is never overwritten
|
||||
>
|
||||
> A global install (`codeman skill install`, or `npx skills add`) is picked up by **every new Claude Code session on the machine**, inside Codeman or not. The skill self-gates: outside a Codeman session (`CODEMAN_MUX` unset) it refuses to act, so a global install costs an idle session nothing.
|
||||
>
|
||||
|
||||
@@ -471,6 +471,28 @@ All four enforce session ownership in multi-user mode; a foreign session id
|
||||
answers `404 NOT_FOUND` (no existence leak), and profiles of two owners of the
|
||||
same directory are distinct by construction.
|
||||
|
||||
## Voice dictation
|
||||
|
||||
Browser dictation transcribed through this server's Claude Code login, i.e. the
|
||||
same speech-to-text service the CLI's own `/voice` mode uses. Gated on the synced
|
||||
`claudeVoiceEnabled` setting (default OFF). Design:
|
||||
[`claude-voice-plan.md`](claude-voice-plan.md).
|
||||
|
||||
- `GET /api/v1/voice/status` -> `{ available, reason?, subscriptionType?,
|
||||
expiresAt? }`. `reason` is `disabled` (setting off), `no-credentials` (nobody
|
||||
signed in to Claude Code on the server), `expired` (the access token elapsed;
|
||||
running any Claude session refreshes it) or `malformed`. The OAuth token
|
||||
itself is never returned by this or any other endpoint.
|
||||
- `GET /ws/voice/stream?language=&keyterms=` (WebSocket, not under `/api`)
|
||||
relays one dictation. Client sends binary frames of signed 16-bit
|
||||
little-endian PCM, 16 kHz mono (<= 64 KB per frame), plus JSON control frames
|
||||
`{"t":"finalize"}` (ask for the final transcript) and `{"t":"stop"}`. Server
|
||||
sends `{"t":"ready"}`, `{"t":"transcript","text","final"}` (each frame is the
|
||||
WHOLE running transcript, not a delta), `{"t":"error","message"}` and
|
||||
`{"t":"closed"}`. Close codes: `4003` disallowed Host/Origin, `4004`
|
||||
unavailable (reason in the close reason), `4008` too many concurrent streams.
|
||||
Streams are capped in count and length (`src/config/voice.ts`).
|
||||
|
||||
## Authentication
|
||||
|
||||
Optional HTTP Basic (`CODEMAN_USERNAME`/`CODEMAN_PASSWORD`) → opaque
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -300,6 +300,11 @@ For reference when writing browser tests:
|
||||
.xterm // Terminal container
|
||||
#helpModal // Help modal
|
||||
#appSettingsModal // Settings modal
|
||||
#sessionOptionsModal // Session Options (same set-* surface)
|
||||
#createCaseModal // Add Case (same set-* surface)
|
||||
.set-rail-item // Rail entry: scrolls in App Settings, switches in the other two
|
||||
.set-section // A settings section (`.hidden` on the inactive ones outside App Settings)
|
||||
.set-row // One setting: label + description left, control right
|
||||
.modal-content // Modal content
|
||||
.modal-close // Modal close button
|
||||
.header-brand .logo // Logo text
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
# Claude voice dictation in Codeman
|
||||
|
||||
Wire Codeman's existing mic button to the same speech-to-text service Claude Code's own
|
||||
`/voice` mode uses, so dictation works with **no third-party API key** for anyone already
|
||||
signed in to Claude Code on the server.
|
||||
|
||||
## Why the CLI's own voice mode cannot be reused directly
|
||||
|
||||
Claude Code 2.1.x ships voice input: `/voice hold|tap|off` arms it, the CLI opens the
|
||||
**host's** microphone (native `audio-capture-napi`, falling back to `sox`/`arecord` on Linux
|
||||
after probing `/proc/asound/cards`), streams PCM upstream and types the transcript into its
|
||||
own composer.
|
||||
|
||||
Every part of that is on the wrong machine for Codeman. The CLI runs inside a tmux pane on
|
||||
the server, which is typically headless and has no sound card at all, while the human is in
|
||||
a browser on a phone somewhere else. Toggling `/voice` in the pane from Codeman would arm a
|
||||
microphone nobody is sitting in front of. So Codeman keeps capturing audio in the browser,
|
||||
where the user actually is, and only borrows the CLI's **transcription backend**.
|
||||
|
||||
## The backend, as the CLI uses it
|
||||
|
||||
Extracted from the 2.1.226 binary (`connectVoiceStream`):
|
||||
|
||||
| | |
|
||||
| --- | --- |
|
||||
| URL | `wss://api.anthropic.com/api/ws/speech_to_text/voice_stream` |
|
||||
| Query | `encoding=linear16`, `sample_rate=16000`, `channels=1`, `endpointing_ms=300`, `utterance_end_ms=1000`, `language=<lang>`, `use_conversation_engine=true`, `stt_provider=deepgram-nova3` |
|
||||
| Headers | `Authorization: Bearer <Claude Code OAuth access token>`, `User-Agent`, `x-app: cli`, `anthropic-client-platform`, optional `x-config-keyterms` |
|
||||
| Audio | raw binary frames, PCM signed 16-bit little-endian, 16 kHz, mono |
|
||||
| Keepalive | `{"type":"KeepAlive"}` on open, then every 8 s |
|
||||
| Finalize | `{"type":"CloseStream"}`, then wait for the endpoint frame |
|
||||
| Downstream | `{"type":"TranscriptText"\|"TranscriptInterim","data":"…"}` (running interim), `{"type":"TranscriptEndpoint"}` (promotes the pending interim to final), `{"type":"TranscriptError",…}`, `{"type":"error","message":…}` |
|
||||
|
||||
Deepgram Nova-3 runs server-side, so the Deepgram-quality result arrives without a Deepgram
|
||||
account. Verified against the live endpoint before this design was written: connect, stream
|
||||
PCM, receive interims and an endpoint frame.
|
||||
|
||||
## Architecture
|
||||
|
||||
The browser cannot call that endpoint itself: it would need the OAuth bearer token in page
|
||||
JavaScript (and CORS would refuse anyway). So the audio goes browser → Codeman → Anthropic,
|
||||
and Codeman is the only thing that ever touches the token.
|
||||
|
||||
```
|
||||
mic → AudioWorklet (Float32 → PCM16 @16 kHz)
|
||||
→ wss://<codeman>/ws/voice/stream [cookie/basic auth, Origin+Host guarded]
|
||||
→ VoiceStreamRelay (reads ~/.claude/.credentials.json per connect)
|
||||
→ wss://api.anthropic.com/api/ws/speech_to_text/voice_stream
|
||||
← {"t":"transcript","text":…,"final":…} → existing _insertText() path
|
||||
```
|
||||
|
||||
Nothing about the insert path changes: the transcript lands in the same preview overlay,
|
||||
the same direct/compose insert modes, the same green Send button.
|
||||
|
||||
### Server pieces
|
||||
|
||||
- **`src/claude-credentials.ts`** — locate and parse the Claude Code OAuth credentials.
|
||||
`parseClaudeCredentials()` is pure (JSON string + `now` → status) and unit-tested;
|
||||
`readClaudeOAuthToken()` wraps it with IO: `$CLAUDE_CONFIG_DIR/.credentials.json` or
|
||||
`~/.claude/.credentials.json`, and on macOS the login keychain
|
||||
(`security find-generic-password -s "Claude Code-credentials"`).
|
||||
**Read-only, always.** Codeman never writes credentials and never refreshes the token: a
|
||||
refresh rotates the refresh token, and racing Claude Code's own refresh could sign the
|
||||
user out of their CLI. An expired token surfaces as a plain "run a Claude session to
|
||||
refresh" error instead.
|
||||
The token is never logged, never returned by any endpoint, and never sent to the browser.
|
||||
|
||||
- **`src/web/voice-stream.ts`** — pure `buildVoiceStreamUrl()` / `buildVoiceStreamHeaders()` /
|
||||
`sanitizeKeyterms()` (ASCII-only, deduped, 1024-char cap, mirroring the CLI), plus
|
||||
`VoiceStreamRelay`, which owns one upstream socket: keepalive timer, audio passthrough,
|
||||
transcript translation, finalize, and the caps below.
|
||||
|
||||
- **`src/web/routes/voice-routes.ts`**
|
||||
- `GET /api/voice/status` → `{ available, reason, subscriptionType?, expiresAt? }`. Never
|
||||
the token. `available:false` with a machine-readable `reason` (`disabled`, `no-credentials`,
|
||||
`expired`) is what the settings row and the provider resolver read.
|
||||
- `GET /ws/voice/stream?language=&keyterms=` → the relay. Same upgrade guard as
|
||||
`/ws/sessions/:id/terminal`: allowed Host, same-site Origin, and the global auth hook has
|
||||
already run on the handshake.
|
||||
|
||||
Caps, because an open mic is an open pipe: one stream per connection, `MAX_VOICE_STREAMS`
|
||||
concurrent server-wide, a hard `MAX_STREAM_MS` per stream, and a per-frame size cap. A tab
|
||||
left recording cannot bill an unbounded amount of upstream audio.
|
||||
|
||||
### Frontend pieces
|
||||
|
||||
- **`voice-pcm-worklet.js`** — an `AudioWorkletProcessor` converting Float32 blocks to PCM16
|
||||
and posting ~256 ms frames back. `MediaRecorder` cannot produce raw PCM, which is why the
|
||||
existing Deepgram path (container audio, auto-detected) cannot be reused as-is. Falls back
|
||||
to `ScriptProcessorNode` where AudioWorklet is unavailable.
|
||||
- **`ClaudeVoiceProvider`** in `voice-input.js` — mirrors `DeepgramProvider`'s shape
|
||||
(`start({language, keyterms, onStream, onResult, onError, onEnd})`) so `VoiceInput` treats
|
||||
the three providers uniformly.
|
||||
- **Provider resolution** — new `voiceSettings.provider`: `auto` (default) | `claude` |
|
||||
`deepgram` | `webspeech`. `auto` picks Claude when `/api/voice/status` reports it
|
||||
available, else Deepgram when a key is set, else Web Speech. Pinning a provider always
|
||||
wins, so an existing Deepgram user can keep exactly what they have.
|
||||
|
||||
### Settings
|
||||
|
||||
- `claudeVoiceEnabled` — synced, **default OFF**, gating the whole server side. Off is the
|
||||
honest default: turning it on means this machine's Claude subscription starts paying for
|
||||
transcription for whoever can reach the UI, and the audio goes to Anthropic rather than to
|
||||
wherever it went before. One switch in Settings → Voice, and the mic works with no key.
|
||||
- `voiceSettings.provider` — per the resolution table above; joins the existing synced
|
||||
`voiceSettings` object.
|
||||
|
||||
## Things worth knowing
|
||||
|
||||
- **This uses an undocumented endpoint with subscription credentials.** It is the user's own
|
||||
token, on the user's own machine, driving the user's own Claude Code install, but it is not
|
||||
a published API and Anthropic can change or restrict it. Default-OFF is deliberate; the
|
||||
Deepgram and Web Speech paths stay untouched as the supported fallbacks.
|
||||
- **Multi-user mode**: every user's dictation would run on the server owner's Claude
|
||||
credentials, exactly as every user's *sessions* already run on them. Consistent, but worth
|
||||
stating out loud in the settings copy.
|
||||
- **Token lifetime** is about 8 hours, refreshed by Claude Code itself whenever it runs. The
|
||||
relay re-reads the file on every connect rather than caching, so a refresh is picked up on
|
||||
the next press of the mic.
|
||||
- **HTTPS or localhost**: `getUserMedia` needs a secure context. Prod is HTTPS behind
|
||||
`tailscale serve`, so this is already satisfied; the existing error copy covers the rest.
|
||||
@@ -124,7 +124,7 @@ Agent use cases this unlocks: a lead session records intentions as the user stat
|
||||
|
||||
1. **Intent store + capture + intent endpoints + skill docs.** Immediately useful to agents even before any UI exists.
|
||||
2. **Context assembler + predictor + predict endpoint + desktop button/modal.** The feature as pitched. The assembler ships with all collectors it can serve from day one (transcript, intent, git, run-summary, siblings); the approvals collector activates when PR #245 lands.
|
||||
3. **Phone accessory key, rethink steering, alternates row.**
|
||||
3. **Phone accessory key, rethink steering, alternates row.** Part 1 (shipped): the alternates row (tappable, swap into the field without losing edits; Rethink rejects the whole shown set), the phone 🧠 keyboard-accessory key (both bar templates, `rmm-enabled` marker class on the bar), and a phone-sized modal (small dialog, not full-screen). Part 2 (shipped): rethink steering, the free-text steer note under the suggestions, sent as `steer`, visible whenever Rethink is live (ready and empty-result phases), cleared on each open; the empty-result copy points at the note, and the footer buttons moved to the styled `btn-toolbar` convention (the bare `btn btn-*` classes they shipped with match no CSS in this codebase and rendered as unstyled UA buttons).
|
||||
4. Explicitly later: proactive predict-on-idle (ghost suggestion chip), auto-compaction of `recentPrompts` into `goals` via a cheap model, codex/gemini capture, cross-case "global" intent.
|
||||
|
||||
## Open questions
|
||||
|
||||
+6
-6
@@ -11,7 +11,7 @@ Codeman's per-case memory of what you are trying to accomplish, and the 🧠 but
|
||||
|
||||
## Turning it on
|
||||
|
||||
App Settings → Panels → **Read My Mind** (synced setting `readMyMindEnabled`, default **OFF**). It gates everything: capture, the header button, and nothing shows anywhere while it is off. The API equivalent:
|
||||
App Settings → Header & Panels → Cross-session features → **Read My Mind** (synced setting `readMyMindEnabled`, default **OFF**). It gates everything: capture, the header button, and nothing shows anywhere while it is off. The API equivalent:
|
||||
|
||||
```bash
|
||||
curl -sk -X PUT https://localhost:3000/api/settings \
|
||||
@@ -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) or the 🧠 key on the keyboard accessory bar (phones and tablets; it appears when the setting is on). 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, and the others render as tappable alternate rows: tap one to swap it into the field (edits you already made are kept on the row you leave).
|
||||
|
||||
- **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 shown (the field and the alternates) recorded as rejected. An optional steer note below the suggestions ("no, I meant the mobile bug") rides along as your own words, the highest-authority signal the predictor gets; it stays in the field across re-runs until you clear it or reopen the modal.
|
||||
- **Dismiss** closes; nothing happens.
|
||||
|
||||
A prediction takes 5-90 seconds and costs real tokens; one runs per session at a time. If the session is sitting on a permission/question dialog, the suggestion is usually an answer to that dialog: that is intentional.
|
||||
@@ -85,15 +85,15 @@ A case with nothing recorded answers an empty profile with `updatedAt: 0`; reads
|
||||
|
||||
The `codeman` agent skill documents the same verbs (SKILL.md §3 plus `reference/endpoints.md`), with the ground rules: read the profile to understand what the user wants, record goals the user actually stated, merge instead of blind-writing (PUT replaces), never delete a profile unprompted, and never send a predicted suggestion into a session unless the user asked. It is the user's memory, not the agent's.
|
||||
|
||||
## What comes next (phase 3+)
|
||||
## What comes next
|
||||
|
||||
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).
|
||||
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 → Header & Panels → Cross-session features), you are on a phone (there it is a key on the keyboard accessory bar instead, visible while typing), or the active session is not claude-mode |
|
||||
| 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 |
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "aicodeman",
|
||||
"version": "1.16.2",
|
||||
"version": "1.16.3",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "aicodeman",
|
||||
"version": "1.16.2",
|
||||
"version": "1.16.3",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"workspaces": [
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "aicodeman",
|
||||
"version": "1.16.2",
|
||||
"version": "1.16.3",
|
||||
"description": "Mission control for AI coding agents - run 20 autonomous agents with real-time monitoring and session persistence",
|
||||
"type": "module",
|
||||
"main": "dist/index.js",
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
/**
|
||||
* @fileoverview Read-only access to the Claude Code OAuth credentials.
|
||||
*
|
||||
* Claude Code stores its subscription OAuth tokens in
|
||||
* `$CLAUDE_CONFIG_DIR/.credentials.json` (default `~/.claude/.credentials.json`,
|
||||
* mode 0600) on Linux/Windows, and in the login keychain on macOS. Codeman reads
|
||||
* the access token to authenticate the voice-dictation relay
|
||||
* (`src/web/voice-stream.ts`) against the same speech-to-text service the CLI's
|
||||
* own `/voice` mode uses.
|
||||
*
|
||||
* ⚠️ READ-ONLY, deliberately. Codeman never writes this file and never performs
|
||||
* an OAuth refresh: a refresh ROTATES the refresh token, so racing Claude Code's
|
||||
* own refresh could invalidate the user's CLI login. An expired access token is
|
||||
* reported as `expired` and the caller tells the user to run a Claude session
|
||||
* (which refreshes it) instead.
|
||||
*
|
||||
* ⚠️ The token is a bearer secret: it is never logged, never persisted, never
|
||||
* included in any API response, and never sent to the browser.
|
||||
*/
|
||||
|
||||
import { readFile } from 'fs/promises';
|
||||
import { execFile } from 'child_process';
|
||||
import { homedir, userInfo } from 'os';
|
||||
import { join } from 'path';
|
||||
|
||||
/** Result of inspecting the credential store. The token is present only on 'ok'. */
|
||||
export type ClaudeCredentialStatus = 'ok' | 'expired' | 'missing' | 'malformed';
|
||||
|
||||
export interface ClaudeOAuthCredentials {
|
||||
status: ClaudeCredentialStatus;
|
||||
/** Bearer token. Present only when status is 'ok'. Never log or serialize this. */
|
||||
accessToken?: string;
|
||||
/** Epoch ms the access token expires at, when the store reports one. */
|
||||
expiresAt?: number;
|
||||
/** e.g. 'max', 'pro'. Display-only, safe to surface. */
|
||||
subscriptionType?: string;
|
||||
}
|
||||
|
||||
/** Skew applied to the stored expiry so a token that dies mid-stream is refused up front. */
|
||||
const EXPIRY_SKEW_MS = 60_000;
|
||||
|
||||
/** macOS keychain service holding the same JSON blob as `.credentials.json`. */
|
||||
const KEYCHAIN_SERVICE = 'Claude Code-credentials';
|
||||
|
||||
/** Keychain lookups shell out; keep them short so a locked keychain cannot hang a request. */
|
||||
const KEYCHAIN_TIMEOUT_MS = 3000;
|
||||
|
||||
/**
|
||||
* Parse a `.credentials.json` payload. Pure: no IO, no clock read (pass `now`),
|
||||
* so the expiry and shape handling are unit-testable.
|
||||
*
|
||||
* Returns 'malformed' for anything that is not the expected `claudeAiOauth`
|
||||
* shape rather than throwing — a hand-edited or half-written file must degrade
|
||||
* to "voice unavailable", never to a 500.
|
||||
*/
|
||||
export function parseClaudeCredentials(raw: string, now: number): ClaudeOAuthCredentials {
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(raw);
|
||||
} catch {
|
||||
return { status: 'malformed' };
|
||||
}
|
||||
if (!parsed || typeof parsed !== 'object') return { status: 'malformed' };
|
||||
|
||||
const oauth = (parsed as { claudeAiOauth?: unknown }).claudeAiOauth;
|
||||
if (!oauth || typeof oauth !== 'object') return { status: 'malformed' };
|
||||
|
||||
const record = oauth as Record<string, unknown>;
|
||||
const accessToken = typeof record.accessToken === 'string' ? record.accessToken.trim() : '';
|
||||
if (!accessToken) return { status: 'malformed' };
|
||||
|
||||
const expiresAt = typeof record.expiresAt === 'number' ? record.expiresAt : undefined;
|
||||
const subscriptionType = typeof record.subscriptionType === 'string' ? record.subscriptionType : undefined;
|
||||
|
||||
// An expired token is a real state (the CLI refreshes on its next run), not a
|
||||
// malformed store: report it separately so the UI can say something useful.
|
||||
if (expiresAt !== undefined && expiresAt - EXPIRY_SKEW_MS <= now) {
|
||||
return { status: 'expired', expiresAt, subscriptionType };
|
||||
}
|
||||
return { status: 'ok', accessToken, expiresAt, subscriptionType };
|
||||
}
|
||||
|
||||
/** Path of the credentials file, honoring CLAUDE_CONFIG_DIR like the CLI does. */
|
||||
export function claudeCredentialsPath(env: NodeJS.ProcessEnv = process.env): string {
|
||||
const configDir = typeof env.CLAUDE_CONFIG_DIR === 'string' && env.CLAUDE_CONFIG_DIR.trim();
|
||||
return join(configDir || join(homedir(), '.claude'), '.credentials.json');
|
||||
}
|
||||
|
||||
/** Read the macOS keychain entry. Resolves to null on any failure (locked, absent, non-mac). */
|
||||
function readKeychainCredentials(): Promise<string | null> {
|
||||
return new Promise((resolve) => {
|
||||
execFile(
|
||||
'security',
|
||||
['find-generic-password', '-a', userInfo().username, '-w', '-s', KEYCHAIN_SERVICE],
|
||||
{ encoding: 'utf-8', timeout: KEYCHAIN_TIMEOUT_MS },
|
||||
(err, stdout) => resolve(err ? null : stdout.trim() || null)
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Locate and parse the Claude Code OAuth credentials.
|
||||
*
|
||||
* File first (present on every platform once the CLI has run there), keychain
|
||||
* second on macOS. Never caches: Claude Code rewrites the store roughly every
|
||||
* 8 hours, and a cached token would go stale inside a long-lived server.
|
||||
*/
|
||||
export async function readClaudeOAuthCredentials(now: number = Date.now()): Promise<ClaudeOAuthCredentials> {
|
||||
let fileResult: ClaudeOAuthCredentials | null = null;
|
||||
try {
|
||||
fileResult = parseClaudeCredentials(await readFile(claudeCredentialsPath(), 'utf-8'), now);
|
||||
} catch {
|
||||
fileResult = null;
|
||||
}
|
||||
if (fileResult && fileResult.status !== 'malformed') return fileResult;
|
||||
|
||||
if (process.platform === 'darwin') {
|
||||
const raw = await readKeychainCredentials();
|
||||
if (raw) {
|
||||
const keychainResult = parseClaudeCredentials(raw, now);
|
||||
if (keychainResult.status !== 'malformed') return keychainResult;
|
||||
}
|
||||
}
|
||||
|
||||
return fileResult ?? { status: 'missing' };
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
/**
|
||||
* @fileoverview Bounds and endpoint config for Claude voice dictation.
|
||||
*
|
||||
* Backs the browser → Codeman → Anthropic dictation relay (`src/web/voice-stream.ts`,
|
||||
* `src/web/routes/voice-routes.ts`; design in `docs/claude-voice-plan.md`).
|
||||
*
|
||||
* Why everything here is bounded: an open microphone is an open pipe. Each live
|
||||
* stream holds a browser socket, an upstream socket and a keepalive timer, and
|
||||
* every second of audio is billed against the server owner's Claude subscription.
|
||||
* A tab left recording (phone in a pocket, forgotten laptop) must cost a bounded
|
||||
* amount, so streams die on their own at `MAX_STREAM_MS` and the server refuses
|
||||
* more than `MAX_CONCURRENT_STREAMS` at once.
|
||||
*
|
||||
* The audio frame cap is a memory guard on a socket that carries attacker-shaped
|
||||
* binary data: PCM16 at 16 kHz mono is 32 KB/s, so a 256 ms frame is ~8 KB and
|
||||
* anything near 64 KB is either a broken client or an attempt to make the relay
|
||||
* buffer for someone else.
|
||||
*/
|
||||
|
||||
/** Upstream speech-to-text service (the one Claude Code's own `/voice` mode uses). */
|
||||
export const VOICE_STREAM_HOST = 'wss://api.anthropic.com';
|
||||
|
||||
/** Path of the streaming speech-to-text endpoint. */
|
||||
export const VOICE_STREAM_PATH = '/api/ws/speech_to_text/voice_stream';
|
||||
|
||||
/**
|
||||
* Base override, for tests (point the relay at a local mock) and for users on an
|
||||
* Anthropic-compatible gateway. Must be a ws:// or wss:// origin.
|
||||
*/
|
||||
export function voiceStreamBase(env: NodeJS.ProcessEnv = process.env): string {
|
||||
const override = typeof env.CODEMAN_VOICE_STREAM_BASE === 'string' ? env.CODEMAN_VOICE_STREAM_BASE.trim() : '';
|
||||
if (override && /^wss?:\/\//.test(override)) return override.replace(/\/+$/, '');
|
||||
return VOICE_STREAM_HOST;
|
||||
}
|
||||
|
||||
/** Upstream drops an idle socket; the CLI pings at 8s and so do we. */
|
||||
export const KEEPALIVE_INTERVAL_MS = 8000;
|
||||
|
||||
/** Hard ceiling on one dictation. Long enough for any real utterance, short enough to bound a forgotten mic. */
|
||||
export const MAX_STREAM_MS = 5 * 60_000;
|
||||
|
||||
/** Concurrent relays server-wide. Dictation is a human-paced, one-at-a-time act. */
|
||||
export const MAX_CONCURRENT_STREAMS = 4;
|
||||
|
||||
/** Largest single audio frame accepted from the browser (~2s of PCM16 @16 kHz mono). */
|
||||
export const MAX_AUDIO_FRAME_BYTES = 64 * 1024;
|
||||
|
||||
/** How long to wait for the final transcript after the client asks to finalize. */
|
||||
export const FINALIZE_TIMEOUT_MS = 3000;
|
||||
|
||||
/** Upstream caps the keyterms header; mirrors the CLI's own limit. */
|
||||
export const MAX_KEYTERMS_HEADER_CHARS = 1024;
|
||||
|
||||
/** Audio format the endpoint is opened with. The browser worklet must match exactly. */
|
||||
export const AUDIO_SAMPLE_RATE = 16000;
|
||||
export const AUDIO_CHANNELS = 1;
|
||||
+23
-4
@@ -36,13 +36,21 @@ export const SEARCH_PER_GROUP_CAP = 25;
|
||||
/** Maximum characters in a result snippet. */
|
||||
export const SEARCH_SNIPPET_MAX = 200;
|
||||
|
||||
/** A live-session row harvested for the session/case source. */
|
||||
/** A session row harvested for the session/case source (live or past). */
|
||||
export interface SessionSearchInput {
|
||||
sessionId: string;
|
||||
sessionName: string;
|
||||
workingDir: string;
|
||||
/** Recency timestamp (e.g. lastActivityAt or createdAt). */
|
||||
timestamp: number;
|
||||
/**
|
||||
* True for a session that is no longer running (issue #261, past sessions come
|
||||
* from the history index, not the live map). Such a result resumes the
|
||||
* conversation instead of switching to a tab that no longer exists.
|
||||
*/
|
||||
history?: boolean;
|
||||
/** Claude conversation UUID to resume, when it differs from the Codeman id. */
|
||||
claudeSessionId?: string;
|
||||
}
|
||||
|
||||
/** A run-summary timeline event harvested for the event source. */
|
||||
@@ -121,14 +129,25 @@ export function searchSources(query: string, sources: SearchSources): SearchResp
|
||||
const sessionRows: SearchResult[] = [];
|
||||
for (const s of sources.sessions) {
|
||||
if (contains(s.sessionName) || contains(s.workingDir) || contains(s.sessionId)) {
|
||||
const label = s.sessionName || s.workingDir.split('/').pop() || s.sessionId;
|
||||
sessionRows.push({
|
||||
type: 'session',
|
||||
sessionId: s.sessionId,
|
||||
sessionName: s.sessionName,
|
||||
sessionName: label,
|
||||
timestamp: s.timestamp,
|
||||
snippet: truncate(s.workingDir ? `${s.sessionName} — ${s.workingDir}` : s.sessionName),
|
||||
snippet: truncate(s.workingDir ? `${label} — ${s.workingDir}` : label),
|
||||
exactMatch: isExact(s.sessionName),
|
||||
jumpTo: { kind: 'session', sessionId: s.sessionId },
|
||||
// A resume needs a directory to run in, so a history row without one
|
||||
// stays a plain session target rather than an action that cannot work.
|
||||
jumpTo:
|
||||
s.history && s.workingDir
|
||||
? {
|
||||
kind: 'resume-session',
|
||||
sessionId: s.sessionId,
|
||||
claudeSessionId: s.claudeSessionId,
|
||||
workingDir: s.workingDir,
|
||||
}
|
||||
: { kind: 'session', sessionId: s.sessionId },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,6 +32,12 @@ export type UnifiedSessionItem = {
|
||||
lastPrompt?: string;
|
||||
sizeBytes?: number;
|
||||
projectKey?: string;
|
||||
/** Git branch recorded in the transcript (#266). */
|
||||
gitBranch?: string;
|
||||
/** Linked-worktree name, when the session ran in one (#266). */
|
||||
worktreeName?: string;
|
||||
/** Main repo root a worktree belongs to (#266). */
|
||||
worktreeRepo?: string;
|
||||
remote?: boolean;
|
||||
/** Pinned to the top of the session manager list (COD-139). */
|
||||
pinned?: boolean;
|
||||
@@ -90,6 +96,9 @@ export type HistoryInput = {
|
||||
/** Most recent user prompt from the transcript (COD-145). */
|
||||
lastPrompt?: string;
|
||||
projectKey?: string;
|
||||
gitBranch?: string;
|
||||
worktreeName?: string;
|
||||
worktreeRepo?: string;
|
||||
};
|
||||
|
||||
/** Mux process-stat view. */
|
||||
@@ -163,6 +172,9 @@ export function mergeUnifiedSessions(sources: UnifiedSources): UnifiedSessionIte
|
||||
overwrite(item, 'firstPrompt', h.firstPrompt);
|
||||
overwrite(item, 'lastPrompt', h.lastPrompt);
|
||||
overwrite(item, 'projectKey', h.projectKey);
|
||||
overwrite(item, 'gitBranch', h.gitBranch);
|
||||
overwrite(item, 'worktreeName', h.worktreeName);
|
||||
overwrite(item, 'worktreeRepo', h.worktreeRepo);
|
||||
const ms = Date.parse(h.lastModified);
|
||||
if (!Number.isNaN(ms) && item.lastActivityAt === undefined) item.lastActivityAt = ms;
|
||||
}
|
||||
@@ -346,7 +358,7 @@ export function filterAndPaginate(
|
||||
const q = (opts.q ?? '').trim().toLowerCase();
|
||||
const filtered = q
|
||||
? items.filter((it) => {
|
||||
const hay = [it.name, it.firstPrompt, it.lastPrompt, it.workingDir, it.sessionId]
|
||||
const hay = [it.name, it.firstPrompt, it.lastPrompt, it.workingDir, it.sessionId, it.worktreeName, it.gitBranch]
|
||||
.filter((v): v is string => typeof v === 'string')
|
||||
.join(' ')
|
||||
.toLowerCase();
|
||||
|
||||
+17
-3
@@ -22,15 +22,19 @@ export type SearchSourceType = 'session' | 'event' | 'file';
|
||||
|
||||
/** Where the frontend should jump when a result card is activated. */
|
||||
export interface SearchJumpTarget {
|
||||
/** Kind of navigation target. */
|
||||
kind: 'session' | 'run-summary' | 'file-preview';
|
||||
/**
|
||||
* Kind of navigation target. `resume-session` marks a session that is no longer
|
||||
* running: selecting it has to REPLAY the conversation rather than switch to a
|
||||
* tab that does not exist.
|
||||
*/
|
||||
kind: 'session' | 'run-summary' | 'file-preview' | 'resume-session';
|
||||
/** Owning Codeman session id (always present — every result is session-scoped). */
|
||||
sessionId: string;
|
||||
/**
|
||||
* Secondary identifier for the target:
|
||||
* - kind 'run-summary': the run-summary event id
|
||||
* - kind 'file-preview': the attachment history item id
|
||||
* - kind 'session': undefined (the sessionId is sufficient)
|
||||
* - kind 'session' / 'resume-session': undefined (the sessionId is sufficient)
|
||||
*/
|
||||
targetId?: string;
|
||||
/**
|
||||
@@ -38,6 +42,16 @@ export interface SearchJumpTarget {
|
||||
* server-private external paths are intentionally omitted to avoid leakage.
|
||||
*/
|
||||
relativePath?: string;
|
||||
/**
|
||||
* `resume-session` only: the Claude conversation UUID to resume, when it differs
|
||||
* from the Codeman session id (resumed and `/clear`-respawned sessions).
|
||||
*/
|
||||
claudeSessionId?: string;
|
||||
/**
|
||||
* `resume-session` only: the directory to resume in. Already visible in the
|
||||
* result snippet for session rows, so this exposes nothing new.
|
||||
*/
|
||||
workingDir?: string;
|
||||
}
|
||||
|
||||
/** A single typed search result card. */
|
||||
|
||||
@@ -19,6 +19,8 @@ export interface ConfigPort {
|
||||
getTerminalHistoryConfig(): Promise<TerminalHistoryConfig>;
|
||||
/** Synced `agentSkillEnabled` app setting (default OFF); gates per-case agent-skill injection. */
|
||||
getAgentSkillEnabled(): Promise<boolean>;
|
||||
/** Synced `claudeVoiceEnabled` app setting (default OFF); gates the Claude voice dictation relay. */
|
||||
getClaudeVoiceEnabled(): Promise<boolean>;
|
||||
getDefaultClaudeMdPath(): Promise<string | undefined>;
|
||||
getLightState(identity?: { username: string; role: 'admin' | 'user' }): unknown;
|
||||
getLightSessionsState(): unknown[];
|
||||
|
||||
+35
-20
@@ -110,34 +110,49 @@
|
||||
}
|
||||
|
||||
// ── Admin Users panel (injected into the App Settings modal) ──────────────
|
||||
// The settings modal is a rail (table of contents) over ONE scrolling
|
||||
// document, so this appends a rail entry plus a real section rather than a
|
||||
// tab button plus a hidden panel.
|
||||
function injectUsersTab() {
|
||||
const modal = document.getElementById('appSettingsModal');
|
||||
if (!modal || modal.querySelector('[data-tab="settings-users"]')) return;
|
||||
const tabs = modal.querySelector('.modal-tabs');
|
||||
const body = modal.querySelector('.modal-body');
|
||||
if (!tabs || !body) return;
|
||||
if (!modal || modal.querySelector('[data-section="settings-users"]')) return;
|
||||
const rail = modal.querySelector('.set-rail-items');
|
||||
const body = modal.querySelector('.set-doc');
|
||||
if (!rail || !body) return;
|
||||
const btn = document.createElement('button');
|
||||
btn.className = 'modal-tab-btn';
|
||||
btn.dataset.tab = 'settings-users';
|
||||
btn.textContent = 'Users';
|
||||
tabs.appendChild(btn);
|
||||
const content = document.createElement('div');
|
||||
content.className = 'modal-tab-content hidden';
|
||||
btn.type = 'button';
|
||||
btn.className = 'set-rail-item';
|
||||
btn.dataset.section = 'settings-users';
|
||||
btn.innerHTML =
|
||||
'<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M22 21v-2a4 4 0 0 0-3-3.87"/></svg><span>Users</span>';
|
||||
rail.appendChild(btn);
|
||||
const content = document.createElement('section');
|
||||
content.className = 'set-section';
|
||||
content.id = 'settings-users';
|
||||
content.dataset.label = 'Users';
|
||||
content.innerHTML = `
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:8px">
|
||||
<strong>Users</strong>
|
||||
<span>
|
||||
<button class="btn btn-sm" id="adminOpenPanel">Open Admin Panel</button>
|
||||
<button class="btn btn-sm" id="adminAddUser">+ Add user</button>
|
||||
</span>
|
||||
<div class="set-section-head">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M22 21v-2a4 4 0 0 0-3-3.87"/></svg>
|
||||
<h2>Users</h2>
|
||||
</div>
|
||||
<p class="form-hint">Users share the host account; this separates workspaces, it does not sandbox
|
||||
<p class="set-section-blurb">Users share the host account; this separates workspaces, it does not sandbox
|
||||
users from each other. Pair with Docker cases for isolation.</p>
|
||||
<div id="adminUsersTable"></div>
|
||||
<p id="adminUsersMsg" style="min-height:1.2em;color:var(--muted,#888)"></p>`;
|
||||
<div class="set-group">
|
||||
<div class="set-group-head"><h4>Accounts</h4></div>
|
||||
<div class="set-group-body">
|
||||
<div class="set-row">
|
||||
<div class="set-row-text"><span class="set-row-label">Manage users</span></div>
|
||||
<div class="set-row-actions">
|
||||
<button class="btn-toolbar btn-sm" id="adminOpenPanel">Open Admin Panel</button>
|
||||
<button class="btn-toolbar btn-sm" id="adminAddUser">+ Add user</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="adminUsersTable"></div>
|
||||
<p id="adminUsersMsg" style="min-height:1.2em;color:var(--text-muted)"></p>
|
||||
</div>
|
||||
</div>`;
|
||||
body.appendChild(content);
|
||||
// Render whenever the tab is shown (the shared switchSettingsTab toggles it).
|
||||
// Render whenever the entry is used (the shared switchSettingsTab scrolls to it).
|
||||
btn.addEventListener('click', renderUsers);
|
||||
content.querySelector('#adminAddUser').onclick = addUserFlow;
|
||||
content.querySelector('#adminOpenPanel').onclick = openAdminPanel;
|
||||
|
||||
+82
-8
@@ -3463,6 +3463,54 @@ 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) {
|
||||
@@ -3680,6 +3728,11 @@ 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.
|
||||
@@ -3751,15 +3804,25 @@ class CodemanApp {
|
||||
document.querySelectorAll('body > .subagent-dropdown').forEach(d => d.remove());
|
||||
this.cancelHideSubagentDropdown();
|
||||
|
||||
// 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)
|
||||
// #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.
|
||||
const parts = [];
|
||||
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)];
|
||||
}
|
||||
const tabOrder = this.sessionOrder;
|
||||
let _tabIdx = 0;
|
||||
for (const id of tabOrder) {
|
||||
const session = this.sessions.get(id);
|
||||
@@ -3828,6 +3891,17 @@ 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,6 +156,47 @@ 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`
|
||||
@@ -261,6 +302,8 @@ if (typeof window !== 'undefined') {
|
||||
window.shouldSkipWebGL = shouldSkipWebGL;
|
||||
window.CodemanTabOverflow = {
|
||||
shouldAutoWrapTabs,
|
||||
computeTabScrollLeft,
|
||||
TAB_SCROLL_REVEAL_PX,
|
||||
};
|
||||
window.CodemanWsReconnect = {
|
||||
plan: planWsReconnect,
|
||||
|
||||
+132
-10
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* @fileoverview Desktop home screen session list: the open tabs as a vertical
|
||||
* column down the left of the welcome overlay.
|
||||
* @fileoverview Desktop home screen session list: the open tabs as a rail docked
|
||||
* down the left edge of the welcome overlay.
|
||||
*
|
||||
* The welcome screen centers ~560px of content in a window that is usually
|
||||
* 1400px+, so the two gutters are dead space. The left one now carries the same
|
||||
@@ -8,11 +8,19 @@
|
||||
* one row per live tab, in TAB ORDER (not sorted by state) so it reads as the
|
||||
* tab strip rotated, and so Alt+1..9 still matches what you see.
|
||||
*
|
||||
* DESKTOP ONLY, and only in a wide enough window: the column is absolutely
|
||||
* DESKTOP ONLY, and only in a wide enough window: the rail is absolutely
|
||||
* positioned so the centered welcome content never moves, which means it can
|
||||
* only exist where the gutter is genuinely wider than the column. Below
|
||||
* only exist where the gutter is genuinely wider than the rail. Below
|
||||
* `HOME_SESSIONS_MIN_WIDTH` nothing renders; on a phone the mobile overview owns
|
||||
* the home screen entirely and this surface stays out of its way.
|
||||
* the home screen entirely and this surface stays out of its way. Width and type
|
||||
* both scale with the viewport (see the `.home-sessions` block in styles.css) —
|
||||
* a fixed 256px card looks abandoned on a 2560px display.
|
||||
*
|
||||
* Each row carries when the session was FIRST CREATED and when it was LAST
|
||||
* ACTIVE, both relative. Those two stamps go stale on their own (a sitting
|
||||
* session emits no event), so a slow clock refreshes them IN PLACE from the
|
||||
* epoch-ms values parked on the elements, rather than re-rendering: a re-render
|
||||
* would restart every row's blink animation and its working ring.
|
||||
*
|
||||
* The working state is deliberately identical to the phone's: a pulsing green
|
||||
* dot ringed by the spinner a tab shows while it loads (`tab-load-spin`, reused
|
||||
@@ -27,19 +35,23 @@
|
||||
* @mixin Extends CodemanApp.prototype via Object.assign
|
||||
* @dependency app.js (this.sessions, this.cases, this.pendingHooks, selectSession)
|
||||
* @dependency mobile-overview.js (_mobileOverviewState, _mobileOverviewCaseFor, shouldUseMobileOverview)
|
||||
* @dependency ralph-panel.js (formatRelativeTime — the app's one relative-time formatter)
|
||||
* @dependency webview-tabs.js (this.webviews, this.webviewOrder, openWebview)
|
||||
* @dependency mobile-handlers.js (MobileDetection)
|
||||
* @loadorder 12.56 of 16, after mobile-overview.js, before entrance-animations.js
|
||||
*/
|
||||
|
||||
/**
|
||||
* Narrowest window that gets the column. The welcome content is 560px wide and
|
||||
* centered, so at 1180px each gutter is 310px — enough for the 256px column plus
|
||||
* its 20px offset and still a visible gap. Anything narrower would overlap the
|
||||
* Narrowest window that gets the rail. The welcome content is 560px wide and
|
||||
* centered, so at 1180px each gutter is 310px, enough for the rail at its
|
||||
* 250px floor and still a visible gap. Anything narrower would overlap the
|
||||
* search panel, which is why this is a width gate and not a device-type gate.
|
||||
*/
|
||||
const HOME_SESSIONS_MIN_WIDTH = 1180;
|
||||
|
||||
/** How often the relative stamps are rewritten while the home screen is up. */
|
||||
const HOME_SESSIONS_CLOCK_MS = 20000;
|
||||
|
||||
/** Pill copy per state. Same words as the phone overview, same reasons. */
|
||||
const HOME_SESSIONS_PILL_LABEL = {
|
||||
needs: 'needs you',
|
||||
@@ -87,6 +99,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
this._wireHomeSessions(el);
|
||||
if (!this.shouldShowHomeSessions()) {
|
||||
el.hidden = true;
|
||||
this._stopHomeSessionsClock();
|
||||
return;
|
||||
}
|
||||
el.hidden = false;
|
||||
@@ -96,6 +109,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
hideHomeSessions() {
|
||||
const el = document.getElementById('homeSessions');
|
||||
if (el) el.hidden = true;
|
||||
this._stopHomeSessionsClock();
|
||||
},
|
||||
|
||||
/** Re-render only when showing (called from the tab renderer's tail). */
|
||||
@@ -173,6 +187,10 @@ Object.assign(CodemanApp.prototype, {
|
||||
dir: this._shortenHomePath ? this._shortenHomePath(session.workingDir) : session.workingDir || '',
|
||||
state,
|
||||
pill: HOME_SESSIONS_PILL_LABEL[state] || state,
|
||||
// Epoch ms, straight off the session payload; formatting happens at
|
||||
// render time so the clock below can redo it without a re-render.
|
||||
createdAt: Number(session.createdAt) || 0,
|
||||
lastActivityAt: Number(session.lastActivityAt) || 0,
|
||||
};
|
||||
});
|
||||
},
|
||||
@@ -193,6 +211,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
if (!rows.length && !webviews.length) {
|
||||
el.hidden = true;
|
||||
el.replaceChildren();
|
||||
this._stopHomeSessionsClock();
|
||||
return;
|
||||
}
|
||||
el.hidden = false;
|
||||
@@ -205,6 +224,95 @@ Object.assign(CodemanApp.prototype, {
|
||||
for (const row of rows) list.appendChild(this._buildHomeSessionRow(row));
|
||||
for (const webview of webviews) list.appendChild(this._buildHomeSessionsWebviewRow(webview));
|
||||
el.appendChild(list);
|
||||
|
||||
this._startHomeSessionsClock();
|
||||
},
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// Age stamps: created / last active
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
/**
|
||||
* The "created 2h ago · active 3m ago" footer line. Both stamps keep their raw
|
||||
* epoch-ms on the element (`data-hs-ts`) so `_tickHomeSessionsTimes()` can
|
||||
* rewrite the text without rebuilding the row.
|
||||
*/
|
||||
_buildHomeSessionsMeta(row) {
|
||||
const meta = document.createElement('span');
|
||||
meta.className = 'home-sessions-row-meta';
|
||||
// Relative times are generated text, and "created"/"active" here are the
|
||||
// same generic words that mean something else on other surfaces.
|
||||
meta.setAttribute('data-i18n-skip', '');
|
||||
|
||||
meta.appendChild(this._buildHomeSessionsStamp('created', row.createdAt, 'home-sessions-meta-created'));
|
||||
|
||||
const sep = document.createElement('span');
|
||||
sep.className = 'home-sessions-meta-sep';
|
||||
sep.setAttribute('aria-hidden', 'true');
|
||||
sep.textContent = '·';
|
||||
meta.appendChild(sep);
|
||||
|
||||
meta.appendChild(this._buildHomeSessionsStamp('active', row.lastActivityAt, 'home-sessions-meta-active'));
|
||||
|
||||
return meta;
|
||||
},
|
||||
|
||||
/** One labelled stamp: a dim key, the relative value, full date in the title. */
|
||||
_buildHomeSessionsStamp(key, timestamp, className) {
|
||||
const wrap = document.createElement('span');
|
||||
wrap.className = `home-sessions-meta-item ${className}`;
|
||||
|
||||
const label = document.createElement('span');
|
||||
label.className = 'home-sessions-meta-key';
|
||||
label.textContent = key;
|
||||
wrap.appendChild(label);
|
||||
|
||||
const value = document.createElement('span');
|
||||
value.dataset.hsTs = String(timestamp || 0);
|
||||
value.textContent = this._homeSessionsAgo(timestamp);
|
||||
wrap.appendChild(value);
|
||||
|
||||
if (timestamp)
|
||||
wrap.title = `${key === 'created' ? 'First created' : 'Last active'}: ${new Date(timestamp).toLocaleString()}`;
|
||||
return wrap;
|
||||
},
|
||||
|
||||
/** Relative label for a stamp. `formatRelativeTime` is the app's one formatter. */
|
||||
_homeSessionsAgo(timestamp) {
|
||||
if (!timestamp) return '—';
|
||||
return this.formatRelativeTime(timestamp) || '—';
|
||||
},
|
||||
|
||||
/**
|
||||
* Rewrites the stamps in place every `HOME_SESSIONS_CLOCK_MS`. In place, not a
|
||||
* re-render: replacing the rows would restart the blink animation on every
|
||||
* waiting row and the ring on every working one, twice a minute, for nothing.
|
||||
*/
|
||||
_startHomeSessionsClock() {
|
||||
if (this._homeSessionsClock) return;
|
||||
this._homeSessionsClock = setInterval(() => {
|
||||
if (!this.isHomeSessionsVisible()) {
|
||||
this._stopHomeSessionsClock();
|
||||
return;
|
||||
}
|
||||
this._tickHomeSessionsTimes();
|
||||
}, HOME_SESSIONS_CLOCK_MS);
|
||||
},
|
||||
|
||||
_stopHomeSessionsClock() {
|
||||
if (!this._homeSessionsClock) return;
|
||||
clearInterval(this._homeSessionsClock);
|
||||
this._homeSessionsClock = null;
|
||||
},
|
||||
|
||||
_tickHomeSessionsTimes() {
|
||||
const el = document.getElementById('homeSessions');
|
||||
if (!el) return;
|
||||
for (const node of el.querySelectorAll('[data-hs-ts]')) {
|
||||
const ts = Number(node.dataset.hsTs) || 0;
|
||||
const text = this._homeSessionsAgo(ts);
|
||||
if (node.textContent !== text) node.textContent = text;
|
||||
}
|
||||
},
|
||||
|
||||
_buildHomeSessionsHeader(count) {
|
||||
@@ -285,7 +393,14 @@ Object.assign(CodemanApp.prototype, {
|
||||
// that collide with state strings on other surfaces.
|
||||
pill.setAttribute('data-i18n-skip', '');
|
||||
pill.textContent = row.pill;
|
||||
item.appendChild(pill);
|
||||
|
||||
// The stamps line wraps onto its own full-width line (the row is flex-wrap)
|
||||
// and the pill rides along at its right end, rather than sitting beside the
|
||||
// name: that hands the whole width of the rail to the session name, which is
|
||||
// what stops it ellipsizing.
|
||||
const meta = this._buildHomeSessionsMeta(row);
|
||||
meta.appendChild(pill);
|
||||
item.appendChild(meta);
|
||||
|
||||
return item;
|
||||
},
|
||||
@@ -328,7 +443,14 @@ Object.assign(CodemanApp.prototype, {
|
||||
pill.className = 'home-sessions-pill home-sessions-pill--web';
|
||||
pill.setAttribute('data-i18n-skip', '');
|
||||
pill.textContent = 'web';
|
||||
item.appendChild(pill);
|
||||
|
||||
// Same bottom line as a session row (minus the stamps, a dashboard has
|
||||
// none), so the pill sits in the same place on every row in the rail.
|
||||
const foot = document.createElement('span');
|
||||
foot.className = 'home-sessions-row-meta';
|
||||
foot.setAttribute('data-i18n-skip', '');
|
||||
foot.appendChild(pill);
|
||||
item.appendChild(foot);
|
||||
|
||||
return item;
|
||||
},
|
||||
|
||||
@@ -255,11 +255,15 @@
|
||||
'Read My Mind: predict your next prompt': '读心术:预测您的下一条提示',
|
||||
'Predict my next prompt': '预测我的下一条提示',
|
||||
'Reading your mind…': '正在读取您的想法…',
|
||||
'No suggestion this time. Rethink to try again.': '这次没有建议。点击「重想」再试一次。',
|
||||
'No suggestion this time. Add a steer note and Rethink to try again.':
|
||||
'这次没有建议。可添加引导备注后点击「重想」再试一次。',
|
||||
Rethink: '重想',
|
||||
Insert: '插入',
|
||||
"Put the text on the session's composer without submitting it": '将文本放入会话输入框但不提交',
|
||||
'Predicted prompt, editable': '预测的提示,可编辑',
|
||||
'Use this suggestion instead': '改用此建议',
|
||||
"Steer the rethink, e.g. 'no, I meant the mobile bug'": '引导重想,例如:"不,我是指移动端的问题"',
|
||||
'Steer note for Rethink': '重想的引导备注',
|
||||
'Select a session first': '请先选择一个会话',
|
||||
'Read My Mind works on Claude sessions only': '读心术仅适用于 Claude 会话',
|
||||
'Prompt sent': '提示已发送',
|
||||
|
||||
+1396
-1061
File diff suppressed because it is too large
Load Diff
@@ -494,6 +494,7 @@ const KeyboardAccessoryBar = {
|
||||
<rect x="8" y="2" width="8" height="4" rx="1" ry="1"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button class="accessory-btn accessory-btn-rmm" data-action="readmymind" title="Read My Mind: predict your next prompt">🧠</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">
|
||||
@@ -570,6 +571,7 @@ const KeyboardAccessoryBar = {
|
||||
</button>
|
||||
<button class="accessory-btn" data-action="pick-path" title="Insert a file or folder path">📁 Path</button>
|
||||
<button class="accessory-btn" data-action="clear-input" title="Clear the current unsent input">⌫ All</button>
|
||||
<button class="accessory-btn accessory-btn-rmm" data-action="readmymind" title="Read My Mind: predict your next prompt">🧠</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>
|
||||
<button class="accessory-btn" data-action="effort-max" title="/effort max">Max</button>
|
||||
@@ -594,6 +596,9 @@ const KeyboardAccessoryBar = {
|
||||
this.element = document.createElement('div');
|
||||
this.element.className = 'keyboard-accessory-bar';
|
||||
this.element.innerHTML = this._simpleButtons;
|
||||
// The 🧠 key is opt-in (`readMyMindEnabled`, synced): it ships in both
|
||||
// templates but stays display:none until the bar carries the marker class.
|
||||
this.syncReadMyMind();
|
||||
|
||||
// Add click handlers — preventDefault stops event from reaching terminal
|
||||
this.element.addEventListener('click', (e) => {
|
||||
@@ -784,6 +789,11 @@ const KeyboardAccessoryBar = {
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'readmymind':
|
||||
// Opens the shared Read My Mind modal (readmymind-ui.js); the modal
|
||||
// takes focus, so deliberately NOT in the terminal-refocus set.
|
||||
app.openReadMyMind?.();
|
||||
break;
|
||||
case 'paste':
|
||||
this.pasteFromClipboard();
|
||||
break;
|
||||
@@ -829,6 +839,17 @@ const KeyboardAccessoryBar = {
|
||||
this._confirmAction = null;
|
||||
},
|
||||
|
||||
/** Reveal/hide the 🧠 key from the synced `readMyMindEnabled` setting.
|
||||
* The marker class lives on the BAR because setMode() rebuilds the buttons'
|
||||
* innerHTML on every layout switch (per-key state would be wiped). Called at
|
||||
* init and re-synced by applyHeaderVisibilitySettings() on every settings
|
||||
* apply, so a live toggle needs no reload. */
|
||||
syncReadMyMind() {
|
||||
if (!this.element) return;
|
||||
const enabled = typeof app !== 'undefined' && typeof app.readMyMindEnabled === 'function' && app.readMyMindEnabled();
|
||||
this.element.classList.toggle('rmm-enabled', enabled === true);
|
||||
},
|
||||
|
||||
/** Send a slash command to the active session.
|
||||
* Sends text and Enter separately so Ink processes them as distinct events. */
|
||||
sendCommand(command) {
|
||||
|
||||
+453
-58
@@ -115,13 +115,17 @@ 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) */
|
||||
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). */
|
||||
.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;
|
||||
@@ -219,24 +223,6 @@ html.mobile-init .file-browser-panel {
|
||||
min-height: 56px;
|
||||
}
|
||||
|
||||
.modal-tabs {
|
||||
overflow-x: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
scrollbar-width: none;
|
||||
flex-wrap: nowrap;
|
||||
}
|
||||
|
||||
.modal-tabs::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.modal-tab-btn {
|
||||
padding: 0.4rem 0.75rem;
|
||||
font-size: 0.7rem;
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Settings grid stays 2-col on tablet but tighter */
|
||||
.settings-grid {
|
||||
gap: 0.4rem 0.75rem;
|
||||
@@ -530,8 +516,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 🧠 header button: never in the phone header; the phone
|
||||
surface is the keyboard-accessory 🧠 key (same `readMyMindEnabled` gate,
|
||||
see keyboard-accessory.js + the rmm-enabled rules in styles.css). */
|
||||
.btn-icon-header.btn-readmymind {
|
||||
display: none !important;
|
||||
}
|
||||
@@ -643,6 +630,7 @@ 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;
|
||||
@@ -680,6 +668,13 @@ html.mobile-init .file-browser-panel {
|
||||
box-shadow: 0 0 8px 2px color-mix(in srgb, var(--green) 55%, transparent) !important;
|
||||
}
|
||||
|
||||
/* No orbiting ring on phone tabs (styles.css draws one on desktop/tablet):
|
||||
the dot is already enlarged to 9px with a glow here, and a 15px ring in a
|
||||
32px tab would sit on top of the tab name. The glow is the phone's tell. */
|
||||
.session-tab .tab-status.busy::after {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Truncate tab names more aggressively on mobile */
|
||||
.session-tab .tab-name {
|
||||
max-width: 50px;
|
||||
@@ -1311,6 +1306,35 @@ html.mobile-init .file-browser-panel {
|
||||
width: calc(100% - 2rem);
|
||||
}
|
||||
|
||||
/* Read My Mind: a small dialog (mirrors modal-sm), not a full-screen
|
||||
takeover — it opens over the keyboard from the accessory 🧠 key and
|
||||
should read as a quick suggestion sheet. Not modal-sm itself because
|
||||
that caps desktop width at 340px; this modal wants 560px there. */
|
||||
.modal-content.readmymind-modal {
|
||||
height: auto;
|
||||
max-height: 85vh;
|
||||
border-radius: 12px;
|
||||
margin: 1rem;
|
||||
width: calc(100% - 2rem);
|
||||
}
|
||||
/* Four footer buttons on a narrow phone: let them wrap instead of clipping,
|
||||
and give buttons + alternate rows finger-sized targets. The flex row
|
||||
itself comes from the base rule in styles.css. */
|
||||
.readmymind-modal .modal-footer {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.readmymind-modal .modal-footer .btn-toolbar {
|
||||
flex: 1 1 auto;
|
||||
justify-content: center;
|
||||
min-height: 38px;
|
||||
}
|
||||
.readmymind-alt {
|
||||
min-height: 38px;
|
||||
}
|
||||
.readmymind-steer-input {
|
||||
min-height: 38px;
|
||||
}
|
||||
|
||||
/* Modal safe area padding - all sides for full-screen modals */
|
||||
.ios-device .modal-content {
|
||||
padding-top: var(--safe-area-top);
|
||||
@@ -2037,45 +2061,8 @@ html.mobile-init .file-browser-panel {
|
||||
|
||||
/* ---- Settings Modal: Mobile Optimizations ---- */
|
||||
|
||||
/* Scrollable tabs row - prevent overflow on small screens */
|
||||
.modal-tabs {
|
||||
overflow-x: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
scrollbar-width: none;
|
||||
gap: 0.25rem;
|
||||
padding: 0 0.75rem 0.5rem 0.75rem;
|
||||
flex-wrap: nowrap;
|
||||
}
|
||||
|
||||
.modal-tabs::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.modal-tab-btn {
|
||||
padding: 0.35rem 0.6rem;
|
||||
font-size: 0.65rem;
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* ---- Case Modal: Mobile Touch Optimizations ---- */
|
||||
|
||||
/* Larger tab buttons for case modal - easy to tap */
|
||||
#createCaseModal .modal-tabs {
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem 1rem 0.75rem;
|
||||
}
|
||||
|
||||
#createCaseModal .modal-tab-btn {
|
||||
flex: 1;
|
||||
min-height: 44px;
|
||||
padding: 0.6rem 1rem;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 500;
|
||||
border-radius: 8px;
|
||||
justify-content: center;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* Touch-friendly form inputs in case modal */
|
||||
#createCaseModal .form-row {
|
||||
@@ -3117,3 +3104,411 @@ html:is([data-skin="paper-gray"], [data-skin="solarized-light"], [data-skin="cat
|
||||
padding: 0.65rem 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* ============================================================================
|
||||
App Settings, compact layout (<= 860px)
|
||||
|
||||
Same single scrolling document as the desktop rail layout; only the
|
||||
navigation changes. The rail collapses to its search field and #appSettingsJump
|
||||
takes over as the sticky "where am I / jump elsewhere" control, so a phone
|
||||
spends its vertical budget on settings instead of on chrome.
|
||||
|
||||
Groups render as one inset rounded list with hairline dividers rather than a
|
||||
stack of separate cards: at 390px the per-card borders were most of the pixels.
|
||||
============================================================================ */
|
||||
@media (max-width: 860px) {
|
||||
:is(#appSettingsModal, #sessionOptionsModal, #createCaseModal) .modal-content.modal-lg {
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
height: 100%;
|
||||
max-height: 100%;
|
||||
}
|
||||
|
||||
:is(#appSettingsModal, #sessionOptionsModal, #createCaseModal) .set-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
/* Rail keeps only its search field, laid out as a bar */
|
||||
:is(#appSettingsModal, #sessionOptionsModal, #createCaseModal) .set-rail {
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
border-right: 0;
|
||||
border-bottom: 1px solid var(--border);
|
||||
background: transparent;
|
||||
padding: 10px 14px;
|
||||
overflow: visible;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
:is(#appSettingsModal, #sessionOptionsModal, #createCaseModal) .set-rail-items,
|
||||
:is(#appSettingsModal, #sessionOptionsModal, #createCaseModal) .set-rail-foot {
|
||||
display: none;
|
||||
}
|
||||
|
||||
:is(#appSettingsModal, #sessionOptionsModal, #createCaseModal) .set-search {
|
||||
margin: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
:is(#appSettingsModal, #sessionOptionsModal, #createCaseModal) .set-search input {
|
||||
padding: 9px 10px 9px 30px;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
/* Save moves into the header; the bottom action bar would cost 60px */
|
||||
:is(#appSettingsModal, #sessionOptionsModal, #createCaseModal) .set-head-save {
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
:is(#appSettingsModal, #sessionOptionsModal, #createCaseModal) .set-foot {
|
||||
display: none;
|
||||
}
|
||||
|
||||
:is(#appSettingsModal, #sessionOptionsModal, #createCaseModal) .set-doc {
|
||||
padding: 0 14px 34px;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
/* ── jump control ──────────────────────────────────────────────────── */
|
||||
:is(#appSettingsModal, #sessionOptionsModal, #createCaseModal) .set-jump {
|
||||
display: flex;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 4;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
margin: 10px 0 2px;
|
||||
padding: 10px 12px;
|
||||
border-radius: 11px;
|
||||
font: inherit;
|
||||
font-size: 0.82rem;
|
||||
color: var(--text);
|
||||
background: rgba(var(--accent-rgb), 0.13);
|
||||
border: 1px solid rgba(var(--accent-rgb), 0.3);
|
||||
-webkit-backdrop-filter: blur(14px);
|
||||
backdrop-filter: blur(14px);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
:is(#appSettingsModal, #sessionOptionsModal, #createCaseModal) .set-jump-ico {
|
||||
color: var(--accent);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
:is(#appSettingsModal, #sessionOptionsModal, #createCaseModal) .set-jump-label {
|
||||
font-weight: 580;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
:is(#appSettingsModal, #sessionOptionsModal, #createCaseModal) .set-jump-chev {
|
||||
margin-left: auto;
|
||||
color: var(--text-muted);
|
||||
flex-shrink: 0;
|
||||
transition: transform 0.18s;
|
||||
}
|
||||
|
||||
:is(#appSettingsModal, #sessionOptionsModal, #createCaseModal) .set-jump[aria-expanded='true'] .set-jump-chev {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
:is(#appSettingsModal, #sessionOptionsModal, #createCaseModal) .set-jump-veil {
|
||||
display: none;
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 8;
|
||||
background: rgba(4, 8, 13, 0.62);
|
||||
}
|
||||
|
||||
:is(#appSettingsModal, #sessionOptionsModal, #createCaseModal) .set-jump-menu {
|
||||
display: none;
|
||||
position: absolute;
|
||||
left: 14px;
|
||||
right: 14px;
|
||||
z-index: 9;
|
||||
padding: 7px;
|
||||
border-radius: 16px;
|
||||
/* Opaque on purpose: --floating-bg is translucent and the settings rows
|
||||
behind the menu bleed through it. */
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--control-border);
|
||||
box-shadow: var(--elevated-shadow);
|
||||
max-height: 70vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
#appSettingsModal.jump-open .set-jump-veil,
|
||||
#appSettingsModal.jump-open .set-jump-menu {
|
||||
display: block;
|
||||
}
|
||||
|
||||
:is(#appSettingsModal, #sessionOptionsModal, #createCaseModal) .set-jump-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 11px;
|
||||
width: 100%;
|
||||
padding: 11px 12px;
|
||||
border: 0;
|
||||
border-radius: 11px;
|
||||
background: transparent;
|
||||
color: var(--text-dim);
|
||||
font: inherit;
|
||||
font-size: 0.82rem;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
:is(#appSettingsModal, #sessionOptionsModal, #createCaseModal) .set-jump-row svg {
|
||||
color: var(--text-muted);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
:is(#appSettingsModal, #sessionOptionsModal, #createCaseModal) .set-jump-row .set-jump-count {
|
||||
margin-left: auto;
|
||||
font-size: 0.62rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
:is(#appSettingsModal, #sessionOptionsModal, #createCaseModal) .set-jump-row.active {
|
||||
background: rgba(var(--accent-rgb), 0.14);
|
||||
color: var(--text);
|
||||
font-weight: 570;
|
||||
}
|
||||
|
||||
:is(#appSettingsModal, #sessionOptionsModal, #createCaseModal) .set-jump-row.active svg {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
/* ── sections step down: the jump pill already names the current one ── */
|
||||
:is(#appSettingsModal, #sessionOptionsModal, #createCaseModal) .set-section {
|
||||
padding-top: 0;
|
||||
}
|
||||
|
||||
:is(#appSettingsModal, #sessionOptionsModal, #createCaseModal) .set-section + .set-section {
|
||||
border-top: 0;
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
:is(#appSettingsModal, #sessionOptionsModal, #createCaseModal) .set-section-head {
|
||||
gap: 7px;
|
||||
margin: 18px 0 2px;
|
||||
}
|
||||
|
||||
:is(#appSettingsModal, #sessionOptionsModal, #createCaseModal) .set-section-head svg {
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: none;
|
||||
color: var(--text-muted);
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
}
|
||||
|
||||
:is(#appSettingsModal, #sessionOptionsModal, #createCaseModal) .set-section-head h2 {
|
||||
font-size: 0.6rem;
|
||||
font-weight: 640;
|
||||
letter-spacing: 0.1em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
:is(#appSettingsModal, #sessionOptionsModal, #createCaseModal) .set-section-head::after {
|
||||
content: '';
|
||||
flex: 1;
|
||||
height: 1px;
|
||||
background: linear-gradient(90deg, var(--border), transparent);
|
||||
}
|
||||
|
||||
:is(#appSettingsModal, #sessionOptionsModal, #createCaseModal) .set-section-blurb {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* ── live layout preview ───────────────────────────────────────────── */
|
||||
:is(#appSettingsModal, #sessionOptionsModal, #createCaseModal) .set-preview {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
:is(#appSettingsModal, #sessionOptionsModal, #createCaseModal) .set-preview-stage {
|
||||
min-height: 62px;
|
||||
}
|
||||
|
||||
/* ── inset grouped list ────────────────────────────────────────────── */
|
||||
:is(#appSettingsModal, #sessionOptionsModal, #createCaseModal) .set-group {
|
||||
margin-top: 14px;
|
||||
}
|
||||
|
||||
:is(#appSettingsModal, #sessionOptionsModal, #createCaseModal) .set-group + .set-group {
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
:is(#appSettingsModal, #sessionOptionsModal, #createCaseModal) .set-group-head {
|
||||
margin-bottom: 7px;
|
||||
padding: 0 3px;
|
||||
}
|
||||
|
||||
:is(#appSettingsModal, #sessionOptionsModal, #createCaseModal) .set-group-hint {
|
||||
padding: 0 3px;
|
||||
}
|
||||
|
||||
:is(#appSettingsModal, #sessionOptionsModal, #createCaseModal) .set-group-body {
|
||||
gap: 0;
|
||||
background: rgba(255, 255, 255, 0.035);
|
||||
border: 1px solid rgba(255, 255, 255, 0.06);
|
||||
border-radius: 13px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
:is(#appSettingsModal, #sessionOptionsModal, #createCaseModal) .set-group-body > .set-row {
|
||||
background: transparent;
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
padding: 12px 13px;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
:is(#appSettingsModal, #sessionOptionsModal, #createCaseModal) .set-group-body > .set-row + .set-row {
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.055);
|
||||
}
|
||||
|
||||
:is(#appSettingsModal, #sessionOptionsModal, #createCaseModal) .set-group-body > .set-chips,
|
||||
:is(#appSettingsModal, #sessionOptionsModal, #createCaseModal) .set-group-body > .set-modelgrid,
|
||||
:is(#appSettingsModal, #sessionOptionsModal, #createCaseModal) .set-group-body > .set-minigrid,
|
||||
:is(#appSettingsModal, #sessionOptionsModal, #createCaseModal) .set-group-body > #appSettingsShortcutsList {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
:is(#appSettingsModal, #sessionOptionsModal, #createCaseModal) .set-group-body > .event-type-grid {
|
||||
padding: 12px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
:is(#appSettingsModal, #sessionOptionsModal, #createCaseModal) .set-row-label {
|
||||
font-size: 0.84rem;
|
||||
}
|
||||
|
||||
:is(#appSettingsModal, #sessionOptionsModal, #createCaseModal) .set-row-desc {
|
||||
font-size: 0.69rem;
|
||||
max-width: none;
|
||||
}
|
||||
|
||||
/* Fields go full width under their label instead of fighting for the row */
|
||||
:is(#appSettingsModal, #sessionOptionsModal, #createCaseModal) .set-row.has-field {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: 9px;
|
||||
}
|
||||
|
||||
:is(#appSettingsModal, #sessionOptionsModal, #createCaseModal) .set-row.has-field .set-select,
|
||||
:is(#appSettingsModal, #sessionOptionsModal, #createCaseModal) .set-row.has-field .set-input {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
max-width: none;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
:is(#appSettingsModal, #sessionOptionsModal, #createCaseModal) .set-row-actions-wide {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
:is(#appSettingsModal, #sessionOptionsModal, #createCaseModal) .set-row-actions-wide .set-input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
:is(#appSettingsModal, #sessionOptionsModal, #createCaseModal) .set-num {
|
||||
width: 76px;
|
||||
}
|
||||
|
||||
/* Bigger touch targets for the toggles and chips */
|
||||
:is(#appSettingsModal, #sessionOptionsModal, #createCaseModal) .switch-sm {
|
||||
width: 40px;
|
||||
height: 24px;
|
||||
}
|
||||
|
||||
:is(#appSettingsModal, #sessionOptionsModal, #createCaseModal) .switch-sm .slider:before {
|
||||
height: 18px;
|
||||
width: 18px;
|
||||
}
|
||||
|
||||
:is(#appSettingsModal, #sessionOptionsModal, #createCaseModal) .switch-sm input:checked + .slider:before {
|
||||
transform: translateX(16px);
|
||||
}
|
||||
|
||||
:is(#appSettingsModal, #sessionOptionsModal, #createCaseModal) .set-chip {
|
||||
font-size: 0.78rem;
|
||||
padding: 9px 14px;
|
||||
}
|
||||
|
||||
:is(#appSettingsModal, #sessionOptionsModal, #createCaseModal) .set-modelgrid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
:is(#appSettingsModal, #sessionOptionsModal, #createCaseModal) .set-minigrid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
:is(#appSettingsModal, #sessionOptionsModal, #createCaseModal) .set-mini .set-select {
|
||||
width: 148px;
|
||||
}
|
||||
|
||||
/* One scrollable line beats a ragged two-row wrap for 7 effort levels */
|
||||
:is(#appSettingsModal, #sessionOptionsModal, #createCaseModal) .set-segment {
|
||||
overflow-x: auto;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
:is(#appSettingsModal, #sessionOptionsModal, #createCaseModal) .set-segment::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
:is(#appSettingsModal, #sessionOptionsModal, #createCaseModal) .set-segment button {
|
||||
flex: 0 0 auto;
|
||||
padding: 8px 12px;
|
||||
}
|
||||
}
|
||||
|
||||
/* ============================================================================
|
||||
Session Options, compact layout (<= 860px)
|
||||
|
||||
App Settings collapses its rail and hands navigation to the sticky
|
||||
#appSettingsJump pill. Session Options has no such pill (and no search), so
|
||||
its rail stays put and becomes a horizontal, scrollable strip — which is
|
||||
what its tab bar was before the two modals started sharing a surface.
|
||||
============================================================================ */
|
||||
@media (max-width: 860px) {
|
||||
:is(#sessionOptionsModal, #createCaseModal) .set-rail {
|
||||
padding: 8px 10px;
|
||||
}
|
||||
|
||||
:is(#sessionOptionsModal, #createCaseModal) .set-rail-items {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
gap: 4px;
|
||||
overflow-x: auto;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
:is(#sessionOptionsModal, #createCaseModal) .set-rail-items::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
:is(#sessionOptionsModal, #createCaseModal) .set-rail-item {
|
||||
white-space: nowrap;
|
||||
padding: 8px 12px;
|
||||
}
|
||||
|
||||
/* The active marker is a left bar in the vertical rail; horizontally that
|
||||
reads as a stray tick, so the strip uses a filled pill instead. */
|
||||
:is(#sessionOptionsModal, #createCaseModal) .set-rail-item.active::before {
|
||||
display: none;
|
||||
}
|
||||
|
||||
:is(#sessionOptionsModal, #createCaseModal) .set-rail-item.active {
|
||||
background: rgba(var(--accent-rgb), 0.13);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -649,6 +649,11 @@ Object.assign(CodemanApp.prototype, {
|
||||
sizeBytes: s.sizeBytes ?? 0,
|
||||
lastModified: new Date(s.lastActivityAt ?? s.createdAt ?? Date.now()).toISOString(),
|
||||
firstPrompt: s.firstPrompt || s.name || '',
|
||||
// Must be carried explicitly: this record is a re-projection, so any
|
||||
// field omitted here silently vanishes from the Cmd+K list (#266).
|
||||
gitBranch: s.gitBranch,
|
||||
worktreeName: s.worktreeName,
|
||||
worktreeRepo: s.worktreeRepo,
|
||||
};
|
||||
const isLive = !!this.sessions?.has?.(s.sessionId);
|
||||
const item = this._buildHistoryItem(record, this.cases, {
|
||||
|
||||
@@ -2,12 +2,16 @@
|
||||
* @fileoverview Read My Mind UI: predict the prompt you were about to type.
|
||||
*
|
||||
* A 🧠 header button (marker-hidden until the synced opt-in `readMyMindEnabled`
|
||||
* setting is ON) opens a modal that asks the server for the user's most likely
|
||||
* setting is ON; phones get a keyboard-accessory 🧠 key gated on the same
|
||||
* setting) 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's other
|
||||
* suggestions render as tappable alternate rows that swap into the field
|
||||
* without losing edits. Buttons are Send (with Enter), Insert (drop on the CLI
|
||||
* composer WITHOUT Enter, for editing), Rethink (re-run with the whole shown
|
||||
* set, main + alternates, recorded as rejected, plus the optional free-text
|
||||
* steer note, e.g. "no, I meant the mobile bug", sent as `steer`), Dismiss.
|
||||
*
|
||||
* Suggestions are NEVER auto-sent: the explicit click here is the security
|
||||
* boundary for observed/injectable predictor inputs, so suggestion text is
|
||||
@@ -20,6 +24,7 @@
|
||||
*
|
||||
* @mixin Extends CodemanApp.prototype via Object.assign
|
||||
* @dependency app.js (CodemanApp class, this.sessions, this.activeSessionId, showToast)
|
||||
* @dependency mobile-handlers.js (MobileDetection.isTouchDevice, focus policy)
|
||||
* @dependency settings-ui.js (loadAppSettingsFromStorage)
|
||||
* @dependency api-client.js at runtime (this._apiJson; loads later but is only called after init)
|
||||
* @loadorder 11.3, after panels-ui.js, before ultracode-panel.js
|
||||
@@ -43,8 +48,11 @@ Object.assign(CodemanApp.prototype, {
|
||||
this.showToast('Read My Mind works on Claude sessions only', 'warning');
|
||||
return;
|
||||
}
|
||||
// Rethink memory resets on each open (a fresh open is a fresh question).
|
||||
this._rmm = { sessionId, shown: null, rejected: [], busy: false };
|
||||
// Rethink memory resets on each open (a fresh open is a fresh question),
|
||||
// and the steer note resets with it.
|
||||
this._rmm = { sessionId, suggestions: [], selected: 0, rejected: [], busy: false };
|
||||
const steer = document.getElementById('readMyMindSteer');
|
||||
if (steer) steer.value = '';
|
||||
document.getElementById('readMyMindModal')?.classList.add('active');
|
||||
this._readMyMindPredict();
|
||||
},
|
||||
@@ -54,14 +62,20 @@ 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 suggestion set. */
|
||||
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 rides every re-run while it stays in the field: what the
|
||||
// user sees in the box is what the predictor gets. Empty on first open
|
||||
// (openReadMyMind clears it), so a plain predict sends neither key.
|
||||
const steer = document.getElementById('readMyMindSteer')?.value.trim() ?? '';
|
||||
if (steer) body.steer = steer.slice(0, 2000);
|
||||
const data = await this._apiJson(`/api/sessions/${state.sessionId}/readmymind`, { method: 'POST', body });
|
||||
|
||||
// The modal may have been dismissed (or reopened for another session) while
|
||||
@@ -69,26 +83,83 @@ 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 && Array.isArray(data.suggestions) ? data.suggestions : []).filter(
|
||||
(s) => s && typeof s.prompt === 'string' && s.prompt.trim()
|
||||
);
|
||||
if (suggestions.length === 0) {
|
||||
this._rmmSetPhase('error');
|
||||
return;
|
||||
}
|
||||
state.shown = suggestion;
|
||||
state.suggestions = suggestions.slice(0, 3);
|
||||
state.selected = 0;
|
||||
this._rmmSetPhase('ready');
|
||||
this._rmmRender();
|
||||
this._rmmFocusPrompt();
|
||||
},
|
||||
|
||||
/** Paint the selected suggestion into the editable field, the rest as alternates. */
|
||||
_rmmRender() {
|
||||
const state = this._rmm;
|
||||
const current = state && state.suggestions[state.selected];
|
||||
if (!current) return;
|
||||
|
||||
const input = document.getElementById('readMyMindPrompt');
|
||||
const why = document.getElementById('readMyMindWhy');
|
||||
const kind = document.getElementById('readMyMindKind');
|
||||
// Predictor output is derived from observable (injectable) content:
|
||||
// value/textContent only, never innerHTML.
|
||||
if (input) input.value = suggestion.prompt;
|
||||
if (why) why.textContent = suggestion.why || '';
|
||||
if (input) input.value = current.prompt;
|
||||
if (why) why.textContent = current.why || '';
|
||||
if (kind) {
|
||||
kind.textContent = suggestion.kind || 'continue';
|
||||
kind.className = `readmymind-kind readmymind-kind-${suggestion.kind || 'continue'}`;
|
||||
kind.textContent = current.kind || 'continue';
|
||||
kind.className = `readmymind-kind readmymind-kind-${current.kind || 'continue'}`;
|
||||
}
|
||||
input?.focus();
|
||||
|
||||
const alternates = document.getElementById('readMyMindAlternates');
|
||||
if (!alternates) return;
|
||||
alternates.replaceChildren();
|
||||
// The container is data-i18n-skip (suggestion text must never be mistaken
|
||||
// for app copy), so the one piece of app copy inside it is pre-translated.
|
||||
const translate = window.codemanT || ((s) => s);
|
||||
state.suggestions.forEach((suggestion, index) => {
|
||||
if (index === state.selected) return;
|
||||
const row = document.createElement('button');
|
||||
row.type = 'button';
|
||||
row.className = 'readmymind-alt';
|
||||
row.title = suggestion.why || '';
|
||||
row.setAttribute('aria-label', translate('Use this suggestion instead'));
|
||||
const badge = document.createElement('span');
|
||||
badge.className = `readmymind-kind readmymind-kind-${suggestion.kind || 'continue'}`;
|
||||
badge.textContent = suggestion.kind || 'continue';
|
||||
const text = document.createElement('span');
|
||||
text.className = 'readmymind-alt-text';
|
||||
text.textContent = suggestion.prompt;
|
||||
row.append(badge, text);
|
||||
row.addEventListener('click', () => this._rmmSelect(index));
|
||||
alternates.appendChild(row);
|
||||
});
|
||||
alternates.style.display = alternates.childElementCount > 0 ? '' : 'none';
|
||||
},
|
||||
|
||||
/** Swap an alternate into the field, folding the current edit back first. */
|
||||
_rmmSelect(index) {
|
||||
const state = this._rmm;
|
||||
if (!state || state.busy || !state.suggestions[index]) return;
|
||||
const input = document.getElementById('readMyMindPrompt');
|
||||
const current = state.suggestions[state.selected];
|
||||
// Keep edits: fold the field text back into the suggestion it belongs to,
|
||||
// so toggling between alternates never loses typing.
|
||||
if (input && current) current.prompt = input.value;
|
||||
state.selected = index;
|
||||
this._rmmRender();
|
||||
this._rmmFocusPrompt();
|
||||
},
|
||||
|
||||
/** Focus the editable field on desktop. On touch devices leave it blurred so
|
||||
* the OS keyboard doesn't pop over the alternates that just rendered. */
|
||||
_rmmFocusPrompt() {
|
||||
if (typeof MobileDetection !== 'undefined' && MobileDetection.isTouchDevice()) return;
|
||||
document.getElementById('readMyMindPrompt')?.focus();
|
||||
},
|
||||
|
||||
/**
|
||||
@@ -114,11 +185,15 @@ 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 the whole shown set (main + alternates) recorded as rejected:
|
||||
* the user saw every row and asked for something else. The steer note (if
|
||||
* any) is read from the field by _readMyMindPredict itself. */
|
||||
rethinkReadMyMind() {
|
||||
const state = this._rmm;
|
||||
if (!state || state.busy) return;
|
||||
if (state.shown && state.shown.prompt) state.rejected.push(state.shown.prompt);
|
||||
for (const suggestion of state.suggestions) {
|
||||
if (suggestion.prompt && suggestion.prompt.trim()) state.rejected.push(suggestion.prompt);
|
||||
}
|
||||
this._readMyMindPredict();
|
||||
},
|
||||
|
||||
@@ -129,6 +204,11 @@ Object.assign(CodemanApp.prototype, {
|
||||
modal.querySelector('.readmymind-loading').style.display = phase === 'loading' ? '' : 'none';
|
||||
modal.querySelector('.readmymind-result').style.display = phase === 'ready' ? '' : 'none';
|
||||
modal.querySelector('.readmymind-error').style.display = phase === 'error' ? '' : 'none';
|
||||
// The steer note belongs to Rethink, so it shows wherever Rethink is live:
|
||||
// the ready phase AND the empty-result phase (typed text survives the
|
||||
// loading round-trip, only the row's visibility toggles).
|
||||
const steerRow = document.getElementById('readMyMindSteerRow');
|
||||
if (steerRow) steerRow.style.display = phase === 'loading' ? 'none' : '';
|
||||
const rethinkBtn = document.getElementById('readMyMindRethink');
|
||||
if (rethinkBtn) rethinkBtn.disabled = phase === 'loading';
|
||||
},
|
||||
|
||||
@@ -489,7 +489,11 @@ Object.assign(CodemanApp.prototype, {
|
||||
const date = new Date(s.lastModified);
|
||||
const timeStr = date.toLocaleDateString('en', { month: 'short', day: 'numeric' })
|
||||
+ ' ' + date.toLocaleTimeString('en', { hour: '2-digit', minute: '2-digit', hour12: false });
|
||||
const shortDir = s.workingDir.replace(/^\/home\/[^/]+\//, '~/');
|
||||
// Shared helper, not a local regex: the copy that used to live here
|
||||
// matched `/home/<user>/` only, so on macOS every row rendered the same
|
||||
// unabbreviated `/Users/<user>/…` prefix and ellipsized away the tail
|
||||
// that identifies it (#273).
|
||||
const shortDir = this._shortenHomePath(s.workingDir);
|
||||
|
||||
const btn = document.createElement('button');
|
||||
btn.className = 'run-mode-option';
|
||||
@@ -1278,8 +1282,8 @@ Object.assign(CodemanApp.prototype, {
|
||||
document.getElementById('presetDescriptionHint').textContent = '';
|
||||
|
||||
// Hide Ralph/Todo tab and Respawn tab for external CLI sessions (not supported)
|
||||
const ralphTabBtn = document.querySelector('#sessionOptionsModal .modal-tab-btn[data-tab="ralph"]');
|
||||
const respawnTabBtn = document.querySelector('#sessionOptionsModal .modal-tab-btn[data-tab="respawn"]');
|
||||
const ralphTabBtn = document.querySelector('#sessionOptionsModal .set-rail-item[data-tab="ralph"]');
|
||||
const respawnTabBtn = document.querySelector('#sessionOptionsModal .set-rail-item[data-tab="respawn"]');
|
||||
if (isExternalCli) {
|
||||
if (ralphTabBtn) ralphTabBtn.style.display = 'none';
|
||||
if (respawnTabBtn) respawnTabBtn.style.display = 'none';
|
||||
@@ -1303,6 +1307,18 @@ Object.assign(CodemanApp.prototype, {
|
||||
}
|
||||
|
||||
const modal = document.getElementById('sessionOptionsModal');
|
||||
|
||||
// Chips mirror their checkbox onto the label, the same way App Settings does
|
||||
// (settings-ui.js: _syncSettingsChips). Registered once per page, never per
|
||||
// open, or a long-lived tab accumulates one listener per visit.
|
||||
if (modal.dataset.chipsReady !== '1') {
|
||||
modal.dataset.chipsReady = '1';
|
||||
modal.addEventListener('change', e => {
|
||||
if (e.target?.closest?.('.set-chip')) this._syncSettingsChips();
|
||||
});
|
||||
}
|
||||
this._syncSettingsChips();
|
||||
|
||||
modal.classList.add('active');
|
||||
|
||||
// Activate focus trap
|
||||
@@ -1500,18 +1516,32 @@ Object.assign(CodemanApp.prototype, {
|
||||
// Session Options Modal Tabs
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
/**
|
||||
* Show one section of the Session Options modal.
|
||||
*
|
||||
* The chrome is the shared `set-*` settings surface, but unlike App Settings
|
||||
* (whose rail is a table of contents over one scrolling document) this rail
|
||||
* is a real switcher: exactly one `.set-section` is visible and the rest
|
||||
* carry `.hidden`. Summary owns its own scroller and Respawn is long, so
|
||||
* stacking them into a single document would bury both.
|
||||
*/
|
||||
switchOptionsTab(tabName) {
|
||||
// Toggle active class on tab buttons
|
||||
document.querySelectorAll('#sessionOptionsModal .modal-tab-btn').forEach(btn => {
|
||||
// Toggle active class on rail entries
|
||||
document.querySelectorAll('#sessionOptionsModal .set-rail-item').forEach(btn => {
|
||||
btn.classList.toggle('active', btn.dataset.tab === tabName);
|
||||
});
|
||||
|
||||
// Toggle hidden class on tab content
|
||||
// Toggle hidden class on the sections
|
||||
document.getElementById('respawn-tab').classList.toggle('hidden', tabName !== 'respawn');
|
||||
document.getElementById('context-tab').classList.toggle('hidden', tabName !== 'context');
|
||||
document.getElementById('ralph-tab').classList.toggle('hidden', tabName !== 'ralph');
|
||||
document.getElementById('summary-tab').classList.toggle('hidden', tabName !== 'summary');
|
||||
|
||||
// A switched-to section starts at its own top, not at the scroll offset the
|
||||
// previous one was left at.
|
||||
const doc = document.getElementById('sessionOptionsDoc');
|
||||
if (doc) doc.scrollTop = 0;
|
||||
|
||||
// Load run summary data when switching to summary tab
|
||||
if (tabName === 'summary' && this.editingSessionId) {
|
||||
this.loadRunSummary(this.editingSessionId);
|
||||
@@ -1782,7 +1812,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
this.switchCaseModalTab('case-create');
|
||||
// Wire up tab buttons
|
||||
const modal = document.getElementById('createCaseModal');
|
||||
modal.querySelectorAll('.modal-tabs .modal-tab-btn').forEach(btn => {
|
||||
modal.querySelectorAll('.set-rail-item').forEach(btn => {
|
||||
btn.onclick = () => this.switchCaseModalTab(btn.dataset.tab);
|
||||
});
|
||||
// Scroll-into-view on focus for mobile keyboard visibility
|
||||
@@ -1803,14 +1833,17 @@ Object.assign(CodemanApp.prototype, {
|
||||
switchCaseModalTab(tabName) {
|
||||
this.caseModalTab = tabName;
|
||||
const modal = document.getElementById('createCaseModal');
|
||||
// Toggle active class on tab buttons
|
||||
modal.querySelectorAll('.modal-tabs .modal-tab-btn').forEach(btn => {
|
||||
// Toggle active class on rail entries
|
||||
modal.querySelectorAll('.set-rail-item').forEach(btn => {
|
||||
btn.classList.toggle('active', btn.dataset.tab === tabName);
|
||||
});
|
||||
// Toggle hidden class on tab content
|
||||
modal.querySelectorAll('.modal-tab-content').forEach(content => {
|
||||
// Toggle hidden class on the panels
|
||||
modal.querySelectorAll('.set-section').forEach(content => {
|
||||
content.classList.toggle('hidden', content.id !== tabName);
|
||||
});
|
||||
// A switched-to panel starts at its own top.
|
||||
const doc = document.getElementById('createCaseDoc');
|
||||
if (doc) doc.scrollTop = 0;
|
||||
// Update submit button (hide for manage tab)
|
||||
const submitBtn = document.getElementById('caseModalSubmit');
|
||||
if (tabName === 'case-manage') {
|
||||
@@ -2676,7 +2709,9 @@ Object.assign(CodemanApp.prototype, {
|
||||
cases.forEach((c, idx) => {
|
||||
const isFirst = idx === 0;
|
||||
const isLast = idx === cases.length - 1;
|
||||
const pathDisplay = c.path ? c.path.replace(/^\/Users\/[^/]+/, '~') : '';
|
||||
// Was `/Users/<user>` only, the mirror image of the Run menu's bug: every
|
||||
// case path on a Linux host rendered in full, unabbreviated.
|
||||
const pathDisplay = c.path ? this._shortenHomePath(c.path) : '';
|
||||
html += `
|
||||
<div class="case-manage-item" data-case="${escapeHtml(c.name)}">
|
||||
<div class="case-manage-info">
|
||||
|
||||
+483
-40
@@ -354,11 +354,8 @@ Object.assign(CodemanApp.prototype, {
|
||||
// Phone overview home screen: only meaningful under 430px, so the row is
|
||||
// hidden elsewhere rather than offering a toggle that changes nothing.
|
||||
document.getElementById('appSettingsMobileOverview').checked = settings.mobileOverviewEnabled ?? defaults.mobileOverviewEnabled ?? false;
|
||||
const phoneOnly = MobileDetection.getDeviceType() === 'mobile' ? '' : 'none';
|
||||
const mobileOverviewItem = document.getElementById('appSettingsMobileOverviewItem');
|
||||
if (mobileOverviewItem) mobileOverviewItem.style.display = phoneOnly;
|
||||
const phoneSection = document.getElementById('appSettingsPhoneSection');
|
||||
if (phoneSection) phoneSection.style.display = phoneOnly;
|
||||
if (mobileOverviewItem) mobileOverviewItem.style.display = MobileDetection.getDeviceType() === 'mobile' ? '' : 'none';
|
||||
// Session Manager, Away Digest and Cron buttons all default OFF (opt-in under
|
||||
// Display → Header Displays; the Cron button also ships with btn-cron--hidden
|
||||
// in the template, so an unchecked box and a hidden button stay consistent).
|
||||
@@ -485,27 +482,34 @@ Object.assign(CodemanApp.prototype, {
|
||||
const voiceCfg = VoiceInput._getDeepgramConfig();
|
||||
document.getElementById('voiceDeepgramKey').value = voiceCfg.apiKey || '';
|
||||
document.getElementById('voiceLanguage').value = voiceCfg.language || 'en-US';
|
||||
document.getElementById('voiceKeyterms').value = voiceCfg.keyterms || 'refactor, endpoint, middleware, callback, async, regex, TypeScript, npm, API, deploy, config, linter, env, webhook, schema, CLI, JSON, CSS, DOM, SSE, backend, frontend, localhost, dependencies, repository, merge, rebase, diff, commit, com';
|
||||
document.getElementById('voiceKeyterms').value = voiceCfg.keyterms || DEFAULT_VOICE_KEYTERMS;
|
||||
document.getElementById('voiceInsertMode').value = voiceCfg.insertMode || 'direct';
|
||||
document.getElementById('voiceProvider').value = voiceCfg.provider || 'auto';
|
||||
document.getElementById('appSettingsClaudeVoice').checked = settings.claudeVoiceEnabled ?? false;
|
||||
// Reset key visibility to hidden
|
||||
const keyInput = document.getElementById('voiceDeepgramKey');
|
||||
keyInput.type = 'password';
|
||||
document.getElementById('voiceKeyToggleBtn').textContent = 'Show';
|
||||
// Update provider status
|
||||
const providerName = VoiceInput.getActiveProviderName();
|
||||
const providerEl = document.getElementById('voiceProviderStatus');
|
||||
providerEl.textContent = providerName;
|
||||
providerEl.className = 'voice-provider-status' + (providerName.startsWith('Deepgram') ? ' active' : '');
|
||||
// Update provider status. The Claude row needs a fresh server probe: the
|
||||
// setting is synced, so another device may have flipped it since page load.
|
||||
this._renderVoiceProviderStatus();
|
||||
VoiceInput.refreshClaudeStatus().then(() => this._renderVoiceProviderStatus());
|
||||
|
||||
// Updates section — show current version, reset transient result/progress UI.
|
||||
this._initUpdatesSection();
|
||||
|
||||
// Reset to first tab and wire up tab switching
|
||||
this.switchSettingsTab('settings-display');
|
||||
// Model cards + effort segment are views over the hidden <select>s above,
|
||||
// so they must be synced AFTER those have been given their stored values.
|
||||
this._initSettingsNav();
|
||||
this._syncSettingsChips();
|
||||
this._syncModelCards();
|
||||
this._syncEffortSegment();
|
||||
// Back to the top of the document (one scroll, not a tab reset). Updates is
|
||||
// first now: the version this install is running, and whether a newer one is
|
||||
// waiting, are the two things worth seeing before any preference. The rest of
|
||||
// the system settings (paths, automation, remote access) tail the document.
|
||||
this.switchSettingsTab('settings-updates');
|
||||
const modal = document.getElementById('appSettingsModal');
|
||||
modal.querySelectorAll('.modal-tabs .modal-tab-btn').forEach(btn => {
|
||||
btn.onclick = () => this.switchSettingsTab(btn.dataset.tab);
|
||||
});
|
||||
modal.classList.add('active');
|
||||
|
||||
// Activate focus trap
|
||||
@@ -514,44 +518,441 @@ Object.assign(CodemanApp.prototype, {
|
||||
},
|
||||
|
||||
/**
|
||||
* Show the App Settings "Codex CLI" tab only on instances where the codex
|
||||
* binary actually resolves. Both settings on it (approval bypass, animated
|
||||
* status effects) are passed to `codex` at launch, so on a box without codex
|
||||
* the tab is a promise nothing can keep.
|
||||
* Show the App Settings "Codex" group only on instances where the codex binary
|
||||
* actually resolves. Both settings in it (approval bypass, animated status
|
||||
* effects) are passed to `codex` at launch, so on a box without codex the
|
||||
* group is a promise nothing can keep.
|
||||
*
|
||||
* Availability comes from the injected `window.__codemanCliAvailable`, shared
|
||||
* with the welcome buttons and the run-mode dropdown, so the tab never flickers
|
||||
* in and back out. Only the tab BUTTON is toggled: the panel already carries
|
||||
* `.modal-tab-content.hidden` unless it is the selected tab, and
|
||||
* openAppSettings() always reopens on Display, so an unreachable button is
|
||||
* enough to keep the panel unreachable.
|
||||
* with the welcome buttons and the run-mode dropdown, so the group never
|
||||
* flickers in and back out. The inputs stay in the DOM either way, so a user
|
||||
* without codex can never silently wipe the codex prefs of an instance that
|
||||
* has it (openAppSettings/saveAppSettings still read and write them).
|
||||
*
|
||||
* Note the inverted default versus the run buttons: an UNKNOWN flag hides this
|
||||
* tab. Hiding a settings tab costs a user nothing (the values stay in the DOM
|
||||
* and are still saved), whereas hiding a run button would leave a working
|
||||
* install with nothing to click.
|
||||
* group. Hiding it costs a user nothing, whereas hiding a run button would
|
||||
* leave a working install with nothing to click.
|
||||
*/
|
||||
_applyCodexSettingsVisibility() {
|
||||
const btn = document.querySelector('#appSettingsModal .modal-tab-btn[data-tab="settings-codex"]');
|
||||
if (btn) btn.style.display = window.__codemanCliAvailable?.codex === true ? '' : 'none';
|
||||
const group = document.getElementById('appSettingsCodexGroup');
|
||||
if (group) group.style.display = window.__codemanCliAvailable?.codex === true ? '' : 'none';
|
||||
},
|
||||
|
||||
switchSettingsTab(tabName) {
|
||||
/**
|
||||
* Scroll the settings document to a section.
|
||||
*
|
||||
* Kept under the historical `switchSettingsTab` name because it is the shared
|
||||
* entry point: openAppSettings() calls it, and admin-ui.js's injected Users
|
||||
* entry routes through it too. Sections are never hidden any more — the rail
|
||||
* is a table of contents over ONE document, so "switching" is a scroll.
|
||||
*/
|
||||
switchSettingsTab(sectionId) {
|
||||
// The Shortcuts list renders lazily so it reflects the CURRENT registry
|
||||
// (defaults + overrides) every time it is reached.
|
||||
if (sectionId === 'settings-shortcuts') this.renderShortcutSettingsList?.();
|
||||
const doc = document.getElementById('appSettingsDoc');
|
||||
const section = document.getElementById(sectionId);
|
||||
if (doc && section && typeof section.offsetTop === 'number') {
|
||||
// On phones the jump pill is sticky at the top of the document, so land
|
||||
// the section head below it instead of underneath it.
|
||||
const jump = document.getElementById('appSettingsJump');
|
||||
const inset = jump && jump.offsetParent ? jump.offsetHeight + 16 : 6;
|
||||
doc.scrollTop = Math.max(0, section.offsetTop - inset);
|
||||
}
|
||||
this._setActiveSettingsSection(sectionId);
|
||||
},
|
||||
|
||||
/** Paint the rail + jump pill for the section currently in view. */
|
||||
_setActiveSettingsSection(sectionId) {
|
||||
const modal = document.getElementById('appSettingsModal');
|
||||
// Toggle active class on tab buttons
|
||||
modal.querySelectorAll('.modal-tabs .modal-tab-btn').forEach(btn => {
|
||||
btn.classList.toggle('active', btn.dataset.tab === tabName);
|
||||
if (!modal || typeof modal.querySelectorAll !== 'function') return;
|
||||
let active = null;
|
||||
modal.querySelectorAll('.set-rail-item').forEach(item => {
|
||||
const on = item.dataset.section === sectionId;
|
||||
item.classList.toggle('active', on);
|
||||
if (on) active = item;
|
||||
});
|
||||
// Toggle hidden class on tab content
|
||||
modal.querySelectorAll('.modal-tab-content').forEach(content => {
|
||||
content.classList.toggle('hidden', content.id !== tabName);
|
||||
modal.querySelectorAll('.set-jump-row').forEach(row => {
|
||||
row.classList.toggle('active', row.dataset.section === sectionId);
|
||||
});
|
||||
// The Shortcuts tab renders lazily so the list reflects the CURRENT
|
||||
// registry (defaults + overrides) every time it is opened.
|
||||
if (tabName === 'settings-shortcuts') this.renderShortcutSettingsList?.();
|
||||
const label = document.getElementById('appSettingsJump')?.querySelector('.set-jump-label');
|
||||
if (label && active) label.textContent = active.textContent.trim();
|
||||
const ico = document.getElementById('appSettingsJump')?.querySelector('.set-jump-ico');
|
||||
const src = active?.querySelector('svg');
|
||||
if (ico && src) ico.innerHTML = src.innerHTML;
|
||||
},
|
||||
|
||||
/**
|
||||
* Wire the settings navigation once per page: rail clicks, the phone jump
|
||||
* menu, scroll-spy, live search, chip/card/segment views over the real inputs,
|
||||
* and the collapsible Advanced group. Idempotent — openAppSettings() calls it
|
||||
* on every open, and re-registering listeners on every open would multiply
|
||||
* them across a long-lived tab.
|
||||
*/
|
||||
_initSettingsNav() {
|
||||
const modal = document.getElementById('appSettingsModal');
|
||||
const doc = document.getElementById('appSettingsDoc');
|
||||
if (!modal || !doc || typeof modal.querySelectorAll !== 'function') return;
|
||||
this._buildModelCards();
|
||||
this._buildEffortSegment();
|
||||
// Rebuilt on every open: admin-ui.js appends its Users entry to the rail
|
||||
// after the first open, and the menu must not drift from the rail.
|
||||
this._buildSettingsJumpMenu();
|
||||
if (modal.dataset.navReady === '1') return;
|
||||
modal.dataset.navReady = '1';
|
||||
|
||||
// Delegated so rail entries injected later (Users) work without rewiring.
|
||||
modal.querySelector('.set-rail-items')?.addEventListener('click', e => {
|
||||
const item = e.target.closest?.('.set-rail-item');
|
||||
if (item?.dataset.section) this.switchSettingsTab(item.dataset.section);
|
||||
});
|
||||
document.getElementById('appSettingsJumpMenu')?.addEventListener('click', e => {
|
||||
const row = e.target.closest?.('.set-jump-row');
|
||||
if (!row?.dataset.section) return;
|
||||
this._toggleSettingsJump(false);
|
||||
this.switchSettingsTab(row.dataset.section);
|
||||
});
|
||||
document.getElementById('appSettingsJump')?.addEventListener('click', () => this._toggleSettingsJump());
|
||||
document.getElementById('appSettingsJumpVeil')?.addEventListener('click', () => this._toggleSettingsJump(false));
|
||||
|
||||
// Scroll-spy: the rail follows the document rather than driving it.
|
||||
doc.addEventListener('scroll', () => {
|
||||
if (this._settingsSpyQueued) return;
|
||||
this._settingsSpyQueued = true;
|
||||
requestAnimationFrame(() => {
|
||||
this._settingsSpyQueued = false;
|
||||
const sections = [...doc.querySelectorAll('.set-section')].filter(s => s.offsetParent !== null);
|
||||
if (!sections.length) return;
|
||||
let current = sections[0].id;
|
||||
for (const s of sections) {
|
||||
if (s.offsetTop - doc.scrollTop <= 140) current = s.id;
|
||||
}
|
||||
this._setActiveSettingsSection(current);
|
||||
});
|
||||
});
|
||||
|
||||
const search = document.getElementById('appSettingsSearch');
|
||||
search?.addEventListener('input', () => this._filterSettings(search.value));
|
||||
|
||||
// Chips are labels wrapping the real checkbox; mirror the checked state onto
|
||||
// the label so the styling does not depend on :has() support.
|
||||
modal.querySelectorAll('.set-chip input').forEach(input => {
|
||||
input.addEventListener('change', () => this._syncSettingsChips());
|
||||
});
|
||||
|
||||
const advHead = modal.querySelector('.set-group-head-toggle');
|
||||
const advGroup = advHead?.closest('.set-group-advanced');
|
||||
if (advHead && advGroup) {
|
||||
const toggle = () => {
|
||||
const open = advGroup.classList.toggle('open');
|
||||
advHead.setAttribute('aria-expanded', open ? 'true' : 'false');
|
||||
};
|
||||
advHead.addEventListener('click', toggle);
|
||||
advHead.addEventListener('keydown', e => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
toggle();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
document.getElementById('appSettingsOpusContext1m')?.addEventListener('change', () => this._applyModelSelection());
|
||||
},
|
||||
|
||||
/** Phone jump menu, mirrored from the rail so the two can never drift. */
|
||||
_buildSettingsJumpMenu() {
|
||||
const modal = document.getElementById('appSettingsModal');
|
||||
const menu = document.getElementById('appSettingsJumpMenu');
|
||||
if (!modal || !menu) return;
|
||||
menu.innerHTML = '';
|
||||
modal.querySelectorAll('.set-rail-item').forEach(item => {
|
||||
const row = document.createElement('button');
|
||||
row.type = 'button';
|
||||
row.className = 'set-jump-row';
|
||||
row.dataset.section = item.dataset.section;
|
||||
row.innerHTML = item.innerHTML;
|
||||
const section = document.getElementById(item.dataset.section);
|
||||
const count = section ? section.querySelectorAll('input, select').length : 0;
|
||||
if (count) {
|
||||
const n = document.createElement('span');
|
||||
n.className = 'set-jump-count';
|
||||
n.textContent = String(count);
|
||||
row.appendChild(n);
|
||||
}
|
||||
menu.appendChild(row);
|
||||
});
|
||||
},
|
||||
|
||||
_toggleSettingsJump(force) {
|
||||
const modal = document.getElementById('appSettingsModal');
|
||||
if (!modal) return;
|
||||
const open = force === undefined ? !modal.classList.contains('jump-open') : force;
|
||||
modal.classList.toggle('jump-open', open);
|
||||
document.getElementById('appSettingsJump')?.setAttribute('aria-expanded', open ? 'true' : 'false');
|
||||
},
|
||||
|
||||
/**
|
||||
* Mirror checkbox state onto the chip labels (see _initSettingsNav).
|
||||
*
|
||||
* Covers Session Options too: it shares the `set-*` surface, and its cycle-step
|
||||
* chips would otherwise depend on `:has()` alone for their checked styling.
|
||||
*/
|
||||
_syncSettingsChips() {
|
||||
document.querySelectorAll('#appSettingsModal .set-chip, #sessionOptionsModal .set-chip').forEach(chip => {
|
||||
chip.classList.toggle('is-on', !!chip.querySelector('input')?.checked);
|
||||
});
|
||||
this._syncLayoutPreview();
|
||||
},
|
||||
|
||||
/**
|
||||
* Redraw the Header & Panels live preview from the chips above it.
|
||||
*
|
||||
* The preview is a scale model of the app, not a second list of settings, so
|
||||
* every icon is CLONED from the chip that owns it (`.set-chip-ico`): each icon
|
||||
* has exactly ONE copy in index.html and a chip can never drift from the button
|
||||
* it previews. A chip joins the preview purely by carrying `data-preview`
|
||||
* (which slot) and `data-preview-order` (where in that slot); nothing here
|
||||
* needs to know the setting's name.
|
||||
*
|
||||
* `data-preview-text` replaces the icon with a text token for the header
|
||||
* entries that are readouts rather than buttons (plan usage, CPU, font size).
|
||||
*/
|
||||
_syncLayoutPreview() {
|
||||
const modal = document.getElementById('appSettingsModal');
|
||||
if (!modal || typeof modal.querySelectorAll !== 'function') return;
|
||||
const slots = {
|
||||
header: document.getElementById('appSettingsPreviewHeader'),
|
||||
panel: document.getElementById('appSettingsPreviewPanels'),
|
||||
toolbar: document.getElementById('appSettingsPreviewToolbar'),
|
||||
float: document.getElementById('appSettingsPreviewFloats'),
|
||||
};
|
||||
if (!slots.header) return;
|
||||
Object.values(slots).forEach(el => {
|
||||
if (el) el.innerHTML = '';
|
||||
});
|
||||
|
||||
const chips = [...modal.querySelectorAll('.set-chip[data-preview]')]
|
||||
.filter(chip => chip.querySelector('input')?.checked)
|
||||
.sort((a, b) => (Number(a.dataset.previewOrder) || 0) - (Number(b.dataset.previewOrder) || 0));
|
||||
|
||||
let shown = 0;
|
||||
for (const chip of chips) {
|
||||
const kind = chip.dataset.preview;
|
||||
const slot = slots[kind];
|
||||
if (!slot) continue;
|
||||
// The label is the chip's own text; the icon span (if any) is skipped by
|
||||
// taking the LAST span, which is always the label.
|
||||
const spans = chip.querySelectorAll('span');
|
||||
const label = (spans[spans.length - 1]?.textContent || '').trim();
|
||||
const el = document.createElement('span');
|
||||
el.title = label;
|
||||
if (kind === 'header') {
|
||||
const text = chip.dataset.previewText;
|
||||
el.className = text ? 'set-preview-chip' : 'set-preview-btn';
|
||||
if (text) el.textContent = text;
|
||||
else this._appendPreviewIcon(el, chip);
|
||||
} else {
|
||||
el.className = `set-preview-${kind}`;
|
||||
this._appendPreviewIcon(el, chip);
|
||||
const name = document.createElement('span');
|
||||
name.textContent = label;
|
||||
el.appendChild(name);
|
||||
}
|
||||
slot.appendChild(el);
|
||||
shown++;
|
||||
}
|
||||
|
||||
const empty = document.getElementById('appSettingsPreviewEmpty');
|
||||
if (empty) empty.hidden = shown > 0;
|
||||
},
|
||||
|
||||
/** Clone a chip's icon into a preview element (see _syncLayoutPreview). */
|
||||
_appendPreviewIcon(target, chip) {
|
||||
const icon = chip.querySelector('.set-chip-ico');
|
||||
if (!icon) return;
|
||||
const clone = icon.cloneNode(true);
|
||||
clone.classList.remove('set-chip-ico');
|
||||
clone.classList.add('set-preview-ico');
|
||||
target.appendChild(clone);
|
||||
},
|
||||
|
||||
/**
|
||||
* Build the model picker cards from the hidden <select>'s own options, so the
|
||||
* select stays the single source of truth that openAppSettings/saveAppSettings
|
||||
* read and write by id. The `[1m]` variants are folded away: context width is a
|
||||
* property of the chosen model (the "1M context window" switch), not a rival
|
||||
* setting that silently loses to it.
|
||||
*/
|
||||
_buildModelCards() {
|
||||
const select = document.getElementById('appSettingsClaudeModel');
|
||||
const grid = document.getElementById('appSettingsModelCards');
|
||||
if (!select || !grid || grid.dataset.built === '1' || !select.options) return;
|
||||
grid.innerHTML = '';
|
||||
[...select.options]
|
||||
.filter(opt => opt.dataset.variant !== '1m')
|
||||
.forEach(opt => {
|
||||
const card = document.createElement('button');
|
||||
card.type = 'button';
|
||||
card.className = 'set-modelcard';
|
||||
card.setAttribute('role', 'radio');
|
||||
card.dataset.value = opt.value;
|
||||
if (opt.dataset.ctx === '1') card.dataset.ctx = '1';
|
||||
const top = document.createElement('span');
|
||||
top.className = 'set-mc-top';
|
||||
const name = document.createElement('span');
|
||||
name.className = 'set-mc-name';
|
||||
name.textContent = opt.textContent;
|
||||
top.appendChild(name);
|
||||
const dot = document.createElement('span');
|
||||
dot.className = 'set-mc-dot';
|
||||
top.appendChild(dot);
|
||||
card.appendChild(top);
|
||||
const meta = document.createElement('span');
|
||||
meta.className = 'set-mc-meta';
|
||||
meta.textContent = opt.dataset.meta || '';
|
||||
card.appendChild(meta);
|
||||
if (opt.dataset.ctx === '1') {
|
||||
const ctx = document.createElement('span');
|
||||
ctx.className = 'set-mc-ctx';
|
||||
ctx.textContent = '1M capable';
|
||||
card.appendChild(ctx);
|
||||
}
|
||||
card.addEventListener('click', () => {
|
||||
this._settingsModelBase = opt.value;
|
||||
this._applyModelSelection();
|
||||
});
|
||||
grid.appendChild(card);
|
||||
});
|
||||
grid.dataset.built = '1';
|
||||
},
|
||||
|
||||
/** Derive card + context-switch state from the select's stored value. */
|
||||
_syncModelCards() {
|
||||
const select = document.getElementById('appSettingsClaudeModel');
|
||||
if (!select) return;
|
||||
const value = select.value || '';
|
||||
this._settingsModelBase = value.endsWith('[1m]') ? value.slice(0, -4) : value;
|
||||
if (value.endsWith('[1m]')) {
|
||||
const ctx = document.getElementById('appSettingsOpusContext1m');
|
||||
if (ctx) ctx.checked = true;
|
||||
}
|
||||
this._applyModelSelection();
|
||||
},
|
||||
|
||||
/** Compose card + context switch back into the select's value. */
|
||||
_applyModelSelection() {
|
||||
const select = document.getElementById('appSettingsClaudeModel');
|
||||
const grid = document.getElementById('appSettingsModelCards');
|
||||
if (!select || !grid) return;
|
||||
const base = this._settingsModelBase || '';
|
||||
let capable = false;
|
||||
grid.querySelectorAll('.set-modelcard').forEach(card => {
|
||||
const on = card.dataset.value === base;
|
||||
card.classList.toggle('selected', on);
|
||||
card.setAttribute('aria-checked', on ? 'true' : 'false');
|
||||
if (on) capable = card.dataset.ctx === '1';
|
||||
});
|
||||
const ctxOn = !!document.getElementById('appSettingsOpusContext1m')?.checked;
|
||||
select.value = base && capable && ctxOn ? `${base}[1m]` : base;
|
||||
// A model with no 1M variant makes the switch inert; say so instead of
|
||||
// leaving a toggle that looks like it does something.
|
||||
const row = document.getElementById('appSettingsContextRow');
|
||||
const desc = document.getElementById('appSettingsContextDesc');
|
||||
const inert = !!base && !capable;
|
||||
row?.classList.toggle('set-row-disabled', inert);
|
||||
if (desc) {
|
||||
desc.textContent = inert
|
||||
? 'The selected model has no 1M variant.'
|
||||
: base
|
||||
? 'Available for Fable 5, Opus and Opus 4.6.'
|
||||
: 'With no model pinned, this starts new sessions on Opus with a 1M window.';
|
||||
}
|
||||
},
|
||||
|
||||
_buildEffortSegment() {
|
||||
const select = document.getElementById('appSettingsThinkingEffort');
|
||||
const seg = document.getElementById('appSettingsEffortSegment');
|
||||
if (!select || !seg || seg.dataset.built === '1' || !select.options) return;
|
||||
seg.innerHTML = '';
|
||||
[...select.options].forEach(opt => {
|
||||
const btn = document.createElement('button');
|
||||
btn.type = 'button';
|
||||
btn.setAttribute('role', 'radio');
|
||||
btn.dataset.value = opt.value;
|
||||
btn.textContent = opt.textContent;
|
||||
btn.addEventListener('click', () => {
|
||||
select.value = opt.value;
|
||||
this._syncEffortSegment();
|
||||
});
|
||||
seg.appendChild(btn);
|
||||
});
|
||||
seg.dataset.built = '1';
|
||||
},
|
||||
|
||||
_syncEffortSegment() {
|
||||
const select = document.getElementById('appSettingsThinkingEffort');
|
||||
const seg = document.getElementById('appSettingsEffortSegment');
|
||||
if (!select || !seg) return;
|
||||
seg.querySelectorAll('button').forEach(btn => {
|
||||
const on = btn.dataset.value === (select.value || '');
|
||||
btn.classList.toggle('selected', on);
|
||||
btn.setAttribute('aria-checked', on ? 'true' : 'false');
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Live filter across every section. Everything stays mounted (that is the
|
||||
* point of the single-document layout), so a search only hides units that do
|
||||
* not match, then collapses groups and sections left with nothing visible.
|
||||
*/
|
||||
_filterSettings(query) {
|
||||
const doc = document.getElementById('appSettingsDoc');
|
||||
if (!doc) return;
|
||||
const q = (query || '').trim().toLowerCase();
|
||||
const UNIT = '.set-row, .set-chip, .set-modelgrid, .set-minigrid, .event-type-grid, #appSettingsShortcutsList';
|
||||
const units = [...doc.querySelectorAll(UNIT)];
|
||||
let anyVisible = false;
|
||||
|
||||
units.forEach(unit => {
|
||||
if (!q) {
|
||||
unit.classList.remove('set-hit-hidden');
|
||||
return;
|
||||
}
|
||||
const hay = `${unit.dataset?.search || ''} ${unit.textContent || ''}`.toLowerCase();
|
||||
const hit = hay.includes(q);
|
||||
unit.classList.toggle('set-hit-hidden', !hit);
|
||||
if (hit) anyVisible = true;
|
||||
});
|
||||
|
||||
// A chip wrapper is only empty when every chip inside it is hidden.
|
||||
doc.querySelectorAll('.set-chips').forEach(wrap => {
|
||||
const hasVisible = [...wrap.querySelectorAll('.set-chip')].some(c => !c.classList.contains('set-hit-hidden'));
|
||||
wrap.classList.toggle('set-hit-hidden', !!q && !hasVisible);
|
||||
});
|
||||
|
||||
doc.querySelectorAll('.set-group').forEach(group => {
|
||||
const hasVisible = [...group.querySelectorAll(UNIT)].some(u => !u.classList.contains('set-hit-hidden'));
|
||||
group.classList.toggle('set-hit-hidden', !!q && !hasVisible);
|
||||
// An Advanced group that matches must open, or the hit stays invisible.
|
||||
if (q && hasVisible) group.classList.add('open');
|
||||
});
|
||||
|
||||
doc.querySelectorAll('.set-section').forEach(section => {
|
||||
const hasVisible = [...section.querySelectorAll('.set-group')].some(g => !g.classList.contains('set-hit-hidden'));
|
||||
section.classList.toggle('set-hit-hidden', !!q && !hasVisible);
|
||||
});
|
||||
|
||||
// The live preview sits outside any group, so it survives the sweep above;
|
||||
// a search is asking for one row, not for the scale model around it.
|
||||
doc.querySelectorAll('.set-preview').forEach(pv => pv.classList.toggle('set-hit-hidden', !!q));
|
||||
|
||||
const empty = document.getElementById('appSettingsSearchEmpty');
|
||||
if (empty) empty.hidden = !q || anyVisible;
|
||||
if (!q) doc.querySelectorAll('.set-group-advanced').forEach(g => g.classList.remove('open'));
|
||||
},
|
||||
|
||||
closeAppSettings() {
|
||||
this._toggleSettingsJump(false);
|
||||
document.getElementById('appSettingsModal').classList.remove('active');
|
||||
|
||||
// Deactivate focus trap and restore focus
|
||||
@@ -1515,6 +1916,37 @@ Object.assign(CodemanApp.prototype, {
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Paint both Voice status rows: which provider a mic press would use, and what
|
||||
* the server reports about its Claude login. Called on open and again once the
|
||||
* /api/voice/status probe resolves.
|
||||
*/
|
||||
_renderVoiceProviderStatus() {
|
||||
const providerEl = document.getElementById('voiceProviderStatus');
|
||||
if (providerEl) {
|
||||
const providerName = VoiceInput.getActiveProviderName();
|
||||
providerEl.textContent = providerName;
|
||||
const live = providerName.startsWith('Deepgram Nova') || providerName.startsWith('Claude (this');
|
||||
providerEl.className = 'voice-provider-status' + (live ? ' active' : '');
|
||||
}
|
||||
const claudeEl = document.getElementById('voiceClaudeStatus');
|
||||
if (!claudeEl) return;
|
||||
const status = VoiceInput._claudeStatus;
|
||||
const text = !status
|
||||
? 'Checking...'
|
||||
: status.available
|
||||
? `Ready${status.subscriptionType ? ` (${status.subscriptionType})` : ''}`
|
||||
: status.reason === 'expired'
|
||||
? 'Login expired - run a Claude session to refresh'
|
||||
: status.reason === 'no-credentials'
|
||||
? 'No Claude Code login on the server'
|
||||
: status.reason === 'malformed'
|
||||
? 'Claude credentials unreadable'
|
||||
: 'Off - enable it above';
|
||||
claudeEl.textContent = text;
|
||||
claudeEl.className = 'voice-provider-status' + (status?.available ? ' active' : '');
|
||||
},
|
||||
|
||||
async saveAppSettings() {
|
||||
// Gesture overlay is injected at page render (server-side), so a change to it
|
||||
// only takes effect on reload — remember the prior value to decide below.
|
||||
@@ -1577,6 +2009,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
// Claude Permissions settings
|
||||
agentTeamsEnabled: document.getElementById('appSettingsAgentTeams').checked,
|
||||
agentSkillEnabled: document.getElementById('appSettingsAgentSkill').checked,
|
||||
claudeVoiceEnabled: document.getElementById('appSettingsClaudeVoice').checked,
|
||||
claudeModel: document.getElementById('appSettingsClaudeModel').value,
|
||||
opusContext1mEnabled: document.getElementById('appSettingsOpusContext1m').checked,
|
||||
remoteAutoReconnect: document.getElementById('appSettingsRemoteAutoReconnect').checked,
|
||||
@@ -1616,6 +2049,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
|
||||
// Save voice settings to localStorage + include in server payload for cross-device sync
|
||||
const voiceSettings = {
|
||||
provider: document.getElementById('voiceProvider').value,
|
||||
apiKey: document.getElementById('voiceDeepgramKey').value.trim(),
|
||||
language: document.getElementById('voiceLanguage').value,
|
||||
keyterms: document.getElementById('voiceKeyterms').value.trim(),
|
||||
@@ -1795,6 +2229,10 @@ Object.assign(CodemanApp.prototype, {
|
||||
|
||||
this.closeAppSettings();
|
||||
|
||||
// Voice availability is a server-side answer, so re-probe after a save:
|
||||
// otherwise the mic keeps using the pre-save provider until the next reload.
|
||||
VoiceInput.refreshClaudeStatus();
|
||||
|
||||
// The gesture overlay is injected at page render (server reads
|
||||
// gestureControlEnabled from settings.json), so a change only takes effect on
|
||||
// reload. Reload when it actually changed — the server PUT above already
|
||||
@@ -2112,11 +2550,16 @@ Object.assign(CodemanApp.prototype, {
|
||||
// Read My Mind 🧠 — hidden unless the synced opt-in `readMyMindEnabled` is
|
||||
// ON (only an explicit true enables, mirroring the Approvals bell). Marker
|
||||
// class (base is display:inline-flex !important); phones hide it in
|
||||
// mobile.css regardless (the phase-3 surface there is an accessory key).
|
||||
// mobile.css regardless (their surface is the keyboard-accessory 🧠 key,
|
||||
// re-synced right below).
|
||||
const readMyMindBtn = document.querySelector('.btn-readmymind');
|
||||
if (readMyMindBtn) {
|
||||
readMyMindBtn.classList.toggle('btn-readmymind--hidden', settings.readMyMindEnabled !== true);
|
||||
}
|
||||
// The accessory-bar 🧠 key shares the setting; its marker class lives on
|
||||
// the bar element (keyboard-accessory.js), so a live toggle from a
|
||||
// settings save reveals/hides it without a reload.
|
||||
if (typeof KeyboardAccessoryBar !== 'undefined') KeyboardAccessoryBar.syncReadMyMind?.();
|
||||
|
||||
// Plan-usage chip — shown by default on desktop, OFF on handhelds (App
|
||||
// Settings → Display → "Plan Usage Limits"). The template always ships it
|
||||
|
||||
+1965
-86
File diff suppressed because it is too large
Load Diff
+240
-35
@@ -1573,6 +1573,24 @@ Object.assign(CodemanApp.prototype, {
|
||||
* - workingDir under a case dir → "#caseName/subdir"
|
||||
* - Otherwise → basename (e.g. "Claudeman")
|
||||
*/
|
||||
/**
|
||||
* Badge text for a session's git worktree, or '' when it isn't on one.
|
||||
* `⑂ <name> · <branch>`, either half alone if that's all we know.
|
||||
* Branch is truncated: the badge row is a single nowrap line.
|
||||
*/
|
||||
_worktreeLabel(s) {
|
||||
// Worktree name is REQUIRED. gitBranch alone is not worktree information —
|
||||
// every ordinary repo session has one, and badging all of them with `⑂ master`
|
||||
// is noise that buries the rows this badge exists to distinguish.
|
||||
const name = s && s.worktreeName;
|
||||
if (!name) return '';
|
||||
let branch = s.gitBranch || '';
|
||||
// A worktree's branch often just restates its name; don't print it twice.
|
||||
if (branch === name || branch === `worktree-${name}`) branch = '';
|
||||
if (branch.length > 24) branch = branch.slice(0, 23) + '\u2026';
|
||||
return '⑂ ' + [name, branch].filter(Boolean).join(' · ');
|
||||
},
|
||||
|
||||
_resolveCaseLabel(workingDir, cases) {
|
||||
if (!workingDir) return '';
|
||||
let best = null;
|
||||
@@ -1592,11 +1610,21 @@ Object.assign(CodemanApp.prototype, {
|
||||
return workingDir.split('/').pop() || workingDir;
|
||||
},
|
||||
|
||||
/** Normalize home prefixes to "~/" on both Linux and macOS */
|
||||
/**
|
||||
* Normalize a home prefix to "~" on both Linux (`/home/<user>`) and macOS
|
||||
* (`/Users/<user>`). The lookahead lets the home directory ITSELF match, so a
|
||||
* path that is exactly `$HOME` renders "~" instead of being left raw.
|
||||
*
|
||||
* This is the only place that pattern belongs. Two hand-rolled copies had
|
||||
* drifted, each broken on the platform its author was not using: the Run
|
||||
* menu's matched `/home/` only, so on macOS nothing was stripped and every
|
||||
* Recent Sessions row spent its first ~19 characters on an identical
|
||||
* `/Users/<user>/` prefix (#273); the case-manage list's matched `/Users/`
|
||||
* only, so no Linux path was ever abbreviated there. Route new path labels
|
||||
* through here rather than writing a third copy.
|
||||
*/
|
||||
_shortenHomePath(p) {
|
||||
return (p || '')
|
||||
.replace(/^\/home\/[^/]+\//, '~/')
|
||||
.replace(/^\/Users\/[^/]+\//, '~/');
|
||||
return (p || '').replace(/^\/(?:home|Users)\/[^/]+(?=\/|$)/, '~');
|
||||
},
|
||||
|
||||
/**
|
||||
@@ -1684,7 +1712,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
pin.title = 'Pinned';
|
||||
titleSpan.appendChild(pin);
|
||||
}
|
||||
titleSpan.appendChild(document.createTextNode(s.name || s.firstPrompt || shortDir));
|
||||
titleSpan.appendChild(document.createTextNode(this._historyRowLabel(s, shortDir)));
|
||||
|
||||
// Badge row: mode (claude/codex/opencode/gemini/antigravity/shell) + a LIVE pill.
|
||||
const badgeRow = document.createElement('div');
|
||||
@@ -1695,6 +1723,18 @@ Object.assign(CodemanApp.prototype, {
|
||||
modeBadge.textContent = s.mode;
|
||||
badgeRow.appendChild(modeBadge);
|
||||
}
|
||||
// Worktree pill (#266): distinguishes sessions from different worktrees of the
|
||||
// same repo, which are otherwise identical in this list. Name AND branch when
|
||||
// both are known; a hand-made `git worktree add` yields no recoverable name,
|
||||
// so it degrades to branch-only rather than guessing one.
|
||||
const wtLabel = this._worktreeLabel(s);
|
||||
if (wtLabel) {
|
||||
const wtBadge = document.createElement('span');
|
||||
wtBadge.className = 'history-item-badge history-item-badge-worktree';
|
||||
wtBadge.textContent = wtLabel;
|
||||
wtBadge.title = s.worktreeRepo ? `worktree of ${s.worktreeRepo}` : wtLabel;
|
||||
badgeRow.appendChild(wtBadge);
|
||||
}
|
||||
if (isLive) {
|
||||
const liveBadge = document.createElement('span');
|
||||
liveBadge.className = 'history-item-badge history-item-badge-live';
|
||||
@@ -2010,7 +2050,18 @@ Object.assign(CodemanApp.prototype, {
|
||||
},
|
||||
|
||||
/** Number of history items shown before "Show More" */
|
||||
_HISTORY_INITIAL_COUNT: 4,
|
||||
_HISTORY_INITIAL_COUNT: 10,
|
||||
|
||||
/**
|
||||
* How many past sessions the home screen loads (also the filter/sort corpus).
|
||||
* 200, not the old 60, so the filter can reach a real backlog, an install with
|
||||
* 35+ conversations would otherwise hit the ceiling before the filter is useful
|
||||
* (raised in @jordan8037310's #263; the endpoint clamps at 500).
|
||||
*/
|
||||
_HISTORY_FETCH_LIMIT: 200,
|
||||
|
||||
/** localStorage key for the per-device sort choice (#263). */
|
||||
_HISTORY_SORT_KEY: 'codeman:historySort',
|
||||
|
||||
async loadHistorySessions() {
|
||||
const container = document.getElementById('historySessions');
|
||||
@@ -2024,7 +2075,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
? Promise.resolve(this.cases)
|
||||
: fetch('/api/cases').then((r) => (r.ok ? r.json() : null)).then((d) => d?.data || []).catch(() => []);
|
||||
const [allSessions, cases] = await Promise.all([
|
||||
this._fetchUnifiedSessions(60),
|
||||
this._fetchUnifiedSessions(this._HISTORY_FETCH_LIMIT),
|
||||
casesPromise,
|
||||
]);
|
||||
if (allSessions.length === 0) {
|
||||
@@ -2032,27 +2083,14 @@ Object.assign(CodemanApp.prototype, {
|
||||
return;
|
||||
}
|
||||
|
||||
list.replaceChildren();
|
||||
const initialCount = this._HISTORY_INITIAL_COUNT;
|
||||
|
||||
// Render initial items
|
||||
for (let i = 0; i < Math.min(initialCount, allSessions.length); i++) {
|
||||
list.appendChild(this._buildHistoryItem(allSessions[i], cases));
|
||||
}
|
||||
|
||||
// Add "Show More" button if there are more items
|
||||
if (allSessions.length > initialCount) {
|
||||
const moreBtn = document.createElement('button');
|
||||
moreBtn.className = 'history-show-more';
|
||||
moreBtn.textContent = `Show ${allSessions.length - initialCount} more`;
|
||||
moreBtn.addEventListener('click', () => {
|
||||
for (let i = initialCount; i < allSessions.length; i++) {
|
||||
list.insertBefore(this._buildHistoryItem(allSessions[i], cases), moreBtn);
|
||||
}
|
||||
moreBtn.remove();
|
||||
});
|
||||
list.appendChild(moreBtn);
|
||||
}
|
||||
// Keep the corpus around: filtering and sorting (issue #260) work on this
|
||||
// array, so a re-render costs no request. Expansion survives the periodic
|
||||
// refresh in panels-ui.js, collapsing the list under the user's cursor
|
||||
// every few seconds would be worse than the original 4-item cap.
|
||||
this._historyAll = allSessions;
|
||||
this._historyCases = cases;
|
||||
this._wireHistoryControls();
|
||||
this._renderHistoryList();
|
||||
|
||||
container.style.display = '';
|
||||
} catch (err) {
|
||||
@@ -2061,6 +2099,161 @@ Object.assign(CodemanApp.prototype, {
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Wire the filter box and sort select once; both re-render from the cached
|
||||
* corpus. The sort choice is restored from (and saved to) localStorage, it is
|
||||
* a per-device display preference, so it stays out of the synced settings
|
||||
* schema, same as `codeman:skin`.
|
||||
*/
|
||||
_wireHistoryControls() {
|
||||
if (this._historyControlsWired) return;
|
||||
const filter = document.getElementById('historyFilter');
|
||||
const sort = document.getElementById('historySort');
|
||||
if (!filter && !sort) return;
|
||||
this._historyControlsWired = true;
|
||||
|
||||
if (sort) {
|
||||
try {
|
||||
const saved = localStorage.getItem(this._HISTORY_SORT_KEY);
|
||||
if (saved && Array.from(sort.options).some((o) => o.value === saved)) sort.value = saved;
|
||||
} catch {
|
||||
/* private mode, the order just won't persist */
|
||||
}
|
||||
}
|
||||
|
||||
if (filter) {
|
||||
filter.addEventListener('input', () => this._renderHistoryList());
|
||||
filter.addEventListener('keydown', (ev) => {
|
||||
if (ev.key === 'Escape' && filter.value) {
|
||||
// Swallow it: Escape at the welcome screen otherwise closes overlays.
|
||||
ev.stopPropagation();
|
||||
filter.value = '';
|
||||
this._renderHistoryList();
|
||||
}
|
||||
});
|
||||
}
|
||||
if (sort) {
|
||||
sort.addEventListener('change', () => {
|
||||
try {
|
||||
localStorage.setItem(this._HISTORY_SORT_KEY, sort.value);
|
||||
} catch {
|
||||
/* private mode, the order just won't persist */
|
||||
}
|
||||
this._renderHistoryList();
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
/** True when a past-session row matches the filter text (name, folder, case, prompt). */
|
||||
_historyRowMatches(s, needle, cases) {
|
||||
const fields = [
|
||||
s.name,
|
||||
s.workingDir,
|
||||
this._resolveCaseLabel(s.workingDir, cases),
|
||||
s.firstPrompt,
|
||||
s.lastPrompt,
|
||||
s.sessionId,
|
||||
];
|
||||
return fields.some((f) => typeof f === 'string' && f.toLowerCase().includes(needle));
|
||||
},
|
||||
|
||||
/**
|
||||
* The text a history row shows as its title. Most transcript-backed rows have
|
||||
* no session name at all, so this falls through to the first prompt and then
|
||||
* to the path, and the A–Z sort keys off the SAME string, or "sort by name"
|
||||
* would silently do nothing for exactly the rows the list is mostly made of.
|
||||
*/
|
||||
_historyRowLabel(s, fallback) {
|
||||
return s.name || s.firstPrompt || fallback || '';
|
||||
},
|
||||
|
||||
/**
|
||||
* Sort past-session rows. 'recent' keeps the backend order (newest first);
|
||||
* the alphabetical modes sort by the visible title or by folder basename.
|
||||
* Pinned rows stay on top in every mode, pinning is an explicit override and
|
||||
* a sort that buried it would read as the pin having been lost.
|
||||
*/
|
||||
_sortHistoryRows(rows, mode) {
|
||||
const label = (s) => this._historyRowLabel(s, this._shortenHomePath(s.workingDir)).toLowerCase();
|
||||
const folder = (s) => ((s.workingDir || '').split('/').pop() || '').toLowerCase();
|
||||
const key = mode === 'name' ? label : folder;
|
||||
// numeric collation so w2-… sorts before w10-…, and base sensitivity so case
|
||||
// does not split a project's rows apart (from @jordan8037310's #263).
|
||||
const sorted =
|
||||
mode === 'recent'
|
||||
? rows.slice()
|
||||
: rows
|
||||
.slice()
|
||||
.sort((a, b) => key(a).localeCompare(key(b), undefined, { sensitivity: 'base', numeric: true }));
|
||||
const pinned = sorted.filter((s) => s.pinned);
|
||||
return pinned.length === 0 ? sorted : pinned.concat(sorted.filter((s) => !s.pinned));
|
||||
},
|
||||
|
||||
/**
|
||||
* Render the "Resume Conversation" list from the cached corpus, applying the
|
||||
* current filter and sort. Collapsed by default to _HISTORY_INITIAL_COUNT;
|
||||
* "Show more" expands the list AND the box (the CSS cap is class-driven, since
|
||||
* a fixed 240px box made expansion pointless, issue #260).
|
||||
*/
|
||||
_renderHistoryList() {
|
||||
const list = document.getElementById('historyList');
|
||||
if (!list) return;
|
||||
const all = this._historyAll || [];
|
||||
const cases = this._historyCases || [];
|
||||
const countEl = document.getElementById('historyCount');
|
||||
const needle = (document.getElementById('historyFilter')?.value || '').trim().toLowerCase();
|
||||
const mode = document.getElementById('historySort')?.value || 'recent';
|
||||
|
||||
const matched = needle ? all.filter((s) => this._historyRowMatches(s, needle, cases)) : all;
|
||||
const rows = this._sortHistoryRows(matched, mode);
|
||||
// Filtering is itself an expansion request: hiding matches behind "Show more"
|
||||
// would defeat the point of typing a filter.
|
||||
const expanded = !!this._historyExpanded || needle.length > 0;
|
||||
const visible = expanded ? rows : rows.slice(0, this._HISTORY_INITIAL_COUNT);
|
||||
|
||||
list.replaceChildren();
|
||||
list.classList.toggle('expanded', expanded);
|
||||
|
||||
if (rows.length === 0) {
|
||||
const empty = document.createElement('div');
|
||||
empty.className = 'history-empty';
|
||||
empty.textContent = `No conversations match "${needle}"`;
|
||||
list.appendChild(empty);
|
||||
}
|
||||
|
||||
for (const s of visible) list.appendChild(this._buildHistoryItem(s, cases));
|
||||
|
||||
const hidden = rows.length - visible.length;
|
||||
if (hidden > 0) {
|
||||
const moreBtn = document.createElement('button');
|
||||
moreBtn.className = 'history-show-more';
|
||||
moreBtn.textContent = `Show ${hidden} more`;
|
||||
moreBtn.addEventListener('click', () => {
|
||||
this._historyExpanded = true;
|
||||
this._renderHistoryList();
|
||||
});
|
||||
list.appendChild(moreBtn);
|
||||
} else if (expanded && !needle && rows.length > this._HISTORY_INITIAL_COUNT) {
|
||||
const lessBtn = document.createElement('button');
|
||||
lessBtn.className = 'history-show-more';
|
||||
lessBtn.textContent = 'Show less';
|
||||
lessBtn.addEventListener('click', () => {
|
||||
this._historyExpanded = false;
|
||||
this._renderHistoryList();
|
||||
list.scrollTop = 0;
|
||||
});
|
||||
list.appendChild(lessBtn);
|
||||
}
|
||||
|
||||
if (countEl) {
|
||||
countEl.textContent = needle
|
||||
? `${rows.length} of ${all.length}`
|
||||
: rows.length > visible.length
|
||||
? `${visible.length} of ${rows.length}`
|
||||
: String(rows.length);
|
||||
}
|
||||
},
|
||||
|
||||
/** Page size for the folder history modal */
|
||||
_FOLDER_HISTORY_PAGE_SIZE: 20,
|
||||
|
||||
@@ -3902,13 +4095,16 @@ Object.assign(CodemanApp.prototype, {
|
||||
/** Render the grouped result cards (or empty/loading states). */
|
||||
_renderSearch(data) {
|
||||
const results = document.getElementById('searchResults');
|
||||
const historyTitle = document.getElementById('historyTitle');
|
||||
// The header carries the title plus the filter/sort controls (issue #260),
|
||||
// hide the whole row, not just the title, or the controls float above the
|
||||
// search results and act on a list that is not on screen.
|
||||
const historyHeader = document.getElementById('historyHeader') || document.getElementById('historyTitle');
|
||||
const historyList = document.getElementById('historyList');
|
||||
if (!results) return;
|
||||
|
||||
const searching = !!data;
|
||||
// Hide the plain "Resume Conversation" history list while a search is active.
|
||||
if (historyTitle) historyTitle.style.display = searching ? 'none' : '';
|
||||
if (historyHeader) historyHeader.style.display = searching ? 'none' : '';
|
||||
if (historyList) historyList.style.display = searching ? 'none' : '';
|
||||
|
||||
results.innerHTML = '';
|
||||
@@ -3977,9 +4173,11 @@ Object.assign(CodemanApp.prototype, {
|
||||
const topRow = document.createElement('div');
|
||||
topRow.className = 'search-result-top';
|
||||
|
||||
// A past session resumes rather than switches tabs, so it says so on the badge.
|
||||
const isPast = r.jumpTo && r.jumpTo.kind === 'resume-session';
|
||||
const badge = document.createElement('span');
|
||||
badge.className = 'search-result-badge search-badge-' + r.type;
|
||||
badge.textContent = (window.CodemanSearch.SOURCE_LABELS[r.type] || r.type).replace(/s$/, '');
|
||||
badge.className = 'search-result-badge search-badge-' + r.type + (isPast ? ' search-badge-past' : '');
|
||||
badge.textContent = isPast ? 'Resume' : (window.CodemanSearch.SOURCE_LABELS[r.type] || r.type).replace(/s$/, '');
|
||||
|
||||
const name = document.createElement('span');
|
||||
name.className = 'search-result-name';
|
||||
@@ -4011,13 +4209,20 @@ Object.assign(CodemanApp.prototype, {
|
||||
|
||||
/**
|
||||
* Navigate to a search result by jumpTo.kind, reusing the existing app methods:
|
||||
* session → selectSession(sessionId) (open/switch to the session)
|
||||
* run-summary → openRunSummary(sessionId) (session options → summary tab)
|
||||
* file-preview→ openFilePreview(path, sessionId, attachmentId)
|
||||
* session → selectSession(sessionId) (open/switch to the session)
|
||||
* resume-session→ resumeHistorySession(...) (past session, no tab to switch to)
|
||||
* run-summary → openRunSummary(sessionId) (session options → summary tab)
|
||||
* file-preview → openFilePreview(path, sessionId, attachmentId)
|
||||
*/
|
||||
_jumpToSearchResult(r) {
|
||||
const jt = r && r.jumpTo;
|
||||
if (!jt) return;
|
||||
// A past session has to be replayed, not switched to. Do it BEFORE hiding the
|
||||
// welcome overlay: resumeHistorySession() owns that transition itself.
|
||||
if (jt.kind === 'resume-session') {
|
||||
this.resumeHistorySession(jt.claudeSessionId || jt.sessionId, jt.workingDir || '', r.sessionName);
|
||||
return;
|
||||
}
|
||||
// Leaving the welcome overlay so the target surface is visible.
|
||||
if (typeof this.hideWelcome === 'function') this.hideWelcome();
|
||||
|
||||
|
||||
+421
-13
@@ -1,7 +1,13 @@
|
||||
/**
|
||||
* @fileoverview Voice input with Deepgram Nova-3 (primary) and Web Speech API (fallback).
|
||||
* @fileoverview Voice input with three providers: Claude (this server's Claude Code
|
||||
* login), Deepgram Nova-3, and the Web Speech API.
|
||||
*
|
||||
* Defines two singleton objects:
|
||||
* Defines three singleton objects:
|
||||
*
|
||||
* - ClaudeVoiceProvider — Dictation through Codeman's own `/ws/voice/stream`, which
|
||||
* relays to the speech-to-text service Claude Code's `/voice` mode uses. No API key:
|
||||
* the server holds the OAuth token, the browser only sends PCM16 @16 kHz (AudioWorklet,
|
||||
* since MediaRecorder cannot emit raw PCM) and receives text. See docs/claude-voice-plan.md.
|
||||
*
|
||||
* - DeepgramProvider — Direct browser-to-Deepgram WebSocket connection for speech-to-text.
|
||||
* Captures audio via MediaRecorder, streams chunks every 250ms, handles KeepAlive pings,
|
||||
@@ -14,6 +20,7 @@
|
||||
* Includes a temporary green Send button that replaces the settings gear icon after voice input.
|
||||
* Web Speech API has auto-retry (up to 2x) for premature onend and iOS Safari stability check.
|
||||
*
|
||||
* @globals {object} ClaudeVoiceProvider
|
||||
* @globals {object} DeepgramProvider
|
||||
* @globals {object} VoiceInput
|
||||
*
|
||||
@@ -22,9 +29,13 @@
|
||||
* @loadorder 3 of 15 — loaded after mobile-handlers.js, before notification-manager.js
|
||||
*/
|
||||
|
||||
// Codeman — Voice input with Deepgram Nova-3 and Web Speech API fallback
|
||||
// Codeman — Voice input with Claude, Deepgram Nova-3 and Web Speech API
|
||||
// Loaded after mobile-handlers.js, before app.js
|
||||
|
||||
/** Dev vocabulary sent to the recognizer as a hint. Shared by every provider and the settings form. */
|
||||
const DEFAULT_VOICE_KEYTERMS =
|
||||
'refactor, endpoint, middleware, callback, async, regex, TypeScript, npm, API, deploy, config, linter, env, webhook, schema, CLI, JSON, CSS, DOM, SSE, backend, frontend, localhost, dependencies, repository, merge, rebase, diff, commit, com';
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// Voice Input (Deepgram Nova-3 + Web Speech API fallback)
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
@@ -245,7 +256,282 @@ const DeepgramProvider = {
|
||||
};
|
||||
|
||||
/**
|
||||
* VoiceInput - Speech-to-text with Deepgram Nova-3 (primary) and Web Speech API (fallback).
|
||||
* ClaudeVoiceProvider - Speech-to-text through this Codeman server's Claude Code
|
||||
* login, i.e. the same service the CLI's own `/voice` mode uses. No API key.
|
||||
*
|
||||
* Audio goes browser -> Codeman -> Anthropic: the OAuth token never leaves the
|
||||
* server, so the browser only ever sends PCM and receives text
|
||||
* (docs/claude-voice-plan.md).
|
||||
*
|
||||
* ⚠️ The upstream endpoint is opened as linear16 / 16 kHz / mono, so capture MUST
|
||||
* be raw PCM at that rate. MediaRecorder cannot emit raw PCM (container formats
|
||||
* only), which is why this path uses an AudioWorklet rather than reusing
|
||||
* DeepgramProvider's recorder. The AudioContext is constructed at 16000 Hz so the
|
||||
* browser does the resampling.
|
||||
*
|
||||
* ⚠️ Transcript frames carry the WHOLE running transcript, not deltas. Callers
|
||||
* must replace, never concatenate.
|
||||
*/
|
||||
const ClaudeVoiceProvider = {
|
||||
_ws: null,
|
||||
_stream: null,
|
||||
_audioContext: null,
|
||||
_workletNode: null,
|
||||
_sourceNode: null,
|
||||
_scriptNode: null,
|
||||
_silenceTimeout: null,
|
||||
_onResult: null,
|
||||
_onError: null,
|
||||
_onEnd: null,
|
||||
_finalized: false,
|
||||
|
||||
/** How long without any transcript before the recording gives up on its own. */
|
||||
SILENCE_MS: 6000,
|
||||
|
||||
/**
|
||||
* Start streaming.
|
||||
* @param {object} opts - { language, keyterms[], onResult(text, isFinal), onError(msg), onEnd(), onStream(stream) }
|
||||
*/
|
||||
async start(opts) {
|
||||
this._onResult = opts.onResult;
|
||||
this._onError = opts.onError;
|
||||
this._onEnd = opts.onEnd;
|
||||
this._finalized = false;
|
||||
|
||||
if (!navigator.mediaDevices?.getUserMedia) {
|
||||
this._onError?.('Microphone requires a secure context (HTTPS). Use --https flag or access via localhost.');
|
||||
this._cleanup();
|
||||
return;
|
||||
}
|
||||
try {
|
||||
this._stream = await navigator.mediaDevices.getUserMedia({
|
||||
audio: { noiseSuppression: true, echoCancellation: true, autoGainControl: true }
|
||||
});
|
||||
} catch (err) {
|
||||
const msg = err.name === 'NotAllowedError'
|
||||
? 'Microphone access denied. Check browser settings.'
|
||||
: 'Microphone error: ' + err.message;
|
||||
this._onError?.(msg);
|
||||
this._cleanup();
|
||||
return;
|
||||
}
|
||||
opts.onStream?.(this._stream);
|
||||
|
||||
const params = new URLSearchParams();
|
||||
if (opts.language) params.set('language', opts.language);
|
||||
if (opts.keyterms?.length) params.set('keyterms', opts.keyterms.join(','));
|
||||
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
try {
|
||||
this._ws = new WebSocket(`${proto}//${location.host}/ws/voice/stream?${params}`);
|
||||
} catch (err) {
|
||||
this._onError?.('Failed to open voice stream: ' + err.message);
|
||||
this._cleanup();
|
||||
return;
|
||||
}
|
||||
this._ws.binaryType = 'arraybuffer';
|
||||
|
||||
this._ws.onopen = () => {
|
||||
// Capture starts only once the socket is up: PCM buffered before that would
|
||||
// be the oldest audio, and dropping it keeps the transcript aligned with what
|
||||
// the user hears themselves saying.
|
||||
this._startCapture().catch((err) => {
|
||||
this._onError?.('Microphone capture failed: ' + err.message);
|
||||
this.stop();
|
||||
});
|
||||
this._resetSilenceTimeout();
|
||||
};
|
||||
|
||||
this._ws.onmessage = (event) => {
|
||||
let msg;
|
||||
try {
|
||||
msg = JSON.parse(event.data);
|
||||
} catch (_e) {
|
||||
return;
|
||||
}
|
||||
if (msg.t === 'transcript' && msg.text) {
|
||||
this._resetSilenceTimeout();
|
||||
this._onResult?.(msg.text, msg.final === true);
|
||||
} else if (msg.t === 'error') {
|
||||
this._onError?.(msg.message || 'Voice transcription failed');
|
||||
}
|
||||
};
|
||||
|
||||
this._ws.onerror = () => {
|
||||
// onclose carries the actionable detail (close code); nothing useful here.
|
||||
};
|
||||
|
||||
this._ws.onclose = (event) => {
|
||||
if (event.code === 4004) {
|
||||
this._onError?.(this._unavailableMessage(event.reason));
|
||||
} else if (event.code === 4008) {
|
||||
this._onError?.('Too many voice streams are already running on this server.');
|
||||
} else if (event.code === 4003) {
|
||||
this._onError?.('Voice stream refused (origin not allowed).');
|
||||
} else if (event.code !== 1000 && !this._finalized) {
|
||||
this._onError?.('Voice stream closed: ' + (event.reason || `code ${event.code}`));
|
||||
}
|
||||
this._stopCapture();
|
||||
const onEnd = this._onEnd;
|
||||
this._onEnd = null;
|
||||
onEnd?.();
|
||||
};
|
||||
},
|
||||
|
||||
/** Map the server's close reason onto something a user can act on. */
|
||||
_unavailableMessage(reason) {
|
||||
if (reason === 'expired') return 'Claude login expired. Run a Claude session to refresh it, then try again.';
|
||||
if (reason === 'disabled') return 'Claude voice is off. Enable it in Settings > Voice.';
|
||||
return 'No Claude Code login found on the server. Sign in with `claude` there, or use Deepgram.';
|
||||
},
|
||||
|
||||
/** Wire mic -> 16 kHz PCM16 frames -> WebSocket. */
|
||||
async _startCapture() {
|
||||
const Ctx = window.AudioContext || window.webkitAudioContext;
|
||||
// Ask for 16 kHz directly so the browser resamples; Safari may hand back its
|
||||
// own rate, which _pcmFromFloat32 then downsamples to match.
|
||||
this._audioContext = new Ctx({ sampleRate: 16000 });
|
||||
if (this._audioContext.state === 'suspended') await this._audioContext.resume();
|
||||
this._sourceNode = this._audioContext.createMediaStreamSource(this._stream);
|
||||
|
||||
if (this._audioContext.audioWorklet) {
|
||||
await this._audioContext.audioWorklet.addModule(this._workletUrl());
|
||||
this._workletNode = new AudioWorkletNode(this._audioContext, 'pcm-frame-processor');
|
||||
this._workletNode.port.onmessage = (event) => this._sendAudio(event.data);
|
||||
this._sourceNode.connect(this._workletNode);
|
||||
// A worklet with no destination is not pulled in some engines; a zero-gain
|
||||
// sink keeps the graph running without echoing the mic to the speakers.
|
||||
const sink = this._audioContext.createGain();
|
||||
sink.gain.value = 0;
|
||||
this._workletNode.connect(sink).connect(this._audioContext.destination);
|
||||
return;
|
||||
}
|
||||
|
||||
// Fallback for engines without AudioWorklet (older Safari): deprecated, but
|
||||
// it is this or no dictation at all there.
|
||||
this._scriptNode = this._audioContext.createScriptProcessor(4096, 1, 1);
|
||||
this._scriptNode.onaudioprocess = (event) => {
|
||||
this._sendAudio(this._pcmFromFloat32(event.inputBuffer.getChannelData(0), this._audioContext.sampleRate));
|
||||
};
|
||||
this._sourceNode.connect(this._scriptNode);
|
||||
this._scriptNode.connect(this._audioContext.destination);
|
||||
},
|
||||
|
||||
/**
|
||||
* Worklet URL carrying this page's cache-bust token.
|
||||
*
|
||||
* ⚠️ Static assets are served `immutable` for a year, and `cacheBustAssets`
|
||||
* only rewrites `.js` refs in `<script>`/`<link>` tags — a URL built here in JS
|
||||
* is invisible to it. So the token is borrowed from voice-input.js's own script
|
||||
* tag, which the server DID rewrite. Consequence: **edit the worklet and this
|
||||
* file together**, or the browser keeps serving the old worklet.
|
||||
*/
|
||||
_workletUrl() {
|
||||
const src = document.querySelector('script[src*="voice-input.js"]')?.getAttribute('src') || '';
|
||||
const q = src.indexOf('?');
|
||||
return 'voice-pcm-worklet.js' + (q === -1 ? '' : src.slice(q));
|
||||
},
|
||||
|
||||
/** Float32 [-1,1] at any rate -> Int16 PCM at 16 kHz (nearest-neighbour decimation). */
|
||||
_pcmFromFloat32(input, sampleRate) {
|
||||
const ratio = sampleRate / 16000;
|
||||
const outLength = Math.floor(input.length / ratio);
|
||||
const out = new Int16Array(outLength);
|
||||
for (let i = 0; i < outLength; i++) {
|
||||
const sample = Math.max(-1, Math.min(1, input[Math.floor(i * ratio)]));
|
||||
out[i] = sample < 0 ? sample * 0x8000 : sample * 0x7fff;
|
||||
}
|
||||
return out.buffer;
|
||||
},
|
||||
|
||||
_sendAudio(arrayBuffer) {
|
||||
if (this._finalized) return;
|
||||
if (this._ws?.readyState !== WebSocket.OPEN) return;
|
||||
try {
|
||||
this._ws.send(arrayBuffer);
|
||||
} catch (_e) {
|
||||
/* socket died mid-frame */
|
||||
}
|
||||
},
|
||||
|
||||
_resetSilenceTimeout() {
|
||||
clearTimeout(this._silenceTimeout);
|
||||
this._silenceTimeout = setTimeout(() => this.stop(), this.SILENCE_MS);
|
||||
},
|
||||
|
||||
/**
|
||||
* Ask for the final transcript and let the server close the socket. Capture stops
|
||||
* immediately, but the WebSocket stays open: the last (and usually best) transcript
|
||||
* arrives AFTER the audio does, so closing here would throw away the utterance.
|
||||
*/
|
||||
stop() {
|
||||
clearTimeout(this._silenceTimeout);
|
||||
this._silenceTimeout = null;
|
||||
if (this._finalized) return;
|
||||
this._finalized = true;
|
||||
this._stopCapture();
|
||||
if (this._ws?.readyState === WebSocket.OPEN) {
|
||||
try {
|
||||
this._ws.send(JSON.stringify({ t: 'finalize' }));
|
||||
} catch (_e) {
|
||||
/* ignore */
|
||||
}
|
||||
} else {
|
||||
const onEnd = this._onEnd;
|
||||
this._onEnd = null;
|
||||
onEnd?.();
|
||||
}
|
||||
},
|
||||
|
||||
/** Tear down the audio graph and release the mic. Idempotent. */
|
||||
_stopCapture() {
|
||||
if (this._workletNode) {
|
||||
this._workletNode.port.onmessage = null;
|
||||
try { this._workletNode.disconnect(); } catch (_e) { /* ignore */ }
|
||||
this._workletNode = null;
|
||||
}
|
||||
if (this._scriptNode) {
|
||||
this._scriptNode.onaudioprocess = null;
|
||||
try { this._scriptNode.disconnect(); } catch (_e) { /* ignore */ }
|
||||
this._scriptNode = null;
|
||||
}
|
||||
if (this._sourceNode) {
|
||||
try { this._sourceNode.disconnect(); } catch (_e) { /* ignore */ }
|
||||
this._sourceNode = null;
|
||||
}
|
||||
if (this._audioContext) {
|
||||
try { this._audioContext.close(); } catch (_e) { /* ignore */ }
|
||||
this._audioContext = null;
|
||||
}
|
||||
if (this._stream) {
|
||||
this._stream.getTracks().forEach(t => t.stop());
|
||||
this._stream = null;
|
||||
}
|
||||
},
|
||||
|
||||
/** Hard stop: drop the socket without waiting for a final transcript. */
|
||||
_cleanup() {
|
||||
this._finalized = true;
|
||||
clearTimeout(this._silenceTimeout);
|
||||
this._silenceTimeout = null;
|
||||
this._stopCapture();
|
||||
if (this._ws) {
|
||||
this._ws.onclose = null;
|
||||
this._ws.onmessage = null;
|
||||
this._ws.onerror = null;
|
||||
if (this._ws.readyState === WebSocket.OPEN) {
|
||||
try { this._ws.close(1000); } catch (_e) { /* ignore */ }
|
||||
}
|
||||
this._ws = null;
|
||||
}
|
||||
this._onResult = null;
|
||||
this._onError = null;
|
||||
this._onEnd = null;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* VoiceInput - Speech-to-text with Claude (this server's Claude Code login),
|
||||
* Deepgram Nova-3, or the Web Speech API.
|
||||
* Toggle mode: tap mic to start, tap again to stop. Auto-stops after silence.
|
||||
* Shows interim transcription in a floating preview overlay.
|
||||
* Inserts final text into the active session (user presses Enter to submit).
|
||||
@@ -273,6 +559,29 @@ const VoiceInput = {
|
||||
this._initRecognition();
|
||||
// Always show buttons — if unsupported, toggle() shows a toast
|
||||
this._showButtons();
|
||||
// Probe the server's Claude voice availability in the background. `auto`
|
||||
// resolution reads the cached answer, so the first mic press does not wait
|
||||
// on a round trip; a miss just falls through to the next provider.
|
||||
this.refreshClaudeStatus();
|
||||
},
|
||||
|
||||
/** Last /api/voice/status answer, or null before the first probe resolves. */
|
||||
_claudeStatus: null,
|
||||
|
||||
/**
|
||||
* Re-probe whether this server can transcribe with its Claude Code login.
|
||||
* Called at init and whenever App Settings opens (the setting is server-side,
|
||||
* so another device could have flipped it).
|
||||
*/
|
||||
async refreshClaudeStatus() {
|
||||
try {
|
||||
const res = await fetch('/api/voice/status');
|
||||
const json = await res.json();
|
||||
this._claudeStatus = json?.success ? json.data : { available: false, reason: 'disabled' };
|
||||
} catch (_e) {
|
||||
this._claudeStatus = { available: false, reason: 'disabled' };
|
||||
}
|
||||
return this._claudeStatus;
|
||||
},
|
||||
|
||||
// --- Deepgram config (localStorage only, never sent to server) ---
|
||||
@@ -294,11 +603,37 @@ const VoiceInput = {
|
||||
return !!(cfg.apiKey && cfg.apiKey.trim());
|
||||
},
|
||||
|
||||
_claudeAvailable() {
|
||||
return this._claudeStatus?.available === true;
|
||||
},
|
||||
|
||||
/**
|
||||
* Which provider a press of the mic would use.
|
||||
*
|
||||
* An explicit pick always wins, even when it cannot run — the resulting error
|
||||
* ("Claude voice is off", "no Deepgram key") is more useful than silently
|
||||
* transcribing somewhere the user did not choose. `auto` prefers Claude because
|
||||
* it needs no key and no per-word billing, then the configured Deepgram key,
|
||||
* then the browser's own engine.
|
||||
*/
|
||||
_resolveProvider() {
|
||||
const pinned = this._getDeepgramConfig().provider;
|
||||
if (pinned === 'claude' || pinned === 'deepgram' || pinned === 'webspeech') return pinned;
|
||||
if (this._claudeAvailable()) return 'claude';
|
||||
if (this._shouldUseDeepgram()) return 'deepgram';
|
||||
return 'webspeech';
|
||||
},
|
||||
|
||||
/** Get the active provider name for display */
|
||||
getActiveProviderName() {
|
||||
if (this._shouldUseDeepgram()) return 'Deepgram Nova-3';
|
||||
if (this.supported) return 'Web Speech API';
|
||||
return 'None';
|
||||
switch (this._resolveProvider()) {
|
||||
case 'claude':
|
||||
return this._claudeAvailable() ? 'Claude (this server’s login)' : 'Claude (unavailable)';
|
||||
case 'deepgram':
|
||||
return this._shouldUseDeepgram() ? 'Deepgram Nova-3' : 'Deepgram (no API key)';
|
||||
default:
|
||||
return this.supported ? 'Web Speech API' : 'None';
|
||||
}
|
||||
},
|
||||
|
||||
/** Try to create a SpeechRecognition instance */
|
||||
@@ -334,13 +669,81 @@ const VoiceInput = {
|
||||
}
|
||||
this._retryCount = 0;
|
||||
|
||||
if (this._shouldUseDeepgram()) {
|
||||
const provider = this._resolveProvider();
|
||||
if (provider === 'claude') {
|
||||
this._startClaude();
|
||||
} else if (provider === 'deepgram') {
|
||||
this._startDeepgram();
|
||||
} else {
|
||||
this._startWebSpeech();
|
||||
}
|
||||
},
|
||||
|
||||
_startClaude() {
|
||||
if (!this._claudeAvailable()) {
|
||||
const reason = this._claudeStatus?.reason;
|
||||
app.showToast(
|
||||
reason === 'expired'
|
||||
? 'Claude login expired on the server. Run a Claude session to refresh it.'
|
||||
: reason === 'no-credentials'
|
||||
? 'No Claude Code login found on the server. Sign in there with `claude`.'
|
||||
: 'Claude voice is off. Enable it in Settings > Voice.',
|
||||
'warning'
|
||||
);
|
||||
// Re-probe so a setting flipped on another device is picked up by the next press.
|
||||
this.refreshClaudeStatus();
|
||||
return;
|
||||
}
|
||||
|
||||
const cfg = this._getDeepgramConfig();
|
||||
this.isRecording = true;
|
||||
this._activeProvider = 'claude';
|
||||
this._accumulatedFinal = '';
|
||||
this._lastTranscript = '';
|
||||
this._hasReceivedResult = false;
|
||||
this._recordingStartedAt = Date.now();
|
||||
this._updateButtons('recording');
|
||||
this._showPreview('Listening...', 'claude');
|
||||
this._startDurationTimer();
|
||||
|
||||
const keyterms = (cfg.keyterms || DEFAULT_VOICE_KEYTERMS)
|
||||
.split(',').map(t => t.trim()).filter(Boolean);
|
||||
|
||||
ClaudeVoiceProvider.start({
|
||||
// The upstream endpoint wants a bare language tag; the Deepgram picker's
|
||||
// 'en-US' style narrows to its base, and 'multi' means auto-detect.
|
||||
language: (cfg.language || 'en-US').split('-')[0],
|
||||
keyterms,
|
||||
onStream: (stream) => this._startLevelMeter(stream),
|
||||
onResult: (text, isFinal) => {
|
||||
if (!this.isRecording) return;
|
||||
this._hasReceivedResult = true;
|
||||
// Each frame is the WHOLE running transcript, so replace rather than append.
|
||||
this._accumulatedFinal = text;
|
||||
if (isFinal) {
|
||||
this._hidePreview();
|
||||
this._insertText(text);
|
||||
this.stop();
|
||||
} else {
|
||||
this._showPreview(text, 'claude');
|
||||
}
|
||||
},
|
||||
onError: (msg) => {
|
||||
const wasRecording = this.isRecording;
|
||||
this.stop();
|
||||
if (wasRecording) app.showToast(msg, 'error');
|
||||
},
|
||||
onEnd: () => {
|
||||
if (this.isRecording) {
|
||||
if (this._accumulatedFinal) this._insertText(this._accumulatedFinal);
|
||||
this.stop();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (navigator.vibrate) navigator.vibrate(50);
|
||||
},
|
||||
|
||||
_startDeepgram() {
|
||||
const cfg = this._getDeepgramConfig();
|
||||
this.isRecording = true;
|
||||
@@ -353,7 +756,7 @@ const VoiceInput = {
|
||||
this._showPreview('Listening...', 'deepgram');
|
||||
this._startDurationTimer();
|
||||
|
||||
const keyterms = (cfg.keyterms || 'refactor, endpoint, middleware, callback, async, regex, TypeScript, npm, API, deploy, config, linter, env, webhook, schema, CLI, JSON, CSS, DOM, SSE, backend, frontend, localhost, dependencies, repository, merge, rebase, diff, commit, com')
|
||||
const keyterms = (cfg.keyterms || DEFAULT_VOICE_KEYTERMS)
|
||||
.split(',').map(t => t.trim()).filter(Boolean);
|
||||
|
||||
DeepgramProvider.start({
|
||||
@@ -452,7 +855,10 @@ const VoiceInput = {
|
||||
this._updateButtons('idle');
|
||||
this._hidePreview();
|
||||
|
||||
if (this._activeProvider === 'deepgram') {
|
||||
if (this._activeProvider === 'claude') {
|
||||
// Finalize, don't hang up: the last transcript arrives after the audio does.
|
||||
ClaudeVoiceProvider.stop();
|
||||
} else if (this._activeProvider === 'deepgram') {
|
||||
DeepgramProvider.stop();
|
||||
} else if (this._activeProvider === 'webspeech') {
|
||||
try {
|
||||
@@ -803,11 +1209,12 @@ const VoiceInput = {
|
||||
timerEl.textContent = '0:00';
|
||||
indicator.appendChild(timerEl);
|
||||
this.previewEl.appendChild(indicator);
|
||||
// Provider badge for Deepgram
|
||||
if (provider === 'deepgram') {
|
||||
// Provider badge (Web Speech gets none — it is the fallback, not a choice)
|
||||
const badgeText = provider === 'deepgram' ? 'DG' : provider === 'claude' ? 'CLAUDE' : '';
|
||||
if (badgeText) {
|
||||
const badge = document.createElement('span');
|
||||
badge.className = 'voice-preview-badge';
|
||||
badge.textContent = 'DG';
|
||||
badge.textContent = badgeText;
|
||||
this.previewEl.appendChild(badge);
|
||||
this.previewEl.appendChild(document.createTextNode(' '));
|
||||
}
|
||||
@@ -861,6 +1268,7 @@ const VoiceInput = {
|
||||
if (this.isRecording) this.stop();
|
||||
this._hideVoiceSendBtn();
|
||||
DeepgramProvider._cleanup();
|
||||
ClaudeVoiceProvider._cleanup();
|
||||
this.recognition = null;
|
||||
this._activeProvider = null;
|
||||
this._stopDurationTimer();
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
/**
|
||||
* @fileoverview AudioWorklet that turns microphone audio into the PCM frames the
|
||||
* Claude voice endpoint expects.
|
||||
*
|
||||
* The endpoint is opened as `encoding=linear16, sample_rate=16000, channels=1`,
|
||||
* i.e. raw signed 16-bit little-endian mono. MediaRecorder cannot produce that
|
||||
* (it only emits container formats — webm/opus, mp4), which is why the Deepgram
|
||||
* path's capture code cannot be reused here: Deepgram sniffs the container,
|
||||
* Anthropic's endpoint does not.
|
||||
*
|
||||
* Sample rate is handled by the AudioContext, constructed at 16000 Hz so the
|
||||
* browser resamples the mic for us. This processor only converts Float32 [-1,1]
|
||||
* to Int16 and batches, because a raw 128-sample render quantum is a ~4 ms
|
||||
* WebSocket frame — 250 frames a second of pure overhead.
|
||||
*
|
||||
* Loaded via `audioWorklet.addModule()` from voice-input.js. Runs on the audio
|
||||
* thread: no DOM, no globals from the page.
|
||||
*
|
||||
* ⚠️ Edit this file and voice-input.js together. Static assets are served
|
||||
* `immutable` for a year and this one is fetched from JS, so it inherits its
|
||||
* cache-bust token from voice-input.js's script tag (see `_workletUrl()`); a
|
||||
* change here alone would keep serving the old copy to every returning browser.
|
||||
*/
|
||||
|
||||
/** ~256 ms at 16 kHz. Big enough to keep frame overhead down, small enough that interim transcripts stay live. */
|
||||
const FRAME_SAMPLES = 4096;
|
||||
|
||||
class PcmFrameProcessor extends AudioWorkletProcessor {
|
||||
constructor() {
|
||||
super();
|
||||
this._buffer = new Int16Array(FRAME_SAMPLES);
|
||||
this._offset = 0;
|
||||
}
|
||||
|
||||
process(inputs) {
|
||||
const channel = inputs[0]?.[0];
|
||||
// No input yet (mic still warming) — keep the processor alive.
|
||||
if (!channel) return true;
|
||||
|
||||
for (let i = 0; i < channel.length; i++) {
|
||||
// Clamp before scaling: values slightly outside [-1,1] are legal in Web Audio
|
||||
// and would wrap around to the opposite sign as Int16, which sounds like a click.
|
||||
const sample = Math.max(-1, Math.min(1, channel[i]));
|
||||
this._buffer[this._offset++] = sample < 0 ? sample * 0x8000 : sample * 0x7fff;
|
||||
|
||||
if (this._offset === FRAME_SAMPLES) {
|
||||
// Transfer a copy: the worklet keeps reusing its own buffer.
|
||||
const frame = new Int16Array(this._buffer);
|
||||
this.port.postMessage(frame.buffer, [frame.buffer]);
|
||||
this._offset = 0;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
registerProcessor('pcm-frame-processor', PcmFrameProcessor);
|
||||
@@ -24,4 +24,5 @@ export { registerSearchRoutes } from './search-routes.js';
|
||||
export { registerMeRoutes } from './me-routes.js';
|
||||
export { registerAdminRoutes } from './admin-routes.js';
|
||||
export { registerWsRoutes } from './ws-routes.js';
|
||||
export { registerVoiceRoutes } from './voice-routes.js';
|
||||
export { registerWebviewRoutes, tryWebviewRefererFallback } from './webview-routes.js';
|
||||
|
||||
@@ -3,7 +3,9 @@
|
||||
*
|
||||
* Registers `GET /api/search?q=&types=&limit=` — a bounded, in-memory search
|
||||
* across three v1 sources, returned in the standard ApiResponse envelope:
|
||||
* 1. sessions/cases — name, working directory, session id
|
||||
* 1. sessions/cases, name, working directory, session id, for LIVE sessions
|
||||
* plus the past-session snapshot in `session-history-index.ts` (issue #261:
|
||||
* the live map alone made every closed session unfindable by folder name)
|
||||
* 2. run-summary events — event title/details (from the live run-summary trackers)
|
||||
* 3. file paths — per-session attachment history (workspace-relative paths only)
|
||||
*
|
||||
@@ -34,6 +36,7 @@ import {
|
||||
} from '../../search-service.js';
|
||||
import type { SearchSourceType } from '../../types/search.js';
|
||||
import type { SessionPort, InfraPort } from '../ports/index.js';
|
||||
import { ensureHistorySessionIndexFresh, getHistorySessionIndex } from '../session-history-index.js';
|
||||
|
||||
/**
|
||||
* Per-source harvest caps. These bound how much in-memory data we hand to the
|
||||
@@ -61,11 +64,17 @@ interface SessionLike {
|
||||
/**
|
||||
* Harvest the three source arrays from the live in-memory stores. Reads only
|
||||
* bounded, already-loaded data — no disk I/O, no terminal buffers.
|
||||
*
|
||||
* Past sessions come from the `session-history-index` snapshot, which is built
|
||||
* outside the request path for exactly that reason. Live rows are harvested
|
||||
* first and win the dedupe, so a session that is both live and in the snapshot
|
||||
* keeps its live jump-to (switch to the tab) instead of a resume.
|
||||
*/
|
||||
function harvestSources(ctx: SessionPort & InfraPort, canSee?: (owner?: string) => boolean): SearchSources {
|
||||
const sessions: SessionSearchInput[] = [];
|
||||
const events: EventSearchInput[] = [];
|
||||
const files: FileSearchInput[] = [];
|
||||
const seenSessionIds = new Set<string>();
|
||||
|
||||
for (const raw of ctx.sessions.values()) {
|
||||
const s = raw as unknown as SessionLike & { owner?: string };
|
||||
@@ -73,6 +82,7 @@ function harvestSources(ctx: SessionPort & InfraPort, canSee?: (owner?: string)
|
||||
const sessionName = s.name ?? '';
|
||||
const timestamp = s.lastActivityAt ?? s.createdAt ?? 0;
|
||||
|
||||
seenSessionIds.add(s.id);
|
||||
sessions.push({
|
||||
sessionId: s.id,
|
||||
sessionName,
|
||||
@@ -95,6 +105,24 @@ function harvestSources(ctx: SessionPort & InfraPort, canSee?: (owner?: string)
|
||||
}
|
||||
}
|
||||
|
||||
// Past sessions: the out-of-band snapshot of the unified list. Unscoped on
|
||||
// disk, so every row goes through the same ownership check as a live one,
|
||||
// host-wide transcript rows carry no owner and are therefore admin-only in
|
||||
// multi-user mode, matching GET /api/sessions/unified.
|
||||
for (const item of getHistorySessionIndex().items) {
|
||||
if (seenSessionIds.has(item.sessionId)) continue;
|
||||
if (canSee && !canSee(item.owner)) continue;
|
||||
seenSessionIds.add(item.sessionId);
|
||||
sessions.push({
|
||||
sessionId: item.sessionId,
|
||||
sessionName: item.name,
|
||||
workingDir: item.workingDir,
|
||||
timestamp: item.timestamp,
|
||||
history: true,
|
||||
claudeSessionId: item.claudeSessionId,
|
||||
});
|
||||
}
|
||||
|
||||
// Events: from the live run-summary trackers, keyed by session id.
|
||||
for (const [sessionId, tracker] of ctx.runSummaryTrackers) {
|
||||
const session = ctx.sessions.get(sessionId) as unknown as (SessionLike & { owner?: string }) | undefined;
|
||||
@@ -134,6 +162,11 @@ export function registerSearchRoutes(app: FastifyInstance, ctx: SessionPort & In
|
||||
)
|
||||
: null;
|
||||
|
||||
// Fire-and-forget: a stale past-session snapshot is rebuilt in the
|
||||
// background. This query still answers from whatever is already in memory,
|
||||
// which is what keeps the request path free of disk I/O.
|
||||
ensureHistorySessionIndexFresh();
|
||||
|
||||
const sources = harvestSources(ctx, canSee);
|
||||
|
||||
// Apply the optional source-type filter before searching so excluded
|
||||
|
||||
@@ -94,7 +94,13 @@ import {
|
||||
type LifecycleInput,
|
||||
type HistoryInput,
|
||||
type MuxStatInput,
|
||||
type UnifiedSessionItem,
|
||||
} from '../../services/unified-session-service.js';
|
||||
import {
|
||||
buildHistorySessionIndexItems,
|
||||
setHistoryIndexRefresher,
|
||||
setHistorySessionIndex,
|
||||
} from '../session-history-index.js';
|
||||
import type { SessionPort, EventPort, ConfigPort, InfraPort, AuthPort } from '../ports/index.js';
|
||||
import { RunSummaryTracker } from '../../run-summary.js';
|
||||
|
||||
@@ -3116,6 +3122,91 @@ export function registerSessionRoutes(
|
||||
return sawNonCli;
|
||||
}
|
||||
|
||||
/** Git/worktree facts recovered from a transcript. Every field is optional —
|
||||
* "unknown" must stay distinguishable from "not a worktree" (#265/#266). */
|
||||
type TranscriptGitInfo = {
|
||||
/** The literal `cwd` Claude Code stamped on its own records. */
|
||||
cwd?: string;
|
||||
gitBranch?: string;
|
||||
worktreeName?: string;
|
||||
/** Main repo root the worktree belongs to. */
|
||||
worktreeRepo?: string;
|
||||
};
|
||||
|
||||
/** `<repo>/.claude/worktrees/<name>` — the layout Claude Code's own worktree feature creates. */
|
||||
const CLAUDE_WORKTREE_PATH = /^(.*)\/\.claude\/worktrees\/([^/]+)\/?$/;
|
||||
|
||||
/**
|
||||
* Recover cwd / branch / worktree from a transcript chunk.
|
||||
*
|
||||
* Claude Code stamps `"cwd"` and `"gitBranch"` on every user/assistant record,
|
||||
* and writes a dedicated `worktree-state` record when the session was started
|
||||
* through its own worktree feature. This reads buffers `scanProjectDir` has
|
||||
* ALREADY loaded, so it costs no extra file I/O.
|
||||
*
|
||||
* Why this matters beyond a label: `decodeProjectKey()` reconstructs a path by
|
||||
* stat-walking the filesystem and falls back to `$HOME` when nothing resolves.
|
||||
* A deleted worktree is the normal end of a worktree's life, so every past
|
||||
* worktree session used to collapse onto `$HOME` (#265). The transcript value
|
||||
* is the literal cwd — non-lossy, and it survives the directory being removed.
|
||||
*
|
||||
* cwd is taken from the FIRST record that carries it (a session's cwd does not
|
||||
* move); gitBranch from the LAST (a branch genuinely changes mid-session, and
|
||||
* the newest value in the scanned chunk is the closest to current).
|
||||
*/
|
||||
function extractTranscriptGitInfo(text: string): TranscriptGitInfo {
|
||||
const info: TranscriptGitInfo = {};
|
||||
let start = 0;
|
||||
while (start < text.length) {
|
||||
const end = text.indexOf('\n', start);
|
||||
const line = end === -1 ? text.slice(start) : text.slice(start, end);
|
||||
start = end === -1 ? text.length : end + 1;
|
||||
|
||||
// Highest-confidence source: Claude's own worktree record. Names the
|
||||
// worktree explicitly, so it beats anything inferred from the path.
|
||||
if (line.includes('"worktree-state"')) {
|
||||
try {
|
||||
const rec = JSON.parse(line) as {
|
||||
worktreeSession?: { worktreeName?: unknown; worktreePath?: unknown; originalCwd?: unknown };
|
||||
};
|
||||
const ws = rec.worktreeSession;
|
||||
if (ws) {
|
||||
if (typeof ws.worktreeName === 'string') info.worktreeName ||= ws.worktreeName;
|
||||
if (typeof ws.originalCwd === 'string') info.worktreeRepo ||= ws.originalCwd;
|
||||
if (typeof ws.worktreePath === 'string') info.cwd ||= ws.worktreePath;
|
||||
}
|
||||
} catch {
|
||||
// Malformed/truncated line — skip
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!line.includes('"cwd"') && !line.includes('"gitBranch"')) continue;
|
||||
if (!line.includes('"type":"user"') && !line.includes('"type":"assistant"')) continue;
|
||||
try {
|
||||
const rec = JSON.parse(line) as { cwd?: unknown; gitBranch?: unknown };
|
||||
if (!info.cwd && typeof rec.cwd === 'string' && rec.cwd) info.cwd = rec.cwd;
|
||||
// Last one wins — closest to the session's current branch.
|
||||
if (typeof rec.gitBranch === 'string' && rec.gitBranch) info.gitBranch = rec.gitBranch;
|
||||
} catch {
|
||||
// Malformed/truncated line — skip
|
||||
}
|
||||
}
|
||||
|
||||
// No explicit worktree record: infer from Claude's own worktree path layout.
|
||||
// A worktree created by hand (`git worktree add` anywhere) has no recoverable
|
||||
// NAME here — it still gets a branch, and the badge degrades to branch-only
|
||||
// rather than guessing.
|
||||
if (!info.worktreeName && info.cwd) {
|
||||
const m = CLAUDE_WORKTREE_PATH.exec(info.cwd);
|
||||
if (m) {
|
||||
info.worktreeName = m[2];
|
||||
info.worktreeRepo ||= m[1];
|
||||
}
|
||||
}
|
||||
return info;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the text of the LAST user message from a JSONL transcript chunk
|
||||
* (COD-145). Mirrors `extractFirstUserPrompt` exactly — same user-message
|
||||
@@ -3367,6 +3458,11 @@ export function registerSessionRoutes(
|
||||
lastModified: string;
|
||||
firstPrompt?: string;
|
||||
lastPrompt?: string;
|
||||
/** True when workingDir came from the transcript rather than decodeProjectKey's guess. */
|
||||
workingDirExact?: boolean;
|
||||
gitBranch?: string;
|
||||
worktreeName?: string;
|
||||
worktreeRepo?: string;
|
||||
};
|
||||
|
||||
// Scan a single project directory and return all valid history sessions in it.
|
||||
@@ -3473,14 +3569,34 @@ export function registerSessionRoutes(
|
||||
headEntrypoint === 'cli' || tailEntrypoint === 'cli' ? 'cli' : (headEntrypoint ?? tailEntrypoint);
|
||||
if (entrypoint && isAutomatedEntrypoint(entrypoint)) continue;
|
||||
|
||||
// Git/worktree facts from the buffers already read above — no extra I/O.
|
||||
// head first (cwd is stamped near the top; median offset ~1KB), tail as the
|
||||
// fallback for transcripts whose head read failed or came up empty.
|
||||
const headGit = head ? extractTranscriptGitInfo(head) : {};
|
||||
const tailGit = tail ? extractTranscriptGitInfo(tail) : {};
|
||||
const git: TranscriptGitInfo = {
|
||||
cwd: headGit.cwd ?? tailGit.cwd,
|
||||
// Last-wins within a chunk; across chunks the tail is the newer one.
|
||||
gitBranch: tailGit.gitBranch ?? headGit.gitBranch,
|
||||
worktreeName: headGit.worktreeName ?? tailGit.worktreeName,
|
||||
worktreeRepo: headGit.worktreeRepo ?? tailGit.worktreeRepo,
|
||||
};
|
||||
|
||||
out.push({
|
||||
sessionId,
|
||||
workingDir,
|
||||
// The transcript's literal cwd beats decodeProjectKey's stat-walked guess,
|
||||
// which silently collapses to $HOME once the directory is gone (#265).
|
||||
// Absent cwd falls back to the old behaviour rather than inventing a path.
|
||||
workingDir: git.cwd ?? workingDir,
|
||||
workingDirExact: git.cwd !== undefined,
|
||||
projectKey: projDir,
|
||||
sizeBytes: fileStat.size,
|
||||
lastModified: fileStat.mtime.toISOString(),
|
||||
firstPrompt,
|
||||
lastPrompt,
|
||||
gitBranch: git.gitBranch,
|
||||
worktreeName: git.worktreeName,
|
||||
worktreeRepo: git.worktreeRepo,
|
||||
});
|
||||
}
|
||||
return out;
|
||||
@@ -3539,16 +3655,20 @@ export function registerSessionRoutes(
|
||||
return { sessions: results.slice(0, 50) };
|
||||
});
|
||||
|
||||
// Unified, read-only session list: merges live + persisted + lifecycle +
|
||||
// transcript history + mux stats into one de-duplicated, searchable list
|
||||
// (COD-121). Pure merge/filter logic lives in unified-session-service.ts.
|
||||
app.get('/api/sessions/unified', async (req) => {
|
||||
const query = req.query as { q?: string; offset?: string; limit?: string };
|
||||
|
||||
if (ctx.testMode) {
|
||||
return { sessions: [], total: 0 };
|
||||
}
|
||||
|
||||
/**
|
||||
* Gather the four read-only views the unified list is merged from, plus mux
|
||||
* stats. This is the expensive half (the lifecycle log and a scan of every
|
||||
* Claude transcript), factored out of the route handler because the
|
||||
* past-session search index rebuilds itself from the very same inputs, off
|
||||
* the request path, see session-history-index.ts.
|
||||
*/
|
||||
async function gatherUnifiedInputs(): Promise<{
|
||||
live: LiveSessionInput[];
|
||||
persisted: PersistedSessionInput[];
|
||||
lifecycle: LifecycleInput[];
|
||||
history: HistoryInput[];
|
||||
mux: MuxStatInput[];
|
||||
}> {
|
||||
// Live (in-memory) sessions.
|
||||
const live: LiveSessionInput[] = [...ctx.sessions.values()].map((s) => {
|
||||
const st = s.toState();
|
||||
@@ -3618,6 +3738,9 @@ export function registerSessionRoutes(
|
||||
firstPrompt: h.firstPrompt,
|
||||
lastPrompt: h.lastPrompt,
|
||||
projectKey: h.projectKey,
|
||||
gitBranch: h.gitBranch,
|
||||
worktreeName: h.worktreeName,
|
||||
worktreeRepo: h.worktreeRepo,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -3649,14 +3772,54 @@ export function registerSessionRoutes(
|
||||
// Mux stats are optional.
|
||||
}
|
||||
|
||||
return { live, persisted, lifecycle, history, mux };
|
||||
}
|
||||
|
||||
/**
|
||||
* Publish a merged unified list as the past-session search index (issue #261).
|
||||
* The snapshot is stored UNSCOPED with a per-row owner, so it must only ever be
|
||||
* built from an unscoped merge, `harvestSources()` in search-routes re-applies
|
||||
* the ownership check on read.
|
||||
*/
|
||||
function publishHistorySessionIndex(merged: UnifiedSessionItem[]): void {
|
||||
const ownerById = new Map<string, string | undefined>();
|
||||
const stored = ctx.store.getState().sessions as Record<string, { id: string; owner?: string }>;
|
||||
for (const p of Object.values(stored)) ownerById.set(p.id, p.owner);
|
||||
// Live wins: a session's owner on disk can lag the running one.
|
||||
for (const s of ctx.sessions.values()) ownerById.set(s.id, s.owner);
|
||||
const liveIds = new Set(ctx.sessions.keys());
|
||||
setHistorySessionIndex(buildHistorySessionIndexItems(merged, ownerById, liveIds));
|
||||
}
|
||||
|
||||
// Rebuild hook for the search route: it kicks this (fire-and-forget) when the
|
||||
// snapshot goes stale, so a search never pays for the scan itself.
|
||||
setHistoryIndexRefresher(async () => {
|
||||
if (ctx.testMode) return;
|
||||
publishHistorySessionIndex(mergeUnifiedSessions(await gatherUnifiedInputs()));
|
||||
});
|
||||
|
||||
// Unified, read-only session list: merges live + persisted + lifecycle +
|
||||
// transcript history + mux stats into one de-duplicated, searchable list
|
||||
// (COD-121). Pure merge/filter logic lives in unified-session-service.ts.
|
||||
app.get('/api/sessions/unified', async (req) => {
|
||||
const query = req.query as { q?: string; offset?: string; limit?: string };
|
||||
|
||||
if (ctx.testMode) {
|
||||
return { sessions: [], total: 0 };
|
||||
}
|
||||
|
||||
const { live, persisted, lifecycle, history, mux } = await gatherUnifiedInputs();
|
||||
|
||||
// Multi-user: a non-admin only sees their own sessions; host-wide transcript
|
||||
// history (not tied to an owned session) is admin-only.
|
||||
let sLive = live;
|
||||
let sPersisted = persisted;
|
||||
let sLifecycle = lifecycle;
|
||||
let sHistory = history;
|
||||
let scoped = false;
|
||||
const uUser = getAuthUser(req);
|
||||
if (isMultiUserMode() && uUser.role !== 'admin') {
|
||||
scoped = true;
|
||||
const ownedLive = new Set(
|
||||
[...ctx.sessions.values()].filter((s) => canAccessOwned(uUser, s.owner)).map((s) => s.id)
|
||||
);
|
||||
@@ -3680,6 +3843,14 @@ export function registerSessionRoutes(
|
||||
history: sHistory,
|
||||
mux,
|
||||
});
|
||||
|
||||
// Refresh the search index off the back of this request, the home screen
|
||||
// fetches this endpoint whenever it opens, which is the same screen the
|
||||
// search box lives on, so the snapshot is warm before anyone types. A scoped
|
||||
// merge is a per-user subset and would corrupt the shared snapshot, so that
|
||||
// path re-merges unscoped instead (multi-user is opt-in and rarely hit).
|
||||
publishHistorySessionIndex(scoped ? mergeUnifiedSessions({ live, persisted, lifecycle, history, mux }) : merged);
|
||||
|
||||
const offset = query.offset !== undefined ? parseInt(query.offset, 10) : undefined;
|
||||
const limit = query.limit !== undefined ? parseInt(query.limit, 10) : undefined;
|
||||
return filterAndPaginate(merged, {
|
||||
|
||||
@@ -0,0 +1,194 @@
|
||||
/**
|
||||
* @fileoverview Claude voice dictation routes.
|
||||
*
|
||||
* - `GET /api/voice/status` — can this server transcribe? (settings gate + credential state)
|
||||
* - `GET /ws/voice/stream` — one dictation: PCM16 audio up, transcripts down
|
||||
*
|
||||
* Design and the upstream protocol: `docs/claude-voice-plan.md`. The relay itself
|
||||
* lives in `../voice-stream.ts`; this file is the auth, gating and lifetime shell
|
||||
* around it.
|
||||
*
|
||||
* ⚠️ `/api/voice/status` reports STATE, never the token: `{ available, reason,
|
||||
* subscriptionType?, expiresAt? }`. The Claude OAuth access token stays inside the
|
||||
* server process — the browser sends audio and receives text, nothing else.
|
||||
*
|
||||
* ⚠️ The WebSocket carries the same upgrade guard as `/ws/sessions/:id/terminal`
|
||||
* (allowed Host + same-site Origin, on top of the global auth hook that already ran
|
||||
* on the handshake). Without it a cross-site page could open a dictation stream on
|
||||
* the user's credentials and bill their subscription.
|
||||
*
|
||||
* ⚠️ The feature is OFF unless `claudeVoiceEnabled` is set: turning it on spends the
|
||||
* server owner's Claude subscription on transcription for anyone who can reach the
|
||||
* UI, which is a decision for the operator rather than a default.
|
||||
*/
|
||||
|
||||
import { createRequire } from 'module';
|
||||
import { FastifyInstance } from 'fastify';
|
||||
import type { WebSocket } from 'ws';
|
||||
import { ApiErrorCode, createErrorResponse } from '../../types.js';
|
||||
import { isAllowedRequestHost, isAllowedRequestOrigin, type HostPolicy } from '../network-auth-policy.js';
|
||||
import { readClaudeOAuthCredentials } from '../../claude-credentials.js';
|
||||
import { VoiceStreamRelay } from '../voice-stream.js';
|
||||
import { MAX_AUDIO_FRAME_BYTES, MAX_CONCURRENT_STREAMS } from '../../config/voice.js';
|
||||
import type { ConfigPort } from '../ports/index.js';
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
const { version: APP_VERSION } = require('../../../package.json') as { version: string };
|
||||
|
||||
/** Why voice is unavailable, in a form the frontend can branch on. */
|
||||
export type VoiceUnavailableReason = 'disabled' | 'no-credentials' | 'expired' | 'malformed';
|
||||
|
||||
export interface VoiceStatus {
|
||||
available: boolean;
|
||||
reason?: VoiceUnavailableReason;
|
||||
/** Display-only ('max', 'pro'); present when the credential store reported one. */
|
||||
subscriptionType?: string;
|
||||
expiresAt?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the server's dictation readiness. Split out and exported so the status
|
||||
* endpoint and the WebSocket upgrade cannot drift apart: the socket must never
|
||||
* accept a stream the status endpoint calls unavailable.
|
||||
*/
|
||||
export async function resolveVoiceStatus(enabled: boolean): Promise<VoiceStatus> {
|
||||
if (!enabled) return { available: false, reason: 'disabled' };
|
||||
const creds = await readClaudeOAuthCredentials();
|
||||
switch (creds.status) {
|
||||
case 'ok':
|
||||
return { available: true, subscriptionType: creds.subscriptionType, expiresAt: creds.expiresAt };
|
||||
case 'expired':
|
||||
return { available: false, reason: 'expired', expiresAt: creds.expiresAt };
|
||||
case 'malformed':
|
||||
return { available: false, reason: 'malformed' };
|
||||
default:
|
||||
return { available: false, reason: 'no-credentials' };
|
||||
}
|
||||
}
|
||||
|
||||
/** Live relays, server-wide. Dictation is human-paced, so the cap is small. */
|
||||
let activeStreams = 0;
|
||||
|
||||
/** Test seam: the cap is process-wide state, so suites must be able to reset it. */
|
||||
export function _resetVoiceStreamCountForTesting(): void {
|
||||
activeStreams = 0;
|
||||
}
|
||||
|
||||
/** Split a comma-separated keyterms query value into terms. */
|
||||
function parseKeyterms(raw: unknown): string[] {
|
||||
if (typeof raw !== 'string' || !raw) return [];
|
||||
return raw
|
||||
.split(',')
|
||||
.map((t) => t.trim())
|
||||
.filter(Boolean)
|
||||
.slice(0, 100);
|
||||
}
|
||||
|
||||
export function registerVoiceRoutes(app: FastifyInstance, ctx: ConfigPort, getHostPolicy: () => HostPolicy): void {
|
||||
app.get('/api/voice/status', async (_req, reply) => {
|
||||
try {
|
||||
return { success: true, data: await resolveVoiceStatus(await ctx.getClaudeVoiceEnabled()) };
|
||||
} catch {
|
||||
reply.code(500);
|
||||
return createErrorResponse(ApiErrorCode.INTERNAL_ERROR, 'Failed to read voice status');
|
||||
}
|
||||
});
|
||||
|
||||
app.get<{ Querystring: { language?: string; keyterms?: string } }>(
|
||||
'/ws/voice/stream',
|
||||
{ websocket: true },
|
||||
async (socket: WebSocket, req) => {
|
||||
// Cross-site upgrade guard first: this socket spends the operator's Claude
|
||||
// subscription, so it must be reachable only from Codeman's own origin.
|
||||
const policy = getHostPolicy();
|
||||
if (!isAllowedRequestHost(req.headers.host, policy) || !isAllowedRequestOrigin(req.headers.origin, policy)) {
|
||||
socket.close(4003, 'Forbidden');
|
||||
return;
|
||||
}
|
||||
|
||||
const status = await resolveVoiceStatus(await ctx.getClaudeVoiceEnabled());
|
||||
if (!status.available) {
|
||||
socket.close(4004, status.reason ?? 'unavailable');
|
||||
return;
|
||||
}
|
||||
// Re-read rather than trusting resolveVoiceStatus's discarded token: the
|
||||
// status helper deliberately never returns it.
|
||||
const creds = await readClaudeOAuthCredentials();
|
||||
if (creds.status !== 'ok' || !creds.accessToken) {
|
||||
socket.close(4004, 'no-credentials');
|
||||
return;
|
||||
}
|
||||
|
||||
if (activeStreams >= MAX_CONCURRENT_STREAMS) {
|
||||
socket.close(4008, 'Too many voice streams');
|
||||
return;
|
||||
}
|
||||
activeStreams++;
|
||||
|
||||
let released = false;
|
||||
const release = () => {
|
||||
if (released) return;
|
||||
released = true;
|
||||
activeStreams--;
|
||||
};
|
||||
|
||||
const send = (payload: Record<string, unknown>) => {
|
||||
if (socket.readyState !== 1) return;
|
||||
try {
|
||||
socket.send(JSON.stringify(payload));
|
||||
} catch {
|
||||
/* client vanished mid-write */
|
||||
}
|
||||
};
|
||||
|
||||
const relay = new VoiceStreamRelay({
|
||||
accessToken: creds.accessToken,
|
||||
appVersion: APP_VERSION,
|
||||
language: req.query.language,
|
||||
keyterms: parseKeyterms(req.query.keyterms),
|
||||
onReady: () => send({ t: 'ready' }),
|
||||
onTranscript: (text, final) => send({ t: 'transcript', text, final }),
|
||||
onError: (message) => send({ t: 'error', message }),
|
||||
onClose: () => {
|
||||
release();
|
||||
send({ t: 'closed' });
|
||||
if (socket.readyState === 1) {
|
||||
try {
|
||||
socket.close(1000, 'Voice stream ended');
|
||||
} catch {
|
||||
/* already closing */
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
// Handlers are attached synchronously before any further await
|
||||
// (@fastify/websocket drops messages that arrive before they exist).
|
||||
socket.on('message', (raw: Buffer, isBinary: boolean) => {
|
||||
if (isBinary) {
|
||||
if (raw.length === 0 || raw.length > MAX_AUDIO_FRAME_BYTES) return;
|
||||
relay.sendAudio(raw);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const msg = JSON.parse(String(raw)) as { t?: string };
|
||||
if (msg.t === 'finalize') relay.finalize();
|
||||
else if (msg.t === 'stop') relay.close();
|
||||
} catch {
|
||||
/* non-JSON control frame — ignore */
|
||||
}
|
||||
});
|
||||
|
||||
socket.on('close', () => {
|
||||
relay.close();
|
||||
release();
|
||||
});
|
||||
socket.on('error', () => {
|
||||
relay.close();
|
||||
release();
|
||||
});
|
||||
|
||||
relay.connect();
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -857,6 +857,16 @@ export const SettingsUpdateSchema = z
|
||||
* add-only at create; a marker keeps user-authored copies untouched.
|
||||
*/
|
||||
agentSkillEnabled: z.boolean().optional(),
|
||||
/**
|
||||
* Let browser dictation transcribe through this machine's Claude Code login,
|
||||
* the same speech-to-text service the CLI's own `/voice` mode uses
|
||||
* (docs/claude-voice-plan.md). SYNCED, default OFF: enabling it spends the
|
||||
* operator's Claude subscription on transcription for anyone who can reach
|
||||
* the UI, and routes microphone audio to Anthropic rather than to whichever
|
||||
* provider was configured before. The Deepgram and Web Speech paths are
|
||||
* untouched by this flag.
|
||||
*/
|
||||
claudeVoiceEnabled: z.boolean().optional(),
|
||||
/**
|
||||
* Approvals Inbox (header bell + drawer, phone overview answer buttons,
|
||||
* push Approve/Deny action buttons). SYNCED, default OFF (opt-in): even
|
||||
@@ -970,6 +980,8 @@ export const SettingsUpdateSchema = z
|
||||
// Voice settings (cross-device sync)
|
||||
voiceSettings: z
|
||||
.object({
|
||||
/** 'auto' | 'claude' | 'deepgram' | 'webspeech'. Unknown values fall back to auto client-side. */
|
||||
provider: z.string().max(20).optional(),
|
||||
apiKey: z.string().max(200).optional(),
|
||||
language: z.string().max(20).optional(),
|
||||
keyterms: z.string().max(500).optional(),
|
||||
|
||||
@@ -166,6 +166,7 @@ import {
|
||||
registerMeRoutes,
|
||||
registerAdminRoutes,
|
||||
registerWsRoutes,
|
||||
registerVoiceRoutes,
|
||||
registerWebviewRoutes,
|
||||
tryWebviewRefererFallback,
|
||||
} from './routes/index.js';
|
||||
@@ -635,6 +636,7 @@ export class WebServer extends EventEmitter {
|
||||
getClaudeModeConfig: this.getClaudeModeConfig.bind(this),
|
||||
getTerminalHistoryConfig: this.getTerminalHistoryConfig.bind(this),
|
||||
getAgentSkillEnabled: this.getAgentSkillEnabled.bind(this),
|
||||
getClaudeVoiceEnabled: this.getClaudeVoiceEnabled.bind(this),
|
||||
getDefaultClaudeMdPath: this.getDefaultClaudeMdPath.bind(this),
|
||||
getLightState: this.getLightState.bind(this),
|
||||
getLightSessionsState: this.getLightSessionsState.bind(this),
|
||||
@@ -982,6 +984,7 @@ export class WebServer extends EventEmitter {
|
||||
registerCronRoutes(this.app, { ...ctx, cron: this.cronService });
|
||||
|
||||
registerWsRoutes(this.app, ctx, () => this.getHostPolicy());
|
||||
registerVoiceRoutes(this.app, ctx, () => this.getHostPolicy());
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1704,6 +1707,15 @@ export class WebServer extends EventEmitter {
|
||||
return settings.agentSkillEnabled === true;
|
||||
}
|
||||
|
||||
// Whether browser dictation may use this machine's Claude Code credentials
|
||||
// (synced `claudeVoiceEnabled` setting, default OFF; docs/claude-voice-plan.md).
|
||||
// OFF by default because turning it on spends the operator's Claude subscription
|
||||
// on transcription for anyone who can reach the UI.
|
||||
private async getClaudeVoiceEnabled(): Promise<boolean> {
|
||||
const settings = await this.readSettings();
|
||||
return settings.claudeVoiceEnabled === true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read My Mind predictor model (docs/readmymind-plan.md): `readMyMindModel`
|
||||
* setting, defaulting to the AI-checker opus model. Prediction quality is
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
/**
|
||||
* @fileoverview Bounded in-memory index of PAST sessions, harvested by `GET /api/search`.
|
||||
*
|
||||
* `GET /api/search` used to build its session corpus from the live in-memory
|
||||
* session map alone, so a folder sitting in the home screen's "Resume
|
||||
* Conversation" list matched nothing (issue #261). The corpus that list renders
|
||||
* comes from `GET /api/sessions/unified`, which reads the lifecycle log and every
|
||||
* Claude transcript file: disk I/O the search path deliberately does not do (its
|
||||
* no-fs property is what keeps a per-keystroke query cheap and traversal-free).
|
||||
*
|
||||
* This module is the seam between the two: a capped snapshot of the unified list
|
||||
* that the search route reads synchronously, refreshed OUT of the request path.
|
||||
* Two things fill it:
|
||||
* 1. `/api/sessions/unified` writes it as a side effect (free, it just merged
|
||||
* that list). The home screen calls that endpoint whenever it opens, which
|
||||
* is the same screen the search box lives on, so it is warm in practice.
|
||||
* 2. `ensureHistorySessionIndexFresh()`, fire-and-forget, single-flight,
|
||||
* TTL-guarded, kicks the registered refresher when a search finds the
|
||||
* snapshot stale. The caller never awaits it: the current query answers from
|
||||
* the existing snapshot and the next one sees fresh data.
|
||||
*
|
||||
* OWNERSHIP: each item carries the `owner` of the session it came from, and rows
|
||||
* not tied to any live/persisted session (host-wide transcript history) carry
|
||||
* `owner: undefined`. `canAccessOwned()` then reproduces the unified route's rule
|
||||
* exactly, in multi-user mode a non-admin sees neither other users' sessions nor
|
||||
* unowned host-wide history, and in single-user mode every check short-circuits
|
||||
* true. The snapshot is written UNSCOPED, so it must never be returned unfiltered.
|
||||
*
|
||||
* Key exports:
|
||||
* - setHistorySessionIndex / getHistorySessionIndex: the snapshot accessors.
|
||||
* - buildHistorySessionIndexItems: pure merged-list → index-item projection.
|
||||
* - setHistoryIndexRefresher / ensureHistorySessionIndexFresh: the refresh hook.
|
||||
*/
|
||||
|
||||
/** One past-session row in the snapshot. Mirrors what the search corpus needs, nothing more. */
|
||||
export interface HistorySessionIndexItem {
|
||||
/** Codeman session id (the search result's session id and dedupe key). */
|
||||
sessionId: string;
|
||||
/** Display name, may be empty for a transcript-only row. */
|
||||
name: string;
|
||||
/** Absolute working directory, the field issue #261 is about matching. */
|
||||
workingDir: string;
|
||||
/** Claude conversation UUID, when known: what a resume actually replays. */
|
||||
claudeSessionId?: string;
|
||||
/** Recency timestamp (lastActivityAt, else createdAt). */
|
||||
timestamp: number;
|
||||
/**
|
||||
* Owning user, when the row is tied to a live or persisted session. `undefined`
|
||||
* means host-wide transcript history, which only admins (or single-user mode)
|
||||
* may see, the same rule `/api/sessions/unified` applies.
|
||||
*/
|
||||
owner?: string;
|
||||
/** True when the session is still in the live map (search harvests those directly). */
|
||||
live: boolean;
|
||||
}
|
||||
|
||||
/** Hard cap on snapshot size, so a host with thousands of transcripts stays bounded. */
|
||||
export const HISTORY_INDEX_MAX_ITEMS = 400;
|
||||
|
||||
/** How long a snapshot is considered fresh before a search triggers a background refresh. */
|
||||
export const HISTORY_INDEX_TTL_MS = 60_000;
|
||||
|
||||
interface HistorySessionIndexSnapshot {
|
||||
items: HistorySessionIndexItem[];
|
||||
/** Epoch ms of the last write; 0 when never populated. */
|
||||
updatedAt: number;
|
||||
}
|
||||
|
||||
let snapshot: HistorySessionIndexSnapshot = { items: [], updatedAt: 0 };
|
||||
let refresher: (() => Promise<void>) | null = null;
|
||||
let refreshInFlight = false;
|
||||
|
||||
/** The merged-list shape this module projects from (a subset of `UnifiedSessionItem`). */
|
||||
export interface MergedSessionLike {
|
||||
sessionId: string;
|
||||
name?: string;
|
||||
workingDir?: string;
|
||||
claudeSessionId?: string;
|
||||
createdAt?: number;
|
||||
lastActivityAt?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Project a merged unified list into index items. PURE, the caller supplies the
|
||||
* owner lookup and the live-id set it already has in hand.
|
||||
*
|
||||
* Rows with no working directory AND no name are dropped: they can never match a
|
||||
* query in a useful way and would only consume the cap.
|
||||
*
|
||||
* @param merged unified-list items, newest-first (the order the merge returns)
|
||||
* @param ownerById owner of a session id, for rows tied to a live/persisted session
|
||||
* @param liveIds session ids currently in the live map
|
||||
*/
|
||||
export function buildHistorySessionIndexItems(
|
||||
merged: MergedSessionLike[],
|
||||
ownerById: Map<string, string | undefined>,
|
||||
liveIds: Set<string>
|
||||
): HistorySessionIndexItem[] {
|
||||
const items: HistorySessionIndexItem[] = [];
|
||||
for (const m of merged) {
|
||||
if (items.length >= HISTORY_INDEX_MAX_ITEMS) break;
|
||||
const name = m.name ?? '';
|
||||
const workingDir = m.workingDir ?? '';
|
||||
if (!name && !workingDir) continue;
|
||||
items.push({
|
||||
sessionId: m.sessionId,
|
||||
name,
|
||||
workingDir,
|
||||
claudeSessionId: m.claudeSessionId,
|
||||
timestamp: m.lastActivityAt ?? m.createdAt ?? 0,
|
||||
owner: ownerById.get(m.sessionId),
|
||||
live: liveIds.has(m.sessionId),
|
||||
});
|
||||
}
|
||||
return items;
|
||||
}
|
||||
|
||||
/** Replace the snapshot. Items are capped defensively even if the caller already did. */
|
||||
export function setHistorySessionIndex(items: HistorySessionIndexItem[], now = Date.now()): void {
|
||||
snapshot = { items: items.slice(0, HISTORY_INDEX_MAX_ITEMS), updatedAt: now };
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the snapshot. The returned array is UNSCOPED, callers must apply the
|
||||
* per-item ownership check before exposing any of it.
|
||||
*/
|
||||
export function getHistorySessionIndex(): HistorySessionIndexSnapshot {
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
/** True when the snapshot has never been written, or is older than the TTL. */
|
||||
export function isHistorySessionIndexStale(now = Date.now(), ttlMs = HISTORY_INDEX_TTL_MS): boolean {
|
||||
return snapshot.updatedAt === 0 || now - snapshot.updatedAt > ttlMs;
|
||||
}
|
||||
|
||||
/**
|
||||
* Register the rebuild function. Called once by the session routes, which own the
|
||||
* transcript scanner and the stores the unified list is merged from.
|
||||
*/
|
||||
export function setHistoryIndexRefresher(fn: (() => Promise<void>) | null): void {
|
||||
refresher = fn;
|
||||
}
|
||||
|
||||
/**
|
||||
* Kick a background rebuild if the snapshot is stale. Returns immediately,
|
||||
* NEVER await this from a request handler, that is the whole point: the search
|
||||
* path answers from the current snapshot and stays free of disk I/O.
|
||||
*/
|
||||
export function ensureHistorySessionIndexFresh(now = Date.now()): void {
|
||||
if (refreshInFlight || !refresher || !isHistorySessionIndexStale(now)) return;
|
||||
refreshInFlight = true;
|
||||
void refresher()
|
||||
.catch(() => {
|
||||
// A failed rebuild leaves the previous snapshot in place; the next search retries.
|
||||
})
|
||||
.finally(() => {
|
||||
refreshInFlight = false;
|
||||
});
|
||||
}
|
||||
|
||||
/** Test hook: drop the snapshot and any registered refresher. */
|
||||
export function resetHistorySessionIndex(): void {
|
||||
snapshot = { items: [], updatedAt: 0 };
|
||||
refresher = null;
|
||||
refreshInFlight = false;
|
||||
}
|
||||
@@ -0,0 +1,300 @@
|
||||
/**
|
||||
* @fileoverview Upstream half of Claude voice dictation: one browser recording
|
||||
* relayed to the speech-to-text service Claude Code's own `/voice` mode uses.
|
||||
*
|
||||
* The browser cannot talk to that service directly — it would need the Claude
|
||||
* OAuth bearer token in page JavaScript, and the endpoint is not CORS-open — so
|
||||
* Codeman sits in the middle and is the only thing that ever holds the token.
|
||||
* See `docs/claude-voice-plan.md` for the protocol table this implements.
|
||||
*
|
||||
* Wire contract (mirrors the CLI's `connectVoiceStream`):
|
||||
* - Query pins the audio format: linear16 PCM, 16 kHz, mono. The browser worklet
|
||||
* produces exactly that; a mismatch transcribes as silence or noise, never an error.
|
||||
* - `{"type":"KeepAlive"}` on open and every 8s, or upstream drops the socket
|
||||
* between utterances.
|
||||
* - Audio frames go up as raw binary.
|
||||
* - Downstream, `TranscriptText`/`TranscriptInterim` carry the RUNNING transcript
|
||||
* (each frame supersedes the previous one — they are not deltas to concatenate),
|
||||
* and `TranscriptEndpoint` promotes the pending interim to final.
|
||||
* - `{"type":"CloseStream"}` finalizes; the endpoint frame that follows is the
|
||||
* last transcript, so `finalize()` waits briefly for it rather than closing.
|
||||
*
|
||||
* The pure builders at the top are unit-tested; `VoiceStreamRelay` owns the socket,
|
||||
* the keepalive timer and the lifetime cap.
|
||||
*/
|
||||
|
||||
import WebSocket from 'ws';
|
||||
import {
|
||||
AUDIO_CHANNELS,
|
||||
AUDIO_SAMPLE_RATE,
|
||||
FINALIZE_TIMEOUT_MS,
|
||||
KEEPALIVE_INTERVAL_MS,
|
||||
MAX_KEYTERMS_HEADER_CHARS,
|
||||
MAX_STREAM_MS,
|
||||
VOICE_STREAM_PATH,
|
||||
voiceStreamBase,
|
||||
} from '../config/voice.js';
|
||||
|
||||
const KEEPALIVE_FRAME = '{"type":"KeepAlive"}';
|
||||
const CLOSE_STREAM_FRAME = '{"type":"CloseStream"}';
|
||||
|
||||
export interface VoiceStreamParams {
|
||||
/** BCP-47-ish language hint. Anything unusable falls back to 'en'. */
|
||||
language?: string;
|
||||
/** Domain vocabulary sent as a recognition hint. */
|
||||
keyterms?: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Collapse keyterms into the single ASCII header value upstream accepts.
|
||||
*
|
||||
* Commas separate terms, so a comma INSIDE a term would silently split it; it is
|
||||
* replaced with a space rather than dropped. Non-ASCII is stripped because the
|
||||
* value travels as an HTTP header, where anything outside the visible ASCII range
|
||||
* is not portable. Deduped and truncated on a term boundary so a long list degrades
|
||||
* to a shorter list instead of a mangled final term.
|
||||
*/
|
||||
export function sanitizeKeyterms(terms: string[]): string {
|
||||
const seen = new Set<string>();
|
||||
const out: string[] = [];
|
||||
let length = 0;
|
||||
for (const term of terms) {
|
||||
const cleaned = term
|
||||
.replace(/,/g, ' ')
|
||||
.replace(/[^\x20-\x7E]/g, '')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
if (!cleaned || seen.has(cleaned)) continue;
|
||||
const cost = cleaned.length + (out.length > 0 ? 1 : 0);
|
||||
if (length + cost > MAX_KEYTERMS_HEADER_CHARS) break;
|
||||
seen.add(cleaned);
|
||||
out.push(cleaned);
|
||||
length += cost;
|
||||
}
|
||||
return out.join(',');
|
||||
}
|
||||
|
||||
/** Normalize a language hint to what the endpoint expects, defaulting to English. */
|
||||
export function normalizeVoiceLanguage(language: string | undefined): string {
|
||||
const trimmed = (language ?? '').trim();
|
||||
if (!trimmed || !/^[a-zA-Z]{2,3}(-[a-zA-Z0-9]{2,8})?$|^multi$/.test(trimmed)) return 'en';
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
/** Full upstream URL with the audio format pinned. */
|
||||
export function buildVoiceStreamUrl(params: VoiceStreamParams = {}, env: NodeJS.ProcessEnv = process.env): string {
|
||||
const query = new URLSearchParams({
|
||||
encoding: 'linear16',
|
||||
sample_rate: String(AUDIO_SAMPLE_RATE),
|
||||
channels: String(AUDIO_CHANNELS),
|
||||
endpointing_ms: '300',
|
||||
utterance_end_ms: '1000',
|
||||
language: normalizeVoiceLanguage(params.language),
|
||||
use_conversation_engine: 'true',
|
||||
stt_provider: 'deepgram-nova3',
|
||||
});
|
||||
return `${voiceStreamBase(env)}${VOICE_STREAM_PATH}?${query.toString()}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Upstream headers. Codeman identifies itself honestly (it is not the CLI), which
|
||||
* the endpoint accepts; the bearer token is the only thing that authenticates.
|
||||
*/
|
||||
export function buildVoiceStreamHeaders(
|
||||
accessToken: string,
|
||||
appVersion: string,
|
||||
keyterms: string[] = []
|
||||
): Record<string, string> {
|
||||
const headers: Record<string, string> = {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
'User-Agent': `codeman/${appVersion} (voice-bridge)`,
|
||||
'x-app': 'codeman',
|
||||
'anthropic-client-platform': 'codeman_web',
|
||||
};
|
||||
const sanitized = sanitizeKeyterms(keyterms);
|
||||
if (sanitized) headers['x-config-keyterms'] = sanitized;
|
||||
return headers;
|
||||
}
|
||||
|
||||
export interface VoiceStreamRelayOptions extends VoiceStreamParams {
|
||||
accessToken: string;
|
||||
appVersion: string;
|
||||
/** Called once the upstream socket is open and audio may flow. */
|
||||
onReady: () => void;
|
||||
/** Running transcript. `final` marks the utterance as complete. */
|
||||
onTranscript: (text: string, final: boolean) => void;
|
||||
/** Human-readable failure. The relay is dead (or dying) by the time this fires. */
|
||||
onError: (message: string) => void;
|
||||
/** Terminal: the relay released its socket and timers. Fires exactly once. */
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* One dictation, upstream. Owns exactly one WebSocket and dies with it: every
|
||||
* exit path (error, upstream close, lifetime cap, caller close) funnels through
|
||||
* `_teardown()`, which fires `onClose` once and clears both timers.
|
||||
*/
|
||||
export class VoiceStreamRelay {
|
||||
private ws: WebSocket | null = null;
|
||||
private keepAlive: ReturnType<typeof setInterval> | null = null;
|
||||
private lifetimeTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
private finalizeTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
private closed = false;
|
||||
private finalizing = false;
|
||||
/** Latest interim, held so a close/finalize can promote it to final. */
|
||||
private pendingTranscript = '';
|
||||
|
||||
constructor(private readonly opts: VoiceStreamRelayOptions) {}
|
||||
|
||||
/** Open the upstream socket. Safe to call once; a second call is a no-op. */
|
||||
connect(): void {
|
||||
if (this.ws || this.closed) return;
|
||||
const url = buildVoiceStreamUrl({ language: this.opts.language, keyterms: this.opts.keyterms });
|
||||
const ws = new WebSocket(url, {
|
||||
headers: buildVoiceStreamHeaders(this.opts.accessToken, this.opts.appVersion, this.opts.keyterms ?? []),
|
||||
});
|
||||
this.ws = ws;
|
||||
|
||||
ws.on('open', () => {
|
||||
// Ping immediately: the gap between upgrade and the browser's first audio
|
||||
// frame is long enough (mic permission, worklet boot) for upstream to drop us.
|
||||
this.safeSend(KEEPALIVE_FRAME);
|
||||
this.keepAlive = setInterval(() => this.safeSend(KEEPALIVE_FRAME), KEEPALIVE_INTERVAL_MS);
|
||||
this.lifetimeTimer = setTimeout(() => {
|
||||
this.opts.onError('Voice stream reached its maximum length');
|
||||
this.close();
|
||||
}, MAX_STREAM_MS);
|
||||
this.opts.onReady();
|
||||
});
|
||||
|
||||
ws.on('message', (raw) => this.handleMessage(String(raw)));
|
||||
|
||||
// An upgrade rejection never reaches 'open', so its status is the only signal
|
||||
// that the token was refused rather than the network being down.
|
||||
ws.on('unexpected-response', (_req, res) => {
|
||||
const status = res.statusCode ?? 0;
|
||||
res.resume();
|
||||
this.opts.onError(
|
||||
status === 401 || status === 403
|
||||
? 'Claude rejected the voice credentials. Run a Claude session to refresh your login.'
|
||||
: `Voice service refused the connection (HTTP ${status})`
|
||||
);
|
||||
this.teardown();
|
||||
});
|
||||
|
||||
ws.on('error', (err: Error) => {
|
||||
if (this.closed) return;
|
||||
this.opts.onError(`Voice stream error: ${err.message}`);
|
||||
});
|
||||
|
||||
ws.on('close', () => {
|
||||
this.promotePending();
|
||||
this.teardown();
|
||||
});
|
||||
}
|
||||
|
||||
/** Relay one raw PCM16 frame upstream. Dropped after finalize, as upstream ignores it. */
|
||||
sendAudio(chunk: Buffer): void {
|
||||
if (this.finalizing || this.closed) return;
|
||||
if (this.ws?.readyState !== WebSocket.OPEN) return;
|
||||
this.ws.send(chunk);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ask upstream for the final transcript. The endpoint frame usually follows
|
||||
* within a few hundred ms; the timer is the backstop so a silent upstream still
|
||||
* yields whatever interim we already have instead of hanging the caller.
|
||||
*/
|
||||
finalize(): void {
|
||||
if (this.finalizing || this.closed) return;
|
||||
this.finalizing = true;
|
||||
if (this.ws?.readyState !== WebSocket.OPEN) {
|
||||
this.promotePending();
|
||||
this.close();
|
||||
return;
|
||||
}
|
||||
this.safeSend(CLOSE_STREAM_FRAME);
|
||||
this.finalizeTimer = setTimeout(() => {
|
||||
this.promotePending();
|
||||
this.close();
|
||||
}, FINALIZE_TIMEOUT_MS);
|
||||
}
|
||||
|
||||
/** Terminal shutdown. Idempotent. */
|
||||
close(): void {
|
||||
if (this.closed) return;
|
||||
const ws = this.ws;
|
||||
this.teardown();
|
||||
if (ws && (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING)) {
|
||||
try {
|
||||
ws.close();
|
||||
} catch {
|
||||
/* already closing */
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private handleMessage(raw: string): void {
|
||||
let msg: { type?: string; data?: string; description?: string; error_code?: string; message?: string };
|
||||
try {
|
||||
msg = JSON.parse(raw);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
switch (msg.type) {
|
||||
case 'TranscriptText':
|
||||
case 'TranscriptInterim': {
|
||||
// Each frame is the whole running transcript, not a delta.
|
||||
if (typeof msg.data === 'string' && msg.data) {
|
||||
this.pendingTranscript = msg.data;
|
||||
this.opts.onTranscript(msg.data, false);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'TranscriptEndpoint': {
|
||||
this.promotePending();
|
||||
if (this.finalizing) this.close();
|
||||
break;
|
||||
}
|
||||
case 'TranscriptError': {
|
||||
this.opts.onError(msg.description || msg.error_code || 'Transcription failed');
|
||||
break;
|
||||
}
|
||||
case 'error': {
|
||||
this.opts.onError(msg.message || 'Voice service error');
|
||||
break;
|
||||
}
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/** Emit the held interim as final, exactly once per utterance. */
|
||||
private promotePending(): void {
|
||||
if (!this.pendingTranscript) return;
|
||||
const text = this.pendingTranscript;
|
||||
this.pendingTranscript = '';
|
||||
this.opts.onTranscript(text, true);
|
||||
}
|
||||
|
||||
private safeSend(frame: string): void {
|
||||
if (this.ws?.readyState !== WebSocket.OPEN) return;
|
||||
try {
|
||||
this.ws.send(frame);
|
||||
} catch {
|
||||
/* socket died between the check and the send */
|
||||
}
|
||||
}
|
||||
|
||||
private teardown(): void {
|
||||
if (this.closed) return;
|
||||
this.closed = true;
|
||||
if (this.keepAlive) clearInterval(this.keepAlive);
|
||||
if (this.lifetimeTimer) clearTimeout(this.lifetimeTimer);
|
||||
if (this.finalizeTimer) clearTimeout(this.finalizeTimer);
|
||||
this.keepAlive = null;
|
||||
this.lifetimeTimer = null;
|
||||
this.finalizeTimer = null;
|
||||
this.opts.onClose();
|
||||
}
|
||||
}
|
||||
+17
-11
@@ -28,7 +28,7 @@ async function bootWith(me: Record<string, unknown>) {
|
||||
const dom = new JSDOM(
|
||||
`<!doctype html><body>
|
||||
<button id="adminPanelBtn" class="btn-admin-panel btn-admin-panel--hidden"></button>
|
||||
<div class="modal" id="appSettingsModal"><div class="modal-tabs"></div><div class="modal-body"></div></div>
|
||||
<div class="modal" id="appSettingsModal"><nav class="set-rail"><div class="set-rail-items"></div></nav><div class="set-doc" id="appSettingsDoc"></div></div>
|
||||
</body>`,
|
||||
{ url: 'http://localhost/', runScripts: 'outside-only' }
|
||||
);
|
||||
@@ -45,22 +45,23 @@ async function bootWith(me: Record<string, unknown>) {
|
||||
}
|
||||
|
||||
describe('admin-ui boot', () => {
|
||||
it('exposes the identity and injects the Users tab for a multi-user admin', async () => {
|
||||
it('exposes the identity and injects the Users section for a multi-user admin', async () => {
|
||||
const { win } = await bootWith({ username: 'root', role: 'admin', multiUser: true, mustChangePassword: false });
|
||||
expect(win.__codemanUser).toMatchObject({ username: 'root', role: 'admin', multiUser: true });
|
||||
const btn = win.document.querySelector('[data-tab="settings-users"]');
|
||||
// The settings modal is a rail over one document: a rail entry, not a tab.
|
||||
const btn = win.document.querySelector('[data-section="settings-users"]');
|
||||
expect(btn).toBeTruthy();
|
||||
expect(win.document.getElementById('settings-users')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('does NOT inject the Users tab for a regular user', async () => {
|
||||
it('does NOT inject the Users section for a regular user', async () => {
|
||||
const { win } = await bootWith({ username: 'joe', role: 'user', multiUser: true, mustChangePassword: false });
|
||||
expect(win.document.querySelector('[data-tab="settings-users"]')).toBeFalsy();
|
||||
expect(win.document.querySelector('[data-section="settings-users"]')).toBeFalsy();
|
||||
});
|
||||
|
||||
it('does NOT inject the Users tab in single-user mode', async () => {
|
||||
const { win } = await bootWith({ username: 'admin', role: 'admin', multiUser: false, mustChangePassword: false });
|
||||
expect(win.document.querySelector('[data-tab="settings-users"]')).toBeFalsy();
|
||||
expect(win.document.querySelector('[data-section="settings-users"]')).toBeFalsy();
|
||||
});
|
||||
|
||||
it('shows the change-password modal when mustChangePassword is set', async () => {
|
||||
@@ -134,11 +135,16 @@ describe('admin panel modal', () => {
|
||||
|
||||
describe('index.html wiring', () => {
|
||||
it('loads admin-ui.js after settings-ui.js and before session-ui.js', () => {
|
||||
const settings = INDEX_HTML.indexOf('settings-ui.js');
|
||||
const admin = INDEX_HTML.indexOf('admin-ui.js');
|
||||
const session = INDEX_HTML.indexOf('session-ui.js');
|
||||
expect(admin).toBeGreaterThan(settings);
|
||||
expect(session).toBeGreaterThan(admin);
|
||||
// Match the SCRIPT TAG, not the bare filename: modal markup earlier in the
|
||||
// document cites these modules in comments ("session-ui.js: openSessionOptions"),
|
||||
// and a bare indexOf finds the comment instead of the load order.
|
||||
const at = (file: string) => {
|
||||
const i = INDEX_HTML.indexOf(`src="${file}"`);
|
||||
expect(i, `no <script src="${file}"> in index.html`).toBeGreaterThan(-1);
|
||||
return i;
|
||||
};
|
||||
expect(at('admin-ui.js')).toBeGreaterThan(at('settings-ui.js'));
|
||||
expect(at('session-ui.js')).toBeGreaterThan(at('admin-ui.js'));
|
||||
});
|
||||
|
||||
it('ships the header Admin Panel button hidden by default', () => {
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
/**
|
||||
* App Settings structural guard.
|
||||
*
|
||||
* The settings modal is a rail (table of contents) over ONE scrolling document.
|
||||
* Its load/save path is pure `getElementById` by a fixed set of ids
|
||||
* (openAppSettings / saveAppSettings in settings-ui.js), so a restructure of the
|
||||
* markup that drops or renames an element does not fail loudly: the setting just
|
||||
* silently stops loading, or stops being saved and falls back to its default.
|
||||
*
|
||||
* These tests read the REAL settings-ui.js and index.html and pin that contract.
|
||||
*/
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
|
||||
const publicDir = resolve(import.meta.dirname, '../src/web/public');
|
||||
const html = readFileSync(resolve(publicDir, 'index.html'), 'utf8');
|
||||
const settingsUi = readFileSync(resolve(publicDir, 'settings-ui.js'), 'utf8');
|
||||
|
||||
/** The App Settings modal markup, so assertions can't be satisfied elsewhere. */
|
||||
function settingsModal(): string {
|
||||
const start = html.indexOf('<div class="modal" id="appSettingsModal">');
|
||||
expect(start).toBeGreaterThan(-1);
|
||||
const end = html.indexOf('<!-- Shortcut Overlay Modal -->', start);
|
||||
expect(end).toBeGreaterThan(start);
|
||||
return html.slice(start, end);
|
||||
}
|
||||
|
||||
/**
|
||||
* Every id the load and save paths touch. Scoped to those two functions on
|
||||
* purpose: settings-ui.js also drives elements that live OUTSIDE the modal
|
||||
* (toasts, header chips), and those are not this file's contract.
|
||||
*/
|
||||
function referencedIds(): string[] {
|
||||
const ids = new Set<string>();
|
||||
for (const fn of ['openAppSettings()', 'async saveAppSettings()']) {
|
||||
const start = settingsUi.indexOf(`\n ${fn} {`);
|
||||
expect(start, `${fn} not found in settings-ui.js`).toBeGreaterThan(-1);
|
||||
const body = settingsUi.slice(start, settingsUi.indexOf('\n },', start));
|
||||
for (const m of body.matchAll(/getElementById\('([A-Za-z0-9_-]+)'\)/g)) ids.add(m[1]);
|
||||
}
|
||||
return [...ids];
|
||||
}
|
||||
|
||||
describe('App Settings modal structure', () => {
|
||||
it('keeps every element settings-ui.js loads or saves by id', () => {
|
||||
const modal = settingsModal();
|
||||
const missing = referencedIds().filter((id) => !modal.includes(`id="${id}"`));
|
||||
expect(missing).toEqual([]);
|
||||
});
|
||||
|
||||
it('carries every section the rail points at, exactly once', () => {
|
||||
const modal = settingsModal();
|
||||
const sections = [...modal.matchAll(/data-section="([a-z-]+)"/g)].map((m) => m[1]);
|
||||
expect(sections.length).toBeGreaterThanOrEqual(9);
|
||||
for (const id of new Set(sections)) {
|
||||
const hits = modal.split(`<section class="set-section" id="${id}"`).length - 1;
|
||||
expect(hits, `section ${id} should exist exactly once`).toBe(1);
|
||||
}
|
||||
});
|
||||
|
||||
it('opens on Updates: the version and the updater above everything else', () => {
|
||||
expect(settingsUi).toContain("this.switchSettingsTab('settings-updates')");
|
||||
const modal = settingsModal();
|
||||
const order = [...modal.matchAll(/<section class="set-section" id="([a-z-]+)"/g)].map((m) => m[1]);
|
||||
// Rail and document must agree, or scroll-spy paints the wrong entry.
|
||||
const rail = [...modal.matchAll(/data-section="([a-z-]+)"/g)].map((m) => m[1]);
|
||||
expect(rail.slice(0, 3)).toEqual(['settings-updates', 'settings-terminal', 'settings-layout']);
|
||||
expect(order.slice(0, 3)).toEqual(['settings-updates', 'settings-terminal', 'settings-layout']);
|
||||
// Updates carries ONLY the version and the update action; the rest of the
|
||||
// system settings tail the document under System, out of the way.
|
||||
const updates = modal.match(/id="settings-updates"([\s\S]*?)<\/section>/)?.[1] ?? '';
|
||||
expect(updates).toContain('id="updateCurrentVersion"');
|
||||
expect(updates).toContain('id="updateCheckBtn"');
|
||||
expect(updates).not.toContain('id="appSettingsClaudeMdPath"');
|
||||
expect(rail[rail.length - 1]).toBe('settings-system');
|
||||
expect(order[order.length - 1]).toBe('settings-system');
|
||||
const system = modal.match(/id="settings-system"([\s\S]*?)<\/section>/)?.[1] ?? '';
|
||||
expect(system).toContain('id="appSettingsClaudeMdPath"');
|
||||
expect(system).toContain('id="appSettingsTunnelEnabled"');
|
||||
});
|
||||
|
||||
it('keeps Local Echo the first row of the second section', () => {
|
||||
const terminal = settingsModal().match(/id="settings-terminal"([\s\S]*?)<\/section>/);
|
||||
const localEcho = terminal?.[1].indexOf('appSettingsLocalEcho') ?? -1;
|
||||
const cjk = terminal?.[1].indexOf('appSettingsCjkInput') ?? -1;
|
||||
expect(localEcho).toBeGreaterThan(-1);
|
||||
expect(localEcho).toBeLessThan(cjk);
|
||||
});
|
||||
|
||||
it('gives every previewed chip an icon to clone, and a slot that exists', () => {
|
||||
// _syncLayoutPreview clones `.set-chip-ico` out of the chip, so a chip that
|
||||
// opts into the preview without an icon renders as an empty button, and one
|
||||
// pointing at a slot id that does not exist renders as nothing at all.
|
||||
const layout = settingsModal().match(/id="settings-layout"([\s\S]*?)<\/section>/)?.[1] ?? '';
|
||||
const chips = [...layout.matchAll(/<label class="set-chip"([^>]*)>([\s\S]*?)<\/label>/g)];
|
||||
const previewed = chips.filter(([, attrs]) => attrs.includes('data-preview='));
|
||||
expect(previewed.length).toBeGreaterThanOrEqual(15);
|
||||
for (const [, attrs, body] of previewed) {
|
||||
const kind = attrs.match(/data-preview="([a-z]+)"/)?.[1];
|
||||
expect(['header', 'panel', 'toolbar', 'float']).toContain(kind);
|
||||
expect(attrs, `chip ${body} needs a preview order`).toMatch(/data-preview-order="\d+"/);
|
||||
// A text token replaces the icon for readouts (plan usage, CPU, font size).
|
||||
const hasIcon = body.includes('class="set-chip-ico') || attrs.includes('data-preview-text=');
|
||||
expect(hasIcon, `chip ${body} has nothing to render in the preview`).toBe(true);
|
||||
}
|
||||
for (const id of [
|
||||
'appSettingsPreviewHeader',
|
||||
'appSettingsPreviewPanels',
|
||||
'appSettingsPreviewToolbar',
|
||||
'appSettingsPreviewFloats',
|
||||
]) {
|
||||
expect(layout).toContain(`id="${id}"`);
|
||||
expect(settingsUi).toContain(`'${id}'`);
|
||||
}
|
||||
});
|
||||
|
||||
it('models: keeps the 1M variants as select options behind the context switch', () => {
|
||||
const modal = settingsModal();
|
||||
const select = modal.match(/id="appSettingsClaudeModel"([\s\S]*?)<\/select>/)?.[1] ?? '';
|
||||
// The cards render the base models; the [1m] rows exist so that base + the
|
||||
// context switch can compose back into a real claudeModel value.
|
||||
for (const value of ['opus[1m]', 'claude-fable-5[1m]', 'claude-opus-4-6[1m]']) {
|
||||
expect(select).toContain(`value="${value}"`);
|
||||
}
|
||||
expect(select).toContain('data-ctx="1"');
|
||||
expect(modal).toContain('id="appSettingsOpusContext1m"');
|
||||
});
|
||||
|
||||
it('has retired the modal-tab chrome everywhere, not just here', () => {
|
||||
// Session Options and Add Case moved onto this same `set-*` surface, so the
|
||||
// old tab classes have no users left. A reappearance means a modal drifted
|
||||
// back off the shared surface (or the dead CSS was resurrected).
|
||||
expect(settingsModal()).not.toContain('modal-tab-content');
|
||||
expect(html).not.toContain('class="modal-tabs"');
|
||||
expect(html).not.toContain('modal-tab-btn');
|
||||
const css = readFileSync(resolve(publicDir, 'styles.css'), 'utf8');
|
||||
expect(css).not.toContain('.modal-tab-btn {');
|
||||
});
|
||||
|
||||
it('exposes the rail hooks admin-ui.js injects the Users section into', () => {
|
||||
const modal = settingsModal();
|
||||
expect(modal).toContain('class="set-rail-items"');
|
||||
expect(modal).toContain('id="appSettingsDoc"');
|
||||
const adminUi = readFileSync(resolve(publicDir, 'admin-ui.js'), 'utf8');
|
||||
expect(adminUi).toContain('.set-rail-items');
|
||||
expect(adminUi).toContain('.set-doc');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,83 @@
|
||||
/**
|
||||
* Claude Code credential parsing.
|
||||
*
|
||||
* The voice relay authenticates with the token this parser returns, so every
|
||||
* degraded store (absent, truncated, hand-edited, expired) must resolve to a
|
||||
* status the caller can act on rather than a throw or a silently empty token.
|
||||
*/
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { parseClaudeCredentials, claudeCredentialsPath } from '../src/claude-credentials.js';
|
||||
|
||||
const NOW = 1_800_000_000_000;
|
||||
|
||||
function store(overrides: Record<string, unknown> = {}): string {
|
||||
return JSON.stringify({
|
||||
claudeAiOauth: {
|
||||
accessToken: 'sk-ant-oat01-test',
|
||||
refreshToken: 'sk-ant-ort01-test',
|
||||
expiresAt: NOW + 3_600_000,
|
||||
subscriptionType: 'max',
|
||||
...overrides,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
describe('parseClaudeCredentials', () => {
|
||||
it('returns the token and display metadata for a live store', () => {
|
||||
const result = parseClaudeCredentials(store(), NOW);
|
||||
expect(result.status).toBe('ok');
|
||||
expect(result.accessToken).toBe('sk-ant-oat01-test');
|
||||
expect(result.subscriptionType).toBe('max');
|
||||
expect(result.expiresAt).toBe(NOW + 3_600_000);
|
||||
});
|
||||
|
||||
it('reports an elapsed token as expired and withholds it', () => {
|
||||
const result = parseClaudeCredentials(store({ expiresAt: NOW - 1000 }), NOW);
|
||||
expect(result.status).toBe('expired');
|
||||
expect(result.accessToken).toBeUndefined();
|
||||
});
|
||||
|
||||
it('treats a token expiring within the skew as already expired', () => {
|
||||
// A token with 30s left would die mid-dictation; refusing up front turns a
|
||||
// confusing mid-utterance disconnect into a clear "refresh your login".
|
||||
expect(parseClaudeCredentials(store({ expiresAt: NOW + 30_000 }), NOW).status).toBe('expired');
|
||||
});
|
||||
|
||||
it('accepts a store with no expiry at all', () => {
|
||||
const raw = JSON.stringify({ claudeAiOauth: { accessToken: 'sk-ant-oat01-test' } });
|
||||
expect(parseClaudeCredentials(raw, NOW).status).toBe('ok');
|
||||
});
|
||||
|
||||
it.each([
|
||||
['not json at all', 'malformed'],
|
||||
['{}', 'malformed'],
|
||||
['null', 'malformed'],
|
||||
['[]', 'malformed'],
|
||||
['{"claudeAiOauth":null}', 'malformed'],
|
||||
['{"claudeAiOauth":{}}', 'malformed'],
|
||||
['{"claudeAiOauth":{"accessToken":""}}', 'malformed'],
|
||||
['{"claudeAiOauth":{"accessToken":" "}}', 'malformed'],
|
||||
['{"claudeAiOauth":{"accessToken":123}}', 'malformed'],
|
||||
])('reports %s as malformed instead of throwing', (raw, expected) => {
|
||||
expect(parseClaudeCredentials(raw, NOW).status).toBe(expected);
|
||||
});
|
||||
|
||||
it('trims whitespace around a token written by hand', () => {
|
||||
const raw = JSON.stringify({ claudeAiOauth: { accessToken: ' sk-ant-oat01-test\n' } });
|
||||
expect(parseClaudeCredentials(raw, NOW).accessToken).toBe('sk-ant-oat01-test');
|
||||
});
|
||||
});
|
||||
|
||||
describe('claudeCredentialsPath', () => {
|
||||
it('honors CLAUDE_CONFIG_DIR like the CLI does', () => {
|
||||
expect(claudeCredentialsPath({ CLAUDE_CONFIG_DIR: '/tmp/alt-claude' })).toBe('/tmp/alt-claude/.credentials.json');
|
||||
});
|
||||
|
||||
it('falls back to ~/.claude when the override is blank', () => {
|
||||
expect(claudeCredentialsPath({ CLAUDE_CONFIG_DIR: ' ' })).toMatch(/\.claude\/\.credentials\.json$/);
|
||||
});
|
||||
|
||||
it('falls back to ~/.claude when unset', () => {
|
||||
expect(claudeCredentialsPath({})).toMatch(/\.claude\/\.credentials\.json$/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,291 @@
|
||||
/**
|
||||
* @fileoverview Issue #260, the home screen's "Resume Conversation" list.
|
||||
*
|
||||
* With ~35 past sessions the list showed 4 rows, then a button that dumped every
|
||||
* remaining row into a fixed 240px box, with no way to sort or filter. The fix
|
||||
* moved rendering into `_renderHistoryList()` over a cached corpus, so what is
|
||||
* worth pinning is the model, not the pixels:
|
||||
* 1. the collapsed page is _HISTORY_INITIAL_COUNT rows, not 4,
|
||||
* 2. "Show more" expands the LIST and marks the box expanded (the CSS cap is
|
||||
* class-driven, without the class, expanding just deepens a scroll well),
|
||||
* 3. filtering matches name / folder / case label / prompt, and implies
|
||||
* expansion (hiding matches behind "Show more" defeats typing a filter),
|
||||
* 4. sorting is alphabetical by name or folder, with pinned rows still on top.
|
||||
*
|
||||
* Loaded via `vm` against a stub CodemanApp with a fake DOM, same harness as
|
||||
* resume-name.test.ts. `_buildHistoryItem` is stubbed: this pins WHICH rows get
|
||||
* rendered and in what order, not how one row looks.
|
||||
*/
|
||||
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
import vm from 'node:vm';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
interface FakeEl {
|
||||
id: string;
|
||||
value: string;
|
||||
textContent: string;
|
||||
scrollTop: number;
|
||||
className: string;
|
||||
children: FakeEl[];
|
||||
classes: Set<string>;
|
||||
listeners: Record<string, ((ev: unknown) => void)[]>;
|
||||
classList: { toggle: (c: string, on: boolean) => void; contains: (c: string) => boolean };
|
||||
replaceChildren: () => void;
|
||||
appendChild: (child: FakeEl) => FakeEl;
|
||||
addEventListener: (type: string, fn: (ev: unknown) => void) => void;
|
||||
style: Record<string, string>;
|
||||
}
|
||||
|
||||
function fakeEl(id: string): FakeEl {
|
||||
const el = {
|
||||
id,
|
||||
value: '',
|
||||
textContent: '',
|
||||
scrollTop: 0,
|
||||
className: '',
|
||||
children: [] as FakeEl[],
|
||||
classes: new Set<string>(),
|
||||
listeners: {} as Record<string, ((ev: unknown) => void)[]>,
|
||||
style: {} as Record<string, string>,
|
||||
} as FakeEl;
|
||||
el.classList = {
|
||||
toggle: (c: string, on: boolean) => (on ? el.classes.add(c) : el.classes.delete(c)),
|
||||
contains: (c: string) => el.classes.has(c),
|
||||
};
|
||||
el.replaceChildren = () => {
|
||||
el.children = [];
|
||||
};
|
||||
el.appendChild = (child: FakeEl) => {
|
||||
el.children.push(child);
|
||||
return child;
|
||||
};
|
||||
el.addEventListener = (type: string, fn: (ev: unknown) => void) => {
|
||||
(el.listeners[type] ||= []).push(fn);
|
||||
};
|
||||
return el;
|
||||
}
|
||||
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
|
||||
/**
|
||||
* The element map the vm's `document.getElementById` resolves against. Swapped
|
||||
* per test, the closure is defined in THIS realm, so the shipping code inside
|
||||
* the vm reads whatever the current test installed.
|
||||
*/
|
||||
let currentEls: Record<string, FakeEl> = {};
|
||||
|
||||
function loadTerminalUiPrototype(): Record<string, any> {
|
||||
const source = readFileSync(resolve(import.meta.dirname, '../src/web/public/terminal-ui.js'), 'utf8');
|
||||
const context = vm.createContext({
|
||||
console,
|
||||
CodemanApp: class CodemanApp {},
|
||||
setInterval: vi.fn(),
|
||||
clearInterval: vi.fn(),
|
||||
setTimeout,
|
||||
clearTimeout,
|
||||
requestAnimationFrame: vi.fn(),
|
||||
document: {
|
||||
addEventListener: vi.fn(),
|
||||
getElementById: (id: string) => currentEls[id] ?? null,
|
||||
createElement: () => fakeEl('created'),
|
||||
},
|
||||
window: { addEventListener: vi.fn(), removeEventListener: vi.fn() },
|
||||
});
|
||||
vm.runInContext(`${source}\nglobalThis.__proto = CodemanApp.prototype;`, context);
|
||||
return (context as unknown as { __proto: Record<string, any> }).__proto;
|
||||
}
|
||||
|
||||
const proto = loadTerminalUiPrototype();
|
||||
|
||||
type Row = {
|
||||
sessionId: string;
|
||||
name?: string;
|
||||
workingDir?: string;
|
||||
firstPrompt?: string;
|
||||
pinned?: boolean;
|
||||
lastActivityAt?: number;
|
||||
};
|
||||
|
||||
/** Host object carrying the real render/filter/sort methods over a fake DOM. */
|
||||
function makeApp(rows: Row[], cases: Array<{ name: string; path: string }> = []) {
|
||||
const els: Record<string, FakeEl> = {
|
||||
historyList: fakeEl('historyList'),
|
||||
historyFilter: fakeEl('historyFilter'),
|
||||
historySort: fakeEl('historySort'),
|
||||
historyCount: fakeEl('historyCount'),
|
||||
};
|
||||
els.historySort.value = 'recent';
|
||||
|
||||
const app: any = {
|
||||
_HISTORY_INITIAL_COUNT: proto._HISTORY_INITIAL_COUNT,
|
||||
_historyAll: rows,
|
||||
_historyCases: cases,
|
||||
_renderHistoryList: proto._renderHistoryList,
|
||||
_historyRowMatches: proto._historyRowMatches,
|
||||
_sortHistoryRows: proto._sortHistoryRows,
|
||||
_historyRowLabel: proto._historyRowLabel,
|
||||
_resolveCaseLabel: proto._resolveCaseLabel,
|
||||
_shortenHomePath: proto._shortenHomePath,
|
||||
// One fake node per row, tagged so assertions can read back the order.
|
||||
_buildHistoryItem: (s: Row) => {
|
||||
const el = fakeEl('item');
|
||||
el.textContent = s.sessionId;
|
||||
return el;
|
||||
},
|
||||
els,
|
||||
/** Rendered row ids, excluding the show-more/less button and empty state. */
|
||||
renderedIds(): string[] {
|
||||
return els.historyList.children.filter((c) => c.id === 'item').map((c) => c.textContent);
|
||||
},
|
||||
button(): FakeEl | undefined {
|
||||
return els.historyList.children.find((c) => c.id === 'created');
|
||||
},
|
||||
};
|
||||
|
||||
// Point the vm's document at this app's elements, then run the shipping method.
|
||||
app._render = () => {
|
||||
currentEls = els;
|
||||
app._renderHistoryList();
|
||||
};
|
||||
return app;
|
||||
}
|
||||
|
||||
function rows(n: number, overrides: Partial<Row> = {}): Row[] {
|
||||
return Array.from({ length: n }, (_, i) => ({
|
||||
sessionId: `s${i}`,
|
||||
name: `w${i}-project${i}`,
|
||||
workingDir: `/home/u/project${i}`,
|
||||
lastActivityAt: 1000 - i,
|
||||
...overrides,
|
||||
}));
|
||||
}
|
||||
|
||||
describe('issue #260: collapsed page size', () => {
|
||||
it('shows more than the old 4 rows before "Show more"', () => {
|
||||
expect(proto._HISTORY_INITIAL_COUNT).toBeGreaterThanOrEqual(8);
|
||||
});
|
||||
|
||||
it('renders the initial page and a "Show more" button for the rest', () => {
|
||||
const app = makeApp(rows(35));
|
||||
app._render();
|
||||
expect(app.renderedIds()).toHaveLength(proto._HISTORY_INITIAL_COUNT);
|
||||
expect(app.button()?.textContent).toBe(`Show ${35 - proto._HISTORY_INITIAL_COUNT} more`);
|
||||
expect(app.els.historyList.classList.contains('expanded')).toBe(false);
|
||||
});
|
||||
|
||||
it('expanding renders every row AND marks the box expanded', () => {
|
||||
const app = makeApp(rows(35));
|
||||
app._historyExpanded = true;
|
||||
app._render();
|
||||
expect(app.renderedIds()).toHaveLength(35);
|
||||
// Without this class the CSS max-height stays at the collapsed cap and the
|
||||
// extra rows land in a four-row scroll well, the original bug.
|
||||
expect(app.els.historyList.classList.contains('expanded')).toBe(true);
|
||||
expect(app.button()?.textContent).toBe('Show less');
|
||||
});
|
||||
|
||||
it('shows no button at all when everything fits', () => {
|
||||
const app = makeApp(rows(3));
|
||||
app._render();
|
||||
expect(app.renderedIds()).toHaveLength(3);
|
||||
expect(app.button()).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('issue #260: filter', () => {
|
||||
it('matches on folder name and shows every match without expanding first', () => {
|
||||
const app = makeApp([
|
||||
...rows(30),
|
||||
{ sessionId: 'x1', name: 'w99-invoices', workingDir: '/home/u/invoices', lastActivityAt: 1 },
|
||||
{ sessionId: 'x2', name: 'w98-other', workingDir: '/home/u/invoices-archive', lastActivityAt: 2 },
|
||||
]);
|
||||
app.els.historyFilter.value = 'invoices';
|
||||
app._render();
|
||||
expect(app.renderedIds().sort()).toEqual(['x1', 'x2']);
|
||||
expect(app.els.historyList.classList.contains('expanded')).toBe(true);
|
||||
expect(app.els.historyCount.textContent).toBe('2 of 32');
|
||||
});
|
||||
|
||||
it('matches on the case label and on a prompt', () => {
|
||||
const app = makeApp(
|
||||
[
|
||||
{ sessionId: 'c1', name: 'w1-x', workingDir: '/home/u/cases/billing', lastActivityAt: 1 },
|
||||
{
|
||||
sessionId: 'p1',
|
||||
name: 'w2-y',
|
||||
workingDir: '/home/u/other',
|
||||
firstPrompt: 'fix the CSV export',
|
||||
lastActivityAt: 2,
|
||||
},
|
||||
],
|
||||
[{ name: 'billing', path: '/home/u/cases/billing' }]
|
||||
);
|
||||
app.els.historyFilter.value = '#billing';
|
||||
app._render();
|
||||
expect(app.renderedIds()).toEqual(['c1']);
|
||||
|
||||
app.els.historyFilter.value = 'csv export';
|
||||
app._render();
|
||||
expect(app.renderedIds()).toEqual(['p1']);
|
||||
});
|
||||
|
||||
it('renders an empty state when nothing matches', () => {
|
||||
const app = makeApp(rows(5));
|
||||
app.els.historyFilter.value = 'zzzz';
|
||||
app._render();
|
||||
expect(app.renderedIds()).toEqual([]);
|
||||
expect(app.els.historyList.children[0].textContent).toContain('No conversations match');
|
||||
});
|
||||
});
|
||||
|
||||
describe('issue #260: sort', () => {
|
||||
const unsorted: Row[] = [
|
||||
{ sessionId: 'b', name: 'beta', workingDir: '/home/u/zeta', lastActivityAt: 300 },
|
||||
{ sessionId: 'a', name: 'alpha', workingDir: '/home/u/yankee', lastActivityAt: 200 },
|
||||
{ sessionId: 'c', name: 'gamma', workingDir: '/home/u/xray', lastActivityAt: 100 },
|
||||
];
|
||||
|
||||
it('recent keeps the backend order', () => {
|
||||
const app = makeApp(unsorted);
|
||||
app._render();
|
||||
expect(app.renderedIds()).toEqual(['b', 'a', 'c']);
|
||||
});
|
||||
|
||||
it('sorts by name', () => {
|
||||
const app = makeApp(unsorted);
|
||||
app.els.historySort.value = 'name';
|
||||
app._render();
|
||||
expect(app.renderedIds()).toEqual(['a', 'b', 'c']);
|
||||
});
|
||||
|
||||
it('sorts by folder basename', () => {
|
||||
const app = makeApp(unsorted);
|
||||
app.els.historySort.value = 'folder';
|
||||
app._render();
|
||||
expect(app.renderedIds()).toEqual(['c', 'a', 'b']);
|
||||
});
|
||||
|
||||
it('sorts transcript rows (no session name) by the prompt shown as their title', () => {
|
||||
// Most past rows come from a transcript and have no name at all. Keying the
|
||||
// A–Z sort off `name` alone made "Name A–Z" a no-op for them.
|
||||
const app = makeApp([
|
||||
{ sessionId: 'z', workingDir: '/home/u/one', firstPrompt: 'zebra crossing' },
|
||||
{ sessionId: 'a', workingDir: '/home/u/two', firstPrompt: 'apple pie' },
|
||||
{ sessionId: 'm', workingDir: '/home/u/three', firstPrompt: 'middle ground' },
|
||||
]);
|
||||
app.els.historySort.value = 'name';
|
||||
app._render();
|
||||
expect(app.renderedIds()).toEqual(['a', 'm', 'z']);
|
||||
});
|
||||
|
||||
it('keeps pinned rows on top in every sort mode', () => {
|
||||
const app = makeApp([{ sessionId: 'p', name: 'zulu', workingDir: '/home/u/zulu', pinned: true }, ...unsorted]);
|
||||
for (const mode of ['recent', 'name', 'folder']) {
|
||||
app.els.historySort.value = mode;
|
||||
app._render();
|
||||
expect(app.renderedIds()[0]).toBe('p');
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,166 @@
|
||||
/**
|
||||
* @fileoverview Issue #273 and its mirror image: abbreviating `$HOME` in path labels.
|
||||
*
|
||||
* The rule ("show `~/project` rather than `/home/<user>/project`") had three
|
||||
* implementations in the frontend, and two of them were platform-specific in
|
||||
* opposite directions, so each looked correct to whoever wrote it:
|
||||
*
|
||||
* - the Run menu's Recent Sessions rows matched `/home/<user>/` only, so on
|
||||
* macOS nothing was stripped, every row spent its first ~19 characters on an
|
||||
* identical `/Users/<user>/` prefix, and the left-to-right ellipsis removed
|
||||
* the tail that identifies the row (#273),
|
||||
* - the case-manage list matched `/Users/<user>` only, so on a Linux host no
|
||||
* case path was ever abbreviated at all.
|
||||
*
|
||||
* Both now call `_shortenHomePath()`, which is pinned here for both layouts, and
|
||||
* a static guard fails if a fourth copy of the pattern appears.
|
||||
*
|
||||
* Loaded via `vm` against a stub CodemanApp with a fake DOM, same harness as
|
||||
* history-list-controls.test.ts. Port: none (no browser, no server).
|
||||
*/
|
||||
|
||||
import { readdirSync, readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
import vm from 'node:vm';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
|
||||
const PUBLIC = resolve(import.meta.dirname, '../src/web/public');
|
||||
|
||||
/**
|
||||
* The container the vm's `document.getElementById` resolves for the case list.
|
||||
* Swapped per test: the closure lives in THIS realm, so the shipping code inside
|
||||
* the vm reads whatever the current test installed.
|
||||
*/
|
||||
let currentCaseList: { innerHTML: string } | null = null;
|
||||
|
||||
function loadTerminalUiPrototype(): Record<string, any> {
|
||||
const source = readFileSync(resolve(PUBLIC, 'terminal-ui.js'), 'utf8');
|
||||
const context = vm.createContext({
|
||||
console,
|
||||
CodemanApp: class CodemanApp {},
|
||||
setInterval: vi.fn(),
|
||||
clearInterval: vi.fn(),
|
||||
setTimeout,
|
||||
clearTimeout,
|
||||
requestAnimationFrame: vi.fn(),
|
||||
document: { addEventListener: vi.fn(), getElementById: () => null, createElement: () => ({}) },
|
||||
window: { addEventListener: vi.fn(), removeEventListener: vi.fn() },
|
||||
});
|
||||
vm.runInContext(`${source}\nglobalThis.__proto = CodemanApp.prototype;`, context);
|
||||
return (context as unknown as { __proto: Record<string, any> }).__proto;
|
||||
}
|
||||
|
||||
function loadSessionUiPrototype(): Record<string, any> {
|
||||
const source = readFileSync(resolve(PUBLIC, 'session-ui.js'), 'utf8');
|
||||
const context = vm.createContext({
|
||||
console,
|
||||
CodemanApp: class CodemanApp {},
|
||||
VoiceInput: {},
|
||||
escapeHtml: (t: unknown) => String(t ?? ''),
|
||||
setTimeout,
|
||||
clearTimeout,
|
||||
localStorage: { getItem: () => null, setItem: () => {} },
|
||||
document: { getElementById: (id: string) => (id === 'caseManageList' ? currentCaseList : null) },
|
||||
window: { addEventListener: vi.fn() },
|
||||
});
|
||||
vm.runInContext(`${source}\nglobalThis.__proto = CodemanApp.prototype;`, context);
|
||||
return (context as unknown as { __proto: Record<string, any> }).__proto;
|
||||
}
|
||||
|
||||
const terminalProto = loadTerminalUiPrototype();
|
||||
const sessionProto = loadSessionUiPrototype();
|
||||
const shorten = (p: unknown) => terminalProto._shortenHomePath.call(terminalProto, p);
|
||||
|
||||
describe('_shortenHomePath', () => {
|
||||
it('abbreviates the Linux home prefix', () => {
|
||||
expect(shorten('/home/arkon/default/claudeman')).toBe('~/default/claudeman');
|
||||
});
|
||||
|
||||
it('abbreviates the macOS home prefix, which the Run menu never did (#273)', () => {
|
||||
expect(shorten('/Users/jordanryan/code/facet/facet-agency-ops')).toBe('~/code/facet/facet-agency-ops');
|
||||
});
|
||||
|
||||
it('abbreviates the home directory itself, not only paths below it', () => {
|
||||
// The case-manage list's old regex had no trailing slash and did collapse
|
||||
// this to "~"; keep that, or a case whose path IS $HOME would regress.
|
||||
expect(shorten('/home/arkon')).toBe('~');
|
||||
expect(shorten('/Users/jordanryan')).toBe('~');
|
||||
});
|
||||
|
||||
it('leaves paths that only look like a home prefix alone', () => {
|
||||
expect(shorten('/homer/bob/x')).toBe('/homer/bob/x');
|
||||
expect(shorten('/Userspace/bob/x')).toBe('/Userspace/bob/x');
|
||||
expect(shorten('/home')).toBe('/home');
|
||||
expect(shorten('/mnt/d/work')).toBe('/mnt/d/work');
|
||||
expect(shorten('/opt/codeman')).toBe('/opt/codeman');
|
||||
});
|
||||
|
||||
it('replaces only the leading occurrence', () => {
|
||||
expect(shorten('/home/arkon/home/bob/x')).toBe('~/home/bob/x');
|
||||
});
|
||||
|
||||
it('tolerates empty and missing input', () => {
|
||||
expect(shorten('')).toBe('');
|
||||
expect(shorten(undefined)).toBe('');
|
||||
expect(shorten(null)).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('renderCaseManageList path labels', () => {
|
||||
function render(cases: Array<{ name: string; path: string; location?: string }>): string {
|
||||
currentCaseList = { innerHTML: '' };
|
||||
const app: any = {
|
||||
cases,
|
||||
_shortenHomePath: terminalProto._shortenHomePath,
|
||||
renderCaseManageList: sessionProto.renderCaseManageList,
|
||||
};
|
||||
app.renderCaseManageList();
|
||||
const html = currentCaseList.innerHTML;
|
||||
currentCaseList = null;
|
||||
return html;
|
||||
}
|
||||
|
||||
it('abbreviates a Linux case path (the mirror of #273)', () => {
|
||||
const html = render([{ name: 'demo', path: '/home/arkon/codeman-cases/demo' }]);
|
||||
expect(html).toContain('~/codeman-cases/demo');
|
||||
expect(html).not.toContain('/home/arkon/codeman-cases/demo');
|
||||
});
|
||||
|
||||
it('still abbreviates a macOS case path', () => {
|
||||
const html = render([{ name: 'demo', path: '/Users/jordanryan/codeman-cases/demo' }]);
|
||||
expect(html).toContain('~/codeman-cases/demo');
|
||||
expect(html).not.toContain('/Users/jordanryan/codeman-cases/demo');
|
||||
});
|
||||
|
||||
it('renders the row when a case has no path at all', () => {
|
||||
const html = render([{ name: 'demo', path: '' }]);
|
||||
expect(html).toContain('demo');
|
||||
expect(html).toContain('class="case-manage-path"');
|
||||
});
|
||||
});
|
||||
|
||||
describe('single implementation of the home-prefix rule', () => {
|
||||
/** Every top-level frontend module (vendor/ and subdirs are not ours). */
|
||||
const sources = readdirSync(PUBLIC)
|
||||
.filter((name) => name.endsWith('.js'))
|
||||
.map((name) => ({ name, text: readFileSync(resolve(PUBLIC, name), 'utf8') }));
|
||||
|
||||
it('has exactly one home-prefix regex, in terminal-ui.js', () => {
|
||||
// Any regex literal anchored at a home root. Three of these had drifted
|
||||
// apart; a fourth would drift the same way.
|
||||
const pattern = /\/\^\\\/(?:\(\?:home\|Users\)|home|Users)\\\//g;
|
||||
const hits = sources.flatMap(({ name, text }) => (text.match(pattern) ?? []).map(() => name));
|
||||
expect(hits).toEqual(['terminal-ui.js']);
|
||||
});
|
||||
|
||||
it('routes both session-ui path labels through the helper', () => {
|
||||
// Deliberately counts calls rather than pinning source lines: the Run menu
|
||||
// row is being restructured in #274, and this guard should survive that as
|
||||
// long as the label still goes through the helper.
|
||||
const sessionUi = sources.find((s) => s.name === 'session-ui.js')!.text;
|
||||
const calls = sessionUi.match(/this\._shortenHomePath\(/g) ?? [];
|
||||
expect(calls.length).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
});
|
||||
@@ -108,6 +108,14 @@ function fakeBarElement() {
|
||||
add: (name: string) => classes.add(name),
|
||||
remove: (name: string) => classes.delete(name),
|
||||
contains: (name: string) => classes.has(name),
|
||||
// init() calls syncReadMyMind(), which toggles the 🧠 marker class on the
|
||||
// bar with an explicit force argument.
|
||||
toggle: (name: string, force?: boolean) => {
|
||||
const on = force === undefined ? !classes.has(name) : force;
|
||||
if (on) classes.add(name);
|
||||
else classes.delete(name);
|
||||
return on;
|
||||
},
|
||||
},
|
||||
get innerHTML() {
|
||||
return html;
|
||||
|
||||
@@ -183,6 +183,171 @@ 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)', () => {
|
||||
|
||||
@@ -15,7 +15,11 @@ import { resolveTerminalHistoryConfig } from '../../src/config/terminal-history.
|
||||
* Creates a mock context that satisfies all port interfaces.
|
||||
* Pre-populated with one session for convenience.
|
||||
*/
|
||||
export function createMockRouteContext(options?: { sessionId?: string; agentSkillEnabled?: boolean }) {
|
||||
export function createMockRouteContext(options?: {
|
||||
sessionId?: string;
|
||||
agentSkillEnabled?: boolean;
|
||||
claudeVoiceEnabled?: boolean;
|
||||
}) {
|
||||
const sessionId = options?.sessionId ?? 'test-session-1';
|
||||
const session = createMockSession(sessionId);
|
||||
const sessions = new Map<string, MockSession>();
|
||||
@@ -90,6 +94,8 @@ export function createMockRouteContext(options?: { sessionId?: string; agentSkil
|
||||
// case's .claude/skills. Overridable per test because the create-time
|
||||
// injection call sites are otherwise unreachable from a route test.
|
||||
getAgentSkillEnabled: vi.fn(async () => options?.agentSkillEnabled ?? false),
|
||||
// Default OFF mirrors the shipped setting: no test opens a voice relay by accident.
|
||||
getClaudeVoiceEnabled: vi.fn(async () => options?.claudeVoiceEnabled ?? false),
|
||||
getDefaultClaudeMdPath: vi.fn(async () => undefined),
|
||||
getLightState: vi.fn(() => ({ sessions: [], status: 'ok' })),
|
||||
getLightSessionsState: vi.fn(() => {
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
// Port: none (pure static analysis — runs in CI, no browser/server).
|
||||
//
|
||||
// Read My Mind phase 3 part 1 guards: the modal's alternates row and the phone
|
||||
// keyboard-accessory 🧠 key. The mobile Playwright suite is excluded from CI,
|
||||
// so like test/mobile-header-buttons-policy.test.ts this parses the frontend
|
||||
// assets directly to pin the wiring that only a phone would exercise:
|
||||
//
|
||||
// 1. the 🧠 key ships in BOTH accessory-bar templates (setMode() swaps the
|
||||
// bar's innerHTML between them, so a key present in only one layout would
|
||||
// silently vanish when the user toggles `extendedKeyboardBar`) and routes
|
||||
// to the shared modal;
|
||||
// 2. the key is hidden unless the bar carries the `rmm-enabled` marker class
|
||||
// — gating must live on the BAR element because setMode() rebuilds the
|
||||
// buttons — synced at init and re-synced by settings-ui.js on every
|
||||
// settings apply (a live toggle needs no reload);
|
||||
// 3. the header 🧠 button STAYS off phones (the key is the phone surface);
|
||||
// 4. the modal renders on phones as a small dialog, not the full-screen
|
||||
// default that phone `.modal-content` rules would impose;
|
||||
// 5. readmymind-ui.js keeps the no-innerHTML discipline (predictor output is
|
||||
// injectable content) and renders alternates into the i18n-skipped
|
||||
// container declared in index.html.
|
||||
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 read = (name: string) => readFileSync(join(PUBLIC, name), 'utf-8');
|
||||
|
||||
describe('read my mind phone key + alternates (static guards)', () => {
|
||||
const accessory = read('keyboard-accessory.js');
|
||||
const styles = read('styles.css');
|
||||
const mobile = read('mobile.css');
|
||||
const html = read('index.html');
|
||||
const ui = read('readmymind-ui.js');
|
||||
const settingsUi = read('settings-ui.js');
|
||||
// Everything phone-specific lives in the max-width 430px block of mobile.css.
|
||||
const phoneBlock = mobile.slice(mobile.indexOf('@media (max-width: 430px)'));
|
||||
|
||||
it('ships the 🧠 key in BOTH accessory bar templates and routes it to the modal', () => {
|
||||
const simple = accessory.match(/_simpleButtons\s*:\s*`([\s\S]*?)`/)?.[1] ?? '';
|
||||
const extended = accessory.match(/_extendedButtons\s*:\s*`([\s\S]*?)`/)?.[1] ?? '';
|
||||
expect(simple).toMatch(/accessory-btn-rmm[^>]*data-action="readmymind"/);
|
||||
expect(extended).toMatch(/accessory-btn-rmm[^>]*data-action="readmymind"/);
|
||||
expect(accessory).toMatch(/case 'readmymind':/);
|
||||
expect(accessory).toMatch(/openReadMyMind/);
|
||||
});
|
||||
|
||||
it('hides the key until the bar carries rmm-enabled, synced at init and on settings apply', () => {
|
||||
expect(styles).toMatch(/\.keyboard-accessory-bar \.accessory-btn-rmm \{\s*display: none;/);
|
||||
expect(styles).toMatch(/\.keyboard-accessory-bar\.rmm-enabled \.accessory-btn-rmm \{\s*display: inline-flex;/);
|
||||
// init() applies the gate as soon as the bar exists…
|
||||
expect(accessory).toMatch(/this\.syncReadMyMind\(\);/);
|
||||
// …and settings-ui re-syncs it on every settings apply (live toggle).
|
||||
expect(settingsUi).toMatch(/KeyboardAccessoryBar\.syncReadMyMind/);
|
||||
});
|
||||
|
||||
it('keeps the header 🧠 button off phones (the accessory key is the phone surface)', () => {
|
||||
expect(phoneBlock).toMatch(/\.btn-icon-header\.btn-readmymind\s*\{\s*display: none !important;/);
|
||||
});
|
||||
|
||||
it('renders the modal as a small dialog on phones, not the full-screen default', () => {
|
||||
expect(phoneBlock).toMatch(/\.modal-content\.readmymind-modal\s*\{[^}]*height: auto;/);
|
||||
});
|
||||
|
||||
it('declares the i18n-skipped alternates container and keeps the no-innerHTML discipline', () => {
|
||||
expect(html).toMatch(/id="readMyMindAlternates"[^>]*data-i18n-skip/);
|
||||
// Predictor output is injectable content: value/textContent only, ever.
|
||||
// (`.innerHTML`: property ACCESS — the fileoverview's "never innerHTML"
|
||||
// prose is allowed to say the word.)
|
||||
expect(ui).not.toMatch(/\.innerHTML/);
|
||||
expect(ui).toContain('readMyMindAlternates');
|
||||
expect(ui).toMatch(/\.textContent = suggestion\.prompt/);
|
||||
});
|
||||
|
||||
// Phase 3 part 2: the Rethink steer note (docs/readmymind-plan.md phase 3).
|
||||
it('wires the rethink steer note end to end: field, payload, phase visibility, reset', () => {
|
||||
// The field lives in the modal, capped to the schema's 2000-char limit,
|
||||
// and Enter in it triggers a rethink (mirroring the prompt field's
|
||||
// Enter-to-send).
|
||||
expect(html).toMatch(/id="readMyMindSteer"[^>]*maxlength="2000"/);
|
||||
expect(html).toMatch(/id="readMyMindSteer"[^>]*onkeydown="[^"]*rethinkReadMyMind\(\)"/);
|
||||
// Predict sends the trimmed note as `steer`, bounded to the schema cap.
|
||||
expect(ui).toMatch(/body\.steer = steer\.slice\(0, 2000\)/);
|
||||
// The row hides ONLY during loading: Rethink is live in both the ready
|
||||
// and the empty-result phases, so the note must be reachable in both.
|
||||
expect(ui).toMatch(/steerRow\.style\.display = phase === 'loading' \? 'none' : ''/);
|
||||
// A fresh open resets the note along with the rethink memory.
|
||||
expect(ui).toMatch(/steer\.value = ''/);
|
||||
});
|
||||
|
||||
it('styles the footer with btn-toolbar (bare "btn btn-*" matches no CSS in this codebase)', () => {
|
||||
const modal = html.slice(html.indexOf('id="readMyMindModal"'), html.indexOf('id="approvalsDrawer"'));
|
||||
// The unstyled classes the footer originally shipped with must not return.
|
||||
expect(modal).not.toMatch(/class="btn /);
|
||||
expect(modal.match(/class="btn-toolbar/g)?.length).toBe(4);
|
||||
expect(modal).toMatch(/class="btn-toolbar btn-primary"[^>]*sendReadMyMind\(true\)/);
|
||||
// btn-toolbar is display:flex (block-level): without the desktop footer
|
||||
// row rule the four buttons would stack vertically.
|
||||
expect(styles).toMatch(/\.readmymind-modal \.modal-footer \{[^}]*display: flex/);
|
||||
// The skin block's bare .btn-toolbar (0,2,1) greys out .btn-primary
|
||||
// (0,2,0), so Send's accent must be re-asserted at higher specificity.
|
||||
expect(styles).toMatch(/\.readmymind-modal \.modal-footer \.btn-toolbar\.btn-primary \{[^}]*var\(--accent\)/);
|
||||
// The phone block sizes the same class for finger targets.
|
||||
expect(phoneBlock).toMatch(/\.readmymind-modal \.modal-footer \.btn-toolbar/);
|
||||
});
|
||||
});
|
||||
@@ -9,12 +9,13 @@
|
||||
* (sessions + events) return results. Source data is injected via the mock
|
||||
* route context (sessions map, runSummaryTrackers map, attachment history).
|
||||
*/
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import Fastify, { type FastifyInstance } from 'fastify';
|
||||
import { registerSearchRoutes } from '../../src/web/routes/search-routes.js';
|
||||
import { installRouteErrorHandler } from '../../src/web/route-error-handler.js';
|
||||
import { createMockRouteContext } from '../mocks/index.js';
|
||||
import { RunSummaryTracker } from '../../src/run-summary.js';
|
||||
import { resetHistorySessionIndex, setHistorySessionIndex } from '../../src/web/session-history-index.js';
|
||||
|
||||
type Ctx = ReturnType<typeof createMockRouteContext>;
|
||||
|
||||
@@ -206,3 +207,97 @@ describe('GET /api/search — caps & filters', () => {
|
||||
expect(types).toEqual(['event']);
|
||||
});
|
||||
});
|
||||
|
||||
// Issue #261: with 3 live sessions and ~35 past ones, searching a past project's
|
||||
// folder name matched nothing, the corpus was the live session map alone. Past
|
||||
// sessions now arrive from the out-of-band history index snapshot.
|
||||
describe('GET /api/search: past sessions (history index)', () => {
|
||||
beforeEach(() => {
|
||||
resetHistorySessionIndex();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
resetHistorySessionIndex();
|
||||
delete process.env.CODEMAN_MULTIUSER;
|
||||
});
|
||||
|
||||
it('matches a past session by folder name with no live session at all', async () => {
|
||||
const { app } = await harness();
|
||||
setHistorySessionIndex([
|
||||
{
|
||||
sessionId: 'cod-9',
|
||||
name: 'w4-needlework',
|
||||
workingDir: '/home/u/projects/needlework',
|
||||
claudeSessionId: 'claude-uuid',
|
||||
timestamp: 1000,
|
||||
live: false,
|
||||
},
|
||||
]);
|
||||
const res = await app.inject({ method: 'GET', url: '/api/search?q=needlework' });
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = JSON.parse(res.body);
|
||||
expect(body.data.totalResults).toBe(1);
|
||||
expect(body.data.groups[0].results[0].jumpTo).toMatchObject({
|
||||
kind: 'resume-session',
|
||||
sessionId: 'cod-9',
|
||||
claudeSessionId: 'claude-uuid',
|
||||
});
|
||||
});
|
||||
|
||||
it('does not duplicate a session that is both live and in the snapshot', async () => {
|
||||
const { app } = await harness((ctx) => {
|
||||
ctx.sessions.set(
|
||||
'dup',
|
||||
fakeSession({ id: 'dup', name: 'needle live', workingDir: '/home/u/needle', lastActivityAt: 5 }) as never
|
||||
);
|
||||
});
|
||||
setHistorySessionIndex([
|
||||
{ sessionId: 'dup', name: 'needle live', workingDir: '/home/u/needle', timestamp: 5, live: true },
|
||||
]);
|
||||
const body = JSON.parse((await app.inject({ method: 'GET', url: '/api/search?q=needle' })).body);
|
||||
expect(body.data.totalResults).toBe(1);
|
||||
// The live harvest wins, so the card still switches to the open tab.
|
||||
expect(body.data.groups[0].results[0].jumpTo.kind).toBe('session');
|
||||
});
|
||||
|
||||
it('multi-user: a non-admin sees neither another user’s past session nor unowned host-wide history', async () => {
|
||||
process.env.CODEMAN_MULTIUSER = '1';
|
||||
const app = Fastify({ logger: false });
|
||||
app.addHook('onRequest', async (req) => {
|
||||
(req as unknown as { authUser: unknown }).authUser = { username: 'bob', role: 'user' };
|
||||
});
|
||||
const ctx = createMockRouteContext();
|
||||
ctx.sessions.clear();
|
||||
ctx.runSummaryTrackers.clear();
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
registerSearchRoutes(app, ctx as any);
|
||||
installRouteErrorHandler(app);
|
||||
await app.ready();
|
||||
|
||||
setHistorySessionIndex([
|
||||
{
|
||||
sessionId: 'mine',
|
||||
name: 'needle-bob',
|
||||
workingDir: '/home/u/needle-bob',
|
||||
timestamp: 3,
|
||||
owner: 'bob',
|
||||
live: false,
|
||||
},
|
||||
{
|
||||
sessionId: 'hers',
|
||||
name: 'needle-alice',
|
||||
workingDir: '/home/u/needle-alice',
|
||||
timestamp: 2,
|
||||
owner: 'alice',
|
||||
live: false,
|
||||
},
|
||||
// Host-wide transcript row: no owning session, so admin-only, the same
|
||||
// rule GET /api/sessions/unified applies when it drops history for non-admins.
|
||||
{ sessionId: 'hostwide', name: 'needle-host', workingDir: '/srv/needle-host', timestamp: 1, live: false },
|
||||
]);
|
||||
|
||||
const body = JSON.parse((await app.inject({ method: 'GET', url: '/api/search?q=needle' })).body);
|
||||
expect(body.data.groups[0].results.map((r: { sessionId: string }) => r.sessionId)).toEqual(['mine']);
|
||||
await app.close();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,302 @@
|
||||
/**
|
||||
* @fileoverview Claude voice dictation routes.
|
||||
*
|
||||
* Covers the status endpoint's gating and the full relay round trip against a
|
||||
* mock upstream (a local `ws` server speaking the Anthropic voice-stream
|
||||
* protocol, selected via CODEMAN_VOICE_STREAM_BASE). WebSocket routes need a
|
||||
* real listening server — app.inject() cannot do upgrades.
|
||||
*
|
||||
* What these pin, beyond "it works":
|
||||
* - the OAuth token never appears in an API response,
|
||||
* - the socket refuses exactly what the status endpoint calls unavailable,
|
||||
* - a cross-site upgrade cannot open a stream on the operator's subscription.
|
||||
*
|
||||
* Port: 3230 (routes), 3231 (mock upstream)
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import Fastify, { type FastifyInstance } from 'fastify';
|
||||
import fastifyWebsocket from '@fastify/websocket';
|
||||
import WebSocket, { WebSocketServer } from 'ws';
|
||||
import { mkdirSync, writeFileSync, rmSync } from 'node:fs';
|
||||
import { homedir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { createMockRouteContext, type MockRouteContext } from '../mocks/index.js';
|
||||
import { registerVoiceRoutes, _resetVoiceStreamCountForTesting } from '../../src/web/routes/voice-routes.js';
|
||||
import { MAX_CONCURRENT_STREAMS } from '../../src/config/voice.js';
|
||||
|
||||
const PORT = 3230;
|
||||
const UPSTREAM_PORT = 3231;
|
||||
const TOKEN = 'sk-ant-oat01-voice-route-test';
|
||||
|
||||
/** State captured by the mock upstream, so tests can assert what Codeman sent. */
|
||||
interface UpstreamCapture {
|
||||
headers: Record<string, string | string[] | undefined>;
|
||||
url: string;
|
||||
binaryFrames: Buffer[];
|
||||
textFrames: string[];
|
||||
socket: WebSocket | null;
|
||||
}
|
||||
|
||||
function writeCredentials(expiresAt: number | undefined): void {
|
||||
const dir = join(homedir(), '.claude');
|
||||
mkdirSync(dir, { recursive: true });
|
||||
writeFileSync(
|
||||
join(dir, '.credentials.json'),
|
||||
JSON.stringify({ claudeAiOauth: { accessToken: TOKEN, expiresAt, subscriptionType: 'max' } })
|
||||
);
|
||||
}
|
||||
|
||||
function removeCredentials(): void {
|
||||
rmSync(join(homedir(), '.claude', '.credentials.json'), { force: true });
|
||||
}
|
||||
|
||||
function waitForClose(ws: WebSocket, timeoutMs = 3000): Promise<{ code: number; reason: string }> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const timer = setTimeout(() => reject(new Error('WS close timeout')), timeoutMs);
|
||||
ws.on('close', (code, reason) => {
|
||||
clearTimeout(timer);
|
||||
resolve({ code, reason: reason.toString() });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/** Wait for the first message satisfying `match`, ignoring earlier frames. */
|
||||
function waitForMessage(
|
||||
ws: WebSocket,
|
||||
match: (msg: Record<string, unknown>) => boolean,
|
||||
timeoutMs = 3000
|
||||
): Promise<Record<string, unknown>> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const timer = setTimeout(() => reject(new Error('WS message timeout')), timeoutMs);
|
||||
const onMessage = (raw: WebSocket.RawData) => {
|
||||
let msg: Record<string, unknown>;
|
||||
try {
|
||||
msg = JSON.parse(String(raw));
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
if (!match(msg)) return;
|
||||
clearTimeout(timer);
|
||||
ws.off('message', onMessage);
|
||||
resolve(msg);
|
||||
};
|
||||
ws.on('message', onMessage);
|
||||
});
|
||||
}
|
||||
|
||||
function waitUntil(predicate: () => boolean, timeoutMs = 3000): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
const tick = () => {
|
||||
if (predicate()) return resolve();
|
||||
if (Date.now() > deadline) return reject(new Error('condition not met in time'));
|
||||
setTimeout(tick, 10);
|
||||
};
|
||||
tick();
|
||||
});
|
||||
}
|
||||
|
||||
describe('voice-routes', () => {
|
||||
let app: FastifyInstance;
|
||||
let ctx: MockRouteContext;
|
||||
let upstream: WebSocketServer;
|
||||
let capture: UpstreamCapture;
|
||||
let voiceEnabled: boolean;
|
||||
|
||||
beforeEach(async () => {
|
||||
_resetVoiceStreamCountForTesting();
|
||||
voiceEnabled = true;
|
||||
capture = { headers: {}, url: '', binaryFrames: [], textFrames: [], socket: null };
|
||||
|
||||
upstream = new WebSocketServer({ port: UPSTREAM_PORT, host: '127.0.0.1' });
|
||||
upstream.on('connection', (socket, req) => {
|
||||
capture.headers = req.headers;
|
||||
capture.url = req.url ?? '';
|
||||
capture.socket = socket;
|
||||
socket.on('message', (raw, isBinary) => {
|
||||
if (isBinary) capture.binaryFrames.push(Buffer.from(raw as Buffer));
|
||||
else capture.textFrames.push(String(raw));
|
||||
});
|
||||
});
|
||||
await new Promise<void>((resolve) => upstream.once('listening', resolve));
|
||||
process.env.CODEMAN_VOICE_STREAM_BASE = `ws://127.0.0.1:${UPSTREAM_PORT}`;
|
||||
|
||||
writeCredentials(Date.now() + 3_600_000);
|
||||
|
||||
app = Fastify({ logger: false });
|
||||
await app.register(fastifyWebsocket);
|
||||
ctx = createMockRouteContext();
|
||||
ctx.getClaudeVoiceEnabled = (async () => voiceEnabled) as typeof ctx.getClaudeVoiceEnabled;
|
||||
registerVoiceRoutes(app, ctx as never, () => ({ bindHost: '127.0.0.1', allowedHosts: [], tunnelHost: null }));
|
||||
await app.listen({ port: PORT, host: '127.0.0.1' });
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
delete process.env.CODEMAN_VOICE_STREAM_BASE;
|
||||
removeCredentials();
|
||||
await app.close();
|
||||
await new Promise<void>((resolve) => upstream.close(() => resolve()));
|
||||
});
|
||||
|
||||
describe('GET /api/voice/status', () => {
|
||||
it('reports available with display metadata when enabled and signed in', async () => {
|
||||
const res = await app.inject({ method: 'GET', url: '/api/voice/status' });
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.json().data).toMatchObject({ available: true, subscriptionType: 'max' });
|
||||
});
|
||||
|
||||
it('never returns the access token', async () => {
|
||||
const res = await app.inject({ method: 'GET', url: '/api/voice/status' });
|
||||
expect(res.body).not.toContain(TOKEN);
|
||||
expect(res.body).not.toContain('sk-ant');
|
||||
});
|
||||
|
||||
it('reports disabled when the setting is off, without touching credentials', async () => {
|
||||
voiceEnabled = false;
|
||||
const res = await app.inject({ method: 'GET', url: '/api/voice/status' });
|
||||
expect(res.json().data).toEqual({ available: false, reason: 'disabled' });
|
||||
});
|
||||
|
||||
it('reports no-credentials when nothing is signed in', async () => {
|
||||
removeCredentials();
|
||||
const res = await app.inject({ method: 'GET', url: '/api/voice/status' });
|
||||
expect(res.json().data).toEqual({ available: false, reason: 'no-credentials' });
|
||||
});
|
||||
|
||||
it('reports expired separately, so the UI can say how to fix it', async () => {
|
||||
writeCredentials(Date.now() - 1000);
|
||||
const res = await app.inject({ method: 'GET', url: '/api/voice/status' });
|
||||
expect(res.json().data.available).toBe(false);
|
||||
expect(res.json().data.reason).toBe('expired');
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /ws/voice/stream', () => {
|
||||
it('relays audio up and transcripts down, finalizing on request', async () => {
|
||||
const ws = new WebSocket(`ws://127.0.0.1:${PORT}/ws/voice/stream?language=en&keyterms=tmux,respawn`);
|
||||
const ready = waitForMessage(ws, (m) => m.t === 'ready');
|
||||
await new Promise((resolve) => ws.once('open', resolve));
|
||||
await ready;
|
||||
|
||||
ws.send(Buffer.alloc(3200));
|
||||
await waitUntil(() => capture.binaryFrames.length > 0);
|
||||
expect(capture.binaryFrames[0].length).toBe(3200);
|
||||
|
||||
const interim = waitForMessage(ws, (m) => m.t === 'transcript' && m.final === false);
|
||||
capture.socket!.send(JSON.stringify({ type: 'TranscriptText', data: 'run the type check' }));
|
||||
expect((await interim).text).toBe('run the type check');
|
||||
|
||||
ws.send(JSON.stringify({ t: 'finalize' }));
|
||||
await waitUntil(() => capture.textFrames.some((f) => f.includes('CloseStream')));
|
||||
|
||||
const final = waitForMessage(ws, (m) => m.t === 'transcript' && m.final === true);
|
||||
capture.socket!.send(JSON.stringify({ type: 'TranscriptEndpoint' }));
|
||||
expect((await final).text).toBe('run the type check');
|
||||
|
||||
const { code } = await waitForClose(ws);
|
||||
expect(code).toBe(1000);
|
||||
});
|
||||
|
||||
it('authenticates upstream with the bearer token and forwards keyterms', async () => {
|
||||
const ws = new WebSocket(`ws://127.0.0.1:${PORT}/ws/voice/stream?keyterms=tmux,respawn`);
|
||||
await waitForMessage(ws, (m) => m.t === 'ready');
|
||||
expect(capture.headers.authorization).toBe(`Bearer ${TOKEN}`);
|
||||
expect(capture.headers['x-config-keyterms']).toBe('tmux,respawn');
|
||||
expect(capture.url).toContain('encoding=linear16');
|
||||
expect(capture.url).toContain('sample_rate=16000');
|
||||
ws.close();
|
||||
});
|
||||
|
||||
it('pings upstream immediately so the idle gap before first audio cannot drop it', async () => {
|
||||
const ws = new WebSocket(`ws://127.0.0.1:${PORT}/ws/voice/stream`);
|
||||
await waitForMessage(ws, (m) => m.t === 'ready');
|
||||
await waitUntil(() => capture.textFrames.some((f) => f.includes('KeepAlive')));
|
||||
ws.close();
|
||||
});
|
||||
|
||||
it('surfaces an upstream transcription error to the browser', async () => {
|
||||
const ws = new WebSocket(`ws://127.0.0.1:${PORT}/ws/voice/stream`);
|
||||
await waitForMessage(ws, (m) => m.t === 'ready');
|
||||
const err = waitForMessage(ws, (m) => m.t === 'error');
|
||||
capture.socket!.send(JSON.stringify({ type: 'TranscriptError', description: 'no audio' }));
|
||||
expect((await err).message).toBe('no audio');
|
||||
ws.close();
|
||||
});
|
||||
|
||||
it('drops an oversized audio frame instead of relaying it', async () => {
|
||||
const ws = new WebSocket(`ws://127.0.0.1:${PORT}/ws/voice/stream`);
|
||||
await waitForMessage(ws, (m) => m.t === 'ready');
|
||||
|
||||
ws.send(Buffer.alloc(200_000));
|
||||
ws.send(Buffer.alloc(1600));
|
||||
await waitUntil(() => capture.binaryFrames.length > 0);
|
||||
// The legal frame arrived; the oversized one was never forwarded.
|
||||
expect(capture.binaryFrames.every((f) => f.length === 1600)).toBe(true);
|
||||
ws.close();
|
||||
});
|
||||
|
||||
it('closes 4004 with the reason when the setting is off', async () => {
|
||||
voiceEnabled = false;
|
||||
const ws = new WebSocket(`ws://127.0.0.1:${PORT}/ws/voice/stream`);
|
||||
const { code, reason } = await waitForClose(ws);
|
||||
expect(code).toBe(4004);
|
||||
expect(reason).toBe('disabled');
|
||||
});
|
||||
|
||||
it('closes 4004 when no Claude login exists on the server', async () => {
|
||||
removeCredentials();
|
||||
const ws = new WebSocket(`ws://127.0.0.1:${PORT}/ws/voice/stream`);
|
||||
const { code, reason } = await waitForClose(ws);
|
||||
expect(code).toBe(4004);
|
||||
expect(reason).toBe('no-credentials');
|
||||
});
|
||||
|
||||
it('closes 4004 rather than streaming on an expired login', async () => {
|
||||
writeCredentials(Date.now() - 1000);
|
||||
const ws = new WebSocket(`ws://127.0.0.1:${PORT}/ws/voice/stream`);
|
||||
const { code, reason } = await waitForClose(ws);
|
||||
expect(code).toBe(4004);
|
||||
expect(reason).toBe('expired');
|
||||
});
|
||||
|
||||
it('refuses a cross-site upgrade (a foreign page must not spend the subscription)', async () => {
|
||||
const ws = new WebSocket(`ws://127.0.0.1:${PORT}/ws/voice/stream`, {
|
||||
headers: { origin: 'https://evil.example' },
|
||||
});
|
||||
const { code } = await waitForClose(ws);
|
||||
expect(code).toBe(4003);
|
||||
expect(capture.socket).toBeNull();
|
||||
});
|
||||
|
||||
it('caps concurrent streams', async () => {
|
||||
const open: WebSocket[] = [];
|
||||
for (let i = 0; i < MAX_CONCURRENT_STREAMS; i++) {
|
||||
const ws = new WebSocket(`ws://127.0.0.1:${PORT}/ws/voice/stream`);
|
||||
await waitForMessage(ws, (m) => m.t === 'ready');
|
||||
open.push(ws);
|
||||
}
|
||||
const extra = new WebSocket(`ws://127.0.0.1:${PORT}/ws/voice/stream`);
|
||||
const { code, reason } = await waitForClose(extra);
|
||||
expect(code).toBe(4008);
|
||||
expect(reason).toBe('Too many voice streams');
|
||||
for (const ws of open) ws.close();
|
||||
});
|
||||
|
||||
it('frees a stream slot when the browser hangs up', async () => {
|
||||
const first = new WebSocket(`ws://127.0.0.1:${PORT}/ws/voice/stream`);
|
||||
await waitForMessage(first, (m) => m.t === 'ready');
|
||||
first.close();
|
||||
await waitForClose(first);
|
||||
|
||||
// The slot is reusable: MAX_CONCURRENT more streams must still be admitted.
|
||||
const reopened: WebSocket[] = [];
|
||||
for (let i = 0; i < MAX_CONCURRENT_STREAMS; i++) {
|
||||
const ws = new WebSocket(`ws://127.0.0.1:${PORT}/ws/voice/stream`);
|
||||
await waitForMessage(ws, (m) => m.t === 'ready');
|
||||
reopened.push(ws);
|
||||
}
|
||||
for (const ws of reopened) ws.close();
|
||||
});
|
||||
});
|
||||
});
|
||||
+30
-25
@@ -274,41 +274,41 @@ describe('Run launch synchronization', () => {
|
||||
});
|
||||
|
||||
describe('Codex quick start settings', () => {
|
||||
it('renders Codex CLI settings in a dedicated app settings tab', () => {
|
||||
it('renders Codex CLI settings in their own group inside Agents & CLIs', () => {
|
||||
const html = readFileSync(resolve(import.meta.dirname, '../src/web/public/index.html'), 'utf8');
|
||||
|
||||
expect(html).toContain('data-tab="settings-codex">Codex CLI</button>');
|
||||
// The settings modal is one scrolling document: Codex is a GROUP that can be
|
||||
// hidden wholesale, not a tab (see _applyCodexSettingsVisibility).
|
||||
const clis = html.match(/<section class="set-section" id="settings-clis"([\s\S]*?)<\/section>/);
|
||||
expect(clis?.[1]).toBeTruthy();
|
||||
|
||||
const claudeTab = html.match(
|
||||
/<div class="modal-tab-content hidden" id="settings-claude">([\s\S]*?)<!-- Codex CLI Tab -->/
|
||||
);
|
||||
expect(claudeTab?.[1]).not.toContain('appSettingsCodexDangerouslyBypassApprovals');
|
||||
expect(claudeTab?.[1]).not.toContain('appSettingsCodexAnimations');
|
||||
const codexGroup = clis![1].match(/id="appSettingsCodexGroup"([\s\S]*)$/);
|
||||
expect(codexGroup?.[1]).toContain('appSettingsCodexDangerouslyBypassApprovals');
|
||||
expect(codexGroup?.[1]).toContain('appSettingsCodexAnimations');
|
||||
expect(codexGroup?.[1]).not.toContain('appSettingsCodexRenderMode');
|
||||
|
||||
const codexTab = html.match(
|
||||
/<div class="modal-tab-content hidden" id="settings-codex">([\s\S]*?)<\/div>\s*<!-- Models Tab -->/
|
||||
);
|
||||
expect(codexTab?.[1]).toContain('appSettingsCodexDangerouslyBypassApprovals');
|
||||
expect(codexTab?.[1]).toContain('appSettingsCodexAnimations');
|
||||
expect(codexTab?.[1]).not.toContain('appSettingsCodexRenderMode');
|
||||
// The Claude settings above it must not have absorbed the codex inputs.
|
||||
const beforeCodex = clis![1].slice(0, clis![1].indexOf('id="appSettingsCodexGroup"'));
|
||||
expect(beforeCodex).not.toContain('appSettingsCodexDangerouslyBypassApprovals');
|
||||
expect(beforeCodex).not.toContain('appSettingsCodexAnimations');
|
||||
});
|
||||
|
||||
describe('Codex CLI tab visibility', () => {
|
||||
// Both settings on the tab are handed to `codex` at launch, so on an instance
|
||||
// where the binary does not resolve the tab is a promise nothing can keep.
|
||||
// renderIndexHtml injects window.__codemanCliAvailable; this pins the client
|
||||
// half. Coupled test: it drives the REAL settings-ui.js against a stub button,
|
||||
// so deleting the call in openAppSettings() is what it is meant to catch.
|
||||
describe('Codex CLI group visibility', () => {
|
||||
// Both settings in the group are handed to `codex` at launch, so on an
|
||||
// instance where the binary does not resolve the group is a promise nothing
|
||||
// can keep. renderIndexHtml injects window.__codemanCliAvailable; this pins
|
||||
// the client half. Coupled test: it drives the REAL settings-ui.js against a
|
||||
// stub element, so deleting the call in openAppSettings() is what it catches.
|
||||
function loadSettingsUi(codexAvailable: boolean | undefined) {
|
||||
const codexTabBtn = { dataset: { tab: 'settings-codex' }, style: { display: 'PRISTINE' } };
|
||||
const codexTabBtn = { id: 'appSettingsCodexGroup', style: { display: 'PRISTINE' } };
|
||||
const CodemanApp = function CodemanApp(this: any) {};
|
||||
const context: any = vm.createContext({
|
||||
CodemanApp,
|
||||
MobileDetection: { getDeviceType: () => 'desktop', isTouchDevice: () => false, isHandheldDevice: () => false },
|
||||
localStorage: { getItem: () => null, setItem: () => {} },
|
||||
document: {
|
||||
getElementById: () => null,
|
||||
querySelector: (sel: string) => (sel.includes('[data-tab="settings-codex"]') ? codexTabBtn : null),
|
||||
getElementById: (id: string) => (id === 'appSettingsCodexGroup' ? codexTabBtn : null),
|
||||
querySelector: () => null,
|
||||
},
|
||||
console,
|
||||
});
|
||||
@@ -319,19 +319,19 @@ describe('Codex quick start settings', () => {
|
||||
return { app: new (CodemanApp as any)(), codexTabBtn };
|
||||
}
|
||||
|
||||
it('hides the Codex tab when the codex binary is not available', () => {
|
||||
it('hides the Codex group when the codex binary is not available', () => {
|
||||
const { app, codexTabBtn } = loadSettingsUi(false);
|
||||
app._applyCodexSettingsVisibility();
|
||||
expect(codexTabBtn.style.display).toBe('none');
|
||||
});
|
||||
|
||||
it('hides the Codex tab when the availability flag was never injected', () => {
|
||||
it('hides the Codex group when the availability flag was never injected', () => {
|
||||
const { app, codexTabBtn } = loadSettingsUi(undefined);
|
||||
app._applyCodexSettingsVisibility();
|
||||
expect(codexTabBtn.style.display).toBe('none');
|
||||
});
|
||||
|
||||
it('shows the Codex tab when codex is available', () => {
|
||||
it('shows the Codex group when codex is available', () => {
|
||||
const { app, codexTabBtn } = loadSettingsUi(true);
|
||||
app._applyCodexSettingsVisibility();
|
||||
expect(codexTabBtn.style.display).toBe('');
|
||||
@@ -760,6 +760,11 @@ describe('case selector refresh', () => {
|
||||
];
|
||||
app.showToast = vi.fn();
|
||||
|
||||
// deleteCase re-renders the case-manage list, whose path label goes through
|
||||
// _shortenHomePath. That method lives in terminal-ui.js, which this harness
|
||||
// does not load (the real app always has it: load order 7 before 12).
|
||||
app._shortenHomePath = (p: string) => p;
|
||||
|
||||
await app.deleteCase('deleted-case');
|
||||
|
||||
expect(quickStartCase.blur).toHaveBeenCalled();
|
||||
|
||||
@@ -233,3 +233,61 @@ describe('searchSources — result card shape & path safety', () => {
|
||||
expect(searchSources('', data).totalResults).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
// Past sessions (issue #261). The corpus used to be the live session map alone,
|
||||
// so a folder in the home screen's Resume list matched nothing. History rows now
|
||||
// arrive marked, and a card for one has to RESUME the conversation, selecting a
|
||||
// tab that no longer exists is a no-op the user reads as a broken result.
|
||||
describe('searchSources: past (history) sessions', () => {
|
||||
it('matches a past session by folder name and returns a resume jump target', () => {
|
||||
const data = sources({
|
||||
sessions: [
|
||||
{
|
||||
sessionId: 'cod-1',
|
||||
sessionName: 'w3-invoices',
|
||||
workingDir: '/home/u/projects/invoices',
|
||||
timestamp: 500,
|
||||
history: true,
|
||||
claudeSessionId: 'claude-uuid-1',
|
||||
},
|
||||
],
|
||||
});
|
||||
const res = searchSources('invoices', data);
|
||||
expect(res.totalResults).toBe(1);
|
||||
expect(res.groups[0].results[0].jumpTo).toEqual({
|
||||
kind: 'resume-session',
|
||||
sessionId: 'cod-1',
|
||||
claudeSessionId: 'claude-uuid-1',
|
||||
workingDir: '/home/u/projects/invoices',
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps a live session on the plain session jump target', () => {
|
||||
const data = sources({
|
||||
sessions: [{ sessionId: 'live-1', sessionName: 'w1-invoices', workingDir: '/home/u/invoices', timestamp: 1 }],
|
||||
});
|
||||
expect(searchSources('invoices', data).groups[0].results[0].jumpTo).toEqual({
|
||||
kind: 'session',
|
||||
sessionId: 'live-1',
|
||||
});
|
||||
});
|
||||
|
||||
it('does not offer a resume for a history row with no working directory', () => {
|
||||
const data = sources({
|
||||
sessions: [{ sessionId: 'cod-2', sessionName: 'needle-run', workingDir: '', timestamp: 1, history: true }],
|
||||
});
|
||||
// Nothing to resume INTO, a resume card here would always fail.
|
||||
expect(searchSources('needle', data).groups[0].results[0].jumpTo.kind).toBe('session');
|
||||
});
|
||||
|
||||
it('falls back to the folder basename when a transcript row has no name', () => {
|
||||
const data = sources({
|
||||
sessions: [
|
||||
{ sessionId: 'cod-3', sessionName: '', workingDir: '/home/u/proj/needle-app', timestamp: 1, history: true },
|
||||
],
|
||||
});
|
||||
const r = searchSources('needle', data).groups[0].results[0];
|
||||
expect(r.sessionName).toBe('needle-app');
|
||||
expect(r.snippet).toContain('/home/u/proj/needle-app');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
/**
|
||||
* Unit tests for the past-session search index (issue #261).
|
||||
*
|
||||
* The index is the seam that lets `GET /api/search` match sessions that are no
|
||||
* longer running WITHOUT doing disk I/O per keystroke. Three properties matter
|
||||
* and are pinned here: the snapshot stays bounded, the refresh never happens on
|
||||
* the caller's timeline (fire-and-forget, single-flight, TTL-guarded), and the
|
||||
* stored rows carry the owner needed to re-apply multi-user scoping on read,
|
||||
* the snapshot is written unscoped, so losing that field would leak one user's
|
||||
* folders into another user's search.
|
||||
*/
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import {
|
||||
buildHistorySessionIndexItems,
|
||||
ensureHistorySessionIndexFresh,
|
||||
getHistorySessionIndex,
|
||||
isHistorySessionIndexStale,
|
||||
resetHistorySessionIndex,
|
||||
setHistoryIndexRefresher,
|
||||
setHistorySessionIndex,
|
||||
HISTORY_INDEX_MAX_ITEMS,
|
||||
HISTORY_INDEX_TTL_MS,
|
||||
type MergedSessionLike,
|
||||
} from '../src/web/session-history-index.js';
|
||||
|
||||
beforeEach(() => {
|
||||
resetHistorySessionIndex();
|
||||
});
|
||||
|
||||
describe('buildHistorySessionIndexItems', () => {
|
||||
const merged: MergedSessionLike[] = [
|
||||
{ sessionId: 'a', name: 'w1-alpha', workingDir: '/home/u/alpha', lastActivityAt: 300 },
|
||||
{ sessionId: 'b', name: '', workingDir: '/home/u/beta', claudeSessionId: 'uuid-b', createdAt: 200 },
|
||||
{ sessionId: 'c', name: 'gamma', workingDir: '', lastActivityAt: 100 },
|
||||
];
|
||||
|
||||
it('projects name, dir, timestamp, owner and liveness', () => {
|
||||
const items = buildHistorySessionIndexItems(
|
||||
merged,
|
||||
new Map([
|
||||
['a', 'alice'],
|
||||
['b', undefined],
|
||||
]),
|
||||
new Set(['a'])
|
||||
);
|
||||
expect(items.map((i) => i.sessionId)).toEqual(['a', 'b', 'c']);
|
||||
expect(items[0]).toMatchObject({ owner: 'alice', live: true, timestamp: 300 });
|
||||
// Transcript-only row: no owner (host-wide) and not live.
|
||||
expect(items[1]).toMatchObject({ owner: undefined, live: false, timestamp: 200, claudeSessionId: 'uuid-b' });
|
||||
});
|
||||
|
||||
it('drops rows with neither a name nor a working directory', () => {
|
||||
const items = buildHistorySessionIndexItems([{ sessionId: 'empty' }, ...merged], new Map(), new Set());
|
||||
expect(items.some((i) => i.sessionId === 'empty')).toBe(false);
|
||||
});
|
||||
|
||||
it('caps the projection at HISTORY_INDEX_MAX_ITEMS', () => {
|
||||
const many: MergedSessionLike[] = Array.from({ length: HISTORY_INDEX_MAX_ITEMS + 50 }, (_, i) => ({
|
||||
sessionId: `s${i}`,
|
||||
name: `session ${i}`,
|
||||
workingDir: `/home/u/p${i}`,
|
||||
lastActivityAt: i,
|
||||
}));
|
||||
expect(buildHistorySessionIndexItems(many, new Map(), new Set())).toHaveLength(HISTORY_INDEX_MAX_ITEMS);
|
||||
});
|
||||
});
|
||||
|
||||
describe('snapshot storage', () => {
|
||||
it('starts empty and stale', () => {
|
||||
expect(getHistorySessionIndex().items).toEqual([]);
|
||||
expect(isHistorySessionIndexStale()).toBe(true);
|
||||
});
|
||||
|
||||
it('caps on write even when the caller did not', () => {
|
||||
const items = Array.from({ length: HISTORY_INDEX_MAX_ITEMS + 10 }, (_, i) => ({
|
||||
sessionId: `s${i}`,
|
||||
name: 'x',
|
||||
workingDir: '/x',
|
||||
timestamp: i,
|
||||
live: false,
|
||||
}));
|
||||
setHistorySessionIndex(items);
|
||||
expect(getHistorySessionIndex().items).toHaveLength(HISTORY_INDEX_MAX_ITEMS);
|
||||
});
|
||||
|
||||
it('goes stale again once the TTL elapses', () => {
|
||||
const t0 = 1_000_000;
|
||||
setHistorySessionIndex([{ sessionId: 's', name: 'n', workingDir: '/d', timestamp: 1, live: false }], t0);
|
||||
expect(isHistorySessionIndexStale(t0 + HISTORY_INDEX_TTL_MS - 1)).toBe(false);
|
||||
expect(isHistorySessionIndexStale(t0 + HISTORY_INDEX_TTL_MS + 1)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('ensureHistorySessionIndexFresh', () => {
|
||||
it('returns synchronously, the rebuild must never be on the request path', async () => {
|
||||
let resolveRefresh: () => void = () => {};
|
||||
const refresher = vi.fn(
|
||||
() =>
|
||||
new Promise<void>((resolve) => {
|
||||
resolveRefresh = resolve;
|
||||
})
|
||||
);
|
||||
setHistoryIndexRefresher(refresher);
|
||||
|
||||
ensureHistorySessionIndexFresh();
|
||||
// Called, but the caller is already past it while the rebuild is pending.
|
||||
expect(refresher).toHaveBeenCalledTimes(1);
|
||||
expect(getHistorySessionIndex().items).toEqual([]);
|
||||
resolveRefresh();
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
it('is single-flight: a second call while a rebuild is pending is a no-op', async () => {
|
||||
let resolveRefresh: () => void = () => {};
|
||||
const refresher = vi.fn(
|
||||
() =>
|
||||
new Promise<void>((resolve) => {
|
||||
resolveRefresh = resolve;
|
||||
})
|
||||
);
|
||||
setHistoryIndexRefresher(refresher);
|
||||
|
||||
ensureHistorySessionIndexFresh();
|
||||
ensureHistorySessionIndexFresh();
|
||||
ensureHistorySessionIndexFresh();
|
||||
expect(refresher).toHaveBeenCalledTimes(1);
|
||||
|
||||
resolveRefresh();
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
// Snapshot still stale (the fake refresher wrote nothing) → next call runs again.
|
||||
ensureHistorySessionIndexFresh();
|
||||
expect(refresher).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('does not rebuild while the snapshot is fresh', () => {
|
||||
const refresher = vi.fn(async () => {});
|
||||
setHistoryIndexRefresher(refresher);
|
||||
setHistorySessionIndex([{ sessionId: 's', name: 'n', workingDir: '/d', timestamp: 1, live: false }]);
|
||||
ensureHistorySessionIndexFresh();
|
||||
expect(refresher).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('keeps the previous snapshot when a rebuild throws, and retries next time', async () => {
|
||||
setHistorySessionIndex([{ sessionId: 'keep', name: 'n', workingDir: '/d', timestamp: 1, live: false }], 1);
|
||||
const refresher = vi.fn(async () => {
|
||||
throw new Error('scan failed');
|
||||
});
|
||||
setHistoryIndexRefresher(refresher);
|
||||
|
||||
ensureHistorySessionIndexFresh();
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
expect(getHistorySessionIndex().items[0].sessionId).toBe('keep');
|
||||
|
||||
ensureHistorySessionIndexFresh();
|
||||
expect(refresher).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('is a no-op when no refresher is registered', () => {
|
||||
expect(() => ensureHistorySessionIndexFresh()).not.toThrow();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,97 @@
|
||||
/**
|
||||
* Session Options structural guard.
|
||||
*
|
||||
* The modal shares the `set-*` settings surface with App Settings, but its rail
|
||||
* is a real switcher: switchOptionsTab shows one `.set-section` and hides the
|
||||
* rest. Like App Settings, its load/save path is `getElementById` by a fixed set
|
||||
* of ids, so dropping or renaming an element in the markup fails silently — the
|
||||
* option just stops loading, or stops being written back.
|
||||
*
|
||||
* These tests read the REAL session-ui.js and index.html and pin that contract.
|
||||
*/
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
|
||||
const publicDir = resolve(import.meta.dirname, '../src/web/public');
|
||||
const html = readFileSync(resolve(publicDir, 'index.html'), 'utf8');
|
||||
const sessionUi = readFileSync(resolve(publicDir, 'session-ui.js'), 'utf8');
|
||||
|
||||
/** The Session Options markup, so assertions can't be satisfied elsewhere. */
|
||||
function optionsModal(): string {
|
||||
const start = html.indexOf('<div class="modal" id="sessionOptionsModal">');
|
||||
expect(start).toBeGreaterThan(-1);
|
||||
const end = html.indexOf('<!-- Close Session Confirmation Modal -->', start);
|
||||
expect(end).toBeGreaterThan(start);
|
||||
return html.slice(start, end);
|
||||
}
|
||||
|
||||
/** Body of a session-ui.js method, by name. */
|
||||
function methodBody(signature: string): string {
|
||||
const start = sessionUi.indexOf(`\n ${signature} {`);
|
||||
expect(start, `${signature} not found in session-ui.js`).toBeGreaterThan(-1);
|
||||
return sessionUi.slice(start, sessionUi.indexOf('\n },', start));
|
||||
}
|
||||
|
||||
const TABS = ['respawn', 'context', 'ralph', 'summary'];
|
||||
|
||||
describe('Session Options modal structure', () => {
|
||||
it('keeps every element openSessionOptions and switchOptionsTab touch by id', () => {
|
||||
const modal = optionsModal();
|
||||
const ids = new Set<string>();
|
||||
for (const sig of ['openSessionOptions(sessionId)', 'switchOptionsTab(tabName)', 'getRalphConfig()']) {
|
||||
for (const m of methodBody(sig).matchAll(/getElementById\('([A-Za-z0-9_-]+)'\)/g)) ids.add(m[1]);
|
||||
}
|
||||
// openSessionOptions also drives elements outside this modal (tabs, toasts);
|
||||
// only the ones it expects to find in here are this file's contract.
|
||||
const outside = new Set(['sessionOptionsDoc']);
|
||||
const missing = [...ids].filter((id) => !outside.has(id) && !modal.includes(`id="${id}"`));
|
||||
expect(missing).toEqual([]);
|
||||
expect(modal).toContain('id="sessionOptionsDoc"');
|
||||
});
|
||||
|
||||
it('pairs each rail entry with exactly one section, in the same order', () => {
|
||||
const modal = optionsModal();
|
||||
const rail = [...modal.matchAll(/class="set-rail-item[^"]*" data-tab="([a-z]+)"/g)].map((m) => m[1]);
|
||||
expect(rail).toEqual(TABS);
|
||||
for (const tab of TABS) {
|
||||
const hits = modal.split(`id="${tab}-tab"`).length - 1;
|
||||
expect(hits, `section ${tab}-tab should exist exactly once`).toBe(1);
|
||||
}
|
||||
// switchOptionsTab queries the rail by THIS class; `.modal-tab-btn` here
|
||||
// would silently stop the active marker from moving.
|
||||
expect(methodBody('switchOptionsTab(tabName)')).toContain("'#sessionOptionsModal .set-rail-item'");
|
||||
expect(methodBody('openSessionOptions(sessionId)')).toContain('.set-rail-item[data-tab="ralph"]');
|
||||
});
|
||||
|
||||
it('opens with exactly one section visible, the rest hidden', () => {
|
||||
const modal = optionsModal();
|
||||
const visible = TABS.filter((t) => modal.includes(`<section class="set-section" id="${t}-tab"`));
|
||||
expect(visible).toEqual(['respawn']);
|
||||
for (const t of TABS.filter((t) => t !== 'respawn')) {
|
||||
expect(modal).toContain(`<section class="set-section hidden" id="${t}-tab"`);
|
||||
}
|
||||
});
|
||||
|
||||
it('keeps the Claude-only rail entries marked, so external CLIs lose them', () => {
|
||||
const modal = optionsModal();
|
||||
for (const tab of ['respawn', 'ralph']) {
|
||||
const entry = modal.match(new RegExp(`<button[^>]*data-tab="${tab}"[^>]*>`))?.[0] ?? '';
|
||||
expect(entry, `${tab} rail entry`).toContain('data-claude-only');
|
||||
}
|
||||
expect(modal.match(/<button[^>]*data-tab="context"[^>]*>/)?.[0]).not.toContain('data-claude-only');
|
||||
});
|
||||
|
||||
it('uses the shared settings surface rather than the modal-tab chrome', () => {
|
||||
const modal = optionsModal();
|
||||
expect(modal).toContain('class="modal-content modal-lg set-shell"');
|
||||
expect(modal).toContain('class="set-body"');
|
||||
expect(modal).not.toContain('class="modal-tabs"');
|
||||
expect(modal).not.toContain('modal-tab-btn');
|
||||
expect(modal).not.toContain('modal-tab-content');
|
||||
// The `set-*` rules are shared by both modals through one :is() scope.
|
||||
const css = readFileSync(resolve(publicDir, 'styles.css'), 'utf8');
|
||||
expect(css).toContain(':is(#appSettingsModal, #sessionOptionsModal, #createCaseModal) .set-row {');
|
||||
expect(css).toContain(':is(#sessionOptionsModal, #createCaseModal) .set-section.hidden {');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,102 @@
|
||||
/**
|
||||
* @fileoverview Worktree/branch identity on session rows (#265, #266).
|
||||
*
|
||||
* Two behaviours are pinned here:
|
||||
* - the unified merge carries gitBranch/worktreeName/worktreeRepo through from
|
||||
* the history source, and filterAndPaginate can search them;
|
||||
* - the client-side badge helper renders `⑂ name · branch`, and stays SILENT
|
||||
* when only a branch is known (a branch is not a worktree — badging those
|
||||
* would put `⑂ master` on every ordinary session).
|
||||
*
|
||||
* The transcript extractor itself lives inside a closure in session-routes.ts
|
||||
* and is covered by the route tests; what matters at this level is that the
|
||||
* fields survive the merge and reach a label.
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
mergeUnifiedSessions,
|
||||
filterAndPaginate,
|
||||
type UnifiedSessionItem,
|
||||
} from '../src/services/unified-session-service.js';
|
||||
|
||||
const historyRow = (over: Record<string, unknown> = {}) => ({
|
||||
sessionId: 's1',
|
||||
workingDir: '/repo/.claude/worktrees/autodev',
|
||||
sizeBytes: 9000,
|
||||
lastModified: '2026-01-01T00:00:00.000Z',
|
||||
...over,
|
||||
});
|
||||
|
||||
describe('worktree fields through the unified merge (#266)', () => {
|
||||
it('carries gitBranch / worktreeName / worktreeRepo from the history source', () => {
|
||||
const merged = mergeUnifiedSessions({
|
||||
history: [historyRow({ gitBranch: 'feat/CF-195', worktreeName: 'autodev', worktreeRepo: '/repo' })],
|
||||
});
|
||||
expect(merged).toHaveLength(1);
|
||||
expect(merged[0].worktreeName).toBe('autodev');
|
||||
expect(merged[0].gitBranch).toBe('feat/CF-195');
|
||||
expect(merged[0].worktreeRepo).toBe('/repo');
|
||||
});
|
||||
|
||||
it('leaves the fields undefined for a non-worktree session rather than inventing them', () => {
|
||||
const merged = mergeUnifiedSessions({ history: [historyRow({ workingDir: '/plain/repo' })] });
|
||||
expect(merged[0].worktreeName).toBeUndefined();
|
||||
expect(merged[0].gitBranch).toBeUndefined();
|
||||
});
|
||||
|
||||
it('finds a session by worktree name and by branch', () => {
|
||||
const items = [
|
||||
{ sessionId: 'a', worktreeName: 'autodev', sources: ['history'] },
|
||||
{ sessionId: 'b', gitBranch: 'feat/CF-195', sources: ['history'] },
|
||||
{ sessionId: 'c', sources: ['history'] },
|
||||
] as unknown as UnifiedSessionItem[];
|
||||
|
||||
expect(filterAndPaginate(items, { q: 'autodev' }).sessions.map((s) => s.sessionId)).toEqual(['a']);
|
||||
expect(filterAndPaginate(items, { q: 'cf-195' }).sessions.map((s) => s.sessionId)).toEqual(['b']);
|
||||
expect(filterAndPaginate(items, { q: 'nothing' }).sessions).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Mirrors `_worktreeLabel` in terminal-ui.js. The frontend is plain browser JS
|
||||
* with no module exports, so the logic is restated here; the rule it encodes —
|
||||
* never print a branch that merely restates the worktree name — is the part
|
||||
* worth pinning.
|
||||
*/
|
||||
function worktreeLabel(s: { worktreeName?: string; gitBranch?: string }): string {
|
||||
const name = s.worktreeName;
|
||||
if (!name) return '';
|
||||
let branch = s.gitBranch || '';
|
||||
if (branch === name || branch === `worktree-${name}`) branch = '';
|
||||
if (branch.length > 24) branch = branch.slice(0, 23) + '…';
|
||||
return '⑂ ' + [name, branch].filter(Boolean).join(' · ');
|
||||
}
|
||||
|
||||
describe('worktree badge label', () => {
|
||||
it('renders name and branch together', () => {
|
||||
expect(worktreeLabel({ worktreeName: 'autodev', gitBranch: 'feat/CF-195' })).toBe('⑂ autodev · feat/CF-195');
|
||||
});
|
||||
|
||||
it('renders NOTHING when only a branch is known — a branch is not a worktree', () => {
|
||||
// Every ordinary repo session carries gitBranch. Badging those would put
|
||||
// `⑂ master` on every row and bury the worktree rows this badge is for.
|
||||
expect(worktreeLabel({ gitBranch: 'master' })).toBe('');
|
||||
expect(worktreeLabel({ gitBranch: 'feat/CF-200' })).toBe('');
|
||||
});
|
||||
|
||||
it('does not repeat the name when the branch just restates it', () => {
|
||||
expect(worktreeLabel({ worktreeName: 'autodev', gitBranch: 'autodev' })).toBe('⑂ autodev');
|
||||
expect(worktreeLabel({ worktreeName: 'autodev', gitBranch: 'worktree-autodev' })).toBe('⑂ autodev');
|
||||
});
|
||||
|
||||
it('is empty for a session that is not on a worktree', () => {
|
||||
expect(worktreeLabel({})).toBe('');
|
||||
});
|
||||
|
||||
it('truncates a long branch so the single-line badge row cannot blow out', () => {
|
||||
const label = worktreeLabel({ worktreeName: 'wt', gitBranch: 'feature/VERY-LONG-BRANCH-NAME-THAT-KEEPS-GOING' });
|
||||
expect(label.length).toBeLessThanOrEqual(2 + 2 + 3 + 24);
|
||||
expect(label.endsWith('…')).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -32,8 +32,8 @@ describe('shortcut registry and overlay', () => {
|
||||
expect(appSource).not.toContain("key: '/'");
|
||||
});
|
||||
|
||||
it('exposes shortcut overrides in a dedicated App Settings shortcuts tab', () => {
|
||||
expect(htmlSource).toContain('data-tab="settings-shortcuts"');
|
||||
it('exposes shortcut overrides in a dedicated App Settings shortcuts section', () => {
|
||||
expect(htmlSource).toContain('data-section="settings-shortcuts"');
|
||||
expect(htmlSource).toContain('id="settings-shortcuts"');
|
||||
expect(htmlSource).toContain('id="appSettingsShortcutsList"');
|
||||
expect(htmlSource).not.toContain('id="appSettingsShortcutOverrides"');
|
||||
@@ -192,7 +192,7 @@ describe('shortcut settings persistence and capture', () => {
|
||||
expect(app.showToast).toHaveBeenCalledWith('Shortcut must include Ctrl, Cmd, or Alt', 'error');
|
||||
});
|
||||
|
||||
it('renders the shortcuts list when the Shortcuts settings tab is opened', () => {
|
||||
it('renders the shortcuts list when the Shortcuts settings section is reached', () => {
|
||||
const { app, elements } = loadSettingsHarness();
|
||||
elements.appSettingsModal = { querySelectorAll: () => [] };
|
||||
app.renderShortcutSettingsList = vi.fn();
|
||||
@@ -200,7 +200,7 @@ describe('shortcut settings persistence and capture', () => {
|
||||
app.switchSettingsTab('settings-shortcuts');
|
||||
expect(app.renderShortcutSettingsList).toHaveBeenCalledTimes(1);
|
||||
|
||||
app.switchSettingsTab('settings-display');
|
||||
app.switchSettingsTab('settings-terminal');
|
||||
expect(app.renderShortcutSettingsList).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
|
||||
@@ -3,12 +3,28 @@ 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 } })
|
||||
.CodemanTabOverflow;
|
||||
return (
|
||||
context.window as {
|
||||
CodemanTabOverflow: {
|
||||
shouldAutoWrapTabs: (input: unknown) => boolean;
|
||||
computeTabScrollLeft: (input: ScrollInput) => number;
|
||||
TAB_SCROLL_REVEAL_PX: number;
|
||||
};
|
||||
}
|
||||
).CodemanTabOverflow;
|
||||
}
|
||||
|
||||
describe('tab overflow layout policy', () => {
|
||||
@@ -63,3 +79,64 @@ 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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
/**
|
||||
* Voice stream request building.
|
||||
*
|
||||
* The audio format lives in the query string, so a drift between these params
|
||||
* and the browser worklet does not fail loudly — it transcribes as noise. These
|
||||
* tests pin the contract, and pin that the bearer token never leaks into a URL
|
||||
* (which would land it in proxy logs).
|
||||
*/
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
buildVoiceStreamHeaders,
|
||||
buildVoiceStreamUrl,
|
||||
normalizeVoiceLanguage,
|
||||
sanitizeKeyterms,
|
||||
} from '../src/web/voice-stream.js';
|
||||
import { MAX_KEYTERMS_HEADER_CHARS } from '../src/config/voice.js';
|
||||
|
||||
describe('buildVoiceStreamUrl', () => {
|
||||
it('pins linear16 / 16 kHz / mono, matching the browser worklet', () => {
|
||||
const url = new URL(buildVoiceStreamUrl({}, {}));
|
||||
expect(url.protocol).toBe('wss:');
|
||||
expect(url.host).toBe('api.anthropic.com');
|
||||
expect(url.pathname).toBe('/api/ws/speech_to_text/voice_stream');
|
||||
expect(url.searchParams.get('encoding')).toBe('linear16');
|
||||
expect(url.searchParams.get('sample_rate')).toBe('16000');
|
||||
expect(url.searchParams.get('channels')).toBe('1');
|
||||
expect(url.searchParams.get('stt_provider')).toBe('deepgram-nova3');
|
||||
});
|
||||
|
||||
it('carries the language hint', () => {
|
||||
expect(new URL(buildVoiceStreamUrl({ language: 'de' }, {})).searchParams.get('language')).toBe('de');
|
||||
});
|
||||
|
||||
it('accepts a ws:// or wss:// base override for tests and gateways', () => {
|
||||
const url = buildVoiceStreamUrl({}, { CODEMAN_VOICE_STREAM_BASE: 'ws://127.0.0.1:3199/' });
|
||||
expect(url.startsWith('ws://127.0.0.1:3199/api/ws/speech_to_text/voice_stream?')).toBe(true);
|
||||
});
|
||||
|
||||
it('ignores a non-websocket override rather than building a broken URL', () => {
|
||||
expect(buildVoiceStreamUrl({}, { CODEMAN_VOICE_STREAM_BASE: 'https://evil.example' })).toContain(
|
||||
'wss://api.anthropic.com'
|
||||
);
|
||||
});
|
||||
|
||||
it('never puts credentials in the URL', () => {
|
||||
expect(buildVoiceStreamUrl({ language: 'en' }, {})).not.toMatch(/token|Bearer|sk-ant/i);
|
||||
});
|
||||
});
|
||||
|
||||
describe('normalizeVoiceLanguage', () => {
|
||||
it.each([
|
||||
['en', 'en'],
|
||||
['en-US', 'en-US'],
|
||||
['multi', 'multi'],
|
||||
['', 'en'],
|
||||
[undefined, 'en'],
|
||||
['not a language', 'en'],
|
||||
['../../etc/passwd', 'en'],
|
||||
])('normalizes %s to %s', (input, expected) => {
|
||||
expect(normalizeVoiceLanguage(input as string | undefined)).toBe(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe('sanitizeKeyterms', () => {
|
||||
it('joins terms with commas', () => {
|
||||
expect(sanitizeKeyterms(['tmux', 'respawn'])).toBe('tmux,respawn');
|
||||
});
|
||||
|
||||
it('replaces an inner comma with a space so one term cannot become two', () => {
|
||||
expect(sanitizeKeyterms(['hello, world'])).toBe('hello world');
|
||||
});
|
||||
|
||||
it('drops non-ASCII, which is not portable in a header value', () => {
|
||||
expect(sanitizeKeyterms(['café', 'naïve'])).toBe('caf,nave');
|
||||
});
|
||||
|
||||
it('strips CR/LF so a term cannot inject a header', () => {
|
||||
const result = sanitizeKeyterms(['ok\r\nX-Evil: 1']);
|
||||
expect(result).not.toContain('\r');
|
||||
expect(result).not.toContain('\n');
|
||||
expect(result).toBe('okX-Evil: 1');
|
||||
});
|
||||
|
||||
it('dedupes and skips empties', () => {
|
||||
expect(sanitizeKeyterms(['a', 'a', '', ' ', 'b'])).toBe('a,b');
|
||||
});
|
||||
|
||||
it('truncates on a term boundary rather than mangling the last term', () => {
|
||||
const terms = Array.from({ length: 500 }, (_, i) => `term${i}`);
|
||||
const result = sanitizeKeyterms(terms);
|
||||
expect(result.length).toBeLessThanOrEqual(MAX_KEYTERMS_HEADER_CHARS);
|
||||
for (const term of result.split(',')) expect(term).toMatch(/^term\d+$/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildVoiceStreamHeaders', () => {
|
||||
it('sends the bearer token and identifies Codeman honestly', () => {
|
||||
const headers = buildVoiceStreamHeaders('sk-ant-oat01-test', '1.2.3');
|
||||
expect(headers.Authorization).toBe('Bearer sk-ant-oat01-test');
|
||||
expect(headers['User-Agent']).toBe('codeman/1.2.3 (voice-bridge)');
|
||||
expect(headers['x-app']).toBe('codeman');
|
||||
});
|
||||
|
||||
it('omits the keyterms header when nothing survives sanitizing', () => {
|
||||
expect(buildVoiceStreamHeaders('t', '1.0.0', ['', ' '])).not.toHaveProperty('x-config-keyterms');
|
||||
});
|
||||
|
||||
it('includes sanitized keyterms when present', () => {
|
||||
expect(buildVoiceStreamHeaders('t', '1.0.0', ['tmux', 'respawn'])['x-config-keyterms']).toBe('tmux,respawn');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user