diff --git a/src/components/chat/agent-selector.tsx b/src/components/chat/agent-selector.tsx index cb1e472..209a087 100644 --- a/src/components/chat/agent-selector.tsx +++ b/src/components/chat/agent-selector.tsx @@ -3,6 +3,7 @@ import { useEffect, useRef, useState } from "react" import { useTranslations } from "next-intl" import { acpListAgents } from "@/lib/tauri" +import { disposeTauriListener } from "@/lib/tauri-listener" import type { AgentType, AcpAgentInfo } from "@/lib/types" import { AGENT_LABELS } from "@/lib/types" import { AgentIcon } from "@/components/agent-icon" @@ -100,7 +101,7 @@ export function AgentSelector({ ) .then((dispose) => { if (cancelled) { - dispose() + disposeTauriListener(dispose, "AgentSelector.agentsUpdated") return } unlisten = dispose @@ -112,9 +113,7 @@ export function AgentSelector({ return () => { cancelled = true window.removeEventListener("focus", onWindowFocus) - if (unlisten) { - unlisten() - } + disposeTauriListener(unlisten, "AgentSelector.agentsUpdated") } }, [defaultAgentType]) diff --git a/src/components/chat/message-input.tsx b/src/components/chat/message-input.tsx index 3f7b7ad..7b9da07 100644 --- a/src/components/chat/message-input.tsx +++ b/src/components/chat/message-input.tsx @@ -1,6 +1,7 @@ "use client" import { useCallback, useEffect, useMemo, useRef, useState } from "react" +import { TauriEvent } from "@tauri-apps/api/event" import { getCurrentWebview } from "@tauri-apps/api/webview" import { open } from "@tauri-apps/plugin-dialog" import Image from "next/image" @@ -10,6 +11,7 @@ import { Textarea } from "@/components/ui/textarea" import { FileSearch, Plus, Send, Square, X } from "lucide-react" import { cn } from "@/lib/utils" import { readFileBase64 } from "@/lib/tauri" +import { disposeTauriListener } from "@/lib/tauri-listener" import type { AvailableCommandInfo, PromptCapabilitiesInfo, @@ -686,46 +688,109 @@ export function MessageInput({ }, [appendResourceAttachments, attachmentTabId]) useEffect(() => { - let unlisten: (() => void) | null = null let cancelled = false + const unlisteners: Array<() => void | Promise> = [] - getCurrentWebview() - .onDragDropEvent((event) => { - const host = containerRef.current - if (!host) return - const payload = event.payload - if (payload.type === "leave") { - setIsDragActive(false) - return + const cleanupListeners = () => { + for (const fn of unlisteners.splice(0)) { + disposeTauriListener(fn, "MessageInput.dragDrop") + } + } + + type DragDropPayload = + | { + type: "enter" | "drop" + paths: string[] + position: { x: number; y: number } } - const inside = pointWithinElement(payload.position, host) - if (payload.type === "drop") { - setIsDragActive(false) - if (Date.now() - lastDomDropAtRef.current < 250) return - if (!inside || disabled || isPrompting) return - void appendPathsFromDrop(payload.paths).catch((error) => { - console.error("[MessageInput] drag drop paths failed:", error) + | { + type: "over" + position: { x: number; y: number } + } + | { type: "leave" } + + const handlePayload = (payload: DragDropPayload) => { + const host = containerRef.current + if (!host) return + if (payload.type === "leave") { + setIsDragActive(false) + return + } + const inside = pointWithinElement(payload.position, host) + if (payload.type === "drop") { + setIsDragActive(false) + if (Date.now() - lastDomDropAtRef.current < 250) return + if (!inside || disabled || isPrompting) return + void appendPathsFromDrop(payload.paths).catch((error) => { + console.error("[MessageInput] drag drop paths failed:", error) + }) + return + } + setIsDragActive(inside && !disabled && !isPrompting) + } + + const setup = async () => { + const webview = getCurrentWebview() + try { + const unlistenEnter = await webview.listen<{ + paths: string[] + position: { x: number; y: number } + }>(TauriEvent.DRAG_ENTER, (event) => { + if (cancelled) return + handlePayload({ + type: "enter", + paths: event.payload.paths, + position: event.payload.position, }) - return - } - setIsDragActive(inside && !disabled && !isPrompting) - }) - .then((fn) => { - if (cancelled) { - fn() - } else { - unlisten = fn - } - }) - .catch(() => { + }) + unlisteners.push(unlistenEnter) + + const unlistenOver = await webview.listen<{ + position: { x: number; y: number } + }>(TauriEvent.DRAG_OVER, (event) => { + if (cancelled) return + handlePayload({ + type: "over", + position: event.payload.position, + }) + }) + unlisteners.push(unlistenOver) + + const unlistenDrop = await webview.listen<{ + paths: string[] + position: { x: number; y: number } + }>(TauriEvent.DRAG_DROP, (event) => { + if (cancelled) return + handlePayload({ + type: "drop", + paths: event.payload.paths, + position: event.payload.position, + }) + }) + unlisteners.push(unlistenDrop) + + const unlistenLeave = await webview.listen( + TauriEvent.DRAG_LEAVE, + () => { + if (cancelled) return + handlePayload({ type: "leave" }) + } + ) + unlisteners.push(unlistenLeave) + } catch { // Ignore non-Tauri environments. - }) + } finally { + if (cancelled) { + cleanupListeners() + } + } + } + + void setup() return () => { cancelled = true - if (unlisten) { - unlisten() - } + cleanupListeners() } }, [appendPathsFromDrop, disabled, isPrompting]) diff --git a/src/components/chat/welcome-input-panel.tsx b/src/components/chat/welcome-input-panel.tsx index 2ee5753..813fd27 100644 --- a/src/components/chat/welcome-input-panel.tsx +++ b/src/components/chat/welcome-input-panel.tsx @@ -36,6 +36,7 @@ import { updateConversationStatus, updateConversationExternalId, } from "@/lib/tauri" +import { disposeTauriListener } from "@/lib/tauri-listener" import { AgentSelector } from "@/components/chat/agent-selector" import { LiveMessageBlock } from "@/components/chat/live-message-block" import { AgentPlanOverlay } from "@/components/chat/agent-plan-overlay" @@ -448,7 +449,7 @@ export function WelcomeInputPanel({ ) .then((dispose) => { if (cancelled) { - dispose() + disposeTauriListener(dispose, "WelcomeInputPanel.agentsUpdated") return } unlisten = dispose @@ -463,9 +464,7 @@ export function WelcomeInputPanel({ clearTimeout(agentStatusRefreshTimerRef.current) agentStatusRefreshTimerRef.current = null } - if (unlisten) { - unlisten() - } + disposeTauriListener(unlisten, "WelcomeInputPanel.agentsUpdated") } }, []) diff --git a/src/components/i18n-provider.tsx b/src/components/i18n-provider.tsx index a9040a0..b5a9ad5 100644 --- a/src/components/i18n-provider.tsx +++ b/src/components/i18n-provider.tsx @@ -23,6 +23,7 @@ import { type IntlLocale, } from "@/lib/i18n" import { getSystemLanguageSettings } from "@/lib/tauri" +import { disposeTauriListener } from "@/lib/tauri-listener" import { AppBootLoading } from "@/components/layout/app-boot-loading" import type { AppLocale, SystemLanguageSettings } from "@/lib/types" @@ -162,7 +163,7 @@ export function AppI18nProvider({ ) .then((dispose) => { if (cancelled) { - dispose() + disposeTauriListener(dispose, "I18nProvider.languageSettings") return } unlisten = dispose @@ -174,9 +175,7 @@ export function AppI18nProvider({ return () => { cancelled = true window.removeEventListener("storage", onStorage) - if (unlisten) { - unlisten() - } + disposeTauriListener(unlisten, "I18nProvider.languageSettings") } }, [setLanguageSettings]) diff --git a/src/components/layout/aux-panel-file-tree-tab.tsx b/src/components/layout/aux-panel-file-tree-tab.tsx index 25f4228..14160ba 100644 --- a/src/components/layout/aux-panel-file-tree-tab.tsx +++ b/src/components/layout/aux-panel-file-tree-tab.tsx @@ -35,6 +35,7 @@ import { startFileTreeWatch, stopFileTreeWatch, } from "@/lib/tauri" +import { disposeTauriListener } from "@/lib/tauri-listener" import { emitAttachFileToSession } from "@/lib/session-attachment-events" import type { FileTreeChangedEvent, @@ -1961,9 +1962,7 @@ export function FileTreeTab() { pendingTreeRefreshRef.current = false pendingTreeRefreshNeedsStatusRef.current = false pendingStatusRefreshRef.current = false - if (unlisten) { - unlisten() - } + disposeTauriListener(unlisten, "AuxPanelFileTree.fileTreeChanged") void stopFileTreeWatch(rootPath) } }, [fetchTree, folder?.path, openFilePreview, t]) diff --git a/src/components/layout/aux-panel-git-changes-tab.tsx b/src/components/layout/aux-panel-git-changes-tab.tsx index 1c95a43..072129d 100644 --- a/src/components/layout/aux-panel-git-changes-tab.tsx +++ b/src/components/layout/aux-panel-git-changes-tab.tsx @@ -46,6 +46,7 @@ import { startFileTreeWatch, stopFileTreeWatch, } from "@/lib/tauri" +import { disposeTauriListener } from "@/lib/tauri-listener" import type { FileTreeChangedEvent, GitStatusEntry } from "@/lib/types" import { AlertDialog, @@ -630,9 +631,7 @@ export function GitChangesTab() { clearTimeout(refreshTimerRef.current) refreshTimerRef.current = null } - if (unlisten) { - unlisten() - } + disposeTauriListener(unlisten, "AuxPanelGitChanges.fileTreeChanged") void stopFileTreeWatch(rootPath) } }, [fetchChanges, folder?.path, isChangesTabActive]) diff --git a/src/components/layout/branch-dropdown.tsx b/src/components/layout/branch-dropdown.tsx index 0937885..1851e37 100644 --- a/src/components/layout/branch-dropdown.tsx +++ b/src/components/layout/branch-dropdown.tsx @@ -80,6 +80,7 @@ import { openCommitWindow, setFolderParentBranch, } from "@/lib/tauri" +import { disposeTauriListener } from "@/lib/tauri-listener" import type { GitBranchList } from "@/lib/types" import { toast } from "sonner" import { useFolderContext } from "@/contexts/folder-context" @@ -161,7 +162,7 @@ export function BranchDropdown({ }) return () => { - if (unlisten) unlisten() + disposeTauriListener(unlisten, "BranchDropdown.gitCommitSucceeded") } }, [folder, onBranchChange, t]) diff --git a/src/components/layout/command-dropdown.tsx b/src/components/layout/command-dropdown.tsx index b5273d5..8e873e3 100644 --- a/src/components/layout/command-dropdown.tsx +++ b/src/components/layout/command-dropdown.tsx @@ -20,6 +20,7 @@ import { terminalKill, terminalList, } from "@/lib/tauri" +import { disposeTauriListener } from "@/lib/tauri-listener" import type { FolderCommand, TerminalEvent } from "@/lib/types" import { CommandManageDialog } from "./command-manage-dialog" @@ -66,7 +67,7 @@ export function CommandDropdown() { const clearRunningByTerminalId = useCallback((terminalId: string) => { const unlisten = exitUnlistenersRef.current.get(terminalId) if (unlisten) { - unlisten() + disposeTauriListener(unlisten, "CommandDropdown.terminalExit") exitUnlistenersRef.current.delete(terminalId) } @@ -85,7 +86,7 @@ export function CommandDropdown() { const clearAllRunningStates = useCallback(() => { for (const unlisten of exitUnlistenersRef.current.values()) { - unlisten() + disposeTauriListener(unlisten, "CommandDropdown.terminalExit") } exitUnlistenersRef.current.clear() setRunningCommandTerminals({}) diff --git a/src/components/layout/window-controls.tsx b/src/components/layout/window-controls.tsx index 4c5e538..9778b09 100644 --- a/src/components/layout/window-controls.tsx +++ b/src/components/layout/window-controls.tsx @@ -4,6 +4,7 @@ import { useEffect, useState } from "react" import { getCurrentWindow } from "@tauri-apps/api/window" import { useTranslations } from "next-intl" import { usePlatform } from "@/hooks/use-platform" +import { disposeTauriListener } from "@/lib/tauri-listener" import { cn } from "@/lib/utils" export function WindowControls() { @@ -59,7 +60,7 @@ export function WindowControls() { if (resizeFrame !== null) { window.cancelAnimationFrame(resizeFrame) } - unlistenResize?.() + disposeTauriListener(unlistenResize, "WindowControls.resize") } }, [isWindows]) diff --git a/src/components/terminal/terminal-view.tsx b/src/components/terminal/terminal-view.tsx index 7329121..8b9130c 100644 --- a/src/components/terminal/terminal-view.tsx +++ b/src/components/terminal/terminal-view.tsx @@ -3,6 +3,7 @@ import { useEffect, useRef } from "react" import { listen } from "@tauri-apps/api/event" import { terminalWrite, terminalResize } from "@/lib/tauri" +import { disposeTauriListener } from "@/lib/tauri-listener" import type { TerminalEvent } from "@/lib/types" import type { ITheme } from "@xterm/xterm" @@ -186,8 +187,8 @@ export function TerminalView({ themeObserver.disconnect() onDataDisposable.dispose() onResizeDisposable.dispose() - unlisten() - unlistenExit() + disposeTauriListener(unlisten, "TerminalView.output") + disposeTauriListener(unlistenExit, "TerminalView.exit") term.dispose() return } @@ -221,8 +222,8 @@ export function TerminalView({ themeObserver.disconnect() onDataDisposable.dispose() onResizeDisposable.dispose() - unlisten() - unlistenExit() + disposeTauriListener(unlisten, "TerminalView.output") + disposeTauriListener(unlistenExit, "TerminalView.exit") resizeObserver.disconnect() term.dispose() fitAddonRef.current = null diff --git a/src/contexts/acp-connections-context.tsx b/src/contexts/acp-connections-context.tsx index 2b36a94..4f10c13 100644 --- a/src/contexts/acp-connections-context.tsx +++ b/src/contexts/acp-connections-context.tsx @@ -11,6 +11,7 @@ import { } from "react" import { useTranslations } from "next-intl" import { listen, type UnlistenFn } from "@tauri-apps/api/event" +import { disposeTauriListener } from "@/lib/tauri-listener" import { acpConnect, acpListAgents, @@ -1420,11 +1421,7 @@ export function AcpConnectionsProvider({ children }: { children: ReactNode }) { }) .then((fn) => { if (cancelled) { - try { - fn() - } catch { - // ignore - } + disposeTauriListener(fn, "AcpConnectionsProvider.globalEvent") } else { unlisten = fn listenerReadyRef.current = true @@ -1444,7 +1441,7 @@ export function AcpConnectionsProvider({ children }: { children: ReactNode }) { clearTimeout(flushTimerRef.current) flushTimerRef.current = null } - unlisten?.() + disposeTauriListener(unlisten, "AcpConnectionsProvider.globalEvent") } }, [bufferUnmappedEvent, handleMappedEvent, resolveListenerReadyWaiters]) diff --git a/src/lib/tauri-listener.ts b/src/lib/tauri-listener.ts new file mode 100644 index 0000000..7608b84 --- /dev/null +++ b/src/lib/tauri-listener.ts @@ -0,0 +1,53 @@ +type TauriUnlisten = () => void | Promise + +const disposedListeners = new WeakSet() + +function getErrorMessage(error: unknown): string { + if (error instanceof Error) return error.message + return String(error) +} + +function isKnownDisposeRaceError(message: string): boolean { + const normalized = message.toLowerCase() + return ( + normalized.includes("listeners[eventid].handlerid") || + normalized.includes("cannot read properties of undefined") || + normalized.includes("undefined is not an object") + ) +} + +function handleDisposeError(error: unknown, scope?: string): void { + const message = getErrorMessage(error) + if (isKnownDisposeRaceError(message)) return + if (scope) { + console.warn(`[${scope}] failed to dispose listener:`, error) + } else { + console.warn("[tauri-listener] failed to dispose listener:", error) + } +} + +/** + * Dispose Tauri listener functions defensively. + * + * React StrictMode, rapid tab/window switches, and async listener setup can + * trigger duplicate dispose paths. Tauri can throw when the internal listener + * is already gone; this helper makes cleanup idempotent. + */ +export function disposeTauriListener( + unlisten: TauriUnlisten | null | undefined, + scope?: string +): void { + if (!unlisten) return + if (disposedListeners.has(unlisten)) return + + disposedListeners.add(unlisten) + + try { + const disposeResult = unlisten() + void Promise.resolve(disposeResult).catch((error) => { + handleDisposeError(error, scope) + }) + } catch (error) { + handleDisposeError(error, scope) + } +}