diff --git a/src/components/conversations/conversation-detail-panel.tsx b/src/components/conversations/conversation-detail-panel.tsx index 54aa86e..fdbfcce 100644 --- a/src/components/conversations/conversation-detail-panel.tsx +++ b/src/components/conversations/conversation-detail-panel.tsx @@ -142,6 +142,7 @@ const ConversationTabView = memo(function ConversationTabView({ appendOptimisticTurn, completeTurn, refetchDetail, + syncTurnMetadata, removeConversation, setExternalId, setLiveMessage, @@ -191,6 +192,7 @@ const ConversationTabView = memo(function ConversationTabView({ const createConversationPendingRef = useRef(false) const externalIdSavedRef = useRef(false) const sessionIdRef = useRef(null) + const syncCancelRef = useRef<(() => void) | null>(null) useEffect(() => { dbConvIdRef.current = dbConversationId @@ -293,9 +295,21 @@ const ConversationTabView = memo(function ConversationTabView({ // Turn completed — promote liveMessage + optimisticTurns to localTurns completeTurn(effectiveConversationId) + // Cancel previous metadata sync (handles rapid consecutive turns) + syncCancelRef.current?.() + syncCancelRef.current = null + const persistedId = dbConvIdRef.current if (!persistedId) return + // Async patch metadata (usage, duration_ms, model, session_stats) + if (persistedId > 0) { + syncCancelRef.current = syncTurnMetadata( + persistedId, + effectiveConversationId + ) + } + if (connStatus !== "disconnected" && connStatus !== "error") { updateConversationStatus(persistedId, "pending_review") .then(() => refreshConversations()) @@ -303,7 +317,13 @@ const ConversationTabView = memo(function ConversationTabView({ console.error("[ConversationTabView] update status:", e) ) } - }, [completeTurn, connStatus, effectiveConversationId, refreshConversations]) + }, [ + completeTurn, + connStatus, + effectiveConversationId, + refreshConversations, + syncTurnMetadata, + ]) useEffect(() => { // Only sync non-null liveMessage updates to state. When conn.liveMessage @@ -415,6 +435,7 @@ const ConversationTabView = memo(function ConversationTabView({ mountedRef.current = true return () => { mountedRef.current = false + syncCancelRef.current?.() if (connStatusRef.current === "prompting") { // Agent still responding — mark for deferred cleanup setPendingCleanup(effectiveConversationId, true) diff --git a/src/contexts/conversation-runtime-context.tsx b/src/contexts/conversation-runtime-context.tsx index 274a0c5..8abf02a 100644 --- a/src/contexts/conversation-runtime-context.tsx +++ b/src/contexts/conversation-runtime-context.tsx @@ -11,7 +11,12 @@ import { } from "react" import type { LiveMessage } from "@/contexts/acp-connections-context" import { getFolderConversation } from "@/lib/tauri" -import type { DbConversationDetail, MessageTurn } from "@/lib/types" +import type { + DbConversationDetail, + MessageTurn, + SessionStats, + TurnUsage, +} from "@/lib/types" import { inferLiveToolName } from "@/lib/tool-call-normalization" export type ConversationSyncState = "idle" | "awaiting_persist" @@ -108,6 +113,17 @@ type Action = conversationId: number pendingCleanup: boolean } + | { + type: "PATCH_TURN_METADATA" + conversationId: number + turnPatches: Array<{ + index: number + usage?: TurnUsage | null + duration_ms?: number | null + model?: string | null + }> + sessionStats?: SessionStats | null + } | { type: "REMOVE_CONVERSATION"; conversationId: number } | { type: "RESET" } @@ -431,6 +447,47 @@ function reducer( } } + case "PATCH_TURN_METADATA": { + const current = state.byConversationId.get(action.conversationId) + if (!current || current.localTurns.length === 0) return state + + const patchedTurns = [...current.localTurns] + let changed = false + for (const patch of action.turnPatches) { + const turn = patchedTurns[patch.index] + if (!turn) continue + const newUsage = turn.usage ?? patch.usage + const newDuration = turn.duration_ms ?? patch.duration_ms + const newModel = turn.model ?? patch.model + if ( + newUsage !== turn.usage || + newDuration !== turn.duration_ms || + newModel !== turn.model + ) { + patchedTurns[patch.index] = { + ...turn, + usage: newUsage, + duration_ms: newDuration, + model: newModel, + } + changed = true + } + } + + if (!changed && !action.sessionStats) return state + + const patchedDetail = + current.detail && action.sessionStats + ? { ...current.detail, session_stats: action.sessionStats } + : current.detail + + return updateSessionInState(state, action.conversationId, () => ({ + ...current, + localTurns: changed ? patchedTurns : current.localTurns, + detail: patchedDetail, + })) + } + case "SET_PENDING_CLEANUP": return updateSessionInState(state, action.conversationId, (current) => ({ ...current, @@ -478,6 +535,10 @@ interface ConversationRuntimeContextValue { conversationId: number, syncState: ConversationSyncState ) => void + syncTurnMetadata: ( + dbConversationId: number, + runtimeConversationId?: number + ) => () => void migrateConversation: ( fromConversationId: number, toConversationId: number @@ -607,6 +668,99 @@ export function ConversationRuntimeProvider({ }) }, []) + const syncTurnMetadata = useCallback( + ( + dbConversationId: number, + runtimeConversationId?: number + ): (() => void) => { + const runtimeId = runtimeConversationId ?? dbConversationId + let cancelled = false + let timerId: ReturnType | null = null + + const trySync = (attempt: number) => { + const delay = attempt === 0 ? 1500 : 3000 + timerId = setTimeout(() => { + if (cancelled) return + const session = + stateRef.current.byConversationId.get(runtimeId) + if (!session || session.localTurns.length === 0) return + if (session.syncState === "awaiting_persist") return + + getFolderConversation(dbConversationId) + .then((parsed) => { + if (cancelled) return + const cur = + stateRef.current.byConversationId.get(runtimeId) + if (!cur || cur.localTurns.length === 0) return + if (cur.syncState === "awaiting_persist") return + + const localAssistantIndices: number[] = [] + for (let i = 0; i < cur.localTurns.length; i++) { + if (cur.localTurns[i].role === "assistant") { + localAssistantIndices.push(i) + } + } + + const parsedAssistantTurns = parsed.turns.filter( + (t) => t.role === "assistant" + ) + + const offset = + parsedAssistantTurns.length - localAssistantIndices.length + const patches: Array<{ + index: number + usage?: TurnUsage | null + duration_ms?: number | null + model?: string | null + }> = [] + + for (let i = 0; i < localAssistantIndices.length; i++) { + const parsedIdx = offset + i + if ( + parsedIdx < 0 || + parsedIdx >= parsedAssistantTurns.length + ) + continue + const pt = parsedAssistantTurns[parsedIdx] + if (!pt.usage && !pt.duration_ms && !pt.model) continue + patches.push({ + index: localAssistantIndices[i], + usage: pt.usage, + duration_ms: pt.duration_ms, + model: pt.model, + }) + } + + if (patches.length > 0 || parsed.session_stats) { + dispatch({ + type: "PATCH_TURN_METADATA", + conversationId: runtimeId, + turnPatches: patches, + sessionStats: parsed.session_stats, + }) + } + + const latestPatch = patches[patches.length - 1] + if (!latestPatch?.usage && attempt < 1) { + trySync(attempt + 1) + } + }) + .catch(() => { + // Silent — localTurns content remains visible + }) + }, delay) + } + + trySync(0) + + return () => { + cancelled = true + if (timerId) clearTimeout(timerId) + } + }, + [] + ) + const completeTurn = useCallback((conversationId: number) => { dispatch({ type: "COMPLETE_TURN", conversationId }) }, []) @@ -677,6 +831,7 @@ export function ConversationRuntimeProvider({ getTimelineTurns, fetchDetail, refetchDetail, + syncTurnMetadata, completeTurn, appendOptimisticTurn, setLiveMessage, @@ -693,6 +848,7 @@ export function ConversationRuntimeProvider({ getTimelineTurns, fetchDetail, refetchDetail, + syncTurnMetadata, completeTurn, appendOptimisticTurn, setLiveMessage,