diff --git a/src-tauri/src/acp/connection.rs b/src-tauri/src/acp/connection.rs index 7a947c1..e3243a8 100644 --- a/src-tauri/src/acp/connection.rs +++ b/src-tauri/src/acp/connection.rs @@ -1343,6 +1343,7 @@ async fn run_conversation_loop<'a>( "acp://event", AcpEvent::TurnComplete { connection_id: conn_id.into(), + session_id: sid.0.to_string(), stop_reason: reason_str.into(), }, ); @@ -1372,6 +1373,7 @@ async fn run_conversation_loop<'a>( "acp://event", AcpEvent::TurnComplete { connection_id: conn_id.into(), + session_id: sid.0.to_string(), stop_reason: reason_str.into(), }, ); diff --git a/src-tauri/src/acp/types.rs b/src-tauri/src/acp/types.rs index c791ed8..6a49295 100644 --- a/src-tauri/src/acp/types.rs +++ b/src-tauri/src/acp/types.rs @@ -80,6 +80,7 @@ pub enum AcpEvent { /// Turn completed TurnComplete { connection_id: String, + session_id: String, stop_reason: String, }, /// Session established with agent-assigned session ID diff --git a/src/components/conversations/conversation-detail-panel.tsx b/src/components/conversations/conversation-detail-panel.tsx index 68ebafb..79ff681 100644 --- a/src/components/conversations/conversation-detail-panel.tsx +++ b/src/components/conversations/conversation-detail-panel.tsx @@ -4,6 +4,7 @@ import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react" import { Plus, RefreshCw, X } from "lucide-react" import { useTranslations } from "next-intl" import { toast } from "sonner" +import { disposeTauriListener } from "@/lib/tauri-listener" import { useFolderContext } from "@/contexts/folder-context" import { useTabContext } from "@/contexts/tab-context" import { useConnectionLifecycle } from "@/hooks/use-connection-lifecycle" @@ -11,8 +12,11 @@ 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 { useDbMessageDetail } from "@/hooks/use-db-message-detail" -import type { AgentType, PromptDraft } from "@/lib/types" +import { + 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, @@ -246,10 +250,129 @@ const ExistingConversationView = memo(function ExistingConversationView({ export function ConversationDetailPanel() { const t = useTranslations("Folder.conversation") - const { folder, newConversation } = useFolderContext() + 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]) + + const flushClosedConversationRefresh = useCallback(() => { + const conversationIds = Array.from(pendingClosedConversationIdsRef.current) + if (conversationIds.length === 0) return + pendingClosedConversationIdsRef.current.clear() + + void (async () => { + await Promise.all( + conversationIds.map(async (conversationId) => { + const summary = + conversationsRef.current.find( + (item) => item.id === conversationId + ) ?? null + if (summary?.status === "in_progress") { + try { + await updateConversationStatus(conversationId, "pending_review") + } catch (error) { + console.error( + "[ConversationDetailPanel] background update status failed:", + error + ) + } + } + + try { + await warmupDetailCache(conversationId) + } catch (error) { + console.error( + "[ConversationDetailPanel] background detail cache refresh failed:", + error + ) + } + }) + ) + + refreshConversations() + })() + }, [refreshConversations]) + + const scheduleClosedConversationRefresh = useCallback( + (conversationId: number) => { + pendingClosedConversationIdsRef.current.add(conversationId) + if (pendingRefreshTimerRef.current) return + + // Delay briefly so local session file writes can settle. + pendingRefreshTimerRef.current = setTimeout(() => { + pendingRefreshTimerRef.current = null + flushClosedConversationRefresh() + }, 1200) + }, + [flushClosedConversationRefresh] + ) + + useEffect(() => { + let cancelled = false + let unlisten: (() => void | Promise) | null = null + const pendingClosedConversationIds = pendingClosedConversationIdsRef.current + + void import("@tauri-apps/api/event") + .then(({ listen }) => + listen("acp://event", (event) => { + const payload = event.payload + if (payload.type !== "turn_complete") return + + const summary = conversationsRef.current.find( + (item) => item.external_id === payload.session_id + ) + if (!summary) return + + const isOpenInTabs = tabsRef.current.some( + (tab) => tab.conversationId === summary.id + ) + if (isOpenInTabs) return + + scheduleClosedConversationRefresh(summary.id) + }) + ) + .then((dispose) => { + if (cancelled) { + disposeTauriListener( + dispose, + "ConversationDetailPanel.backgroundRefresh" + ) + return + } + unlisten = dispose + }) + .catch(() => { + // Ignore when non-tauri runtime. + }) + + return () => { + cancelled = true + if (pendingRefreshTimerRef.current) { + clearTimeout(pendingRefreshTimerRef.current) + pendingRefreshTimerRef.current = null + } + pendingClosedConversationIds.clear() + disposeTauriListener( + unlisten, + "ConversationDetailPanel.backgroundRefresh" + ) + } + }, [scheduleClosedConversationRefresh]) const conversationTabs = useMemo( () => diff --git a/src/hooks/use-db-message-detail.ts b/src/hooks/use-db-message-detail.ts index 71a2ba2..440aa33 100644 --- a/src/hooks/use-db-message-detail.ts +++ b/src/hooks/use-db-message-detail.ts @@ -6,6 +6,7 @@ import type { DbConversationDetail } from "@/lib/types" // Module-level cache: survives component unmount/remount const detailCache = new Map() +const detailInFlight = new Map>() const detailListeners = new Map< number, Set<(detail: DbConversationDetail) => void> @@ -50,6 +51,38 @@ export function invalidateDetailCache(conversationId: number) { detailCache.delete(conversationId) } +async function loadAndCacheDetail( + conversationId: number +): Promise { + const existing = detailInFlight.get(conversationId) + if (existing) return existing + + const promise = getFolderConversation(conversationId) + .then((detail) => { + setCachedDetail(conversationId, detail) + return detail + }) + .finally(() => { + detailInFlight.delete(conversationId) + }) + + detailInFlight.set(conversationId, promise) + return promise +} + +export async function warmupDetailCache( + conversationId: number +): Promise { + return loadAndCacheDetail(conversationId) +} + +export async function refreshDetailCache( + conversationId: number +): Promise { + detailCache.delete(conversationId) + return loadAndCacheDetail(conversationId) +} + interface State { key: number detail: DbConversationDetail | null @@ -109,9 +142,8 @@ export function useDbMessageDetail(conversationId: number) { if (detailCache.has(conversationId)) return let cancelled = false - getFolderConversation(conversationId) + loadAndCacheDetail(conversationId) .then((d) => { - setCachedDetail(conversationId, d) if (!cancelled) { setState((prev) => prev.key === conversationId diff --git a/src/lib/types.ts b/src/lib/types.ts index 15e4d7e..362638e 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -440,7 +440,12 @@ export type AcpEvent = tool_call: unknown options: PermissionOptionInfo[] } - | { type: "turn_complete"; connection_id: string; stop_reason: string } + | { + type: "turn_complete" + connection_id: string + session_id: string + stop_reason: string + } | { type: "session_started" connection_id: string