diff --git a/src/app/folder/layout.tsx b/src/app/folder/layout.tsx index 29d52fa..9f8dc8f 100644 --- a/src/app/folder/layout.tsx +++ b/src/app/folder/layout.tsx @@ -10,6 +10,7 @@ import { FolderProvider } from "@/contexts/folder-context" import { TaskProvider } from "@/contexts/task-context" import { AlertProvider } from "@/contexts/alert-context" import { AcpConnectionsProvider } from "@/contexts/acp-connections-context" +import { ConversationRuntimeProvider } from "@/contexts/conversation-runtime-context" import { TabProvider } from "@/contexts/tab-context" import { SessionStatsProvider } from "@/contexts/session-stats-context" import { SidebarProvider, useSidebarContext } from "@/contexts/sidebar-context" @@ -643,36 +644,38 @@ function FolderLayoutInner({ children }: { children: React.ReactNode }) { - - - - - + + + + - -
- - - {children} - - - -
-
-
-
-
-
-
+ + +
+ + + {children} + + + +
+
+
+ + + + +
diff --git a/src/components/chat/welcome-input-panel.tsx b/src/components/chat/welcome-input-panel.tsx deleted file mode 100644 index 8268d05..0000000 --- a/src/components/chat/welcome-input-panel.tsx +++ /dev/null @@ -1,949 +0,0 @@ -"use client" - -import { memo, 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, - type LiveMessage, -} 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 } from "@/components/ai-elements/message-thread" -import { Message, MessageContent } from "@/components/ai-elements/message" -import { ContentPartsRenderer } from "@/components/message/content-parts-renderer" -import { VirtualizedMessageThread } from "@/components/message/virtualized-message-thread" - -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) -} - -type WelcomeThreadItem = - | { key: string; kind: "history"; message: AdaptedMessage } - | { - key: string - kind: "live" - message: LiveMessage - isStreaming: boolean - } - -const WelcomeHistoryMessage = memo(function WelcomeHistoryMessage({ - message, -}: { - message: AdaptedMessage -}) { - return ( -
- - {message.role === "user" && message.userImages?.length ? ( - - ) : null} - - - - {message.role === "user" && message.userResources?.length ? ( - - ) : null} - - {message.role === "assistant" && ( - - )} -
- ) -}) - -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 - - const showLive = Boolean( - conn.liveMessage && - (connStatus === "prompting" || - (conn.liveMessage.content.length > 0 && showLiveTransitionRef.current)) - ) - - const threadItems = useMemo(() => { - const items: WelcomeThreadItem[] = history.map((message) => ({ - key: `history-${message.id}`, - kind: "history", - message, - })) - if (showLive && conn.liveMessage) { - items.push({ - key: `live-${conn.liveMessage.id}`, - kind: "live", - message: conn.liveMessage, - isStreaming: connStatus === "prompting", - }) - } - return items - }, [history, showLive, conn.liveMessage, connStatus]) - - const renderThreadItem = useCallback((item: WelcomeThreadItem) => { - if (item.kind === "live") { - return ( - - ) - } - return - }, []) - - // ── Welcome phase ── - if (phase === "welcome") { - return ( -
-
- { - setAgentsLoaded(true) - setUsableAgentCount( - agents.filter((agent) => agent.enabled && agent.available) - .length - ) - }} - onOpenAgentsSettings={handleOpenAgentsSettings} - disabled={isConnecting} - /> - - {autoConnectError || agentConnectError ? ( - - ) : null} - - -
-
- ) - } - - return ( - -
- - item.key} - renderItem={renderThreadItem} - estimateSize={180} - overscan={10} - /> - - {showLive && connStatus === "prompting" && ( - - )} - -
-
- ) -} diff --git a/src/components/conversations/conversation-detail-panel.tsx b/src/components/conversations/conversation-detail-panel.tsx index 79ff681..cee99e7 100644 --- a/src/components/conversations/conversation-detail-panel.tsx +++ b/src/components/conversations/conversation-detail-panel.tsx @@ -10,20 +10,36 @@ import { useTabContext } from "@/contexts/tab-context" import { useConnectionLifecycle } from "@/hooks/use-connection-lifecycle" import { MessageListView } from "@/components/message/message-list-view" import { ConversationShell } from "@/components/chat/conversation-shell" -import { WelcomeInputPanel } from "@/components/chat/welcome-input-panel" -import { updateConversationStatus } from "@/lib/tauri" +import { AgentSelector } from "@/components/chat/agent-selector" import { + createConversation, + openSettingsWindow, + updateConversationExternalId, + updateConversationStatus, +} from "@/lib/tauri" +import { useConversationRuntime } from "@/contexts/conversation-runtime-context" +import { + invalidateDetailCache, + refreshDetailCache, useDbMessageDetail, - warmupDetailCache, } from "@/hooks/use-db-message-detail" -import type { AcpEvent, AgentType, PromptDraft } from "@/lib/types" -import type { AdaptedMessage } from "@/lib/adapters/ai-elements-adapter" import { - buildUserMessageTextPartsFromDraft, extractUserImagesFromDraft, extractUserResourcesFromDraft, + getPromptDraftDisplayText, } from "@/lib/prompt-draft" -import { buildConversationDraftStorageKey } from "@/lib/message-input-draft" +import type { + AcpEvent, + AgentType, + ContentBlock, + MessageTurn, + PromptDraft, +} from "@/lib/types" +import { + buildConversationDraftStorageKey, + buildNewConversationDraftStorageKey, + moveMessageInputDraft, +} from "@/lib/message-input-draft" import { ContextMenu, ContextMenuContent, @@ -32,62 +48,184 @@ import { ContextMenuTrigger, } from "@/components/ui/context-menu" -interface ExistingConversationViewProps { +interface ConversationTabViewProps { tabId: string - conversationId: number + conversationId: number | null agentType: AgentType + workingDir?: string isActive: boolean reloadSignal: number } -const ExistingConversationView = memo(function ExistingConversationView({ +function buildOptimisticUserTurnFromDraft( + draft: PromptDraft, + attachedResourcesFallback: string +): MessageTurn { + const displayText = getPromptDraftDisplayText( + draft, + attachedResourcesFallback + ) + const resources = extractUserResourcesFromDraft(draft) + const resourceLines = resources.map((resource) => { + const label = resource.uri.toLowerCase().startsWith("file://") + ? resource.name + : `@${resource.name}` + return `[${label}](${resource.uri})` + }) + const text = [displayText, ...resourceLines].join("\n").trim() + + const blocks: ContentBlock[] = [] + for (const image of extractUserImagesFromDraft(draft)) { + blocks.push({ + type: "image", + data: image.data, + mime_type: image.mime_type, + uri: image.uri ?? null, + }) + } + blocks.push({ type: "text", text }) + + return { + id: `optimistic-${crypto.randomUUID()}`, + role: "user", + blocks, + timestamp: new Date().toISOString(), + } +} + +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 buildVirtualConversationId(seed: string): number { + let hash = 0 + for (let i = 0; i < seed.length; i += 1) { + hash = (hash * 31 + seed.charCodeAt(i)) | 0 + } + const normalized = Math.abs(hash) + 1 + return -normalized +} + +const ConversationTabView = memo(function ConversationTabView({ tabId, conversationId, agentType, + workingDir, isActive, reloadSignal, -}: ExistingConversationViewProps) { +}: ConversationTabViewProps) { const t = useTranslations("Folder.conversation") + const tWelcome = useTranslations("Folder.chat.welcomeInputPanel") const sharedT = useTranslations("Folder.chat.shared") - const { refreshConversations, folder } = useFolderContext() - const contextKey = `conv-${agentType}-${conversationId}` - - // Get external_id to resume existing agent session via LoadSessionRequest. - // Gate workingDir on loading so auto-connect waits for sessionId to resolve. + const { folder, folderId, refreshConversations } = useFolderContext() + const { bindConversationTab } = useTabContext() const { - detail, - loading: detailLoading, - error: detailError, - refetch: refetchConversationDetail, - } = useDbMessageDetail(conversationId) - const externalId = detail?.summary.external_id ?? undefined + acknowledgePersistedDetail, + appendOptimisticTurn, + migrateConversation, + setExternalId, + setLiveMessage, + setSyncState, + } = useConversationRuntime() + + const temporaryConversationId = useMemo( + () => buildVirtualConversationId(`draft-${tabId}`), + [tabId] + ) + const [createdConversationId, setCreatedConversationId] = useState< + number | null + >(null) + const dbConversationId = conversationId ?? createdConversationId + const [draftAgentType, setDraftAgentType] = useState(agentType) + const selectedAgent = conversationId != null ? agentType : draftAgentType + const [modeId, setModeId] = useState(null) + const [agentsLoaded, setAgentsLoaded] = useState(false) + const [usableAgentCount, setUsableAgentCount] = useState(0) + const [agentConnectError, setAgentConnectError] = useState( + null + ) + + const hasPersistedConversation = dbConversationId != null + const canAutoConnect = + hasPersistedConversation || (agentsLoaded && usableAgentCount > 0) + const effectiveConversationId = dbConversationId ?? temporaryConversationId + const latestReloadSignal = useRef(reloadSignal) const pendingReloadState = useRef<{ signal: number sawLoading: boolean } | null>(null) + const dbConvIdRef = useRef(conversationId) + const statusUpdatedRef = useRef(false) + const selectedAgentRef = useRef(selectedAgent) + const pendingPromptRef = useRef<{ + draft: PromptDraft + modeId: string | null + } | null>(null) + const createConversationPendingRef = useRef(false) + const reconcileTimerRef = useRef | null>(null) + const externalIdSavedRef = useRef(false) + const sessionIdRef = useRef(null) + + useEffect(() => { + dbConvIdRef.current = dbConversationId + }, [dbConversationId]) + + useEffect(() => { + selectedAgentRef.current = selectedAgent + }, [selectedAgent]) + + const { + detail, + loading: detailLoading, + error: detailError, + refetch: refetchConversationDetail, + } = useDbMessageDetail(effectiveConversationId) + const externalId = detail?.summary.external_id ?? undefined + const draftStorageKey = useMemo(() => { + if (dbConversationId != null) { + return buildConversationDraftStorageKey(selectedAgent, dbConversationId) + } + return buildNewConversationDraftStorageKey({ folderId }) + }, [dbConversationId, folderId, selectedAgent]) + const workingDirForConnection = useMemo(() => { + if (dbConversationId != null) { + return detailLoading ? undefined : folder?.path + } + return workingDir ?? folder?.path + }, [dbConversationId, detailLoading, folder?.path, workingDir]) const { conn, modeLoading, configOptionsLoading, + autoConnectError, handleFocus, - handleSend, + handleSend: lifecycleSend, handleSetConfigOption, handleCancel, handleRespondPermission, } = useConnectionLifecycle({ - contextKey, - agentType, - isActive, - workingDir: detailLoading ? undefined : folder?.path, - sessionId: externalId, + contextKey: tabId, + agentType: selectedAgent, + isActive: isActive && canAutoConnect, + workingDir: workingDirForConnection, + sessionId: dbConversationId != null ? externalId : undefined, }) - - const [pendingMessages, setPendingMessages] = useState([]) - const [modeId, setModeId] = useState(null) - const clearPending = useCallback(() => setPendingMessages([]), []) - + 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] @@ -108,79 +246,145 @@ const ExistingConversationView = memo(function ExistingConversationView({ return conn.modes?.current_mode_id ?? connectionModes[0]?.id ?? null }, [conn.modes?.current_mode_id, connectionModes, modeId]) - // Track status transitions for updating conversation metadata - const prevStatusRef = useRef(conn.status) - const statusUpdatedRef = useRef(false) + const clearReconcileTimer = useCallback(() => { + if (!reconcileTimerRef.current) return + clearTimeout(reconcileTimerRef.current) + reconcileTimerRef.current = null + }, []) - // Wrap handleSend to update status - const handleSendWithPersist = useCallback( - (draft: PromptDraft, selectedModeId?: string | null) => { - setPendingMessages([ - { - id: `pending-${Date.now()}`, - role: "user", - content: buildUserMessageTextPartsFromDraft( - draft, - sharedT("attachedResources") - ), - userImages: extractUserImagesFromDraft(draft), - userResources: extractUserResourcesFromDraft(draft), - timestamp: new Date().toISOString(), - }, - ]) - updateConversationStatus(conversationId, "in_progress") - .then(() => refreshConversations()) - .catch((e) => console.error("[ExistingConv] update status:", e)) - statusUpdatedRef.current = false - handleSend(draft, selectedModeId) + const refreshFromDb = useCallback( + async (refreshConversationId: number) => { + try { + const refreshed = await refreshDetailCache(refreshConversationId) + acknowledgePersistedDetail(refreshConversationId, refreshed) + } catch (error) { + setSyncState(refreshConversationId, "failed") + console.error( + "[ConversationTabView] refresh detail cache failed:", + error + ) + } }, - [conversationId, handleSend, refreshConversations, sharedT] + [acknowledgePersistedDetail, setSyncState] ) - // Update status on turn complete + useEffect(() => { + if (connSessionId) { + sessionIdRef.current = connSessionId + } + }, [connSessionId]) + + useEffect(() => { + setLiveMessage(effectiveConversationId, conn.liveMessage ?? null) + return () => { + setLiveMessage(effectiveConversationId, null) + } + }, [conn.liveMessage, effectiveConversationId, setLiveMessage]) + + useEffect(() => { + if (!dbConversationId) return + setExternalId(dbConversationId, detail?.summary.external_id ?? null) + }, [dbConversationId, detail?.summary.external_id, setExternalId]) + + useEffect(() => { + if (!dbConversationId) return + if (!connSessionId) return + setExternalId(dbConversationId, connSessionId) + }, [connSessionId, dbConversationId, setExternalId]) + + const trySaveExternalId = useCallback(() => { + if ( + externalIdSavedRef.current || + !dbConvIdRef.current || + !sessionIdRef.current + ) { + return + } + externalIdSavedRef.current = true + updateConversationExternalId( + dbConvIdRef.current, + sessionIdRef.current + ).catch((e: unknown) => + console.error("[ConversationTabView] update external_id:", e) + ) + }, []) + + useEffect(() => { + if (connSessionId) { + trySaveExternalId() + } + }, [connSessionId, trySaveExternalId]) + + useEffect(() => { + if (!dbConversationId) return + if (!detail) return + if (connStatus === "prompting") return + acknowledgePersistedDetail(dbConversationId, detail) + }, [acknowledgePersistedDetail, connStatus, dbConversationId, detail]) + + const prevStatusRef = useRef(connStatus) useEffect(() => { const prev = prevStatusRef.current - prevStatusRef.current = conn.status + prevStatusRef.current = connStatus + if (prev !== "prompting" || connStatus === "prompting") return - if (prev === "prompting" && conn.status !== "prompting") { - // Mark as pending_review unless it's a terminal state - if (conn.status !== "disconnected" && conn.status !== "error") { - updateConversationStatus(conversationId, "pending_review") - .then(() => refreshConversations()) - .catch((e: unknown) => - console.error("[ExistingConv] update status:", e) - ) - } + setSyncState(effectiveConversationId, "reconciling") + const persistedId = dbConvIdRef.current + if (!persistedId) return + + invalidateDetailCache(persistedId) + clearReconcileTimer() + reconcileTimerRef.current = setTimeout(() => { + void refreshFromDb(persistedId) + }, 1200) + + if (connStatus !== "disconnected" && connStatus !== "error") { + updateConversationStatus(persistedId, "pending_review") + .then(() => refreshConversations()) + .catch((e: unknown) => + console.error("[ConversationTabView] update status:", e) + ) } - }, [conn.status, conversationId, refreshConversations]) + }, [ + clearReconcileTimer, + connStatus, + effectiveConversationId, + refreshConversations, + refreshFromDb, + setSyncState, + ]) - // Update status on disconnect/error useEffect(() => { - if (conn.status === "connected" || conn.status === "prompting") { + if (connStatus === "connected" && pendingPromptRef.current) { + const pending = pendingPromptRef.current + pendingPromptRef.current = null + lifecycleSend(pending.draft, pending.modeId) + } + }, [connStatus, lifecycleSend]) + + useEffect(() => { + if (connStatus === "connected" || connStatus === "prompting") { statusUpdatedRef.current = false return } if (statusUpdatedRef.current) return - if (conn.status === "disconnected") { + const persistedId = dbConvIdRef.current + if (!persistedId) return + if (connStatus === "disconnected") { statusUpdatedRef.current = true - updateConversationStatus(conversationId, "completed") - .then(() => { - setPendingMessages([]) - refreshConversations() - }) - .catch((e) => console.error("[ExistingConv] update status:", e)) - } else if (conn.status === "error") { + updateConversationStatus(persistedId, "completed") + .then(() => refreshConversations()) + .catch((e) => console.error("[ConversationTabView] update status:", e)) + } else if (connStatus === "error") { statusUpdatedRef.current = true - updateConversationStatus(conversationId, "cancelled") - .then(() => { - setPendingMessages([]) - refreshConversations() - }) - .catch((e) => console.error("[ExistingConv] update status:", e)) + updateConversationStatus(persistedId, "cancelled") + .then(() => refreshConversations()) + .catch((e) => console.error("[ConversationTabView] update status:", e)) } - }, [conn.status, conversationId, refreshConversations]) + }, [connStatus, refreshConversations]) useEffect(() => { + if (dbConversationId == null) return if (reloadSignal === latestReloadSignal.current) return latestReloadSignal.current = reloadSignal pendingReloadState.current = { @@ -188,7 +392,7 @@ const ExistingConversationView = memo(function ExistingConversationView({ sawLoading: false, } refetchConversationDetail() - }, [reloadSignal, refetchConversationDetail]) + }, [dbConversationId, reloadSignal, refetchConversationDetail]) useEffect(() => { const pending = pendingReloadState.current @@ -211,15 +415,182 @@ const ExistingConversationView = memo(function ExistingConversationView({ toast.success(t("reloaded")) }, [detailLoading, detailError, t]) + useEffect(() => clearReconcileTimer, [clearReconcileTimer]) + + const handleSend = useCallback( + (draft: PromptDraft, selectedModeIdArg?: string | null) => { + if (!hasPersistedConversation && !canAutoConnect) { + setAgentConnectError(tWelcome("enableAgentFirstPlaceholder")) + return + } + + const optimisticTurn = buildOptimisticUserTurnFromDraft( + draft, + sharedT("attachedResources") + ) + appendOptimisticTurn( + effectiveConversationId, + optimisticTurn, + optimisticTurn.id + ) + setSyncState(effectiveConversationId, "awaiting_persist") + + if (connStatus === "connected") { + lifecycleSend(draft, selectedModeIdArg) + } else { + pendingPromptRef.current = { + draft, + modeId: selectedModeIdArg ?? null, + } + if ( + (!connStatus || + connStatus === "disconnected" || + connStatus === "error") && + workingDirForConnection + ) { + connConnect( + selectedAgent, + workingDirForConnection, + dbConversationId != null ? externalId : undefined, + { + source: "auto_link", + } + ).catch((e) => { + setAgentConnectError(normalizeErrorMessage(e)) + }) + } + } + + const persistedId = dbConvIdRef.current + if (persistedId) { + updateConversationStatus(persistedId, "in_progress") + .then(() => refreshConversations()) + .catch((e: unknown) => + console.error("[ConversationTabView] update status:", e) + ) + statusUpdatedRef.current = false + return + } + + if (createConversationPendingRef.current) return + createConversationPendingRef.current = true + const title = getPromptDraftDisplayText( + draft, + sharedT("attachedResources") + ).slice(0, 80) + createConversation(folderId, selectedAgent, title) + .then((newConversationId) => { + dbConvIdRef.current = newConversationId + setCreatedConversationId(newConversationId) + migrateConversation(temporaryConversationId, newConversationId) + setExternalId(newConversationId, sessionIdRef.current ?? null) + bindConversationTab(tabId, newConversationId, selectedAgent, title) + moveMessageInputDraft( + buildNewConversationDraftStorageKey({ folderId }), + buildConversationDraftStorageKey(selectedAgent, newConversationId) + ) + trySaveExternalId() + statusUpdatedRef.current = false + updateConversationStatus(newConversationId, "in_progress") + .then(() => refreshConversations()) + .catch((e: unknown) => + console.error("[ConversationTabView] update status:", e) + ) + void refreshFromDb(newConversationId) + }) + .catch((e: unknown) => + console.error("[ConversationTabView] create conversation:", e) + ) + .finally(() => { + createConversationPendingRef.current = false + }) + }, + [ + appendOptimisticTurn, + bindConversationTab, + canAutoConnect, + connConnect, + connStatus, + dbConversationId, + effectiveConversationId, + externalId, + folderId, + hasPersistedConversation, + lifecycleSend, + migrateConversation, + refreshConversations, + refreshFromDb, + selectedAgent, + setExternalId, + setSyncState, + sharedT, + tWelcome, + tabId, + temporaryConversationId, + trySaveExternalId, + workingDirForConnection, + ] + ) + + const handleOpenAgentsSettings = useCallback(() => { + openSettingsWindow("agents", { agentType: selectedAgent }).catch((err) => { + console.error( + "[ConversationTabView] failed to open settings window:", + err + ) + }) + }, [selectedAgent]) + + const handleAgentSelect = useCallback( + (nextAgentType: AgentType) => { + if (nextAgentType === selectedAgentRef.current) return + if (dbConvIdRef.current) return + + setDraftAgentType(nextAgentType) + setModeId(null) + setAgentConnectError(null) + connDisconnect() + .catch((e) => + console.error("[ConversationTabView] disconnect old agent:", e) + ) + .finally(() => { + if (!workingDirForConnection) return + connConnect(nextAgentType, workingDirForConnection, undefined, { + source: "auto_link", + }) + .then(() => { + setAgentConnectError(null) + }) + .catch((e) => { + setAgentConnectError(normalizeErrorMessage(e)) + if (!isExpectedAutoLinkError(e)) { + console.error("[ConversationTabView] switch agent:", e) + } + }) + }) + }, + [connConnect, connDisconnect, workingDirForConnection] + ) + + const messageListNode = ( + + ) + + const showDraftHeader = !hasPersistedConversation + return ( - + {showDraftHeader ? ( +
+
+ { + setAgentsLoaded(true) + setUsableAgentCount( + agents.filter((agent) => agent.enabled && agent.available) + .length + ) + }} + onOpenAgentsSettings={handleOpenAgentsSettings} + disabled={isConnecting || dbConversationId != null} + /> + {autoConnectError || agentConnectError ? ( + + ) : null} +
+
{messageListNode}
+
+ ) : ( + messageListNode + )}
) }) export function ConversationDetailPanel() { const t = useTranslations("Folder.conversation") + const { + acknowledgePersistedDetail, + getConversationIdByExternalId, + setSyncState, + } = useConversationRuntime() const { folder, newConversation, conversations, refreshConversations } = useFolderContext() const { tabs, activeTabId, openNewConversationTab, closeTab } = useTabContext() const [reloadByTabId, setReloadByTabId] = useState>({}) - const tabsRef = useRef(tabs) const conversationsRef = useRef(conversations) const pendingClosedConversationIdsRef = useRef>(new Set()) const pendingRefreshTimerRef = useRef | null>( null ) - useEffect(() => { - tabsRef.current = tabs - }, [tabs]) - useEffect(() => { conversationsRef.current = conversations }, [conversations]) @@ -294,8 +690,10 @@ export function ConversationDetailPanel() { } try { - await warmupDetailCache(conversationId) + const detail = await refreshDetailCache(conversationId) + acknowledgePersistedDetail(conversationId, detail) } catch (error) { + setSyncState(conversationId, "failed") console.error( "[ConversationDetailPanel] background detail cache refresh failed:", error @@ -306,7 +704,7 @@ export function ConversationDetailPanel() { refreshConversations() })() - }, [refreshConversations]) + }, [acknowledgePersistedDetail, refreshConversations, setSyncState]) const scheduleClosedConversationRefresh = useCallback( (conversationId: number) => { @@ -333,17 +731,20 @@ export function ConversationDetailPanel() { const payload = event.payload if (payload.type !== "turn_complete") return + const runtimeConversationId = getConversationIdByExternalId( + payload.session_id + ) const summary = conversationsRef.current.find( (item) => item.external_id === payload.session_id ) - if (!summary) return + const matchedConversationId = + runtimeConversationId ?? summary?.id ?? null + if (!matchedConversationId) return - const isOpenInTabs = tabsRef.current.some( - (tab) => tab.conversationId === summary.id - ) - if (isOpenInTabs) return + invalidateDetailCache(matchedConversationId) + setSyncState(matchedConversationId, "reconciling") - scheduleClosedConversationRefresh(summary.id) + scheduleClosedConversationRefresh(matchedConversationId) }) ) .then((dispose) => { @@ -372,27 +773,18 @@ export function ConversationDetailPanel() { "ConversationDetailPanel.backgroundRefresh" ) } - }, [scheduleClosedConversationRefresh]) + }, [ + getConversationIdByExternalId, + acknowledgePersistedDetail, + scheduleClosedConversationRefresh, + setSyncState, + ]) - const conversationTabs = useMemo( - () => - tabs.filter((t) => t.kind === "conversation" && t.conversationId != null), - [tabs] - ) - - const newConvTabs = useMemo( - () => tabs.filter((t) => t.kind === "new_conversation"), - [tabs] - ) - const hasNoTabs = - conversationTabs.length === 0 && newConvTabs.length === 0 && !activeTabId + const hasNoTabs = tabs.length === 0 && !activeTabId const activeConversationTab = useMemo( () => tabs.find( - (tab) => - tab.id === activeTabId && - tab.kind === "conversation" && - tab.conversationId != null + (tab) => tab.id === activeTabId && tab.conversationId != null ) ?? null, [tabs, activeTabId] ) @@ -435,19 +827,14 @@ export function ConversationDetailPanel() { // Empty state: no tabs at all — show full-screen welcome if (hasNoTabs) { - return ( - - ) + return null } return (
- {conversationTabs.map((tab) => { + {tabs.map((tab) => { const active = tab.id === activeTabId return (
-
) })} - {newConvTabs.map((tab) => { - const active = tab.id === activeTabId - return ( -
- -
- ) - })}
diff --git a/src/components/conversations/sidebar-conversation-list.tsx b/src/components/conversations/sidebar-conversation-list.tsx index 9a46a3f..46a8eaf 100644 --- a/src/components/conversations/sidebar-conversation-list.tsx +++ b/src/components/conversations/sidebar-conversation-list.tsx @@ -94,7 +94,8 @@ export function SidebarConversationList({ refreshConversations, } = useFolderContext() - const { openTab, closeTab, openNewConversationTab } = useTabContext() + const { openTab, closeConversationTab, openNewConversationTab } = + useTabContext() const { addTask, updateTask } = useTaskContext() const [importing, setImporting] = useState(false) @@ -206,10 +207,10 @@ export function SidebarConversationList({ const handleDelete = useCallback( async (id: number, agentType: string) => { await deleteConversation(id) - closeTab(`conv-${agentType}-${id}`) + closeConversationTab(id, agentType as Parameters[1]) refreshConversations() }, - [closeTab, refreshConversations] + [closeConversationTab, refreshConversations] ) const handleStatusChange = useCallback( diff --git a/src/components/layout/aux-panel-file-tree-tab.tsx b/src/components/layout/aux-panel-file-tree-tab.tsx index 14160ba..8cda1d6 100644 --- a/src/components/layout/aux-panel-file-tree-tab.tsx +++ b/src/components/layout/aux-panel-file-tree-tab.tsx @@ -842,10 +842,7 @@ export function FileTreeTab() { const activeSessionTabId = useMemo(() => { const activeTab = tabs.find((tab) => tab.id === activeTabId) if (!activeTab) return null - if ( - activeTab.kind !== "conversation" && - activeTab.kind !== "new_conversation" - ) { + if (activeTab.kind !== "conversation") { return null } return activeTab.id diff --git a/src/components/layout/aux-panel-session-files-tab.tsx b/src/components/layout/aux-panel-session-files-tab.tsx index 11d9ca6..7b9c1ee 100644 --- a/src/components/layout/aux-panel-session-files-tab.tsx +++ b/src/components/layout/aux-panel-session-files-tab.tsx @@ -1,21 +1,14 @@ "use client" -import { useEffect, useMemo, useRef, useState } from "react" +import { useMemo, useState } from "react" import { ChevronRight, FileIcon } from "lucide-react" import { useTranslations } from "next-intl" import { useFolderContext } from "@/contexts/folder-context" import { useTabContext } from "@/contexts/tab-context" -import type { LiveMessage } from "@/contexts/acp-connections-context" +import { useConversationRuntime } from "@/contexts/conversation-runtime-context" import { useWorkspaceContext } from "@/contexts/workspace-context" -import { useConnection } from "@/hooks/use-connection" import { useDbMessageDetail } from "@/hooks/use-db-message-detail" import { extractSessionFilesGrouped } from "@/lib/session-files" -import { getPendingPromptText } from "@/lib/pending-prompt-text" -import { - inferLiveToolName, - normalizeToolName, -} from "@/lib/tool-call-normalization" -import type { ConnectionStatus, MessageTurn } from "@/lib/types" import { CommitFileAdditions, CommitFileDeletions, @@ -27,8 +20,6 @@ import { } from "@/components/ui/collapsible" import { cn } from "@/lib/utils" -const LIVE_FILE_WRITE_OPS = new Set(["edit", "write", "apply_patch"]) - function isRemovedFileDiff(diff: string | null): boolean { if (!diff) return false @@ -61,123 +52,17 @@ function toFolderRelativePath(filePath: string, folderPath?: string): string { return normalizedFilePath } -function extractTurnText(turn: MessageTurn | null): string | null { - if (!turn || turn.role !== "user") return null - - for (const block of turn.blocks) { - if (block.type !== "text") continue - const text = block.text.trim() - if (text) return text - } - - return null -} - -function mergeLiveTurns(params: { - turns: MessageTurn[] - liveMessage: LiveMessage | null - connStatus: ConnectionStatus | null - pendingPromptText: string | null - fallbackPromptText: string -}): MessageTurn[] { - const { - turns, - liveMessage, - connStatus, - pendingPromptText, - fallbackPromptText, - } = params - if (!liveMessage || connStatus !== "prompting") return turns - - const liveBlocks = liveMessage.content.flatMap((block) => { - if (block.type !== "tool_call") return [] - - const toolName = inferLiveToolName({ - title: block.info.title, - kind: block.info.kind, - rawInput: block.info.raw_input, - }) - const normalizedToolName = normalizeToolName(toolName) - if (!LIVE_FILE_WRITE_OPS.has(normalizedToolName)) return [] - - return [ - { - type: "tool_use" as const, - tool_use_id: block.info.tool_call_id, - tool_name: toolName, - input_preview: block.info.raw_input, - }, - ] - }) - - if (liveBlocks.length === 0) return turns - - const now = new Date().toISOString() - const mergedTurns = [...turns] - const lastTurn = mergedTurns[mergedTurns.length - 1] - const lastUserTurn = - [...mergedTurns].reverse().find((turn) => turn.role === "user") ?? null - const pendingText = pendingPromptText?.trim() ?? "" - const shouldReuseExistingUserTurn = - pendingText.length > 0 && extractTurnText(lastUserTurn) === pendingText - - if ((!lastTurn || lastTurn.role !== "user") && !shouldReuseExistingUserTurn) { - mergedTurns.push({ - id: `live-user-${liveMessage.id}`, - role: "user", - blocks: [ - { type: "text", text: pendingPromptText?.trim() || fallbackPromptText }, - ], - timestamp: now, - }) - } - - mergedTurns.push({ - id: `live-assistant-${liveMessage.id}`, - role: "assistant", - blocks: liveBlocks, - timestamp: now, - }) - - return mergedTurns -} - -function SessionFilesContent({ - conversationId, - liveMessage, - connStatus, - pendingPromptText, -}: { - conversationId: number - liveMessage: LiveMessage | null - connStatus: ConnectionStatus | null - pendingPromptText: string | null -}) { +function SessionFilesContent({ conversationId }: { conversationId: number }) { const t = useTranslations("Folder.sessionFiles") - const { detail, loading, refetch } = useDbMessageDetail(conversationId) + const { loading } = useDbMessageDetail(conversationId) + const { getTimelineTurns } = useConversationRuntime() const { openSessionFileDiff } = useWorkspaceContext() const { folder } = useFolderContext() const [openGroups, setOpenGroups] = useState>({}) - const prevStatusRef = useRef(connStatus) - - useEffect(() => { - const prev = prevStatusRef.current - prevStatusRef.current = connStatus - if (prev === "prompting" && connStatus && connStatus !== "prompting") { - refetch() - } - }, [connStatus, refetch]) const turns = useMemo( - () => - mergeLiveTurns({ - turns: detail?.turns ?? [], - liveMessage, - connStatus, - pendingPromptText, - fallbackPromptText: t("currentResponse"), - }), - [detail?.turns, liveMessage, connStatus, pendingPromptText, t] + () => getTimelineTurns(conversationId).map((item) => item.turn), + [conversationId, getTimelineTurns] ) const groups = useMemo( () => (turns.length > 0 ? extractSessionFilesGrouped(turns) : []), @@ -197,7 +82,7 @@ function SessionFilesContent({ ) } - if (loading) { + if (loading && groups.length === 0) { return (

@@ -381,9 +266,6 @@ export function SessionFilesTab() { const activeTab = tabs.find((t) => t.id === activeTabId) const conversationId = activeTab?.conversationId - const contextKey = activeTab?.id ?? "__session-files-tab__" - const conn = useConnection(contextKey) - const pendingPromptText = getPendingPromptText(contextKey) if (!activeTab) { return ( @@ -408,12 +290,7 @@ export function SessionFilesTab() { return (

- +
) diff --git a/src/components/message/message-list-view.tsx b/src/components/message/message-list-view.tsx index ad7ad3d..99b37a8 100644 --- a/src/components/message/message-list-view.tsx +++ b/src/components/message/message-list-view.tsx @@ -1,11 +1,11 @@ "use client" -import { memo, useCallback, useEffect, useMemo, useRef } from "react" +import { memo, useCallback, useEffect, useMemo } from "react" import { useDbMessageDetail } from "@/hooks/use-db-message-detail" +import { useConversationRuntime } from "@/contexts/conversation-runtime-context" import { ContentPartsRenderer } from "./content-parts-renderer" import { adaptMessageTurns, - type AdaptedMessage, type AdaptedContentPart, type MessageGroup, type UserImageDisplay, @@ -18,9 +18,7 @@ import { LiveTurnStats } from "./live-turn-stats" import { UserResourceLinks } from "./user-resource-links" import { UserImageAttachments } from "./user-image-attachments" import { useSessionStats } from "@/contexts/session-stats-context" -import { LiveMessageBlock } from "@/components/chat/live-message-block" import { AgentPlanOverlay } from "@/components/chat/agent-plan-overlay" -import type { LiveMessage } from "@/contexts/acp-connections-context" import { MessageThread } from "@/components/ai-elements/message-thread" import { Message, MessageContent } from "@/components/ai-elements/message" import { Loader2 } from "lucide-react" @@ -35,9 +33,6 @@ import { VirtualizedMessageThread } from "@/components/message/virtualized-messa interface MessageListViewProps { conversationId: number connStatus?: ConnectionStatus | null - liveMessage?: LiveMessage | null - pendingMessages?: AdaptedMessage[] - onPendingClear?: () => void isActive?: boolean } @@ -50,24 +45,14 @@ interface ResolvedMessageGroup extends MessageGroup { type ThreadRenderItem = | { key: string - kind: "historical" - group: ResolvedMessageGroup - } - | { - key: string - kind: "pending" + kind: "turn" group: ResolvedMessageGroup + phase: "persisted" | "optimistic" | "streaming" } | { key: string kind: "typing" } - | { - key: string - kind: "live" - message: LiveMessage - isStreaming: boolean - } function fallbackExtractUserResources( group: MessageGroup, @@ -140,11 +125,13 @@ function resolveMessageGroup( const HistoricalMessageGroup = memo(function HistoricalMessageGroup({ group, + dimmed = false, }: { group: ResolvedMessageGroup + dimmed?: boolean }) { return ( -
+
{group.role === "user" && group.images.length > 0 ? ( @@ -168,28 +155,6 @@ const HistoricalMessageGroup = memo(function HistoricalMessageGroup({ ) }) -const PendingMessageGroup = memo(function PendingMessageGroup({ - group, -}: { - group: ResolvedMessageGroup -}) { - return ( -
- - {group.role === "user" && group.images.length > 0 ? ( - - ) : null} - - - - {group.role === "user" && group.resources.length > 0 ? ( - - ) : null} - -
- ) -}) - const PendingTypingIndicator = memo(function PendingTypingIndicator() { return ( @@ -207,33 +172,15 @@ const PendingTypingIndicator = memo(function PendingTypingIndicator() { export function MessageListView({ conversationId, connStatus, - liveMessage, - pendingMessages, - onPendingClear, isActive = true, }: MessageListViewProps) { const t = useTranslations("Folder.chat.messageList") const sharedT = useTranslations("Folder.chat.shared") const { detail, loading, error } = useDbMessageDetail(conversationId) - const turnCount = detail?.turns.length ?? 0 - - // 移除了 prompting 结束后的立即刷新 - // 原因:后端自动持久化可能有延迟,立即刷新会读到不完整数据 - // 现在通过清空 pending 来避免累积问题,等用户切换会话或手动刷新时再加载 - - const prevTurnCountRef = useRef(turnCount) - const prevConvIdRef = useRef(conversationId) - useEffect(() => { - if (prevConvIdRef.current !== conversationId) { - prevConvIdRef.current = conversationId - prevTurnCountRef.current = turnCount - return - } - if (turnCount > prevTurnCountRef.current && onPendingClear) { - onPendingClear() - } - prevTurnCountRef.current = turnCount - }, [turnCount, onPendingClear, conversationId]) + const { getSession, getTimelineTurns } = useConversationRuntime() + const session = getSession(conversationId) + const liveMessage = session?.liveMessage ?? null + const timelineTurns = getTimelineTurns(conversationId) const { setSessionStats } = useSessionStats() const sessionStats = detail?.session_stats ?? null @@ -244,106 +191,105 @@ export function MessageListView({ } }, [isActive, sessionStats, setSessionStats]) - const shouldUseSmoothResize = !(isActive && !loading && detail) + const shouldUseSmoothResize = !(isActive && !loading && timelineTurns.length) + const attachedResourcesText = sharedT("attachedResources") - const messages = useMemo( + const groupedTimeline = useMemo( () => - detail - ? adaptMessageTurns(detail.turns, { - attachedResources: sharedT("attachedResources"), - toolCallFailed: sharedT("toolCallFailed"), - }) - : [], - [detail, sharedT] + timelineTurns.reduce< + Array<{ + phase: "persisted" | "optimistic" | "streaming" + turns: typeof timelineTurns + }> + >((acc, item) => { + const current = acc[acc.length - 1] + if (current && current.phase === item.phase) { + current.turns.push(item) + return acc + } + acc.push({ + phase: item.phase, + turns: [item], + }) + return acc + }, []), + [timelineTurns] ) - const groups = useMemo(() => groupAdaptedMessages(messages), [messages]) + const threadItems = useMemo(() => { + const items: ThreadRenderItem[] = [] + for ( + let chunkIndex = 0; + chunkIndex < groupedTimeline.length; + chunkIndex++ + ) { + const chunk = groupedTimeline[chunkIndex] + const adapted = adaptMessageTurns( + chunk.turns.map((item) => item.turn), + { + attachedResources: sharedT("attachedResources"), + toolCallFailed: sharedT("toolCallFailed"), + } + ) + const groups = groupAdaptedMessages(adapted).map((group) => + resolveMessageGroup(group, attachedResourcesText) + ) + for (let groupIndex = 0; groupIndex < groups.length; groupIndex++) { + const group = groups[groupIndex] + items.push({ + key: `${chunk.phase}-${chunkIndex}-${group.id}-${groupIndex}`, + kind: "turn", + group, + phase: chunk.phase, + }) + } + } + const lastPhase = timelineTurns[timelineTurns.length - 1]?.phase ?? null + if (connStatus === "prompting" && lastPhase === "optimistic") { + items.push({ key: "pending-typing", kind: "typing" }) + } + return items + }, [ + attachedResourcesText, + connStatus, + groupedTimeline, + sharedT, + timelineTurns, + ]) + + const historicalMessages = useMemo( + () => + adaptMessageTurns( + timelineTurns + .filter((item) => item.phase !== "streaming") + .map((item) => item.turn), + { + attachedResources: sharedT("attachedResources"), + toolCallFailed: sharedT("toolCallFailed"), + } + ), + [sharedT, timelineTurns] + ) const historicalPlanEntries = useMemo( - () => extractLatestPlanEntriesFromMessages(messages), - [messages] + () => extractLatestPlanEntriesFromMessages(historicalMessages), + [historicalMessages] ) const historicalPlanKey = useMemo( () => buildPlanKey(historicalPlanEntries), [historicalPlanEntries] ) - const pendingGroups = useMemo( - () => - pendingMessages?.length ? groupAdaptedMessages(pendingMessages) : [], - [pendingMessages] - ) - const attachedResourcesText = sharedT("attachedResources") - - const resolvedGroups = useMemo( - () => - groups.map((group) => resolveMessageGroup(group, attachedResourcesText)), - [groups, attachedResourcesText] - ) - const resolvedPendingGroups = useMemo( - () => - pendingGroups.map((group) => - resolveMessageGroup(group, attachedResourcesText) - ), - [pendingGroups, attachedResourcesText] - ) - - const showLiveMessage = Boolean( - liveMessage && - (connStatus === "prompting" || - (liveMessage.content.length > 0 && resolvedPendingGroups.length > 0)) - ) - - const threadItems = useMemo(() => { - const items: ThreadRenderItem[] = [ - ...resolvedGroups.map((group) => ({ - key: `history-${group.id}`, - kind: "historical" as const, - group, - })), - ...resolvedPendingGroups.map((group) => ({ - key: `pending-${group.id}`, - kind: "pending" as const, - group, - })), - ] - - if (resolvedPendingGroups.length > 0 && !showLiveMessage) { - items.push({ key: "pending-typing", kind: "typing" }) - } - - if (showLiveMessage && liveMessage) { - items.push({ - key: `live-${liveMessage.id}`, - kind: "live", - message: liveMessage, - isStreaming: connStatus === "prompting", - }) - } - - return items - }, [ - resolvedGroups, - resolvedPendingGroups, - showLiveMessage, - liveMessage, - connStatus, - ]) - const renderThreadItem = useCallback((item: ThreadRenderItem) => { switch (item.kind) { - case "historical": - return - case "pending": - return - case "typing": - return - case "live": + case "turn": return ( - ) + case "typing": + return default: return null } @@ -362,7 +308,9 @@ export function MessageListView({ const agentPlanOverlayKey = liveMessage?.id ?? `history-${conversationId}` - if (loading && !detail) { + const hasRenderableContent = threadItems.length > 0 || Boolean(liveMessage) + + if (loading && !hasRenderableContent) { return (
@@ -373,7 +321,7 @@ export function MessageListView({ ) } - if (error) { + if (error && !hasRenderableContent) { return (
@@ -385,8 +333,6 @@ export function MessageListView({ ) } - if (!detail) return null - return (
- {showLiveMessage && liveMessage && connStatus === "prompting" && ( + {liveMessage && connStatus === "prompting" && (
) diff --git a/src/contexts/acp-connections-context.tsx b/src/contexts/acp-connections-context.tsx index 03ed93b..dd58c4d 100644 --- a/src/contexts/acp-connections-context.tsx +++ b/src/contexts/acp-connections-context.tsx @@ -974,7 +974,6 @@ export interface AcpActionsValue { requestId: string, optionId: string ): Promise - migrateContextKey(fromKey: string, toKey: string): void setActiveKey(key: string | null): void touchActivity(contextKey: string): void } @@ -1754,56 +1753,6 @@ export function AcpConnectionsProvider({ children }: { children: ReactNode }) { dispatch({ type: "REMOVE_ALL" }) }, [dispatch]) - const migrateContextKey = useCallback( - (fromKey: string, toKey: string) => { - if (!fromKey || !toKey || fromKey === toKey) return - - const current = storeRef.current.connections - const conn = current.get(fromKey) - if (!conn) return - - const targetConn = current.get(toKey) - const migratedConn = targetConn - ? { - ...conn, - // Preserve the most recent error from the target, if any. - error: targetConn.error ?? conn.error, - contextKey: toKey, - } - : { ...conn, contextKey: toKey } - - const next = new Map(current) - next.delete(fromKey) - next.set(toKey, migratedConn) - storeRef.current.connections = next - - for (const [connectionId, mappedKey] of reverseMapRef.current) { - if (mappedKey === fromKey) { - reverseMapRef.current.set(connectionId, toKey) - } - } - - const lastActive = lastActivityRef.current.get(fromKey) - if (lastActive != null) { - lastActivityRef.current.set(toKey, lastActive) - lastActivityRef.current.delete(fromKey) - } - - if (connectingKeysRef.current.delete(fromKey)) { - connectingKeysRef.current.add(toKey) - } - - if (storeRef.current.activeKey === fromKey) { - storeRef.current.activeKey = toKey - notifyActiveKeyListeners() - } - - notifyKeyListeners(fromKey) - notifyKeyListeners(toKey) - }, - [notifyActiveKeyListeners, notifyKeyListeners] - ) - const sendPrompt = useCallback( async (contextKey: string, blocks: PromptInputBlock[]) => { const conn = storeRef.current.connections.get(contextKey) @@ -1869,7 +1818,6 @@ export function AcpConnectionsProvider({ children }: { children: ReactNode }) { setConfigOption, cancel, respondPermission, - migrateContextKey, setActiveKey, touchActivity, }), @@ -1882,7 +1830,6 @@ export function AcpConnectionsProvider({ children }: { children: ReactNode }) { setConfigOption, cancel, respondPermission, - migrateContextKey, setActiveKey, touchActivity, ] diff --git a/src/contexts/conversation-runtime-context.tsx b/src/contexts/conversation-runtime-context.tsx new file mode 100644 index 0000000..77abc03 --- /dev/null +++ b/src/contexts/conversation-runtime-context.tsx @@ -0,0 +1,651 @@ +"use client" + +import { + createContext, + useCallback, + useContext, + useMemo, + useReducer, + type ReactNode, +} from "react" +import type { LiveMessage } from "@/contexts/acp-connections-context" +import type { DbConversationDetail, MessageTurn } from "@/lib/types" +import { inferLiveToolName } from "@/lib/tool-call-normalization" + +export type ConversationSyncState = + | "idle" + | "awaiting_persist" + | "reconciling" + | "failed" + +export type ConversationTimelinePhase = "persisted" | "optimistic" | "streaming" + +export interface ConversationTimelineTurn { + key: string + turn: MessageTurn + phase: ConversationTimelinePhase +} + +export interface ConversationRuntimeSession { + conversationId: number + externalId: string | null + persistedTurns: MessageTurn[] + optimisticTurns: MessageTurn[] + liveMessage: LiveMessage | null + syncState: ConversationSyncState + activeTurnToken: string | null + lastHydratedAt: number | null + lastPersistedAt: number | null + persistedUpdatedAt: string | null + persistedMessageCount: number +} + +interface ConversationRuntimeState { + byConversationId: Map + conversationIdByExternalId: Map +} + +const initialState: ConversationRuntimeState = { + byConversationId: new Map(), + conversationIdByExternalId: new Map(), +} + +type Action = + | { type: "HYDRATE_FROM_DETAIL"; detail: DbConversationDetail } + | { + type: "APPEND_OPTIMISTIC_TURN" + conversationId: number + turn: MessageTurn + turnToken: string + } + | { + type: "SET_LIVE_MESSAGE" + conversationId: number + liveMessage: LiveMessage | null + } + | { + type: "ACK_PERSISTED_DETAIL" + conversationId: number + detail: DbConversationDetail + turnToken?: string | null + } + | { + type: "SET_EXTERNAL_ID" + conversationId: number + externalId: string | null + } + | { + type: "SET_SYNC_STATE" + conversationId: number + syncState: ConversationSyncState + } + | { + type: "MIGRATE_CONVERSATION" + fromConversationId: number + toConversationId: number + } + | { type: "REMOVE_CONVERSATION"; conversationId: number } + | { type: "RESET" } + +function createEmptySession( + conversationId: number +): ConversationRuntimeSession { + return { + conversationId, + externalId: null, + persistedTurns: [], + optimisticTurns: [], + liveMessage: null, + syncState: "idle", + activeTurnToken: null, + lastHydratedAt: null, + lastPersistedAt: null, + persistedUpdatedAt: null, + persistedMessageCount: 0, + } +} + +function formatLivePlanEntries( + entries: Array<{ content: string; priority: string; status: string }> +): string { + if (entries.length === 0) { + return "Plan updated." + } + const lines = entries.map( + (entry) => `- [${entry.status}] ${entry.content} (${entry.priority})` + ) + return `Plan updated:\n${lines.join("\n")}` +} + +function buildStreamingTurnFromLiveMessage( + conversationId: number, + liveMessage: LiveMessage +): MessageTurn | null { + const blocks: MessageTurn["blocks"] = [] + + for (const block of liveMessage.content) { + switch (block.type) { + case "text": + if (block.text.length > 0) { + blocks.push({ type: "text", text: block.text }) + } + break + case "thinking": + if (block.text.length > 0) { + blocks.push({ type: "thinking", text: block.text }) + } + break + case "plan": { + blocks.push({ + type: "thinking", + text: formatLivePlanEntries(block.entries), + }) + break + } + case "tool_call": { + const toolName = inferLiveToolName({ + title: block.info.title, + kind: block.info.kind, + rawInput: block.info.raw_input, + }) + blocks.push({ + type: "tool_use", + tool_use_id: block.info.tool_call_id, + tool_name: toolName, + input_preview: block.info.raw_input, + }) + const isFinalState = + block.info.status === "completed" || block.info.status === "failed" + if (isFinalState) { + blocks.push({ + type: "tool_result", + tool_use_id: block.info.tool_call_id, + output_preview: block.info.raw_output ?? block.info.content, + is_error: block.info.status === "failed", + }) + } + break + } + } + } + + if (blocks.length === 0) return null + + return { + id: `live-${conversationId}-${liveMessage.id}`, + role: "assistant", + blocks, + timestamp: new Date(liveMessage.startedAt).toISOString(), + } +} + +function shouldAcceptPersistedSnapshot( + current: ConversationRuntimeSession | undefined, + detail: DbConversationDetail +): boolean { + if (!current) return true + + const nextUpdatedAt = detail.summary.updated_at ?? null + const nextMessageCount = detail.summary.message_count + const nextTurnCount = detail.turns.length + + if (nextMessageCount < current.persistedMessageCount) return false + if (nextTurnCount < current.persistedTurns.length) return false + if (!current.persistedUpdatedAt || !nextUpdatedAt) return true + if (nextUpdatedAt < current.persistedUpdatedAt) return false + + return true +} + +function upsertExternalIdIndex( + index: Map, + previousExternalId: string | null, + nextExternalId: string | null, + conversationId: number +): Map { + const next = new Map(index) + if (previousExternalId) { + next.delete(previousExternalId) + } + if (nextExternalId) { + next.set(nextExternalId, conversationId) + } + return next +} + +function reduceHydrateDetail( + state: ConversationRuntimeState, + conversationId: number, + detail: DbConversationDetail +): ConversationRuntimeState { + const current = state.byConversationId.get(conversationId) + const nextExternalId = detail.summary.external_id ?? null + const acceptSnapshot = shouldAcceptPersistedSnapshot(current, detail) + const optimisticTurns = current?.optimisticTurns ?? [] + const persistedTurns = acceptSnapshot + ? detail.turns + : (current?.persistedTurns ?? []) + const nextPersistedUpdatedAt = acceptSnapshot + ? (detail.summary.updated_at ?? null) + : (current?.persistedUpdatedAt ?? null) + const nextPersistedMessageCount = acceptSnapshot + ? detail.summary.message_count + : (current?.persistedMessageCount ?? 0) + const shouldDropOptimistic = + optimisticTurns.length > 0 && + persistedTurns.length >= (current?.persistedTurns.length ?? 0) + 1 + + const nextSession: ConversationRuntimeSession = { + ...(current ?? createEmptySession(conversationId)), + externalId: nextExternalId, + persistedTurns, + optimisticTurns: shouldDropOptimistic ? [] : optimisticTurns, + syncState: shouldDropOptimistic ? "idle" : (current?.syncState ?? "idle"), + activeTurnToken: shouldDropOptimistic + ? null + : (current?.activeTurnToken ?? null), + lastHydratedAt: Date.now(), + lastPersistedAt: acceptSnapshot + ? Date.now() + : (current?.lastPersistedAt ?? null), + persistedUpdatedAt: nextPersistedUpdatedAt, + persistedMessageCount: nextPersistedMessageCount, + } + + const nextByConversationId = new Map(state.byConversationId) + nextByConversationId.set(conversationId, nextSession) + const nextExternalIndex = upsertExternalIdIndex( + state.conversationIdByExternalId, + current?.externalId ?? null, + nextExternalId, + conversationId + ) + + return { + byConversationId: nextByConversationId, + conversationIdByExternalId: nextExternalIndex, + } +} + +function reducer( + state: ConversationRuntimeState, + action: Action +): ConversationRuntimeState { + switch (action.type) { + case "HYDRATE_FROM_DETAIL": + return reduceHydrateDetail(state, action.detail.summary.id, action.detail) + + case "APPEND_OPTIMISTIC_TURN": { + const current = + state.byConversationId.get(action.conversationId) ?? + createEmptySession(action.conversationId) + const nextSession: ConversationRuntimeSession = { + ...current, + optimisticTurns: [...current.optimisticTurns, action.turn], + syncState: "awaiting_persist", + activeTurnToken: action.turnToken, + } + const nextByConversationId = new Map(state.byConversationId) + nextByConversationId.set(action.conversationId, nextSession) + return { ...state, byConversationId: nextByConversationId } + } + + case "SET_LIVE_MESSAGE": { + const current = + state.byConversationId.get(action.conversationId) ?? + createEmptySession(action.conversationId) + const nextSession: ConversationRuntimeSession = { + ...current, + liveMessage: action.liveMessage, + } + const nextByConversationId = new Map(state.byConversationId) + nextByConversationId.set(action.conversationId, nextSession) + return { ...state, byConversationId: nextByConversationId } + } + + case "ACK_PERSISTED_DETAIL": { + const nextState = reduceHydrateDetail( + state, + action.conversationId, + action.detail + ) + const session = nextState.byConversationId.get(action.conversationId) + if (!session) return nextState + const nextSession: ConversationRuntimeSession = { + ...session, + syncState: "idle", + activeTurnToken: + action.turnToken != null && + action.turnToken === session.activeTurnToken + ? null + : session.activeTurnToken, + } + const nextByConversationId = new Map(nextState.byConversationId) + nextByConversationId.set(action.conversationId, nextSession) + return { ...nextState, byConversationId: nextByConversationId } + } + + case "SET_EXTERNAL_ID": { + const current = + state.byConversationId.get(action.conversationId) ?? + createEmptySession(action.conversationId) + const nextSession: ConversationRuntimeSession = { + ...current, + externalId: action.externalId, + } + const nextByConversationId = new Map(state.byConversationId) + nextByConversationId.set(action.conversationId, nextSession) + const nextExternalIndex = upsertExternalIdIndex( + state.conversationIdByExternalId, + current.externalId, + action.externalId, + action.conversationId + ) + return { + byConversationId: nextByConversationId, + conversationIdByExternalId: nextExternalIndex, + } + } + + case "SET_SYNC_STATE": { + const current = + state.byConversationId.get(action.conversationId) ?? + createEmptySession(action.conversationId) + const nextSession: ConversationRuntimeSession = { + ...current, + syncState: action.syncState, + } + const nextByConversationId = new Map(state.byConversationId) + nextByConversationId.set(action.conversationId, nextSession) + return { ...state, byConversationId: nextByConversationId } + } + + case "MIGRATE_CONVERSATION": { + if (action.fromConversationId === action.toConversationId) return state + const from = state.byConversationId.get(action.fromConversationId) + if (!from) return state + const to = + state.byConversationId.get(action.toConversationId) ?? + createEmptySession(action.toConversationId) + + const preferFromSnapshot = + from.persistedTurns.length >= to.persistedTurns.length + + const merged: ConversationRuntimeSession = { + ...to, + ...from, + conversationId: action.toConversationId, + persistedTurns: preferFromSnapshot + ? from.persistedTurns + : to.persistedTurns, + optimisticTurns: [...from.optimisticTurns, ...to.optimisticTurns], + liveMessage: to.liveMessage ?? from.liveMessage, + syncState: to.syncState !== "idle" ? to.syncState : from.syncState, + activeTurnToken: to.activeTurnToken ?? from.activeTurnToken, + lastHydratedAt: + Math.max(from.lastHydratedAt ?? 0, to.lastHydratedAt ?? 0) || null, + lastPersistedAt: + Math.max(from.lastPersistedAt ?? 0, to.lastPersistedAt ?? 0) || null, + persistedUpdatedAt: + (to.persistedUpdatedAt ?? "") > (from.persistedUpdatedAt ?? "") + ? to.persistedUpdatedAt + : from.persistedUpdatedAt, + persistedMessageCount: Math.max( + from.persistedMessageCount, + to.persistedMessageCount + ), + } + + const nextByConversationId = new Map(state.byConversationId) + nextByConversationId.delete(action.fromConversationId) + nextByConversationId.set(action.toConversationId, merged) + + const nextExternalIndex = new Map(state.conversationIdByExternalId) + for (const [externalId, conversationId] of nextExternalIndex.entries()) { + if (conversationId === action.fromConversationId) { + nextExternalIndex.set(externalId, action.toConversationId) + } + } + if (merged.externalId) { + nextExternalIndex.set(merged.externalId, action.toConversationId) + } + + return { + byConversationId: nextByConversationId, + conversationIdByExternalId: nextExternalIndex, + } + } + + case "REMOVE_CONVERSATION": { + const current = state.byConversationId.get(action.conversationId) + if (!current) return state + const nextByConversationId = new Map(state.byConversationId) + nextByConversationId.delete(action.conversationId) + const nextExternalIndex = new Map(state.conversationIdByExternalId) + if (current.externalId) { + nextExternalIndex.delete(current.externalId) + } + return { + byConversationId: nextByConversationId, + conversationIdByExternalId: nextExternalIndex, + } + } + + case "RESET": + return initialState + } +} + +interface ConversationRuntimeContextValue { + getSession: (conversationId: number) => ConversationRuntimeSession | null + getConversationIdByExternalId: (externalId: string) => number | null + getTimelineTurns: (conversationId: number) => ConversationTimelineTurn[] + hydrateFromDetail: (detail: DbConversationDetail) => void + appendOptimisticTurn: ( + conversationId: number, + turn: MessageTurn, + turnToken: string + ) => void + setLiveMessage: ( + conversationId: number, + liveMessage: LiveMessage | null + ) => void + acknowledgePersistedDetail: ( + conversationId: number, + detail: DbConversationDetail, + turnToken?: string | null + ) => void + setExternalId: (conversationId: number, externalId: string | null) => void + setSyncState: ( + conversationId: number, + syncState: ConversationSyncState + ) => void + migrateConversation: ( + fromConversationId: number, + toConversationId: number + ) => void + removeConversation: (conversationId: number) => void + reset: () => void +} + +const ConversationRuntimeContext = + createContext(null) + +export function ConversationRuntimeProvider({ + children, +}: { + children: ReactNode +}) { + const [state, dispatch] = useReducer(reducer, initialState) + + const getSession = useCallback( + (conversationId: number) => + state.byConversationId.get(conversationId) ?? null, + [state.byConversationId] + ) + + const getConversationIdByExternalId = useCallback( + (externalId: string) => + state.conversationIdByExternalId.get(externalId) ?? null, + [state.conversationIdByExternalId] + ) + + const getTimelineTurns = useCallback( + (conversationId: number): ConversationTimelineTurn[] => { + const session = state.byConversationId.get(conversationId) + if (!session) return [] + + const persisted: ConversationTimelineTurn[] = session.persistedTurns.map( + (turn, index) => ({ + key: `persisted-${conversationId}-${turn.id}-${index}`, + turn, + phase: "persisted", + }) + ) + const optimistic: ConversationTimelineTurn[] = + session.optimisticTurns.map((turn, index) => ({ + key: `optimistic-${conversationId}-${turn.id}-${index}`, + turn, + phase: "optimistic", + })) + const streamingMessage = session.liveMessage + const streamingTurn = streamingMessage + ? buildStreamingTurnFromLiveMessage(conversationId, streamingMessage) + : null + + if (!streamingTurn) { + return [...persisted, ...optimistic] + } + + return [ + ...persisted, + ...optimistic, + { + key: `streaming-${conversationId}-${streamingMessage?.id ?? "unknown"}`, + turn: streamingTurn, + phase: "streaming", + }, + ] + }, + [state.byConversationId] + ) + + const hydrateFromDetail = useCallback((detail: DbConversationDetail) => { + dispatch({ type: "HYDRATE_FROM_DETAIL", detail }) + }, []) + + const appendOptimisticTurn = useCallback( + (conversationId: number, turn: MessageTurn, turnToken: string) => { + dispatch({ + type: "APPEND_OPTIMISTIC_TURN", + conversationId, + turn, + turnToken, + }) + }, + [] + ) + + const setLiveMessage = useCallback( + (conversationId: number, liveMessage: LiveMessage | null) => { + dispatch({ type: "SET_LIVE_MESSAGE", conversationId, liveMessage }) + }, + [] + ) + + const acknowledgePersistedDetail = useCallback( + ( + conversationId: number, + detail: DbConversationDetail, + turnToken?: string | null + ) => { + dispatch({ + type: "ACK_PERSISTED_DETAIL", + conversationId, + detail, + turnToken, + }) + }, + [] + ) + + const setExternalId = useCallback( + (conversationId: number, externalId: string | null) => { + dispatch({ type: "SET_EXTERNAL_ID", conversationId, externalId }) + }, + [] + ) + + const setSyncState = useCallback( + (conversationId: number, syncState: ConversationSyncState) => { + dispatch({ type: "SET_SYNC_STATE", conversationId, syncState }) + }, + [] + ) + + const migrateConversation = useCallback( + (fromConversationId: number, toConversationId: number) => { + dispatch({ + type: "MIGRATE_CONVERSATION", + fromConversationId, + toConversationId, + }) + }, + [] + ) + + const removeConversation = useCallback((conversationId: number) => { + dispatch({ type: "REMOVE_CONVERSATION", conversationId }) + }, []) + + const reset = useCallback(() => { + dispatch({ type: "RESET" }) + }, []) + + const value = useMemo( + () => ({ + getSession, + getConversationIdByExternalId, + getTimelineTurns, + hydrateFromDetail, + appendOptimisticTurn, + setLiveMessage, + acknowledgePersistedDetail, + setExternalId, + setSyncState, + migrateConversation, + removeConversation, + reset, + }), + [ + getSession, + getConversationIdByExternalId, + getTimelineTurns, + hydrateFromDetail, + appendOptimisticTurn, + setLiveMessage, + acknowledgePersistedDetail, + setExternalId, + setSyncState, + migrateConversation, + removeConversation, + reset, + ] + ) + + return ( + + {children} + + ) +} + +export function useConversationRuntime() { + const ctx = useContext(ConversationRuntimeContext) + if (!ctx) { + throw new Error( + "useConversationRuntime must be used within ConversationRuntimeProvider" + ) + } + return ctx +} diff --git a/src/contexts/tab-context.tsx b/src/contexts/tab-context.tsx index 6121096..ae1b4d2 100644 --- a/src/contexts/tab-context.tsx +++ b/src/contexts/tab-context.tsx @@ -22,8 +22,8 @@ import type { interface TabItemInternal { id: string - kind: "conversation" | "new_conversation" - conversationId?: number + kind: "conversation" + conversationId: number | null agentType: AgentType title: string isPinned: boolean @@ -43,18 +43,13 @@ interface TabContextValue { title?: string ) => void closeTab: (tabId: string) => void + closeConversationTab: (conversationId: number, agentType: AgentType) => void closeOtherTabs: (tabId: string) => void closeAllTabs: () => void switchTab: (tabId: string) => void pinTab: (tabId: string) => void openNewConversationTab: (agentType: AgentType, workingDir: string) => void - promoteNewConversationTab: ( - tabId: string, - conversationId: number, - agentType: AgentType, - title: string - ) => void - linkTabConversation: ( + bindConversationTab: ( tabId: string, conversationId: number, agentType: AgentType, @@ -83,13 +78,6 @@ function makeConversationTabId( function makeNewConversationTabId(): string { return `new-${Date.now()}-${Math.random().toString(36).slice(2, 8)}` } - -/** - * Find a tab that represents the given conversation, regardless of whether - * it has been promoted to a canonical id yet. Checks canonical id first, - * then falls back to matching by conversationId + agentType (covers the - * linked-but-not-yet-promoted new_conversation tabs). - */ function findTabIndexForConversation( tabs: TabItemInternal[], agentType: AgentType, @@ -130,7 +118,7 @@ export function TabProvider({ children }: TabProviderProps) { return [ { id: tabId, - kind: "conversation" as const, + kind: "conversation", conversationId: selectedConversation.id, agentType: selectedConversation.agentType, title: t("loadingConversation"), @@ -187,7 +175,7 @@ export function TabProvider({ children }: TabProviderProps) { const restoredTabs: TabItemInternal[] = opened.map((oc) => ({ id: makeConversationTabId(oc.agent_type, oc.conversation_id), - kind: "conversation" as const, + kind: "conversation", conversationId: oc.conversation_id, agentType: oc.agent_type, title: t("loadingConversation"), @@ -300,13 +288,20 @@ export function TabProvider({ children }: TabProviderProps) { cancelNewConversation() return } - if (tab.kind === "conversation" && tab.conversationId != null) { + if (tab.conversationId != null) { selectConversation(tab.conversationId, tab.agentType) - } else if (tab.kind === "new_conversation" && tab.workingDir) { - startNewConversation(tab.agentType, tab.workingDir) + } else { + const workingDir = tab.workingDir ?? folder?.path + if (!workingDir) { + clearSelection() + cancelNewConversation() + return + } + startNewConversation(tab.agentType, workingDir) } }, [ + folder?.path, selectConversation, clearSelection, startNewConversation, @@ -386,10 +381,11 @@ export function TabProvider({ children }: TabProviderProps) { [activateConversationPane, selectConversation, t] ) - const makeReplacementNewConversationTab = useCallback( + const makeReplacementDraftTab = useCallback( (preferred?: TabItemInternal): TabItemInternal => ({ id: makeNewConversationTabId(), - kind: "new_conversation", + kind: "conversation", + conversationId: null, agentType: preferred?.agentType ?? "codex", title: t("newConversation"), isPinned: true, @@ -410,7 +406,7 @@ export function TabProvider({ children }: TabProviderProps) { const next = prev.filter((t) => t.id !== tabId) if (next.length === 0) { - const replacementTab = makeReplacementNewConversationTab(closingTab) + const replacementTab = makeReplacementDraftTab(closingTab) neighborToSync = replacementTab return [replacementTab] } @@ -433,11 +429,19 @@ export function TabProvider({ children }: TabProviderProps) { activateConversationPane() } }, - [ - activateConversationPane, - makeReplacementNewConversationTab, - syncFolderContext, - ] + [activateConversationPane, makeReplacementDraftTab, syncFolderContext] + ) + + const closeConversationTab = useCallback( + (conversationId: number, agentType: AgentType) => { + const target = rawTabsRef.current.find( + (tab) => + tab.conversationId === conversationId && tab.agentType === agentType + ) + if (!target) return + closeTab(target.id) + }, + [closeTab] ) const closeOtherTabs = useCallback( @@ -459,21 +463,17 @@ export function TabProvider({ children }: TabProviderProps) { const closeAllTabs = useCallback(() => { const seedTab = rawTabsRef.current.find( - (t) => t.kind === "new_conversation" && t.workingDir + (t) => t.conversationId == null && t.workingDir ) ?? rawTabsRef.current.find((t) => t.id === activeTabIdRef.current) ?? rawTabsRef.current[0] - const replacementTab = makeReplacementNewConversationTab(seedTab) + const replacementTab = makeReplacementDraftTab(seedTab) setTabs([replacementTab]) setActiveTabId(replacementTab.id) syncFolderContext(replacementTab) activateConversationPane() - }, [ - activateConversationPane, - makeReplacementNewConversationTab, - syncFolderContext, - ]) + }, [activateConversationPane, makeReplacementDraftTab, syncFolderContext]) const switchTab = useCallback( (tabId: string) => { @@ -501,10 +501,7 @@ export function TabProvider({ children }: TabProviderProps) { const openNewConversationTab = useCallback( (agentType: AgentType, workingDir: string) => { const existingTab = rawTabsRef.current.find( - (t) => - t.kind === "new_conversation" && - t.agentType === agentType && - !t.conversationId + (t) => t.conversationId == null && t.agentType === agentType ) if (existingTab) { @@ -517,7 +514,8 @@ export function TabProvider({ children }: TabProviderProps) { const tabId = makeNewConversationTabId() const newTab: TabItemInternal = { id: tabId, - kind: "new_conversation", + kind: "conversation", + conversationId: null, agentType, title: t("newConversation"), isPinned: true, @@ -532,71 +530,45 @@ export function TabProvider({ children }: TabProviderProps) { [activateConversationPane, startNewConversation, syncFolderContext, t] ) - const linkTabConversation = useCallback( + const bindConversationTab = useCallback( ( tabId: string, conversationId: number, agentType: AgentType, title: string ) => { + let nextActiveTabId: string | null = null setTabs((prev) => - prev.map((t) => - t.id === tabId ? { ...t, conversationId, agentType, title } : t - ) + prev.flatMap((tab) => { + if (tab.id === tabId) { + const nextTab = { ...tab, conversationId, agentType, title } + return [nextTab] + } + + if ( + tab.conversationId === conversationId && + tab.agentType === agentType + ) { + if (activeTabIdRef.current === tabId) { + nextActiveTabId = tab.id + } + return [] + } + + return [tab] + }) ) - }, - [] - ) - - const promoteNewConversationTab = useCallback( - ( - tabId: string, - conversationId: number, - agentType: AgentType, - title: string - ) => { - let activateId: string | undefined - - setTabs((prev) => { - const index = prev.findIndex((t) => t.id === tabId) - if (index < 0) return prev - - const newId = makeConversationTabId(agentType, conversationId) - - // Check if a *different* tab already represents this conversation - const dupeIndex = findTabIndexForConversation( - prev, - agentType, - conversationId + if (nextActiveTabId) { + setActiveTabId(nextActiveTabId) + const target = rawTabsRef.current.find( + (tab) => tab.id === nextActiveTabId ) - if (dupeIndex >= 0 && dupeIndex !== index) { - activateId = prev[dupeIndex].id - return prev.filter((t) => t.id !== tabId) + if (target) { + syncFolderContext(target) } - - const promoted: TabItemInternal = { - ...prev[index], - id: newId, - kind: "conversation", - conversationId, - agentType, - title, - isPinned: true, - } - activateId = newId - - const updated = [...prev] - updated[index] = promoted - return updated - }) - - if (activateId) { - setActiveTabId(activateId) - selectConversation(conversationId, agentType) - activateConversationPane() } }, - [activateConversationPane, selectConversation] + [syncFolderContext] ) const value = useMemo( @@ -605,13 +577,13 @@ export function TabProvider({ children }: TabProviderProps) { activeTabId, openTab, closeTab, + closeConversationTab, closeOtherTabs, closeAllTabs, switchTab, pinTab, openNewConversationTab, - promoteNewConversationTab, - linkTabConversation, + bindConversationTab, reorderTabs, }), [ @@ -619,13 +591,13 @@ export function TabProvider({ children }: TabProviderProps) { activeTabId, openTab, closeTab, + closeConversationTab, closeOtherTabs, closeAllTabs, switchTab, pinTab, openNewConversationTab, - promoteNewConversationTab, - linkTabConversation, + bindConversationTab, reorderTabs, ] ) diff --git a/src/hooks/use-connection-lifecycle.ts b/src/hooks/use-connection-lifecycle.ts index 36a4332..b18e924 100644 --- a/src/hooks/use-connection-lifecycle.ts +++ b/src/hooks/use-connection-lifecycle.ts @@ -6,11 +6,6 @@ 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" -import { getPromptDraftDisplayText } from "@/lib/prompt-draft" -import { - clearPendingPromptText, - setPendingPromptText, -} from "@/lib/pending-prompt-text" interface UseConnectionLifecycleOptions { contextKey: string @@ -50,7 +45,6 @@ export function useConnectionLifecycle({ sessionId, }: UseConnectionLifecycleOptions): UseConnectionLifecycleReturn { const t = useTranslations("Folder.chat.connectionLifecycle") - const sharedT = useTranslations("Folder.chat.shared") const { setActiveKey, touchActivity } = useAcpActions() const { addTask, updateTask, removeTask } = useTaskContext() const conn = useConnection(contextKey) @@ -201,11 +195,6 @@ export function useConnectionLifecycle({ } }, [status, addTask, updateTask, removeTask, agentType, t]) - useEffect(() => { - if (status === "prompting") return - clearPendingPromptText(contextKey) - }, [status, contextKey]) - const clearSelectorTask = useCallback(() => { if (selectorTaskTimeoutRef.current) { clearTimeout(selectorTaskTimeoutRef.current) @@ -313,10 +302,6 @@ export function useConnectionLifecycle({ const handleSend = useCallback( (draft: PromptDraft, modeId?: string | null) => { touchActivity(contextKey) - setPendingPromptText( - contextKey, - getPromptDraftDisplayText(draft, sharedT("attachedResources")) - ) void (async () => { const currentModeId = modeIdRef.current if (modeId && modeId !== currentModeId) { @@ -330,7 +315,7 @@ export function useConnectionLifecycle({ console.error("[ConnLifecycle] sendPrompt:", e) ) }, - [connSetMode, sendPrompt, contextKey, touchActivity, sharedT] + [connSetMode, sendPrompt, contextKey, touchActivity] ) const handleCancel = useCallback(() => { diff --git a/src/hooks/use-db-message-detail.ts b/src/hooks/use-db-message-detail.ts index 440aa33..d0a6acf 100644 --- a/src/hooks/use-db-message-detail.ts +++ b/src/hooks/use-db-message-detail.ts @@ -91,8 +91,22 @@ interface State { fetchSeq: number } +function isVirtualConversationId(conversationId: number): boolean { + return !Number.isFinite(conversationId) || conversationId <= 0 +} + export function useDbMessageDetail(conversationId: number) { + const isVirtualId = isVirtualConversationId(conversationId) const getCachedState = useCallback((id: number): State => { + if (isVirtualConversationId(id)) { + return { + key: id, + detail: null, + loading: false, + error: null, + fetchSeq: 0, + } + } const cached = detailCache.get(id) return { key: id, @@ -110,19 +124,24 @@ export function useDbMessageDetail(conversationId: number) { const derivedState = state.key === conversationId ? state : getCachedState(conversationId) - useEffect( - () => - subscribeDetail(conversationId, (detail) => { - setState((prev) => - prev.key === conversationId - ? { ...prev, detail, loading: false, error: null } - : prev - ) - }), - [conversationId] - ) + useEffect(() => { + if (isVirtualId) return + return subscribeDetail(conversationId, (detail) => { + setState((prev) => ({ + key: conversationId, + detail, + loading: false, + error: null, + fetchSeq: prev.key === conversationId ? prev.fetchSeq : 0, + })) + }) + }, [conversationId, isVirtualId]) const refetch = useCallback(() => { + if (isVirtualConversationId(conversationId)) { + setState(getCachedState(conversationId)) + return + } detailCache.delete(conversationId) setState((prev) => { const base = @@ -138,6 +157,7 @@ export function useDbMessageDetail(conversationId: number) { }, [conversationId, getCachedState]) useEffect(() => { + if (isVirtualId) return // Skip fetch if cache already has data if (detailCache.has(conversationId)) return @@ -180,7 +200,7 @@ export function useDbMessageDetail(conversationId: number) { return () => { cancelled = true } - }, [conversationId, derivedState.fetchSeq]) + }, [conversationId, derivedState.fetchSeq, isVirtualId]) return useMemo( () => ({ diff --git a/src/lib/pending-prompt-text.ts b/src/lib/pending-prompt-text.ts deleted file mode 100644 index 3b1ba4c..0000000 --- a/src/lib/pending-prompt-text.ts +++ /dev/null @@ -1,18 +0,0 @@ -const pendingPromptTextByContextKey = new Map() - -export function setPendingPromptText(contextKey: string, text: string): void { - const normalized = text.trim() - if (!normalized) { - pendingPromptTextByContextKey.delete(contextKey) - return - } - pendingPromptTextByContextKey.set(contextKey, normalized) -} - -export function getPendingPromptText(contextKey: string): string | null { - return pendingPromptTextByContextKey.get(contextKey) ?? null -} - -export function clearPendingPromptText(contextKey: string): void { - pendingPromptTextByContextKey.delete(contextKey) -}