diff --git a/src/components/ClaudeCodeSession.tsx b/src/components/ClaudeCodeSession.tsx index f0f164f21..9048e6c48 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"; @@ -542,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); }); @@ -567,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) @@ -798,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); }); diff --git a/src/components/FloatingPromptInput.tsx b/src/components/FloatingPromptInput.tsx index 1f042b2c6..a02653cf8 100644 --- a/src/components/FloatingPromptInput.tsx +++ b/src/components/FloatingPromptInput.tsx @@ -23,19 +23,9 @@ 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 { isAnyElementVisible } from "@/lib/domVisibility"; +import { getCurrentWebviewWindow } from "@tauri-apps/api/webviewWindow"; interface FloatingPromptInputProps { /** @@ -364,6 +354,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) { @@ -372,6 +365,10 @@ 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. + if (!isAnyElementVisible([textareaRef.current, expandedTextareaRef.current])) return; + if (event.payload.type === 'enter' || event.payload.type === 'over') { setDragActive(true); } else if (event.payload.type === 'leave') { 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) => { 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/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); +} 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)); + }; +}