Skip to content

fix(desktop): Esc cancels a running turn when the focus bus is stale - #74406

Closed
Ahmett101 wants to merge 1 commit into
NousResearch:mainfrom
Ahmett101:fix/74374-esc-interrupt-desktop
Closed

fix(desktop): Esc cancels a running turn when the focus bus is stale#74406
Ahmett101 wants to merge 1 commit into
NousResearch:mainfrom
Ahmett101:fix/74374-esc-interrupt-desktop

Conversation

@Ahmett101

Copy link
Copy Markdown
Contributor

Summary
On the desktop app, pressing Esc during an active assistant turn did nothing (#74374), even though the Stop button halted the run immediately. The haltRun path was fine — the break was in the keybinding routing: the global useComposerEscCancel handler bailed whenever getActiveComposer() !== target, which silently no-op'd every time the active-composer tracker was out of sync with the mounted composer. That desync happens routinely — a tab switch, a tile mount, a dialog restore, or (the actual repro) the user clicking into the transcript so focus never lands on the composer input and the tracker never updates. The composer-local Esc handler (composer/index.tsx:766-783) only fires when the input itself has focus, so it never covered this surface.

Changes

  • apps/desktop/src/app/chat/composer/focus.ts: add a module-level mountedComposers set plus registerMountedComposer/unregisterMountedComposer/getMountedComposers. This is the source of truth for which composers are actually live, independent of the input-focus-driven activeTarget.
  • apps/desktop/src/app/chat/composer/index.tsx: register the composer's target on mount and unregister on unmount (in a useEffect keyed on scope.target), so the mount set reconciles against reality and a stale target can never resolve to a phantom composer.
  • apps/desktop/src/app/chat/composer/hooks/use-composer-esc-cancel.ts: extract a pure resolveEscTarget(target, activeTarget, busyByTarget, mounted) helper. The authoritative path (activeTarget === target && mounted.has(target)) is unchanged; when the focus bus is stale, fall back to the lone mounted busy composer. The multiple-busy ambiguity stays a no-op (never halt the wrong tile), and an unmounted target returns null (no stale-closure cancel).
  • apps/desktop/src/app/chat/composer/hooks/use-composer-esc-cancel.test.ts: 6 unit tests for resolveEscTarget — authoritative match, stale-tracker lone-busy fallback, multiple-busy ambiguity, unmounted-target rejection, focus-bus preference agreement, and unmount reconciliation. No DOM needed; the helper is pure.

How to Test
cd apps/desktop && npx vitest run src/app/chat/composer/hooks/use-composer-esc-cancel.test.ts

6 passed

Manual: with a streaming turn running in the desktop app, click into the transcript and press Esc — the run now halts, matching the Stop button.

Checklist

  • Tests pass — 6/6
  • Follows Conventional Commits
  • Changes scoped to this fix only
  • Cross-platform impact assessed — Electron renderer; no platform-sensitive code touched
  • profile-safe paths used — no hardcoded ~ paths
  • .env not used for non-credential settings — no env changes

Risk & Impact
Low. The authoritative path is unchanged (same behavior when the focus bus agrees); the new code only fires when the tracker is stale, and only when exactly one mounted composer is busy. Existing Stop-button behavior is untouched. The mount-tracking set is a soft signal used only by this fallback.

Type: Bug fix
Closes: #74374

The global Esc-to-cancel handler bailed whenever getActiveComposer() !== target,
which silently no-op'd whenever the active-composer tracker was out of sync with
the mounted composer — after a tab switch, a tile mount, a dialog restore, or
simply because focus never landed on the composer input (the transcript click
repro in NousResearch#74374). The Stop button still worked because it bypasses the bus, so
the halt path itself was fine; only the keybinding routing was broken.

Track which composers are actually mounted (registerMountedComposer/
unregisterMountedComposer in focus.ts) and let the Esc handler fall back to the
lone mounted busy composer when the focus bus disagrees with reality. The
multiple-busy-composers ambiguity stays a no-op rather than halting the wrong
one, and mounts/unmounts reconcile the set so a stale target can never resolve
to a phantom composer. The pure resolveEscTarget helper is unit-tested.

Closes NousResearch#74374
@teknium1

Copy link
Copy Markdown
Contributor

Thanks for the focused investigation. This is an automated hermes-sweeper review: current main already provides the requested stale-focus Esc routing guarantee.

  • Commit f12e652 (fix(desktop): heal type-to-focus onto the visible chat surface) changed apps/desktop/src/app/chat/composer/focus.ts:127-136 so a stale or buried composer claim resolves to the visible chat target.
  • getActiveComposer() returns that healed target at apps/desktop/src/app/chat/composer/focus.ts:197, and the global Esc handler consumes it at apps/desktop/src/app/chat/composer/hooks/use-composer-esc-cancel.ts:40.
  • Existing regression coverage in apps/desktop/src/app/chat/composer/focus.test.ts:150-182 verifies the buried-tile/visible-main case and documents that the healed cache covers Esc routing.

Closing as implemented on main.

@teknium1 teknium1 closed this Jul 30, 2026
@teknium1 teknium1 added the sweeper:implemented-on-main Sweeper: behavior already present on current main label Jul 30, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area/sessions Session lifecycle, resume, persistence, history comp/desktop Electron desktop app (apps/desktop/*) P2 Medium — degraded but workaround exists sweeper:implemented-on-main Sweeper: behavior already present on current main type/bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug]: Desktop — Esc does not interrupt a running turn (Stop button works)

3 participants