"use client" import { useCallback, useEffect, useMemo, useRef, useState } from "react" import { useTranslations } from "next-intl" import { MessageInput } from "@/components/chat/message-input" import type { AgentType, PromptDraft, SessionStats } from "@/lib/types" import { useFolderContext } from "@/contexts/folder-context" import { useTabContext } from "@/contexts/tab-context" import { useSessionStats } from "@/contexts/session-stats-context" import { useAcpActions } from "@/contexts/acp-connections-context" import { useConnectionLifecycle } from "@/hooks/use-connection-lifecycle" import type { AdaptedMessage } from "@/lib/adapters/ai-elements-adapter" import { adaptLiveMessageFromAcp, adaptMessageTurns, } from "@/lib/adapters/ai-elements-adapter" import { buildUserMessageTextPartsFromDraft, extractUserImagesFromDraft, extractUserResourcesFromDraft, getPromptDraftDisplayText, } from "@/lib/prompt-draft" import { buildPlanKey, extractLatestPlanEntriesFromMessages, } from "@/lib/agent-plan" import { buildConversationDraftStorageKey, buildNewConversationDraftStorageKey, moveMessageInputDraft, } from "@/lib/message-input-draft" import { createConversation, getFolderConversation, openSettingsWindow, 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" import { LiveTurnStats } from "@/components/message/live-turn-stats" import { TurnStats } from "@/components/message/turn-stats" import { UserResourceLinks } from "@/components/message/user-resource-links" import { UserImageAttachments } from "@/components/message/user-image-attachments" import { ConversationShell } from "@/components/chat/conversation-shell" import { MessageThread, MessageThreadContent, } from "@/components/ai-elements/message-thread" import { Message, MessageContent } from "@/components/ai-elements/message" import { ContentPartsRenderer } from "@/components/message/content-parts-renderer" const ACP_AGENTS_UPDATED_EVENT = "app://acp-agents-updated" interface WelcomeInputPanelProps { defaultAgentType?: AgentType workingDir?: string tabId?: string isActive?: boolean } interface AgentsUpdatedEventPayload { reason?: string agent_type?: AgentType | null } 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 } function buildInlineAutoConnectErrorMessage( raw: string, options: { fallback: string append: (message: string) => string alreadyContainsPath: (message: string) => boolean } ): string { const normalized = raw.trim().replace(/[。.!?,,;;::]+$/u, "") if (!normalized) return options.fallback if (options.alreadyContainsPath(normalized)) return normalized return options.append(normalized) } export function WelcomeInputPanel({ defaultAgentType, workingDir, tabId, isActive = true, }: WelcomeInputPanelProps) { const t = useTranslations("Folder.chat.welcomeInputPanel") const tabT = useTranslations("Folder.tabContext") const sharedT = useTranslations("Folder.chat.shared") const fallbackContextId = useMemo(() => crypto.randomUUID(), []) const contextKey = tabId ?? `new-${fallbackContextId}` const { folderId, refreshConversations } = useFolderContext() const { promoteNewConversationTab, linkTabConversation } = useTabContext() const { setSessionStats } = useSessionStats() const { migrateContextKey } = useAcpActions() const latestSessionStatsRef = useRef(null) const isActiveRef = useRef(isActive) const statsRefreshSeqRef = useRef(0) useEffect(() => { isActiveRef.current = isActive }, [isActive]) // Reset or restore token stats when tab becomes active useEffect(() => { if (isActive) { setSessionStats(latestSessionStatsRef.current) } }, [isActive, setSessionStats]) const applySessionStats = useCallback( (stats: SessionStats | null) => { latestSessionStatsRef.current = stats if (isActiveRef.current) { setSessionStats(stats) } }, [setSessionStats] ) const hasTokenStats = useCallback((stats: SessionStats | null): boolean => { if (!stats) return false return ( stats.total_usage !== null || stats.total_tokens != null || stats.context_window_used_tokens != null || stats.context_window_max_tokens != null ) }, []) const hasAssistantUsage = useCallback( (messages: AdaptedMessage[]): boolean => { for (let i = messages.length - 1; i >= 0; i -= 1) { const message = messages[i] if (message.role !== "assistant") continue return message.usage != null } return false }, [] ) const refreshConversationFromDb = useCallback( async (expectedTurnCount?: number) => { const conversationId = dbConvIdRef.current if (!conversationId) return const refreshSeq = ++statsRefreshSeqRef.current const maxAttempts = 10 const retryDelayMs = 400 let latestMessages: AdaptedMessage[] | null = null let latestStats: SessionStats | null = null for (let attempt = 0; attempt < maxAttempts; attempt += 1) { if (refreshSeq !== statsRefreshSeqRef.current) return try { const detail = await getFolderConversation(conversationId) if (refreshSeq !== statsRefreshSeqRef.current) return const messages = adaptMessageTurns(detail.turns, { attachedResources: sharedT("attachedResources"), toolCallFailed: sharedT("toolCallFailed"), }) const stats = detail.session_stats ?? null latestMessages = messages latestStats = stats const hasExpectedTurns = expectedTurnCount == null || detail.turns.length >= expectedTurnCount const canShowTurnTokenStats = hasAssistantUsage(messages) const canShowSessionTokenStats = hasTokenStats(stats) if ( hasExpectedTurns && (canShowTurnTokenStats || canShowSessionTokenStats) ) { setHistory(messages) if (canShowSessionTokenStats) { applySessionStats(stats) } return } } catch { // Ignore transient read failures while session file is syncing. } if (attempt < maxAttempts - 1) { await new Promise((resolve) => setTimeout(resolve, retryDelayMs)) } } if (refreshSeq !== statsRefreshSeqRef.current) return if (latestMessages) { setHistory(latestMessages) } if (latestStats && hasTokenStats(latestStats)) { applySessionStats(latestStats) } }, [applySessionStats, hasAssistantUsage, hasTokenStats, sharedT] ) useEffect(() => { return () => { statsRefreshSeqRef.current += 1 } }, []) const [phase, setPhase] = useState<"welcome" | "conversation">("welcome") const [selectedAgent, setSelectedAgent] = useState( defaultAgentType ?? "codex" ) const [history, setHistory] = useState([]) const historyRef = useRef([]) useEffect(() => { historyRef.current = history }, [history]) const historicalPlanEntries = useMemo( () => extractLatestPlanEntriesFromMessages(history), [history] ) const historicalPlanKey = useMemo( () => buildPlanKey(historicalPlanEntries), [historicalPlanEntries] ) const [modeId, setModeId] = useState(null) const [dbConversationId, setDbConversationId] = useState(null) const [agentsLoaded, setAgentsLoaded] = useState(false) const [usableAgentCount, setUsableAgentCount] = useState(0) const [agentConnectError, setAgentConnectError] = useState( null ) const canAutoConnect = agentsLoaded && usableAgentCount > 0 const pendingPromptRef = useRef<{ draft: PromptDraft modeId: string | null } | null>(null) const newConversationDraftStorageKey = useMemo( () => buildNewConversationDraftStorageKey({ folderId, }), [folderId] ) const activeDraftStorageKey = useMemo(() => { if (dbConversationId != null) { return buildConversationDraftStorageKey(selectedAgent, dbConversationId) } return newConversationDraftStorageKey }, [dbConversationId, newConversationDraftStorageKey, selectedAgent]) // DB persistence state const dbConvIdRef = useRef(null) const statusUpdatedRef = useRef(false) const tabPromotedRef = useRef(false) const tabIdRef = useRef(tabId) const selectedAgentRef = useRef(selectedAgent) const convTitleRef = useRef(null) useEffect(() => { tabIdRef.current = tabId }, [tabId]) useEffect(() => { selectedAgentRef.current = selectedAgent }, [selectedAgent]) const { conn, modeLoading, configOptionsLoading, autoConnectError, handleFocus, handleSend: lifecycleSend, handleSetConfigOption, handleCancel, handleRespondPermission, } = useConnectionLifecycle({ contextKey, agentType: selectedAgent, isActive: isActive && canAutoConnect, workingDir, }) // Destructure stable callback + volatile status separately. // conn.connect is stable (depends only on actions + contextKey). // conn.status changes on state transitions (~5/turn), NOT on every // streaming delta (hundreds/sec) — much cheaper than depending on `conn`. const { status: connStatus, connect: connConnect, disconnect: connDisconnect, sessionId: connSessionId, } = conn const isConnecting = connStatus === "connecting" || connStatus === "downloading" const connectionModes = useMemo( () => conn.modes?.available_modes ?? [], [conn.modes?.available_modes] ) const connectionConfigOptions = useMemo( () => conn.configOptions ?? [], [conn.configOptions] ) const connectionCommands = useMemo( () => conn.availableCommands ?? [], [conn.availableCommands] ) const selectedModeId = useMemo(() => { if (connectionModes.length === 0) return null if (modeId && connectionModes.some((mode) => mode.id === modeId)) { return modeId } return conn.modes?.current_mode_id ?? connectionModes[0]?.id ?? null }, [conn.modes?.current_mode_id, connectionModes, modeId]) // Persist the agent-assigned session ID as external_id once both // the DB conversation ID and the ACP session ID are available. const externalIdSavedRef = useRef(false) const sessionIdRef = useRef(null) const refreshingCurrentAgentRef = useRef(false) const agentStatusRefreshTimerRef = useRef | null>(null) const phaseRef = useRef(phase) const workingDirRef = useRef(workingDir) const connStatusRef = useRef(connStatus) const isConnectingRef = useRef(false) const connConnectRef = useRef(connConnect) const connDisconnectRef = useRef(connDisconnect) useEffect(() => { if (connSessionId) { sessionIdRef.current = connSessionId } }, [connSessionId]) useEffect(() => { phaseRef.current = phase }, [phase]) useEffect(() => { workingDirRef.current = workingDir }, [workingDir]) useEffect(() => { connStatusRef.current = connStatus }, [connStatus]) useEffect(() => { isConnectingRef.current = isConnecting }, [isConnecting]) useEffect(() => { connConnectRef.current = connConnect }, [connConnect]) useEffect(() => { connDisconnectRef.current = connDisconnect }, [connDisconnect]) const trySaveExternalId = useCallback(() => { if ( externalIdSavedRef.current || !dbConvIdRef.current || !sessionIdRef.current ) return externalIdSavedRef.current = true updateConversationExternalId( dbConvIdRef.current, sessionIdRef.current ).catch((e: unknown) => console.error("[WelcomePanel] update external_id:", e) ) }, []) // Trigger when session ID arrives from ACP useEffect(() => { if (connSessionId) trySaveExternalId() }, [connSessionId, trySaveExternalId]) useEffect(() => { let cancelled = false let unlisten: (() => void) | null = null const syncCurrentAgentStatus = async () => { if (cancelled) return if (phaseRef.current !== "welcome") return const currentWorkingDir = workingDirRef.current if (!currentWorkingDir) return if (refreshingCurrentAgentRef.current) return const currentConnStatus = connStatusRef.current if (currentConnStatus === "prompting" || isConnectingRef.current) return refreshingCurrentAgentRef.current = true try { setAgentConnectError(null) if (currentConnStatus === "connected") { await connDisconnectRef.current() } await connConnectRef.current( selectedAgentRef.current, currentWorkingDir, undefined, { source: "auto_link", } ) if (!cancelled) { setAgentConnectError(null) } } catch (error) { if (!cancelled) { setAgentConnectError(normalizeErrorMessage(error)) } if (!isExpectedAutoLinkError(error)) { console.error("[WelcomePanel] refresh current agent status:", error) } } finally { refreshingCurrentAgentRef.current = false } } void import("@tauri-apps/api/event") .then(({ listen }) => listen(ACP_AGENTS_UPDATED_EVENT, (event) => { if (cancelled) return if (event.payload?.reason === "agent_reordered") return const changedAgentType = event.payload?.agent_type if ( changedAgentType && changedAgentType !== selectedAgentRef.current ) { return } if (agentStatusRefreshTimerRef.current) { clearTimeout(agentStatusRefreshTimerRef.current) } agentStatusRefreshTimerRef.current = setTimeout(() => { void syncCurrentAgentStatus() }, 120) }) ) .then((dispose) => { if (cancelled) { disposeTauriListener(dispose, "WelcomeInputPanel.agentsUpdated") return } unlisten = dispose }) .catch(() => { // Ignore when non-tauri runtime. }) return () => { cancelled = true if (agentStatusRefreshTimerRef.current) { clearTimeout(agentStatusRefreshTimerRef.current) agentStatusRefreshTimerRef.current = null } disposeTauriListener(unlisten, "WelcomeInputPanel.agentsUpdated") } }, []) const prevStatusRef = useRef(connStatus) // Accumulate history when prompting completes useEffect(() => { const prev = prevStatusRef.current prevStatusRef.current = connStatus if (prev === "prompting" && connStatus !== "prompting") { if (conn.liveMessage && conn.liveMessage.content.length > 0) { const adapted = adaptLiveMessageFromAcp(conn.liveMessage, { isLiveStreaming: false, toolCallFailedText: sharedT("toolCallFailed"), planUpdatedText: sharedT("planUpdated"), }) setHistory((h) => [...h, adapted]) } // Agent turn ended — mark as pending_review unless it's a terminal state if ( dbConvIdRef.current && connStatus !== "disconnected" && connStatus !== "error" ) { updateConversationStatus(dbConvIdRef.current, "pending_review") .then(() => refreshConversations()) .catch((e: unknown) => console.error("[WelcomePanel] update status:", e) ) } void refreshConversationFromDb( historyRef.current.length + (conn.liveMessage ? 1 : 0) ) } // eslint-disable-next-line react-hooks/exhaustive-deps -- conn.liveMessage, lifecycleSend intentionally omitted: effect only fires on status transitions }, [connStatus, refreshConversations, refreshConversationFromDb, sharedT]) // When connection becomes "connected" and we have a pending prompt, send it useEffect(() => { if (connStatus === "connected" && pendingPromptRef.current) { const pending = pendingPromptRef.current pendingPromptRef.current = null lifecycleSend(pending.draft, pending.modeId) } }, [connStatus, lifecycleSend]) // Promote tab helper — call once when conversation ends or component unmounts const promoteTab = useCallback(() => { if (tabPromotedRef.current || !dbConvIdRef.current) return tabPromotedRef.current = true const tid = tabIdRef.current const convId = dbConvIdRef.current const agent = selectedAgentRef.current const title = convTitleRef.current || tabT("untitledConversation") const canonicalContextKey = `conv-${agent}-${convId}` // Keep in-flight stream/state attached when this new-conversation view // is closed and later reopened as a canonical conversation tab. migrateContextKey(contextKey, canonicalContextKey) if (tid) { promoteNewConversationTab(tid, convId, agent, title) } refreshConversations() }, [ promoteNewConversationTab, refreshConversations, migrateContextKey, contextKey, tabT, ]) // Update conversation status on disconnect/error + promote tab useEffect(() => { if (!dbConvIdRef.current || statusUpdatedRef.current) return if (connStatus === "disconnected") { statusUpdatedRef.current = true updateConversationStatus(dbConvIdRef.current, "completed").catch((e) => console.error("[WelcomePanel] update status:", e) ) promoteTab() } else if (connStatus === "error") { statusUpdatedRef.current = true updateConversationStatus(dbConvIdRef.current, "cancelled").catch((e) => console.error("[WelcomePanel] update status:", e) ) promoteTab() } }, [connStatus, promoteTab]) // Promote tab on unmount if not yet promoted (e.g. user closes tab) useEffect(() => { return () => { promoteTab() } }, [promoteTab]) const handleAgentSelect = useCallback( (agentType: AgentType) => { if (agentType === selectedAgent) return setSelectedAgent(agentType) setModeId(null) setAgentConnectError(null) connDisconnect() .catch((e) => console.error("[WelcomePanel] disconnect old agent:", e)) .finally(() => { connConnect(agentType, workingDir, undefined, { source: "auto_link", }) .then(() => { setAgentConnectError(null) }) .catch((e) => { setAgentConnectError(normalizeErrorMessage(e)) if (!isExpectedAutoLinkError(e)) { console.error("[WelcomePanel] switch agent:", e) } }) }) }, [selectedAgent, connConnect, connDisconnect, workingDir] ) // Welcome phase: submit first message. const handleWelcomeSend = useCallback( (draft: PromptDraft, selectedModeId?: string | null) => { const displayText = getPromptDraftDisplayText( draft, sharedT("attachedResources") ) const userMsg: AdaptedMessage = { id: crypto.randomUUID(), role: "user", content: buildUserMessageTextPartsFromDraft( draft, sharedT("attachedResources") ), userImages: extractUserImagesFromDraft(draft), userResources: extractUserResourcesFromDraft(draft), timestamp: new Date().toISOString(), } setHistory([userMsg]) setPhase("conversation") applySessionStats(null) statsRefreshSeqRef.current += 1 // If already connected, send directly; otherwise queue for when connected if (connStatus === "connected") { lifecycleSend(draft, selectedModeId) } else { pendingPromptRef.current = { draft, modeId: selectedModeId ?? null, } // Ensure connection is being established if ( !connStatus || connStatus === "disconnected" || connStatus === "error" ) { connConnect(selectedAgent, workingDir, undefined, { source: "auto_link", }).catch((e) => { setAgentConnectError(normalizeErrorMessage(e)) }) } } // DB persistence: create conversation const title = displayText.slice(0, 80) convTitleRef.current = title createConversation(folderId, selectedAgent, title) .then((convId) => { dbConvIdRef.current = convId setDbConversationId(convId) moveMessageInputDraft( newConversationDraftStorageKey, buildConversationDraftStorageKey(selectedAgent, convId) ) // Link tab to DB conversation so status dot updates and tab is persisted if (tabIdRef.current) { linkTabConversation(tabIdRef.current, convId, selectedAgent, title) } // If ACP session ID already arrived, save external_id now trySaveExternalId() refreshConversations() }) .catch((e: unknown) => console.error("[WelcomePanel] create conversation:", e) ) }, [ selectedAgent, workingDir, connStatus, connConnect, lifecycleSend, folderId, refreshConversations, linkTabConversation, trySaveExternalId, applySessionStats, newConversationDraftStorageKey, sharedT, ] ) // Conversation phase: prepend user message to history before sending const handleSendWithHistory = useCallback( (draft: PromptDraft, selectedModeId?: string | null) => { const userMsg: AdaptedMessage = { id: crypto.randomUUID(), role: "user", content: buildUserMessageTextPartsFromDraft( draft, sharedT("attachedResources") ), userImages: extractUserImagesFromDraft(draft), userResources: extractUserResourcesFromDraft(draft), timestamp: new Date().toISOString(), } setHistory((h) => [...h, userMsg]) lifecycleSend(draft, selectedModeId) // Update status if (dbConvIdRef.current) { updateConversationStatus(dbConvIdRef.current, "in_progress") .then(() => refreshConversations()) .catch((e: unknown) => console.error("[WelcomePanel] update status:", e) ) statusUpdatedRef.current = false } }, [lifecycleSend, refreshConversations, sharedT] ) const handleOpenAgentsSettings = useCallback(() => { openSettingsWindow("agents", { agentType: selectedAgent }).catch((err) => { console.error("[WelcomePanel] failed to open settings window:", err) }) }, [selectedAgent]) const buildAutoConnectErrorMessage = useCallback( (raw: string) => buildInlineAutoConnectErrorMessage(raw, { fallback: t("autoConnectFallback"), append: (message) => t("autoConnectAppend", { message, path: t("agentsSettingsPath"), }), alreadyContainsPath: (message) => [t("agentsSettingsPath"), "Settings > Agents"].some((path) => message.includes(path) ), }), [t] ) // Track live message visibility across turn completion. // Hooks must be called before any conditional returns. const prevConnStatusForLiveRef = useRef(connStatus) const showLiveTransitionRef = useRef(false) const prevHistoryLenRef = useRef(history.length) if (connStatus === "prompting") { showLiveTransitionRef.current = false } else if (prevConnStatusForLiveRef.current === "prompting") { showLiveTransitionRef.current = true } prevConnStatusForLiveRef.current = connStatus // Once the effect adds the adapted message to history, hide the live block. if ( history.length > prevHistoryLenRef.current && showLiveTransitionRef.current ) { showLiveTransitionRef.current = false } prevHistoryLenRef.current = history.length // ── Welcome phase ── if (phase === "welcome") { return (
{ setAgentsLoaded(true) setUsableAgentCount( agents.filter((agent) => agent.enabled && agent.available) .length ) }} onOpenAgentsSettings={handleOpenAgentsSettings} disabled={isConnecting} /> {autoConnectError || agentConnectError ? ( ) : null}
) } // ── Conversation phase ── const showLive = Boolean( conn.liveMessage && (connStatus === "prompting" || (conn.liveMessage.content.length > 0 && showLiveTransitionRef.current)) ) return (
{history.map((msg) => (
{msg.role === "user" && msg.userImages?.length ? ( ) : null} {msg.role === "user" && msg.userResources?.length ? ( ) : null} {msg.role === "assistant" && ( )}
))} {showLive && }
{showLive && }
) }