From 204de35ed59ec20ce0d78452291f469cb4eb8376 Mon Sep 17 00:00:00 2001 From: Shahul KA Date: Fri, 6 Nov 2026 01:19:56 +0400 Subject: [PATCH 1/7] feat: add shared backend event listener that works on desktop and web 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 (#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. --- src/lib/apiAdapter.ts | 9 ++++++ src/lib/tauriEvents.ts | 25 ++++++++++++++++ src/lib/tauriEventsCore.ts | 58 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 92 insertions(+) create mode 100644 src/lib/tauriEvents.ts create mode 100644 src/lib/tauriEventsCore.ts diff --git a/src/lib/apiAdapter.ts b/src/lib/apiAdapter.ts index ece52c491..ad6468eaa 100644 --- a/src/lib/apiAdapter.ts +++ b/src/lib/apiAdapter.ts @@ -268,6 +268,15 @@ function mapCommandToEndpoint(command: string, _params?: any): string { return endpoint; } +/** + * Whether the app runs inside the Tauri desktop runtime (as opposed to web + * mode). Uses the same detection as API calls, so events and commands always + * agree on the environment. + */ +export function isTauriRuntime(): boolean { + return detectEnvironment(); +} + /** * Get environment info for debugging */ diff --git a/src/lib/tauriEvents.ts b/src/lib/tauriEvents.ts new file mode 100644 index 000000000..6ce1ea7a1 --- /dev/null +++ b/src/lib/tauriEvents.ts @@ -0,0 +1,25 @@ +/** + * Event listener for backend events (`claude-output`, `claude-complete`, ...). + * + * Use this instead of importing `listen` from `@tauri-apps/api/event` + * directly: it also works in web mode, where the API adapter delivers the same + * events as DOM `CustomEvent`s. + */ +import { listen as tauriListen } from "@tauri-apps/api/event"; +import { isTauriRuntime } from "./apiAdapter"; +import { createListen } from "./tauriEventsCore"; + +export type { AppEvent, AppEventHandler, UnlistenFn } from "./tauriEventsCore"; + +/** + * Listens for a backend event. + * + * @param eventName - Event name, e.g. `claude-output:` + * @param handler - Called with `{ payload }` for every event + * @returns A function that stops listening + */ +export const listen = createListen({ + isTauri: isTauriRuntime, + tauriListen, + target: window, +}); diff --git a/src/lib/tauriEventsCore.ts b/src/lib/tauriEventsCore.ts new file mode 100644 index 000000000..b2fc1b26d --- /dev/null +++ b/src/lib/tauriEventsCore.ts @@ -0,0 +1,58 @@ +/** + * Environment-independent core of the event listener used by the UI. + * + * Kept free of runtime imports so the selection logic can be exercised + * without a Tauri runtime or a bundler. The bound instance lives in + * `tauriEvents.ts`. + */ + +/** Event passed to listeners; matches the `payload` part of Tauri's `Event`. */ +export interface AppEvent { + payload: T; +} + +/** Callback invoked for each received event. */ +export type AppEventHandler = (event: AppEvent) => void; + +/** Function that stops listening. */ +export type UnlistenFn = () => void; + +/** Signature shared by Tauri's `listen` and the web fallback. */ +export type ListenFn = ( + eventName: string, + handler: AppEventHandler +) => Promise; + +/** Dependencies of {@link createListen}. */ +export interface ListenDeps { + /** Returns true when running inside the Tauri desktop runtime. Called on every `listen`. */ + isTauri: () => boolean; + /** Tauri's `listen` from `@tauri-apps/api/event`. */ + tauriListen: (eventName: string, handler: AppEventHandler) => Promise; + /** Target that receives `CustomEvent`s in web mode (normally `window`). */ + target: EventTarget; +} + +/** + * Creates a `listen` function that uses Tauri's event system in the desktop + * app and falls back to DOM `CustomEvent`s (dispatched by the web-mode API + * adapter) in the browser. + * + * The environment is checked when `listen` is called, not when the module is + * loaded, so web-mode initialisation order cannot flip the decision. + * + * @param deps - Environment check, Tauri listener and DOM event target + */ +export function createListen(deps: ListenDeps): ListenFn { + return (eventName: string, handler: AppEventHandler): Promise => { + if (deps.isTauri()) { + return deps.tauriListen(eventName, handler as AppEventHandler); + } + + const domHandler = (event: Event) => { + handler({ payload: (event as CustomEvent).detail }); + }; + deps.target.addEventListener(eventName, domHandler); + return Promise.resolve(() => deps.target.removeEventListener(eventName, domHandler)); + }; +} From febfeebea55f71dc91c12b0100ca0d490b0e9453 Mon Sep 17 00:00:00 2001 From: Shahul KA Date: Fri, 6 Nov 2026 01:20:41 +0400 Subject: [PATCH 2/7] fix: stream Claude output into the open chat on desktop 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 (#462). Use the shared listen() from @/lib/tauriEvents. The listener call sites are unchanged. --- src/components/ClaudeCodeSession.tsx | 33 +--------------------------- 1 file changed, 1 insertion(+), 32 deletions(-) diff --git a/src/components/ClaudeCodeSession.tsx b/src/components/ClaudeCodeSession.tsx index f0f164f21..7b4d1f219 100644 --- a/src/components/ClaudeCodeSession.tsx +++ b/src/components/ClaudeCodeSession.tsx @@ -15,38 +15,7 @@ import { Label } from "@/components/ui/label"; import { Popover } from "@/components/ui/popover"; import { api, type Session } from "@/lib/api"; import { cn } from "@/lib/utils"; - -// Conditional imports for Tauri APIs -let tauriListen: any; -type UnlistenFn = () => void; - -try { - if (typeof window !== 'undefined' && window.__TAURI__) { - tauriListen = require("@tauri-apps/api/event").listen; - } -} catch (e) { - console.log('[ClaudeCodeSession] Tauri APIs not available, using web mode'); -} - -// Web-compatible replacements -const listen = tauriListen || ((eventName: string, callback: (event: any) => void) => { - console.log('[ClaudeCodeSession] Setting up DOM event listener for:', eventName); - - // In web mode, listen for DOM events - const domEventHandler = (event: any) => { - console.log('[ClaudeCodeSession] DOM event received:', eventName, event.detail); - // Simulate Tauri event structure - callback({ payload: event.detail }); - }; - - window.addEventListener(eventName, domEventHandler); - - // Return unlisten function - return Promise.resolve(() => { - console.log('[ClaudeCodeSession] Removing DOM event listener for:', eventName); - window.removeEventListener(eventName, domEventHandler); - }); -}); +import { listen, type UnlistenFn } from "@/lib/tauriEvents"; import { StreamMessage } from "./StreamMessage"; import { FloatingPromptInput, type FloatingPromptInputRef } from "./FloatingPromptInput"; import { ErrorBoundary } from "./ErrorBoundary"; From 32381a800fbdd027621f3aa184b8e34ffc809560 Mon Sep 17 00:00:00 2001 From: Shahul KA Date: Fri, 6 Nov 2026 01:21:36 +0400 Subject: [PATCH 3/7] fix: restore file drag-and-drop into the prompt on desktop 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 #462). Import it statically and register the drag-drop listener only when running in the Tauri runtime. --- src/components/FloatingPromptInput.tsx | 18 +++++------------- 1 file changed, 5 insertions(+), 13 deletions(-) diff --git a/src/components/FloatingPromptInput.tsx b/src/components/FloatingPromptInput.tsx index 1f042b2c6..06d4ceab1 100644 --- a/src/components/FloatingPromptInput.tsx +++ b/src/components/FloatingPromptInput.tsx @@ -23,19 +23,8 @@ import { FilePicker } from "./FilePicker"; import { SlashCommandPicker } from "./SlashCommandPicker"; import { ImagePreview } from "./ImagePreview"; import { type FileEntry, type SlashCommand } from "@/lib/api"; - -// Conditional import for Tauri webview window -let tauriGetCurrentWebviewWindow: any; -try { - if (typeof window !== 'undefined' && window.__TAURI__) { - tauriGetCurrentWebviewWindow = require("@tauri-apps/api/webviewWindow").getCurrentWebviewWindow; - } -} catch (e) { - console.log('[FloatingPromptInput] Tauri webview API not available, using web mode'); -} - -// Web-compatible replacement -const getCurrentWebviewWindow = tauriGetCurrentWebviewWindow || (() => ({ listen: () => Promise.resolve(() => {}) })); +import { isTauriRuntime } from "@/lib/apiAdapter"; +import { getCurrentWebviewWindow } from "@tauri-apps/api/webviewWindow"; interface FloatingPromptInputProps { /** @@ -364,6 +353,9 @@ const FloatingPromptInputInner = ( let lastDropTime = 0; const setupListener = async () => { + // Native file drag-drop is only available in the desktop app. + if (!isTauriRuntime()) return; + try { // If a listener from a previous mount/render is still around, clean it up. if (unlistenDragDropRef.current) { From 4a0f6c5ba42e5bccfbc6911941744e28665211a9 Mon Sep 17 00:00:00 2001 From: Shahul KA Date: Fri, 6 Nov 2026 01:21:59 +0400 Subject: [PATCH 4/7] refactor: import Tauri listen statically in useClaudeMessages 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. --- .../claude-code-session/useClaudeMessages.ts | 13 ++----------- 1 file changed, 2 insertions(+), 11 deletions(-) diff --git a/src/components/claude-code-session/useClaudeMessages.ts b/src/components/claude-code-session/useClaudeMessages.ts index 5a2e819e6..698e4e467 100644 --- a/src/components/claude-code-session/useClaudeMessages.ts +++ b/src/components/claude-code-session/useClaudeMessages.ts @@ -2,16 +2,7 @@ import { useState, useCallback, useRef, useEffect } from 'react'; import { api } from '@/lib/api'; import { getEnvironmentInfo } from '@/lib/apiAdapter'; import type { ClaudeStreamMessage } from '../AgentExecution'; - -// Conditional import for Tauri -let tauriListen: any; -try { - if (typeof window !== 'undefined' && window.__TAURI__) { - tauriListen = require('@tauri-apps/api/event').listen; - } -} catch (e) { - console.log('[useClaudeMessages] Tauri event API not available, using web mode'); -} +import { listen as tauriListen } from '@tauri-apps/api/event'; interface UseClaudeMessagesOptions { onSessionInfo?: (info: { sessionId: string; projectId: string }) => void; @@ -135,7 +126,7 @@ export function useClaudeMessages(options: UseClaudeMessagesOptions = {}) { const envInfo = getEnvironmentInfo(); console.log('[TRACE] Environment info:', envInfo); - if (envInfo.isTauri && tauriListen) { + if (envInfo.isTauri) { // Tauri mode - use Tauri's event system console.log('[TRACE] Setting up Tauri event listener for claude-stream'); eventListenerRef.current = await tauriListen("claude-stream", (event: any) => { From 4e008ccd7b8163fb99e56ba15717b311dbb905d3 Mon Sep 17 00:00:00 2001 From: Shahul KA Date: Fri, 6 Nov 2026 01:39:17 +0400 Subject: [PATCH 5/7] fix: only the visible chat tab reacts to dropped files 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. --- src/components/FloatingPromptInput.tsx | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/components/FloatingPromptInput.tsx b/src/components/FloatingPromptInput.tsx index 06d4ceab1..dc6b31dbf 100644 --- a/src/components/FloatingPromptInput.tsx +++ b/src/components/FloatingPromptInput.tsx @@ -364,6 +364,13 @@ const FloatingPromptInputInner = ( const webview = getCurrentWebviewWindow(); unlistenDragDropRef.current = await webview.onDragDropEvent((event: any) => { + // Drag-drop events are window-wide and every chat tab stays mounted + // (inactive tabs are only hidden), so only the visible prompt reacts. + const isVisible = [textareaRef.current, expandedTextareaRef.current].some( + (el) => !!el && el.getClientRects().length > 0 + ); + if (!isVisible) return; + if (event.payload.type === 'enter' || event.payload.type === 'over') { setDragActive(true); } else if (event.payload.type === 'leave') { From d7a97d2148d30b072dffecd09dfb18eb140f5fbb Mon Sep 17 00:00:00 2001 From: Shahul KA Date: Fri, 6 Nov 2026 01:39:17 +0400 Subject: [PATCH 6/7] fix: avoid handling a stream line twice while switching listeners 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 (#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. --- src/components/ClaudeCodeSession.tsx | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/src/components/ClaudeCodeSession.tsx b/src/components/ClaudeCodeSession.tsx index 7b4d1f219..9048e6c48 100644 --- a/src/components/ClaudeCodeSession.tsx +++ b/src/components/ClaudeCodeSession.tsx @@ -511,20 +511,31 @@ export const ClaudeCodeSession: React.FC = ({ let currentSessionId: string | null = claudeSessionId || effectiveSession?.id || null; + // The backend emits every line twice: first on the session-scoped + // channel, then on the generic one. While switching from generic to + // scoped listeners both can be active for a moment, so once a scoped + // listener has received an event on a channel, the generic listener + // for that channel ignores what follows (one flag per channel, so a + // completion event can never be skipped). + const scopedChannelActive = { output: false, error: false, complete: false }; + // Helper to attach session-specific listeners **once we are sure** const attachSessionSpecificListeners = async (sid: string) => { console.log('[ClaudeCodeSession] Attaching session-specific listeners for', sid); const specificOutputUnlisten = await listen(`claude-output:${sid}`, (evt: any) => { + scopedChannelActive.output = true; handleStreamMessage(evt.payload); }); const specificErrorUnlisten = await listen(`claude-error:${sid}`, (evt: any) => { + scopedChannelActive.error = true; console.error('Claude error (scoped):', evt.payload); setError(evt.payload); }); const specificCompleteUnlisten = await listen(`claude-complete:${sid}`, (evt: any) => { + scopedChannelActive.complete = true; console.log('[ClaudeCodeSession] Received claude-complete (scoped):', evt.payload); processComplete(evt.payload); }); @@ -536,6 +547,7 @@ export const ClaudeCodeSession: React.FC = ({ // Generic listeners (catch-all) const genericOutputUnlisten = await listen('claude-output', async (event: any) => { + if (scopedChannelActive.output) return; handleStreamMessage(event.payload); // Attempt to extract session_id on the fly (for the very first init) @@ -767,11 +779,13 @@ export const ClaudeCodeSession: React.FC = ({ }; const genericErrorUnlisten = await listen('claude-error', (evt: any) => { + if (scopedChannelActive.error) return; console.error('Claude error:', evt.payload); setError(evt.payload); }); const genericCompleteUnlisten = await listen('claude-complete', (evt: any) => { + if (scopedChannelActive.complete) return; console.log('[ClaudeCodeSession] Received claude-complete (generic):', evt.payload); processComplete(evt.payload); }); From 2eb9b07c8923bf558b01216a818b03fcb46f8dfa Mon Sep 17 00:00:00 2001 From: Shahul KA Date: Fri, 6 Nov 2026 01:42:19 +0400 Subject: [PATCH 7/7] refactor: extract the drag-drop visibility check into a helper 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. --- src/components/FloatingPromptInput.tsx | 6 ++---- src/lib/domVisibility.ts | 12 ++++++++++++ 2 files changed, 14 insertions(+), 4 deletions(-) create mode 100644 src/lib/domVisibility.ts diff --git a/src/components/FloatingPromptInput.tsx b/src/components/FloatingPromptInput.tsx index dc6b31dbf..a02653cf8 100644 --- a/src/components/FloatingPromptInput.tsx +++ b/src/components/FloatingPromptInput.tsx @@ -24,6 +24,7 @@ import { SlashCommandPicker } from "./SlashCommandPicker"; import { ImagePreview } from "./ImagePreview"; import { type FileEntry, type SlashCommand } from "@/lib/api"; import { isTauriRuntime } from "@/lib/apiAdapter"; +import { isAnyElementVisible } from "@/lib/domVisibility"; import { getCurrentWebviewWindow } from "@tauri-apps/api/webviewWindow"; interface FloatingPromptInputProps { @@ -366,10 +367,7 @@ const FloatingPromptInputInner = ( unlistenDragDropRef.current = await webview.onDragDropEvent((event: any) => { // Drag-drop events are window-wide and every chat tab stays mounted // (inactive tabs are only hidden), so only the visible prompt reacts. - const isVisible = [textareaRef.current, expandedTextareaRef.current].some( - (el) => !!el && el.getClientRects().length > 0 - ); - if (!isVisible) return; + if (!isAnyElementVisible([textareaRef.current, expandedTextareaRef.current])) return; if (event.payload.type === 'enter' || event.payload.type === 'over') { setDragActive(true); diff --git a/src/lib/domVisibility.ts b/src/lib/domVisibility.ts new file mode 100644 index 000000000..47c5850ee --- /dev/null +++ b/src/lib/domVisibility.ts @@ -0,0 +1,12 @@ +/** + * Returns true if at least one of the given elements is rendered on screen. + * + * An element inside a `display: none` ancestor (for example an inactive tab + * that stays mounted but hidden) has no client rects. Unlike `offsetParent`, + * this also works for `position: fixed` elements such as modals. + * + * @param elements - Elements to check; `null`/`undefined` entries count as not visible + */ +export function isAnyElementVisible(elements: ReadonlyArray): boolean { + return elements.some((el) => !!el && el.getClientRects().length > 0); +}