Commit Graph

7297 Commits

Author SHA1 Message Date
Halldrix
eec5d2af64 fix(build): record the baked stamp clock, not the live one (#123308)
write-build-stamp.mjs rewrites the stamp's `builtAt` clock on every build, so the
whole-file hash moved under a receipt that had just recorded it. Two builds
overlapping on one tree failed with "Build inputs changed during compilation" for
a difference the build machinery itself made, and the caller turned that into
SystemExit(1).

Hash the stamp's provenance identity instead, key-sorted so a cosmetic
re-serialisation is not a change. The clock is excluded from that comparison but
recorded as `stampClock` — the value the bake actually embedded, captured from
the same read that produces __HERMES_INSTALL_STAMP__ — because packaging copies
the live stamp into the bundle after the receipt is written. Re-reading the stamp
at record time would certify a clock the output never contained, and
productCurrent would then report a bundle current whose packaged clock disagrees
with its baked main, which is exactly what detectBundleSwap must never do.

recordProduct still compares the prepared inputs, so a clock-only restamp cannot
throw. productCurrent additionally requires the recorded clock to match, so a
restamp between bake and packaging is a rebuild rather than a silent divergence.
A receipt predating the field compares by hash alone until the next build
rewrites it; the release packaging path never consults productCurrent.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-10-01 00:15:05 -05:00
Brooklyn Nicholson
90390bbd1a style(desktop): sort dead-session-prune import in contrib controller 2026-10-01 00:12:40 -05:00
Brooklyn Nicholson
0c59646fbc fix(desktop): drop the vestigial pruneComposerPopoutZones import
main removed pruneComposerPopoutZones from @/store/composer-popout (the
popout zones were reworked); dead-session drafts are pruned through
composer.ts's stashedDraftScopes/clearSessionDraft instead. The import was
unused and broke the desktop typecheck.
2026-10-01 00:12:40 -05:00
David Metcalfe
a2f0200111 fix(desktop): prune dead session references from renderer storage (#79001)
Pins, composer drafts, and queued prompts persist session ids in
localStorage and re-assert/probe them at boot. Sessions removed from
state.db (retention purge, manual delete) leave dead ids behind:
nothing ever drops them, so every boot re-requests the dead ids and the
gateway answers 404 "Session not found" once per stale reference,
forever.

Add a dead-session prune sweep that runs after the first sidebar list
payload (and re-runs, debounced, on later list changes). A stored id is
only declared dead after a by-id probe 404s on every known profile — a
404 is profile-scoped, so a single miss never prunes anything, and
non-404 probe failures (gateway mid-restart, network) defer the id
instead of dropping it. Dead pins are forgotten from pin-sync
bookkeeping before unpinning so the reconcile does not re-PATCH the
dead id (which would recreate the 404).

Fixes #79001
2026-10-01 00:12:40 -05:00
John Paul Soliva
612d8e44a2 fix(desktop): a fence character inside a code listing no longer ends the block
preprocessMarkdown and normalizeFilePreviewMath told code from prose with
CODE_FENCE_SPLIT_RE, which ends a fenced block at the first ``` or ~~~
anywhere, mid-line included, and whatever the opening marker was. The
rest of the listing then ran through the prose rewrites: citation
stripping turned arr[0] into arr, and $5 became \$5. EMPTY_FENCE_BLOCK_RE
matched an inner closing fence followed by the outer one, so a markdown
example fenced with ```` or ~~~ lost both closers, and the rest of the
message rendered inside the code block.

Both now use one line-based splitter with the CommonMark fence rule that
normalizeFenceBlocks already applies: a fence opens on its own line, and
only a line holding the same character, at least as long, closes it.
2026-10-01 00:07:19 -05:00
Brooklyn Nicholson
b20281dcc0 fix(desktop): preserve stale guard fork protection 2026-10-01 00:04:44 -05:00
Brooklyn Nicholson
46c60181f2 test(desktop): keep the notice/tip guard regressions and pin own-residue vs competing-view semantics 2026-10-01 00:04:44 -05:00
salch-cred
36ff1abd51 fix(desktop): graft this window's own turn residue instead of refusing the send (fixes #124005) 2026-10-01 00:04:44 -05:00
finn763
b236dca1b6 fix(desktop): keep pre-compression transcript reachable after in-place /compress (#105256) 2026-10-01 00:04:27 -05:00
finn763
722bcedb38 fix(desktop): keep one-question clarify answerable on a single-shape request
A backend without the batch wire parks one single-shape gateway request
per question while the tool args carry the canonical one-entry questions
array. The batch card has no qids to answer with, so it sat a
permanently-disabled '0 of 1 answered' preview until the live request
timed out unanswered. Route that skew to the live single card, whose own
question-text match still stands down on disagreement.

Closes #123126
2026-10-01 00:03:20 -05:00
brooklyn
b4ed99f3bb refactor(desktop/i18n): split ar.ts into ar_<topic> siblings
ar.ts had grown to 3,770 lines (2K-law violation, #79963). Split the
Arabic catalog into topic siblings and recompose it in ar.ts via
defineLocale():

  ar.ts -> ar_artifacts.ts     (artifacts, artifactCard, artifactPreview, preview)
          ar_assistant.ts     (assistant)
          ar_boot.ts          (boot, remoteDisplayBanner, updates, guidedGreeting, install, onboarding)
          ar_capabilities.ts   (catalog, skills, agents)
          ar_chat.ts           (composer, statusStack, prompts, desktop, tips)
          ar_chrome.ts         (externalOpenFailed, fileMenu, titlebar, keybinds, sidebar, shell, rightSidebar, zones, contextMenu)
          ar_command_center.ts (commandCenter, messaging, profiles, cron)
          ar_common.ts         (sharedMetrics, common, ui)
          ar_connectors.ts     (sessionImport)
          ar_diagnostics.ts    (sendDiagnostics, notifications, errors)
          ar_settings.ts       (language, settings, modelAssignment, modelPicker, modelVisibility, interfaceMode)

Pure move: every member block moved byte-verbatim; the composed catalog
is value-identical to HEAD's ar.ts (4,935 leaf keys). Members added on
main since the original proposal were assigned to their topic shard.

Fixes #79963
Part of #78647

Co-authored-by: Finn763 <148605355+Finn763@users.noreply.github.com>
2026-10-01 00:01:52 -05:00
Brooklyn Nicholson
374406c2f8 style(desktop): import order in the focused-pane test suites 2026-10-01 00:01:30 -05:00
Brooklyn Nicholson
349372199a fix(desktop): build the focused-pane cwd on main's session focus; keep it through Files/Terminal chrome
Main now derives $focusedStoredSessionId from $focusedTreePaneId, which keeps
the last chat while the sessions list holds focus. Drop this branch's second
derivation and widen that rule instead: any non-chat zone (Files, Terminal,
preview tabs) keeps the remembered chat, so the Files rail no longer jumps to
the primary's project when you click into it.
2026-10-01 00:01:30 -05:00
Brooklyn Nicholson
3d33017cd1 test(desktop): drive the tree-based focused-session derivation in the statusbar suite
$focusedStoredSessionId now derives from $activeTreeGroup + $layoutTree, so
focusing a tile in tests must set the main zone's active pane, not only the
session-focus pane atom.
2026-10-01 00:01:30 -05:00
Brooklyn Nicholson
5285869db4 fix(desktop): import $activeTreeGroup for the focused-session derivation 2026-10-01 00:01:30 -05:00
Mohammad
b90aa65651 fix(desktop): add session-history fallback to $focusedWorkspaceCwd for primary sessions
When switching between multiple open project tabs, the primary workspace
could temporarily report an empty cwd before workspaceCwdOwner caught up.
Fall back to the confirmed session cwd from $sessions / $focusedSessionState
so the right sidebar immediately resolves the workspace directory.
2026-10-01 00:01:30 -05:00
Mohammad
4c9cd25d96 fix(desktop): preserve file git change tints across multi-cwd sessions and sidebar refreshes
- Bind $repoChangeByPath to $repoStatusByCwd so file status decorations
  persist independently of active session ownership or detached chats.
- Add directory-level untracked inheritance to repoChangeKindForPath.
- Register right-sidebar workspace cwd in registerRepoStatusCwd and trigger
  refreshRepoStatus on sidebar tree refresh.
2026-10-01 00:01:30 -05:00
Mohammad
2cfb32447a fix(desktop): retain focused tile cwd when clicking sidebar chrome and expose $focusedWorkspaceCwd 2026-10-01 00:01:30 -05:00
Mohammad
1f44d42daf fix(desktop): sync right sidebar file tree to focused session tile
When switching between session tabs/tiles in the desktop app, the right sidebar file tree retained the primary workspace cwd ($selectedStoredSessionId) instead of updating to the focused session tile's cwd.

Bind RightSidebarPane to $focusedStoredSessionId and $focusedSessionState so the file tree updates immediately upon tab/tile interaction.
2026-10-01 00:01:30 -05:00
686f6c61
d63c6eb874 fix(desktop): keep the Linux tray across a minimize toggle
Tray.destroy() leaves the StatusNotifierItem interface exported, so a later
new Tray() in the same process cannot re-export and the panel icon dies.
Park the instance until quit or host loss instead of destroying it on disable.
2026-09-30 23:57:09 -05:00
Fabio Motta
6e78408e70 test(desktop): cover simple-git host tuple path 2026-09-30 23:57:02 -05:00
Fabio Motta
e7585e05ad fix(desktop): silence trusted simple-git binary warning 2026-09-30 23:57:02 -05:00
Brooklyn Nicholson
be7f7b1af9 test(desktop): voice-prefs mocks export $bargeInEnabled 2026-09-30 23:49:04 -05:00
Brooklyn Nicholson
4b3cbf8c60 fix(desktop): STT filter trailing strip + live barge-in disarm (#127275)
Address review feedback from @kvnloo:

1. The client-direct exact-phrase hallucination check stripped '.'/'!'
   anywhere in the transcript, while the relay only strips trailing
   ('cleaned.rstrip(".!")' in tools/voice_mode_transcript.py). 'thank. you'
   was dropped client-direct but kept relayed. Now a trailing strip
   (/[.!]+$/), with a regression pinning internal punctuation as a real
   turn on both paths.

2. Turning voice.barge_in off after the monitor armed did not disarm it:
   ensureBargeMonitor() gates on $bargeInEnabled only at creation, so a
   live config refresh saying barge-in is off left the armed monitor
   interrupting on speech. An atom subscription now stops + clears the
   live monitor on true->false (a mid-flight capture is dropped and the
   loop resumes normal listening). Focused mid-turn toggle test added.
2026-09-30 23:49:04 -05:00
Brooklyn Nicholson
bd32c718d8 style(desktop): type-only import and named-import order in the pop-out bridge 2026-09-30 23:45:41 -05:00
Brooklyn Nicholson
7c161aa1b4 fix(desktop): sort popout bridge imports in browser-popout-shell 2026-09-30 23:45:41 -05:00
Brooklyn Nicholson
bf4d603da9 fix(desktop): tighten popout bridge request typing
tsc -p found request.id used as string after a truthiness guard; narrow to
typeof string and capture before the async closure. Cast the script-runner
stub in the test to the runner type.
2026-09-30 23:45:41 -05:00
Brooklyn Nicholson
33ebf9f11a fix(desktop): keep agent browser control after pop-out
After pop-out the live webview lives in the Browser window while the chat
window still owns the active-session gate, so drive_preview/read_preview
answered NOTHING_OPEN/empty from the chat window. Forward both over the
shared window relay (IPC in the app, BroadcastChannel as a dev fallback)
and answer with the pop-out's result; a silent pop-out falls back to the
legacy local error. Both pop-out legs now ride one relay surface.

Adapted from #101530 by FalconOrtiz (rebased onto the server-requests
split; transport moved from BroadcastChannel-only to the IPC relay so it
works in packaged file:// windows).

Co-authored-by: FalconOrtiz <falcon.ortiz11@gmail.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-30 23:45:41 -05:00
Miguel Chaves
809b1aa50b fix(desktop): route pop-out browser annotations to origin 2026-09-30 23:45:41 -05:00
hermes-seaeye[bot]
484ebdf16a fmt(js): npm run fix on merge (#129961)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-10-01 04:11:49 +00:00
Brooklyn Nicholson
544df42d78 fix(desktop/radio): resume the preserved stream when dictation lands in the composer
Fluid Voice (and dictation tools like it) pauses other audio over the
macOS media session to hear the microphone, then inserts the transcript
into the focused field — a guarded paste in Chromium/Electron. No
media-session resume signal ever follows, which is why preserving the
stream alone (the reporter's own experiment) still left radio paused.

Track the generation that last reached 'playing' (playGeneration); an
external pause keeps the element intact (status 'paused'), and a paste
into an editable field resumes it via a plain element.play(). The
plugin's own Pause stays destructive (toggle() from 'live' runs stop(),
clearing the element), so a paste never resumes an intentionally paused
stream and Play reconnects from scratch. Never resumed on window focus.

Credit: Kokhlo's PR #108115 contributed the non-destructive external
pause and the survival test.

Co-authored-by: kokhlo <konstantin.khlopkov93@gmail.com>
2026-09-30 23:11:19 -05:00
kokhlo
356a80bfe3 fix(desktop/radio): preserve stream on external pause for Fluid Voice resume (#108113)
Browser/media controls pause the element outside our buttons. The old handler
tore down the stream (cleared src, invoked load(), removed the element, and
advanced the generation) before macOS Fluid Voice or any external media
controller could resume it.

The internal Pause control already increments the generation first, so its
resulting media pause is safely ignored by current(). The external-event
branch can therefore be non-destructive: set status to 'paused' instead of
calling stop(). The 'playing' event already transitions the state back to
'live', so resume is possible.

Add a focused lifecycle test: external pause changes status to 'paused',
element and src survive, and a synthetic 'playing' event brings the stream
back to 'live'.
2026-09-30 23:11:19 -05:00
Brooklyn Nicholson
346aab2f8c fix(desktop): fix lint in skill provenance files (hermes-agent 128387) 2026-09-30 23:10:24 -05:00
Brooklyn Nicholson
0002e60e5c fix(desktop): complete SkillInfo fixture fields in provenance test 2026-09-30 23:10:24 -05:00
Brooklyn Nicholson
c13232e90c feat(skills): external mounts get an 'external' provenance tier (#108032)
Skills mounted from skills.external_dirs are externally authored, but every
provenance surface flattened them into 'agent': the usage report, the
dashboard's set-based twin, the desktop catalog/detail UI, and the learning
journey graph (where a used external mount rendered as a learned skill).

- tools/skill_usage: provenance() gains an 'external' tier (external-only:
  a local copy of the same name keeps winning, mirroring is_agent_created's
  external exclusion and _external_skill_names() scans external dirs once
  for the router's set-based twin.
- web_routers/skills: /api/skills classifies hub > bundled > external > agent.
- learning_graph: skills whose path resolves under an external dir are tagged
  source='external' and are never learning milestones — a mount is configured,
  not learned, even when used.
- desktop: SkillInfo.provenance gains 'external'; the catalog labels mounts
  'external' and the detail pane keeps them editable in place. Origin only,
  never mutability: read-only enforcement for external mounts was deliberately
  rejected by maintainers (commit 8c8fc6c1ec / PR #17512 — a refusal just
  breeds silent local duplicates). Provenance decisions extracted into
  skill-provenance.ts pure helpers (testable without source-reading tests).
- curator: usage report/filter knows the 'external' origin.

The desktop edit logic in this commit is the natural reading of 8c8fc6c1ec
(external dirs exist so the user can point Hermes at a vault they curate
elsewhere); if maintainers prefer the read-only half as well, it slots in
behind isEditableProvenance() in one place.
EOF
)
2026-09-30 23:10:24 -05:00
Brooklyn Nicholson
ab910f0c7b refactor(desktop/i18n): split zh-hant.ts into zh-hant_<topic> siblings
Part of #79958

Splits the Traditional Chinese override table (4419 lines on main) into the
same 11 topic files as the source PR, leaving zh-hant.ts as the composing
defineLocale file (177 lines) with the export name zhHant unchanged:

- zh-hant_artifacts.ts (artifacts, artifactCard, artifactPreview, preview)
- zh-hant_assistant.ts (assistant)
- zh-hant_boot.ts (boot, remoteDisplayBanner, updates, guidedGreeting, install, onboarding)
- zh-hant_capabilities.ts (skills, starmap, agents)
- zh-hant_chat.ts (composer, statusStack, prompts, desktop, tips)
- zh-hant_chrome.ts (fileMenu, titlebar, sidebar, shell, rightSidebar, zones, contextMenu)
- zh-hant_command_center.ts (commandCenter, messaging, profiles, cron)
- zh-hant_common.ts (common, billingBlock, ui)
- zh-hant_connectors.ts (sessionImport)
- zh-hant_diagnostics.ts (notifications, sendDiagnostics, errors)
- zh-hant_settings.ts (language, settings, modelPicker, modelVisibility)

Rebuilt against current main: main added six top-level keys since the source
PR branched (externalOpenFailed, sharedMetrics, intro, catalog, modelAssignment,
interfaceMode). They sit outside every topic boundary of the split scheme, so
they stay inline in the composing file at their original positions; the 41
mapped keys moved to the same topics with the same filenames as the source PR.

zh-hant is a sparse override: siblings carry satisfies Pick<TranslationOverrides,...>
and the composition keeps the original top-level key order. No re-export shim.

Co-authored-by: finn763 <165816600+finn763@users.noreply.github.com>
2026-09-30 23:09:55 -05:00
aydnOktay
3e1539ba1d fix(desktop): keep the mid-run composer input full-width (#99728)
Unstacked editor sat in a 1fr grid column with flex-1 + an 8rem min-width floor, so the contenteditable collapsed to the measured 129pt sliver while queue controls crowded the row. Fill the grid track with w-full min-w-0 instead.
2026-09-30 23:09:39 -05:00
David Metcalfe
aed18fdfbf fix(desktop): keep MEDIA attachments and prose when a turn also generated an image
stripGeneratedImageEchoes stripped EVERY markdown image and #media: link
from a final message whenever an image_generate tool result was present
in the same turn's parts. A completed response that carried a MEDIA:
directive for a different file lost its attachment link entirely, and a
message whose only content was such a directive rendered blank after the
empty text part was dropped by dedupeGeneratedImageEchoesInParts
(#80621).

Scope the strip to echoes of the actual generated source:
- markdown images are removed only when the src matches a source,
  with a lookahead so a longer path (a.png.bak) is not eaten by a
  prefix source (a.png)
- #media: links are removed only when the encoded path matches a
  source, anchored to the closing paren so prefix paths survive
- bare-path stripping no longer matches inside markdown link
  destinations, so [label](/tmp/a.png) is not corrupted to [label]()

Also wrap parseMarkdownIntoBlocksCached in a lexer-throw fallback so a
single pathological message cannot unwind through MessageRenderBoundary
to the root boundary and blank the whole transcript.

Closes #80621

Rebased onto current main: the strip keeps main's whitespace semantics — main deliberately stopped normalizing whole-document whitespace through media extraction (3e1b7af21b), so the same-file echo test now expects the two-space gap the removed image leaves instead of the old collapsed form. The scoped strip itself is unchanged.
2026-09-30 23:09:23 -05:00
Derek Moore
9733b3fd8a test(desktop): run ui test project in development mode for React act
React 19.1+ stubs `react`.act to undefined in the production build.
@testing-library/react@16's act() delegates to `React.act`, so any run
with NODE_ENV=production throws "React.act is not a function" and every
render() in the ui (jsdom) project fails — ~957 tests across the whole
src tree.

Force NODE_ENV=development for the ui test worker via the project's
test.env so `act` resolves to a real function regardless of any ambient
NODE_ENV (e.g. a shell that inherited NODE_ENV=production from a desktop
launch). This restores the ui project without touching the app's React
runtime or the production build.

Verified: src/store (597), src/lib (604), src/components (545), src/app
(1374), src/i18n + misc (28) all pass. Remaining ui failures are two
pre-existing test files (no-native-title.test.ts, ipc.test.ts) that
import node: builtins under jsdom — unrelated to this change.
2026-09-30 23:08:21 -05:00
Brooklyn Nicholson
4235742381 fix(desktop): fall back the Skills Hub picker to GitHub Pages when Vercel refuses
Vercel's WAF denies some residential IP ranges for the whole docs domain,
which leaves the Bot Mode Skills Hub picker on a 403 block page with no
alternate origin (#118203). Probe the primary picker URL before each
browse session and fall back to the equivalent GitHub Pages deployment
when it is unreachable, refuses us, or hangs past an 8s deadline; picker
installs are accepted from either trusted origin but still gated on our
own frame's contentWindow. Documented as an FAQ troubleshooting entry.

Re-lands the surviving half of #118206 (its Capabilities picker target
was since deleted by the native catalog).

Fixes #118203

Co-authored-by: KoNit-K <konit.block@protonmail.com>
2026-09-30 23:07:37 -05:00
Brooklyn Nicholson
e18c62c896 fix(desktop): sort imports in file-tree test for eslint (hermes-agent 128581) 2026-09-30 23:07:23 -05:00
chenbin
41fd7847ad fix(desktop): stop context-menu item clicks from spurious preview open
Closes #101263

ContextMenuItem onSelect handlers (Copy path, Delete…, Reveal…) fire
their intended actions AND open the file preview — every menu item except
Rename… triggers onPreviewFile alongside its own action.

Root cause: Radix ContextMenu renders its portal content in document.body,
so clicks on menu items bubble as React synthetic fiber events (not DOM
events) to the nearest fiber ancestor — the row container div.  The
container's onClick=node.handleClick then fires onActivate →
onPreviewFile → openPreview for every menu item click.

A stopPropagation() wrapper before node.handleClick(e) does NOT stop
this: React dispatches the portal click directly onto the portal branch
of the fiber tree (there is no shared DOM bubble path through the row),
so the container's onClick still runs for it and the wrapper then calls
handleClick itself.

The Rename… flow already has a guard ($renamingPath) because it mounts an
inline input that would be immediately blurred by a preview panel opening.
The same guard cannot be applied broadly because single-click and
double-click legitimately need to activate previews.

Fix: only run arborist's handleClick when the click's DOM target is
actually inside the row container (e.currentTarget.contains(e.target)).
Portal-originated clicks — whose target lives in document.body, outside
the row — fail the containment check and are ignored. Real row clicks
(single, double, shift-attach) land inside the row's DOM and pass it.

review/file-tree.tsx is NOT affected: its menu wraps the row div as a
plain parent (no portal→fiber re-entry through the row), so it is left
untouched.

Regression test (tree.test.tsx) renders the real ProjectTree with the
real FileEntryContextMenu under jsdom: clicking "Copy path" in the
portaled menu must not call onPreviewFile (fails on main), while a real
single click still selects the row and a real double click still
previews the file (positive controls).
2026-09-30 23:07:23 -05:00
Xipong
7df7b2776f perf(desktop): bound messages-below scroll measurements 2026-09-30 23:07:15 -05:00
Xipong
9be397bdfc test(desktop): use workspace Electron resolver and clean timeline lint 2026-09-30 23:07:15 -05:00
Xipong
e6499b5a68 test(desktop): measure timeline geometry cost in native Chromium 2026-09-30 23:07:15 -05:00
Xipong
7115cd9592 perf(desktop): bound timeline position reads during history scrolling
Cache mounted prompt membership and binary-search current outer group geometry.
Invalidate membership only for message DOM changes; keep geometry live for
resizes and preserve content-visibility by never measuring skipped descendants.

Refs NousResearch/hermes-agent#125766
Refs NousResearch/hermes-agent#118782
2026-09-30 23:07:15 -05:00
Brooklyn Nicholson
da2f473bdb fix(desktop): track parkText/focusInput in the barge submit callback 2026-09-30 23:07:03 -05:00
Brooklyn Nicholson
32bff7c7f7 fix(desktop): replace import() type annotation in voice hook test 2026-09-30 23:07:03 -05:00
Brooklyn Nicholson
a236180b40 fix(desktop): park an undeliverable voice transcript instead of dropping it
A barge-in capture that survives the 5 s interrupt-settle window with
live busy still true could never be submitted: the composer's
submitVoiceTurn refuses while busy. Fold #123371's onSubmitRef pattern
(submitCapturedUtterance reads the latest callback, mirroring
onInterruptRef) and, when busy still never clears, park the transcript
in the composer input and focus it so a spoken interruption is always
visible — never silently dropped (#123357 part 1). Also fixes the
composer-voice test mocks for current main (scope/focus exports,
nanostores computed).

Co-authored-by: Yuan Li <dskwelmcy@163.com>
2026-09-30 23:07:03 -05:00
ishuowang
9c02599e35 fix(desktop): use live busy state for voice submits 2026-09-30 23:07:03 -05:00