Files
codeg/src/hooks/use-connection-lifecycle.ts

380 lines
12 KiB
TypeScript

"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<string | null>(null)
const selectorTaskTimeoutRef = useRef<ReturnType<typeof setTimeout> | 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<string | null>(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<string | null>(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,
}
}