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 }) {
-
-
-
-
-
+
+
+
+
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
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)
-}