Skip to content

Fix: deliver backend events to the chat on desktop (spinner never stops, #462) - #488

Merged
mufeedvh merged 7 commits into
winfunc:mainfrom
bash-1337:fix/462-tauri-events-esm
Nov 6, 2026
Merged

mufeedvh merged 7 commits into
winfunc:mainfrom
bash-1337:fix/462-tauri-events-esm

Conversation

@bash-1337

Copy link
Copy Markdown
Collaborator

Problem

On the desktop app, after sending a prompt the chat shows a loading spinner forever and no response appears, although Claude runs and answers. The answer only shows up after closing and reopening the chat, because history is then read from the session file. Cancel does nothing, because the session id from the init message never arrives. Dragging an image into the prompt does nothing either.

Two defects combined:

  1. Wrong environment check. The listener was chosen at module load with if (window.__TAURI__). In Tauri v2, window.__TAURI__ only exists when app.withGlobalTauri is enabled, which it is not in tauri.conf.json, so on desktop the check is false. It is also inverted: web mode's initializeWebMode() installs a fake window.__TAURI__, so the check is true in the browser.
  2. require() in an ESM bundle. Inside the check the code called require("@tauri-apps/api/event"). require does not exist in Vite's ESM output, so even with withGlobalTauri enabled it throws, and the catch hides it ("Tauri APIs not available, using web mode").

Either way, the desktop app listened for DOM events on window while the backend emits through Tauri IPC (claude-output, claude-error, claude-complete), so nothing arrived. FloatingPromptInput.tsx used the same pattern for getCurrentWebviewWindow (drag-and-drop got a stub without onDragDropEvent), and useClaudeMessages.ts had the same require().

Approach

  • Shared listener (src/lib/tauriEvents.ts): statically imports Tauri's listen and decides at call time whether to use it, with the detection the API adapter already uses for commands (detectEnvironment, which checks __TAURI_INTERNALS__; exported as isTauriRuntime). Commands and events can therefore never disagree about the environment. Web mode keeps the DOM CustomEvent fallback with the same { payload } shape.
  • Testable core (src/lib/tauriEventsCore.ts): the selection logic (createListen) has no runtime imports, so it can be checked without a Tauri runtime.
  • Chat (ClaudeCodeSession.tsx): the require() block and inline fallback are replaced by the shared listen; listener call sites are unchanged.
  • Drag-and-drop (FloatingPromptInput.tsx): static getCurrentWebviewWindow import; the native listener is registered only in the Tauri runtime.
  • useClaudeMessages.ts: static import instead of require(), behaviour otherwise unchanged (currently only used by ClaudeCodeSession.refactored.tsx).
  • Two follow-ups for code that now runs on desktop for the first time:
    • One tab per drop. Drag-drop events are window-wide and inactive chat tabs stay mounted (hidden), so a dropped image would be added to every open chat. Only the tab whose prompt is on screen reacts now (src/lib/domVisibility.ts).
    • No double handling during the listener switch. The backend emits each line on the session-scoped channel, then on the generic one, and the chat swaps generic for scoped listeners after init. Lines arriving during the swap were handled twice. Once a scoped listener receives an event on a channel, the generic listener for that channel ignores it. Flags are per channel, so a completion event is never skipped.
  • Not changed: withGlobalTauri stays off (it would not fix the require() layer and would expose the whole Tauri API on window). No Rust changes, no new dependencies.

Testing

Reproduce-then-fix check. The original code from main (TypeScript types stripped with Node's stripTypeScriptTypes, logic untouched) and this branch were run in the same simulated environments. Desktop means __TAURI_INTERNALS__ present, no window.__TAURI__ and no require, as in the real app. Web mode means after initializeWebMode(). A fake IPC emitter stands in for the backend's app.emit().

Scenario main This PR
Desktop: claude-output and claude-complete reach the chat fail (nothing received) pass
Desktop: drag-drop listener registered fail pass
Web mode: events reach the chat pass pass
Web mode: no native drag-drop, no error pass pass

With withGlobalTauri forced on, the original code still falls back to DOM events, logging "Tauri APIs not available, using web mode". That confirms the require() layer.

Listener switch. A model of the chat's generic-to-scoped switch, with the backend's emit order (scoped, then generic), slow listener registration, and 0–20 lines arriving mid-switch:

Lines during switch Without guard With guard
0 / 1 0 duplicates 0 duplicates
3 2 duplicates 0 duplicates
8 / 20 4 duplicates 0 duplicates
In every case with the guard: 0 lost lines and exactly 1 completion event.

Unit checks

  • Listener: Tauri or web is chosen per call, DOM events are ignored in Tauri mode, unlisten removes the handler, and a failing Tauri listen rejects instead of silently falling back.
  • Drag-drop visibility: with two mounted tabs, unguarded both react, guarded only the visible one; expanded mode and unmounted refs are handled.

Build and static checks

  • npx tsc --noEmit: no new errors. The only error is the existing one in src/lib/analytics/index.ts.
  • vite build and tauri build --debug --bundles app succeed.
  • cargo test: 9/9 pass (no Rust changes).
  • No require() of Tauri APIs remains in src/.

Manual (macOS desktop build)

Documentation

New exports have JSDoc comments. No README or configuration change; no new dependencies.

Fixes #462

Components loaded Tauri's listen() with require() behind a window.__TAURI__
check. Neither works: require() does not exist in Vite's ESM bundle, and
window.__TAURI__ is only defined when withGlobalTauri is enabled (it is not),
while web mode installs a fake one, so the check is false on desktop and true
in the browser (winfunc#462).

Add src/lib/tauriEvents.ts: a static import of Tauri's listen, chosen at call
time with the same environment detection the API adapter uses for commands
(exported as isTauriRuntime), and a DOM CustomEvent fallback for web mode.
The selection logic lives in a dependency-free core module so it can be
checked without a Tauri runtime.
ClaudeCodeSession always ended up with its DOM-event fallback in the desktop
app (see previous commit), so claude-output / claude-complete events from the
backend never arrived: the chat showed a spinner until it was reopened and
cancel did nothing because the session id from the init message was never
received (winfunc#462).

Use the shared listen() from @/lib/tauriEvents. The listener call sites are
unchanged.
FloatingPromptInput loaded getCurrentWebviewWindow with the same
require()/window.__TAURI__ pattern, so on desktop it always got a stub
without onDragDropEvent and dropping images into the prompt did nothing
(same root cause as winfunc#462).

Import it statically and register the drag-drop listener only when running
in the Tauri runtime.
Replace the last require('@tauri-apps/api/event') with a static ESM import so
no component depends on require() at runtime. The hook keeps its existing
desktop/web branches; it is currently only used by
ClaudeCodeSession.refactored.tsx.
Native drag-drop events are window-wide and every chat tab stays mounted
(inactive tabs are only hidden), so now that the drag-drop listener is
registered on desktop, dropping an image added its path to the prompt of
every open chat tab. Ignore drag-drop events in tabs whose prompt is not
visible.
The backend emits each line on the session-scoped channel and then on the
generic one. The chat starts with generic listeners and swaps in scoped ones
after the init message; during the swap both are active, so lines arriving
then were processed twice. This path was unreachable on desktop before events
were delivered (winfunc#462).

Once a scoped listener has received an event on a channel, the generic
listener for that channel ignores it. Flags are per channel so a completion
event is never skipped.
Move the "is this prompt on screen" check used by the drag-drop handler into
src/lib/domVisibility.ts so it can be tested without rendering the component.
No behaviour change.
@mufeedvh
mufeedvh merged commit c24004d into winfunc:main Nov 6, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Bug: Tauri events never reach frontend — require() fails in ESM/Vite context, falls back to DOM listeners

3 participants