"use client" import { useCallback, useEffect, useRef, useState } from "react" import { useTranslations } from "next-intl" import { useAcpActions } from "@/contexts/acp-connections-context" import { useTaskContext } from "@/contexts/task-context" import { useConnection, type UseConnectionReturn } from "@/hooks/use-connection" import { AGENT_LABELS, type AgentType, type PromptDraft } from "@/lib/types" interface UseConnectionLifecycleOptions { contextKey: string agentType: AgentType isActive: boolean workingDir?: string sessionId?: string } export interface UseConnectionLifecycleReturn { conn: UseConnectionReturn modeLoading: boolean configOptionsLoading: boolean selectorsLoading: boolean autoConnectError: string | null handleFocus: () => void handleSend: (draft: PromptDraft, modeId?: string | null) => void handleSetConfigOption: (configId: string, valueId: string) => void handleCancel: () => void handleRespondPermission: (requestId: string, optionId: string) => void } function normalizeErrorMessage(error: unknown): string { if (error instanceof Error) return error.message return String(error) } function isExpectedAutoLinkError(error: unknown): boolean { if (!error || typeof error !== "object") return false return (error as { alerted?: unknown }).alerted === true } export function useConnectionLifecycle({ contextKey, agentType, isActive, workingDir, sessionId, }: UseConnectionLifecycleOptions): UseConnectionLifecycleReturn { const t = useTranslations("Folder.chat.connectionLifecycle") const { setActiveKey, touchActivity } = useAcpActions() const { addTask, updateTask, removeTask } = useTaskContext() const conn = useConnection(contextKey) // Destructure stable callbacks (depend only on actions + contextKey) // vs. volatile derived state (status, liveMessage, etc.) const { status, selectorsReady, connect: connConnect, disconnect: connDisconnect, sendPrompt, setMode: connSetMode, setConfigOption: connSetConfigOption, cancel: connCancel, respondPermission: connRespondPermission, modes, configOptions, } = conn const isInteractiveStatus = status === "connected" || status === "prompting" const hasSelectorsData = modes !== null || configOptions !== null const effectiveSelectorsReady = selectorsReady || hasSelectorsData const selectorTaskIdRef = useRef(null) const selectorTaskTimeoutRef = useRef | null>( null ) const selectorTaskSuppressedRef = useRef(false) // Visual-only loading indicators for selector chips const modeLoading = status === "connecting" || status === "downloading" || (isInteractiveStatus && !effectiveSelectorsReady) const configOptionsLoading = status === "connecting" || status === "downloading" || (isInteractiveStatus && !effectiveSelectorsReady) // Gate for send button: block until the backend session is fully // initialized (selectorsReady from the real backend event, not cache). const selectorsLoading = isInteractiveStatus && !selectorsReady const [lastAutoConnectError, setLastAutoConnectError] = useState<{ contextKey: string agentType: AgentType message: string } | null>(null) // Refs for auto-connect effect, which intentionally avoids volatile // dependencies to prevent reconnect loops. Synced via useEffect — // effects run in declaration order, so these are current before // the auto-connect effect reads them. const statusRef = useRef(status) useEffect(() => { statusRef.current = status }, [status]) const contextKeyRef = useRef(contextKey) useEffect(() => { contextKeyRef.current = contextKey }, [contextKey]) const connConnectRef = useRef(connConnect) useEffect(() => { connConnectRef.current = connConnect }, [connConnect]) const agentTypeRef = useRef(agentType) useEffect(() => { agentTypeRef.current = agentType }, [agentType]) const sessionIdRef = useRef(sessionId) useEffect(() => { sessionIdRef.current = sessionId }, [sessionId]) const modeIdRef = useRef(modes?.current_mode_id ?? null) useEffect(() => { modeIdRef.current = modes?.current_mode_id ?? null }, [modes?.current_mode_id]) // Sync activeKey when this view is the active tab useEffect(() => { if (isActive && contextKey) { setActiveKey(contextKey) touchActivity(contextKey) } }, [isActive, contextKey, setActiveKey, touchActivity]) // Auto-connect when tab becomes active and workingDir is available. // Depends on isActive + workingDir so that connections wait for folder // info to load (workingDir transitions from undefined → folder.path). // Status changes must NOT re-trigger this to avoid infinite reconnect // loops on transient errors. useEffect(() => { if (!isActive) return if (!workingDir) return let cancelled = false const s = statusRef.current if (!s || s === "disconnected" || s === "error") { connConnectRef .current(agentTypeRef.current, workingDir, sessionIdRef.current, { source: "auto_link", }) .then(() => { if (!cancelled) { setLastAutoConnectError(null) } }) .catch((e: unknown) => { if (!cancelled) { setLastAutoConnectError({ contextKey: contextKeyRef.current, agentType: agentTypeRef.current, message: normalizeErrorMessage(e), }) } if (!isExpectedAutoLinkError(e)) { console.error("[ConnLifecycle] auto-connect:", e) } }) } return () => { cancelled = true } }, [isActive, workingDir]) // Manage task status for connection progress const taskIdRef = useRef(null) useEffect(() => { if (status === "connecting" || status === "downloading") { if (!taskIdRef.current) { const id = `acp-connect-${Date.now()}` taskIdRef.current = id const agent = AGENT_LABELS[agentType] addTask( id, t("tasks.connectingTitle", { agent }), t("tasks.connectingDescription") ) } updateTask(taskIdRef.current, { status: "running" }) } else if (status === "connected" || status === "prompting") { if (taskIdRef.current) { updateTask(taskIdRef.current, { status: "completed" }) taskIdRef.current = null } } else if (status === "error") { if (taskIdRef.current) { updateTask(taskIdRef.current, { status: "failed", error: t("errors.connectionFailed"), }) taskIdRef.current = null } } else if (status === "disconnected" || status === null) { if (taskIdRef.current) { removeTask(taskIdRef.current) taskIdRef.current = null } } }, [status, addTask, updateTask, removeTask, agentType, t]) const clearSelectorTask = useCallback(() => { if (selectorTaskTimeoutRef.current) { clearTimeout(selectorTaskTimeoutRef.current) selectorTaskTimeoutRef.current = null } if (selectorTaskIdRef.current) { removeTask(selectorTaskIdRef.current) selectorTaskIdRef.current = null } }, [removeTask]) useEffect(() => { const isInteractive = status === "connected" || status === "prompting" if (!isInteractive) { selectorTaskSuppressedRef.current = false clearSelectorTask() return } if (selectorTaskSuppressedRef.current) { clearSelectorTask() return } // Use the real backend selectorsReady (not effectiveSelectorsReady // which includes cache) so the task shows during session creation // even when cached selectors are available. if (selectorsReady) { clearSelectorTask() return } if (!selectorTaskIdRef.current) { const id = `acp-session-init-${Date.now()}` selectorTaskIdRef.current = id const agent = AGENT_LABELS[agentType] addTask( id, t("tasks.initSessionTitle", { agent }), t("tasks.initSessionDescription") ) updateTask(id, { status: "running" }) } if (!selectorTaskTimeoutRef.current) { selectorTaskTimeoutRef.current = setTimeout(() => { selectorTaskTimeoutRef.current = null selectorTaskSuppressedRef.current = true clearSelectorTask() }, 5000) } }, [ status, selectorsReady, agentType, addTask, updateTask, clearSelectorTask, t, ]) // Keep a ref to disconnect so the unmount cleanup always calls the // latest version without adding it as a dependency. const connDisconnectRef = useRef(connDisconnect) useEffect(() => { connDisconnectRef.current = connDisconnect }, [connDisconnect]) // Clean up on unmount (e.g. tab closed): disconnect the ACP connection // so it doesn't leak, and remove lingering tasks. // However, if the agent is actively prompting (generating a response), // keep it alive so it can finish in the background — the idle sweep // will clean it up once it transitions back to "connected". useEffect(() => { return () => { if (statusRef.current !== "prompting") { connDisconnectRef.current().catch(() => {}) } if (taskIdRef.current) { removeTask(taskIdRef.current) } selectorTaskSuppressedRef.current = false clearSelectorTask() } }, [removeTask, clearSelectorTask]) const handleFocus = useCallback(() => { touchActivity(contextKey) if (!status || status === "disconnected" || status === "error") { setLastAutoConnectError(null) connConnect(agentType, workingDir, sessionId, { source: "auto_link", }).catch((e: unknown) => { if (!isExpectedAutoLinkError(e)) { console.error("[ConnLifecycle] connect:", e) } }) } }, [ agentType, workingDir, sessionId, status, connConnect, contextKey, touchActivity, ]) const autoConnectError = status === "connected" || status === "prompting" ? null : lastAutoConnectError?.contextKey === contextKey && lastAutoConnectError.agentType === agentType ? lastAutoConnectError.message : null // sendPrompt, connCancel, connRespondPermission are stable (depend // only on actions + contextKey), so these callbacks are effectively stable. const handleSend = useCallback( (draft: PromptDraft, modeId?: string | null) => { touchActivity(contextKey) void (async () => { const currentModeId = modeIdRef.current if (modeId && modeId !== currentModeId) { await connSetMode(modeId) // Optimistically track selected mode to avoid duplicate set_mode // calls before CurrentModeUpdate arrives from the agent. modeIdRef.current = modeId } await sendPrompt(draft.blocks) })().catch((e: unknown) => console.error("[ConnLifecycle] sendPrompt:", e) ) }, [connSetMode, sendPrompt, contextKey, touchActivity] ) const handleCancel = useCallback(() => { connCancel().catch((e: unknown) => console.error("[ConnLifecycle] cancel:", e) ) }, [connCancel]) const handleSetConfigOption = useCallback( (configId: string, valueId: string) => { touchActivity(contextKey) connSetConfigOption(configId, valueId).catch((e: unknown) => console.error("[ConnLifecycle] setConfigOption:", e) ) }, [connSetConfigOption, contextKey, touchActivity] ) const handleRespondPermission = useCallback( (requestId: string, optionId: string) => { touchActivity(contextKey) connRespondPermission(requestId, optionId).catch((e: unknown) => console.error("[ConnLifecycle] respondPermission:", e) ) }, [connRespondPermission, contextKey, touchActivity] ) return { conn, modeLoading, configOptionsLoading, selectorsLoading, autoConnectError, handleFocus, handleSend, handleSetConfigOption, handleCancel, handleRespondPermission, } }