* fix: follow session switch when moving between different-agent steps
Moving a task to a workflow step with a different agent profile makes the
backend complete the current session and promote a new primary session for
the new agent. The chat UI was supposed to follow that swap, but the
follow-focus in handleTaskUpdated was suppressed whenever the user had
pinned the session they were on (clicking into a session pins it). The pin
was on the very session being retired, so the user stayed stranded on the
now-completed session — still showing the old agent and plan mode.
The activeSessionId === previousPrimary guard already scopes the follow to
"user is sitting on the session being replaced", so the pin should only
protect a *different* still-live session (active-session drift), never the
one being swapped away. Extract shouldFollowPrimarySwap encoding that rule.
Because it keys off task.updated's primary_session_id it is independent of
WS event ordering and does not depend on the new session row having loaded.
* fix: address review feedback on session-switch follow-focus
Scope the follow-focus fix to genuine session retirement and fix the real
root-cause race, per Greptile/Codex/CodeRabbit review:
- Revert the task.updated pin bypass. Manual "Set as Primary" swaps the
primary without retiring the old session, so bypassing the pin there would
yank a pinned user off a still-live session. Restore pin preservation and
add a guard so a first-primary assignment (null previous primary) never
steals focus.
- Fix the actual bug in the session state-transition handoff: when a retired
session has no non-terminal successor in the per-task list yet (the switch
promotes the new primary via task.updated before the old session's terminal
state_changed lands), fall back to the task's authoritative primary. This
path only runs when the active session truly reaches a terminal state, so it
never fires for manual set-primary.
---------
Co-authored-by: Kandev Agent <agent@kandev.dev>
* feat(web): add app status bar
* feat(web): add app status bar visibility toggle
* feat(web): persist status bar item order
* fix(web): refine app status bar behavior
* fix(web): polish app status bar presentation
* test(web): split plugin slot coverage groups
* fix(web): address status surface review feedback
* fix(web): stabilize status bar overlay tests
* feat(plugins): add capability-gated Host reader for conversation content
Plugins can now read historical user/agent message content over the Host
data API, gated by a new `api_read:messages` capability (ADR 0047). This
unblocks "summarize yesterday"-style plugins that the live `message.added`
bus event alone cannot serve.
- proto: `ListMessages` RPC + `Message`/`MessageFilter` on `service Host`.
- SDK: `Host.Messages() MessageReader`, Go-native `Message`/`MessageFilter`.
- host: `api_read:messages` accessor + denied reader; content sanitized via
`sysprompt.StripSystemContent` (no raw system prompts); RFC3339 `since`/
`until` → `InvalidArgument` on bad input.
- data: new `ListMessagesForPlugin` filtered query (session/task ids, type,
created_at range) through the task service, never the DB file.
- tests: in-process + wire capability gating, content stripping, filter/time
passthrough, pagination, and the SQLite query.
- docs: ADR 0047, spec, plugins-manifest, plugins-authoring.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(plugins): add Host.InvokeUtilityAgent for settings-selectable utility agent
Plugins can now delegate a one-shot LLM step to an operator-configured agent,
gated by a new `agent_invoke` capability (ADR 0048), so a plugin needs no
provider API key of its own.
- setting: new `utility_agent_profile_id` user setting (Settings > System >
Utility Agent), a profile dropdown cloned from the existing utility-agent
path; selectable in the UI.
- capability: boolean `agent_invoke`, enforced like `state`/`secrets`.
- proto/SDK: `InvokeUtilityAgent(prompt) -> text` RPC and
`Host.InvokeUtilityAgent(ctx, prompt)`.
- backend: reuses the sessionless host-utility inference tier (ADR 0002) via a
thin adapter — no agent loop, no task/session/workspace; resolves the
configured profile to (agent type, model). Unconfigured / deleted profile ->
gRPC FailedPrecondition.
- tests: capability gating, completion path (asserts the configured profile is
used and text returned), no-utility-agent and stale-profile error paths,
SDK/wire round-trips, and the settings apply/trim behavior.
- docs: ADR 0048, spec, plugins-manifest, plugins-authoring.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(plugins): resolve utility-agent deps live and bound message filter size
Address CI + review feedback on the host plugin capabilities:
- docs: register the new /settings/system/utility-agent route in
docs/public/coverage.json (fixes the "Validate public docs" CI failure).
- utility agent (ADR 0048): hostUtilityMgr is wired late in boot — after
StartActivePlugins has already spawned boot-active plugins — so snapshotting
the utility deps in hostForPlugin stranded those hosts with nil deps and made
InvokeUtilityAgent return Unimplemented for their lifetime. Read the deps live
from the Service (mutex-guarded) at call time instead; add a regression test
for late wiring reaching an already-spawned host.
- messages (ADR 0047): cap the combined session/task/type filter values at 400
so an oversized filter fails fast with InvalidArgument instead of hitting
SQLite's host-parameter limit at query execution; add a test.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Kandev Agent <agent@kandev.dev>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat: add plugin system with grpc runtime and native ui plugins
- go plugin backends ship as release tarballs (per-platform binaries, checksums) that
kandev installs from url/upload or filesystem sideload+sync, then spawns and
supervises via hashicorp/go-plugin (unix socket / loopback tcp+automtls)
- strict typed grpc contract (kandev.plugin.v1): DeliverEvent/InvokeTool/HandleWebhook
plus a capability-gated host service (state, secrets, emit); author sdk in
pkg/pluginsdk; packaging cli in cmd/plugin-pack
- native frontend bundles: window.registerKandevPlugin + registry/host loader with
dynamic routes, nav items, slots and ws handlers; plugins settings page with
install dialog, sync button and enable/disable/uninstall
- gated behind the plugins feature flag (prod off, dev/e2e on); covered by unit and
integration tests plus playwright e2e against a real packaged fixture plugin
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(plugins): hydrate base app context on plugin routes
Plugin-owned SPA routes classify as RouteUnknown, so the boot payload shipped
only feature flags — no workspaces, workflows, or kanban context. Native plugin
UI (e.g. host.ui.TaskCreateDialog) then had an empty store. Hydrate the same
base context as the Home route for unknown SPA routes.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(plugins): expose TaskCreateDialog on host.ui
Lets a native plugin open kandev's real create-task modal (prefilled via
initialValues) instead of POSTing directly.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(plugins): harden filesystem/URL sinks against CodeQL findings
Add sink-level guards on the plugin trust boundary: securejoin containment on
archive extraction (zip-slip), plugin-id validation before every fs_store path,
http/https scheme validation on install-from-URL, and non-interpolated ws-handler
error logging.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(plugins): address review findings (races, resource caps, lifecycle)
- serialize per-plugin startup to close a TOCTOU double-spawn race, and let a
plugin restart after exhausting attempts
- bound plugin startup with an explicit 30s timeout
- reject backslash paths and cap decompressed bytes by actual size in pkgtar;
only mark a package Signed when a verifier actually ran and passed
- delete plugin_state on uninstall; validate webhook keys against the manifest
and cap webhook body size; restart an active plugin when a new version installs
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(plugins): sanitize plugin-id path sinks for CodeQL
Pin the plugin id to a single path segment with filepath.Base at recordPath/
configPath and pkgtar.Remove — a barrier the taint analysis recognizes.
Behaviour-identical for valid ids (already validated by safePluginID); clears
the go/path-injection alerts.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(plugins): inline plugin-id validation at path sinks for CodeQL
filepath.Base does not strip '..', so it is not a path-injection barrier. Call
the recognized safePluginID / id-pattern guard inline in Delete/GetConfig/
SetConfig and pkgtar.Remove — the same barrier that already cleared the Get sink.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(plugins): address code-review findings (backend)
- add capability-gating tests for the Host RPC service (was untested)
- close a stop-during-start orphan race so Disable/Uninstall can't leave a
supervised subprocess behind
- on failed upgrade, remove only the new version dir (not all versions + data);
restart the prior version
- validate manifest version and reject ids ending in .config (record-file alias)
- make Deliverer.Refresh non-blocking and cancel in-flight delivery on worker stop
- clamp out-of-range webhook status, skip-and-warn corrupt records in List,
write records atomically, semver-aware sideload version pick
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(plugins): address code-review findings (frontend)
- wrap plugin routes + settings routes in a shared PluginErrorBoundary so a
throwing plugin page shows a fallback instead of white-screening the SPA
- fix disable/re-enable in one session: keep the cached registration and
re-run initialize instead of relying on a cached ES-module re-import
- prefix root-relative bundle/style URLs with apiBaseUrl (split-origin/Tauri)
- surface partial-install warnings via toast.warning instead of a bare success
- dedupe/remove injected style links on unload; gate PluginNavItems on the flag
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(plugins): serialize per-plugin lifecycle + guard config writes
- add a per-plugin keyed lock in Service so concurrent Enable/Disable/Install/
Uninstall/UpdateConfig for one id are serialized (no status-machine race)
- take s.mu in SetConfig/GetConfig and write config atomically
- bound each plugin initialize() with a timeout so one hung bundle can't stall
the rest of the boot load
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* test(plugins): split service_test.go to satisfy file-length lint
Move the install/URL test group into service_install_test.go; no logic change.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* ci: build and ship the e2e fixture plugin package to E2E shards
The plugin E2E specs' global-setup requires apps/backend/.build/kandev-plugin-e2e-1.0.0.tar.gz.
Build it in the E2E build job, upload it as an artifact, and download it in both
the regular and container shard jobs.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Kandev Agent <agent@kandev.dev>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* fix(scripts): launch dev-isolated backend via __backend subcommand
The kandev binary became a bundle launcher with the restart supervisor
(ADR 0019) and exits without KANDEV_BUNDLE_DIR when invoked bare; the
isolated dev instance must run the backend entrypoint directly.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(mcp): session spawning, sibling messaging, and renameable tabs
- spawn_session_kandev spawns an additional agent session on an existing
task (default: the caller's own) via LaunchSession(IntentStart), with
optional agent_profile_id (defaults to the spawner's profile) and a
session name; the initial prompt carries spawner attribution and reply
instructions.
- message_task_kandev accepts an optional session_id to target a specific
session, enabling same-task sibling messaging. Explicit targets are
pinned through dispatch so the idle-path primary-session re-resolution
cannot reroute a sibling message back to its sender (regression-tested).
- task_sessions gains a name column (migration ordered after the
agent_execution_id-drop rebuild), a session.rename WS action, and inline
tab rename UI; custom names take precedence over derived agent/model
labels across the tab, agents dropdown, mobile list, chat header, and
dockview panel titles.
- Inter-session messages stamp sender_session_name into metadata so the
sender badge shows which session sent the message.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(mcp): address review findings on session spawn/rename/messaging
- include name in task-session DTOs (list + full, no omitempty) so
persisted tab names survive list hydration and clears propagate
- sanitize sender task/session ids embedded in <kandev-system> blocks
- reject cross-workspace spawn targets; require sender_task_id
- truncate session names by runes, not bytes
- distinguish not-found from internal errors in message target lookup
- guard TabRenameInput blur-commit after Enter/Escape resolution
- cap optimistic rename length client-side and toast on rename failure
- add tests: spawn launch-failure and cross-workspace paths, renameSession
API client
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(mcp): round-two review fixes for spawn/rename lint and hardening
- extract spawn validation/authorization helpers (cyclop/nestif limits)
- make stripSystemTag replace until stable to block nested tag evasion
- load session before rename so a durable rename can never skip its
broadcast; surface lookup failures to the caller instead
- drop redundant sessionLauncher nil check (guarded registration)
- add maxLength to the rename input matching the server cap
- add tests: nested sanitizer evasion, spawn rename-failure tolerance
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Kandev Agent <agent@kandev.dev>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* feat(web): add opt-in notification sound for waiting-for-input
Play a configurable per-device sound when a session starts waiting for
user input. Sounds are synthesized with the Web Audio API (no binary
assets), follow the same suppression rules as desktop notifications,
and work independently of the browser Notification permission.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* fix(web): drop stale sound plays when AudioContext resume is delayed
Scheduling notes on a suspended context queued every pending alert and
played them stacked once the user finally interacted. Schedule only
after resume() fulfills and drop plays older than 2s.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* fix(web): harden notification sound playback edge cases
Catch AudioContext construction failures so a broken audio stack cannot
abort the WS handler before the desktop notification is shown, route
interrupted (iOS) contexts through the resume path, and coalesce plays
queued while the context is not running so only one pending cue fires.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* fix(web): make sound test stubs constructible and refine resume queue
vi.fn with an arrow implementation is not constructible, so the
AudioContext stubs silently misbehaved on CI's Node runtime. Also keep
the newest pending play instead of dropping later requests, recheck the
preference before a resumed waiting-for-input cue fires, and validate
picker values with the existing type guard.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
---------
Co-authored-by: Kandev Agent <agent@kandev.dev>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
* feat: unarchive tasks with worktree branch recovery
Archived tasks can now be restored. Unarchive works for manual/legacy
archives (empty cascade id, root-only) and cascade archives. Archive
deletes the local branch, so worktree recreate now falls back to
fetching the branch from origin, and unarchive writes the old branch
back as checkout_branch so a fresh session resumes the prior work.
Unrecoverable branches (never pushed) surface a warning. WS archive now
routes through the handoff cascade so it stays stamped and reversible.
UI: unarchive action on /tasks archived rows and the task top bar.
* fix: address PR review feedback on unarchive recovery
Forward PRNumber to the recreate fetch so fork-PR heads (only reachable
via pull/<N>/head) are recoverable, publish task.updated per task in the
cascade unarchive loop so the UI restores without a reload, and
re-publish task.updated after a checkout_branch restore since the
unarchive event goes out with the pre-restore repository rows.
* fix: harden unarchive recovery per review feedback
Propagate branch-probe errors instead of misreporting recoverable work
as gone, and reserve ErrBranchUnrecoverable for a confirmed-missing
remote ref (transient fetch failures keep their own error). Validate
the archive root exists so WS/HTTP archive no longer reports success
for unknown task IDs, CAS-guard manual unarchive on an empty cascade
stamp, detach branch recovery from the request context, and skip
checkout_branch restore for multi-branch repos. UI: in-flight guard on
the list unarchive button, pluralized unrecoverable-branch toast.
* docs: document CAS guard on UnarchiveTask interface contract
---------
Co-authored-by: Kandev Agent <agent@kandev.dev>
* fix(sidebar): keep created subtasks during hydration
* fix(sidebar): preserve placeholder subtasks during hydration
* fix(sidebar): mark websocket placeholder snapshots
* perf(kanban): memoize derived board data to stop re-render storms
The board rebuilt the `tasks` array (and `stepOptions`/`sharedProps`) on every
store change, producing fresh object identities that defeated downstream memos
and re-rendered every card on each WebSocket message. Wrap them in useMemo so
the board only re-renders when its inputs actually change.
* perf(session-runtime): bound runtime memory and dedupe git-status compare
Three runtime-store fixes for the crash a heavy rebase triggered:
- Cap shell + terminal output buffers at 2MB (matching the existing process
buffer) so a chatty shell can't grow the store without limit.
- Purge per-session runtime state (messages, turns, shell/process buffers, and
the bySessionId maps) when a session is removed; env-scoped maps are dropped
only once no other session shares the environment.
- Run setGitStatus's deep per-file diff comparison once per update and return
whether anything changed, instead of recomputing it 2-3x. Under a massive
rebase (thousands of files, frequent updates) that comparison was the
dominant CPU cost and drove the render storm.
* refactor(session-runtime): drop now-unused gitStatusWouldMutate
The git-status handler no longer pre-checks for changes (setGitStatus now
returns whether it changed), leaving gitStatusWouldMutate with no callers.
* fix(session-runtime): enforce terminal cap on first and oversized chunks
appendTerminalChunk only evicted when more than one chunk was buffered, so a
single chunk larger than the 2MB cap slipped through untrimmed; the new-terminal
branch also pushed the first chunk without going through the cap at all. Clamp
each chunk to the tail of the cap and route the new-terminal path through
appendTerminalChunk. Addresses CodeRabbit review.
* fix(acp): handle claude-acp async_launched subagents end-to-end
Claude Agent SDK's Task tool with run_in_background=true returns
session/prompt with stop_reason=end_turn while subagents stay alive
in the background, reporting `_meta.claudeCode.toolResponse.status =
"async_launched"` instead of a terminal top-level status. kandev
previously treated stop_reason as authoritative, cancelled the
subagent card on prompt return, and fired an "agent finished without
producing any output" notice — even though the dispatch did produce
work and the subagent's child events would arrive seconds later.
Backend:
- `SubagentTaskPayload` gains `IsAsync` / `OutputFile` /
`CanReadOutputFile` so the async-launched envelope is preserved.
- `convertToolCallResultUpdate` recognizes async_launched as terminal
for the Task tool itself (dispatch is the completion).
- `cancelActiveToolCalls` preserves subagent_task tool calls through
the prompt-return sweep so a late terminal tool_call_update from
the SDK still lands correctly (covers the in-turn-complete case).
Frontend:
- `subagentMetaChips` emits a "background" chip when `is_async` or
`status == "async_launched"` so the card is visually distinct.
- `computeEmptyTurnNotice` suppresses the notice when any subagent
task message exists for the turn (the dispatch IS output).
Reproduced via `acpdbg prompt` against `claude-acp` 0.31.1; captured
frames in acp-debug/claude-acp-prompt-20260602-110119.jsonl drive the
backend test fixtures verbatim.
* fix(acp): harden async_launched override against future SDK frames
Address PR review feedback (greptile #1244, claude review):
- Move the async_launched override inside the mutex lock so it can
inspect the stored payload kind before flipping the status.
- Drop the `status == ""` precondition. The earlier in_progress
synthesis (Title/RawInput/Content/Locations) was implicitly trumping
the override; a future SDK frame that combines async_launched with
any of those fields would have left the card stuck in_progress
forever. The override is now unconditional for async_launched
subagent_task frames.
- Gate the override on `payload.Kind() == ToolKindSubagentTask`. A
hypothetical future claude-acp tool that reuses the `async_launched`
literal in its own `_meta.claudeCode.toolResponse.status` would have
been incorrectly marked complete by the prior unscoped check.
Pinned by two new tests:
- TestConvertToolCallResultUpdate_AsyncLaunched_OverridesInProgress
- TestConvertToolCallResultUpdate_AsyncLaunched_NonSubagentNotTerminated
---------
Co-authored-by: Kandev Agent <agent@kandev.dev>
Three areas were broken in the MCP add_branch_to_task flow:
1. Predictable branch names. autoBranchName counted up from existing
branch-N rows. Concurrent add_branch calls raced onto the same name
and the worktree directory leaked ordering. Now uses a random
branch-<3 random chars> suffix via worktree.SmallSuffix.
2. Sibling materialize did not flow to the UI. The materializer
published AgentctlReady with the NEW sibling worktree's path, which
the frontend handler wrote onto the session row — clobbering the
primary's worktree_id/branch and pointing the file browser at the
sibling instead of the task root. Added TaskWorkspacePath to the
payload, detected the sibling case on the frontend (existing
worktree_id != payload's), kept the primary's id/branch, repointed
worktree_path to the task root, bumped commits refetch, and dropped
the pre-multi-repo legacy git-status entry so the Changes panel
stops surfacing the frozen single-repo snapshot. File browser's
resetKey now includes the worktree count so the tree reloads when
the sibling lands.
3. workspace_path promotion + agentctl rescan refused to act on
historical envs. A few envs had workspace_path pointing at the
source repo's local_path (orchestrator computeWorkspacePath fallback
to req.RepositoryPath when resp.WorktreePath is empty) instead of
the primary worktree. The materializer's promotion gate compared
against env.WorkspacePath's parent, so those envs were never
promoted, and agentctl's resolveRescanPath only accepted
parent-of-current so the rescan to the real task root was rejected.
Trackers stayed on the wrong directory and the sibling's commits
never appeared in the Commits panel even after a full reload.
Anchored promotion on env.TaskDirName (basename cross-check) and
added a recovery branch to resolveRescanPath that accepts any
absolute directory which actually contains git-repo subdirs — the
bearer-token middleware already gates the endpoint and
scanRepositorySubdirs validates the children, so the relaxed check
stays safe.
Co-authored-by: Kandev Agent <agent@kandev.dev>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
* feat: add voice mode for chat input
Adds a voice input button to the chat composer with three transcription engines:
in-browser Web Speech API, in-browser Whisper via @huggingface/transformers, and
a server-side OpenAI Whisper fallback gated on KANDEV_VOICE_OPENAI_API_KEY.
User-configurable from a new /settings/voice-mode page:
- Master on/off toggle (defaults to on; opt-out, not opt-in)
- Engine preference (auto / Web Speech / Whisper Web / Whisper Server)
- Language hint (auto-detect or BCP-47 pinned)
- Activation mode (click-to-toggle or hold-to-talk)
- Auto-send after transcription
- Whisper Web model size (tiny / base / small)
- Editable Cmd+Shift+M keyboard shortcut
Voice settings live under user_settings.voice_mode as a nested JSON object —
no schema migration. Setting changes broadcast via the existing
user.settings.updated WS event so any open chat tab updates live.
The mic button is rendered on the right of the composer alongside the send
button (round primary fill; destructive fill with pulse ring while recording).
On browsers without a usable engine (e.g. iOS Chrome over HTTP) the button
stays visible as a muted greyed icon — tapping it surfaces a toast with the
reason (typically a missing secure context).
Whisper Web runs in a Web Worker with fp16 decoder weights (avoids the
MatMulNBits scale-missing crash that q4/q8 hit on WASM). Models are pulled
from the onnx-community/* mirror and cached by transformers.js in the Cache
Storage on first use.
* fix: address voice mode PR review feedback and E2E selector clash
Bot-review fixes from #1159:
- Map http.MaxBytesError from c.FormFile to 413 instead of bare 400.
- Strip BCP-47 region suffix before passing language hint to Whisper Web.
- Recover from worker crash: terminate + null worker + reset ready flag so
next init/transcribe creates a fresh worker instead of hanging.
- Read live store state via storeApi.getState() in voice-mode settings
savers to avoid stale-closure clobbering concurrent settings updates.
- Detach Web Speech callbacks before abort() so trailing onend/onerror
events don't mutate hook state after cancel().
- Spread caller init before method/body in voice-api fetch so a caller-
supplied init can't override the multipart POST.
- Replace t.Fatalf with t.Errorf+return inside test HTTP handler goroutine
(FailNow from non-test goroutine is unsafe).
- Add Enabled/AutoSend zeroing assertions in partial-update test so the
bool-overwrite-on-PATCH behavior is locked in explicitly.
E2E fix:
- toolbar-overflow spec used button.rounded-full which now matches both
the send and voice buttons (strict-mode violation). Switched to the
submit-message-button testid for an unambiguous locator.
* fix: scope voice settings rollback + whisper worker error to changed keys
Round 2 of PR review feedback on #1159:
- voice-mode settings rollback now restores only the keys patched by the
failing request (not the whole snapshot), preserving concurrent edits
to unrelated voiceMode fields.
- Shortcut rollback diffs previous vs next and reverts only the changed
keys (restore prior value or delete if absent), preserving other
concurrent shortcut edits.
- Whisper worker error handler captures the worker reference at attach
time and only clears refs when this.worker still matches it — a stale
error from a previously replaced worker no longer nukes the active one.
- Update stale whisper-web-models JSDoc that still pointed at Xenova
mirrors.
* fix: voice mode progress scale, hold-mode race, race-aware rollback
Round 3 of PR review feedback on #1159:
- Normalise Whisper progress to 0–1 in ensureWhisperClient (transformers.js
emits 0–100, the button's display + the `ready: 1` convention expect
0–1 — fixes the 5000% mid-download display).
- Claim driverRef synchronously at the top of finishCapture so concurrent
pointerup + pointerleave invocations early-return instead of racing and
clobbering a freshly-started recording's ref.
- Race-aware rollback: voice-mode and shortcut catch branches now skip
reverting a key when latest[key] !== next[key]. A failed earlier request
no longer overwrites a later successful save to the same key.
- Update the Worker wire-protocol comment to show onnx-community/whisper-base
instead of the stale Xenova/* example.
---------
Co-authored-by: Kandev Agent <agent@kandev.dev>