Skip to content
Merged
47 changes: 15 additions & 32 deletions src/components/ClaudeCodeSession.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -542,20 +511,31 @@ export const ClaudeCodeSession: React.FC<ClaudeCodeSessionProps> = ({

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);
});
Expand All @@ -567,6 +547,7 @@ export const ClaudeCodeSession: React.FC<ClaudeCodeSessionProps> = ({

// 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)
Expand Down Expand Up @@ -798,11 +779,13 @@ export const ClaudeCodeSession: React.FC<ClaudeCodeSessionProps> = ({
};

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);
});
Expand Down
23 changes: 10 additions & 13 deletions src/components/FloatingPromptInput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
/**
Expand Down Expand Up @@ -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) {
Expand All @@ -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') {
Expand Down
13 changes: 2 additions & 11 deletions src/components/claude-code-session/useClaudeMessages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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) => {
Expand Down
9 changes: 9 additions & 0 deletions src/lib/apiAdapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
*/
Expand Down
12 changes: 12 additions & 0 deletions src/lib/domVisibility.ts
Original file line number Diff line number Diff line change
@@ -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<Element | null | undefined>): boolean {
return elements.some((el) => !!el && el.getClientRects().length > 0);
}
25 changes: 25 additions & 0 deletions src/lib/tauriEvents.ts
Original file line number Diff line number Diff line change
@@ -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:<sessionId>`
* @param handler - Called with `{ payload }` for every event
* @returns A function that stops listening
*/
export const listen = createListen({
isTauri: isTauriRuntime,
tauriListen,
target: window,
});
58 changes: 58 additions & 0 deletions src/lib/tauriEventsCore.ts
Original file line number Diff line number Diff line change
@@ -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<T>`. */
export interface AppEvent<T> {
payload: T;
}

/** Callback invoked for each received event. */
export type AppEventHandler<T> = (event: AppEvent<T>) => void;

/** Function that stops listening. */
export type UnlistenFn = () => void;

/** Signature shared by Tauri's `listen` and the web fallback. */
export type ListenFn = <T = unknown>(
eventName: string,
handler: AppEventHandler<T>
) => Promise<UnlistenFn>;

/** 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<any>) => Promise<UnlistenFn>;
/** 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 <T = unknown>(eventName: string, handler: AppEventHandler<T>): Promise<UnlistenFn> => {
if (deps.isTauri()) {
return deps.tauriListen(eventName, handler as AppEventHandler<any>);
}

const domHandler = (event: Event) => {
handler({ payload: (event as CustomEvent<T>).detail });
};
deps.target.addEventListener(eventName, domHandler);
return Promise.resolve(() => deps.target.removeEventListener(eventName, domHandler));
};
}