380 lines
12 KiB
TypeScript
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,
|
|
}
|
|
}
|