diff --git a/src/components/conversations/conversation-detail-panel.tsx b/src/components/conversations/conversation-detail-panel.tsx index fc50116..54aa86e 100644 --- a/src/components/conversations/conversation-detail-panel.tsx +++ b/src/components/conversations/conversation-detail-panel.tsx @@ -29,11 +29,7 @@ import { updateConversationStatus, } from "@/lib/tauri" import { useConversationRuntime } from "@/contexts/conversation-runtime-context" -import { - invalidateDetailCache, - refreshDetailCache, - useDbMessageDetail, -} from "@/hooks/use-db-message-detail" +import { useConversationDetail } from "@/hooks/use-conversation-detail" import { extractUserImagesFromDraft, extractUserResourcesFromDraft, @@ -143,17 +139,21 @@ const ConversationTabView = memo(function ConversationTabView({ const { bindConversationTab } = useTabContext() const { setSessionStats } = useSessionStats() const { - acknowledgePersistedDetail, appendOptimisticTurn, - migrateConversation, + completeTurn, + refetchDetail, + removeConversation, setExternalId, setLiveMessage, + setPendingCleanup, setSyncState, } = useConversationRuntime() - const temporaryConversationId = useMemo( - () => buildVirtualConversationId(`draft-${tabId}`), - [tabId] + // Stable runtime session key — set once at mount, never changes. + // For new conversations this is a virtual (negative) ID; for existing + // conversations opened from the sidebar it equals the real DB ID. + const [effectiveConversationId] = useState( + () => conversationId ?? buildVirtualConversationId(`draft-${tabId}`) ) const [createdConversationId, setCreatedConversationId] = useState< number | null @@ -173,7 +173,11 @@ const ConversationTabView = memo(function ConversationTabView({ const hasPersistedConversation = dbConversationId != null const canAutoConnect = hasPersistedConversation || (agentsLoaded && usableAgentCount > 0) - const effectiveConversationId = dbConversationId ?? temporaryConversationId + + // Clear pendingCleanup when tab is (re)opened + useEffect(() => { + setPendingCleanup(effectiveConversationId, false) + }, [effectiveConversationId, setPendingCleanup]) const latestReloadSignal = useRef(reloadSignal) const pendingReloadState = useRef<{ @@ -181,10 +185,10 @@ const ConversationTabView = memo(function ConversationTabView({ sawLoading: boolean } | null>(null) const dbConvIdRef = useRef(conversationId) + const mountedRef = useRef(true) const statusUpdatedRef = useRef(false) const selectedAgentRef = useRef(selectedAgent) const createConversationPendingRef = useRef(false) - const reconcileTimerRef = useRef | null>(null) const externalIdSavedRef = useRef(false) const sessionIdRef = useRef(null) @@ -200,8 +204,7 @@ const ConversationTabView = memo(function ConversationTabView({ detail, loading: detailLoading, error: detailError, - refetch: refetchConversationDetail, - } = useDbMessageDetail(effectiveConversationId) + } = useConversationDetail(effectiveConversationId) useEffect(() => { if (!isActive) return @@ -271,56 +274,65 @@ const ConversationTabView = memo(function ConversationTabView({ return conn.modes?.current_mode_id ?? connectionModes[0]?.id ?? null }, [conn.modes?.current_mode_id, connectionModes, modeId]) - const clearReconcileTimer = useCallback(() => { - if (!reconcileTimerRef.current) return - clearTimeout(reconcileTimerRef.current) - reconcileTimerRef.current = null - }, []) - - const refreshFromDb = useCallback( - async (refreshConversationId: number) => { - try { - const refreshed = await refreshDetailCache(refreshConversationId) - // Skip ACK during prompting to avoid clearing liveMessage / - // resetting syncState while streaming. The useEffect with the - // connStatus === "prompting" guard will handle it naturally - // once prompting ends. - if (connStatusRef.current === "prompting") return - acknowledgePersistedDetail(refreshConversationId, refreshed) - } catch (error) { - setSyncState(refreshConversationId, "failed") - console.error( - "[ConversationTabView] refresh detail cache failed:", - error - ) - } - }, - [acknowledgePersistedDetail, setSyncState] - ) - useEffect(() => { if (connSessionId) { sessionIdRef.current = connSessionId } }, [connSessionId]) + // completeTurn MUST be declared BEFORE setLiveMessage so that React runs + // its cleanup/setup before setLiveMessage's cleanup. When connStatus + // transitions away from "prompting", completeTurn snapshots and promotes + // the liveMessage first, then setLiveMessage's cleanup safely clears it. + const prevConnStatusRef = useRef(connStatus) useEffect(() => { - setLiveMessage(effectiveConversationId, conn.liveMessage ?? null) + const wasPrompting = prevConnStatusRef.current === "prompting" + prevConnStatusRef.current = connStatus + if (!wasPrompting || connStatus === "prompting") return + + // Turn completed — promote liveMessage + optimisticTurns to localTurns + completeTurn(effectiveConversationId) + + const persistedId = dbConvIdRef.current + if (!persistedId) return + + if (connStatus !== "disconnected" && connStatus !== "error") { + updateConversationStatus(persistedId, "pending_review") + .then(() => refreshConversations()) + .catch((e: unknown) => + console.error("[ConversationTabView] update status:", e) + ) + } + }, [completeTurn, connStatus, effectiveConversationId, refreshConversations]) + + useEffect(() => { + // Only sync non-null liveMessage updates to state. When conn.liveMessage + // goes null (agent finished streaming), don't clear state.liveMessage — + // COMPLETE_TURN needs to snapshot it when connStatus transitions. + // Clearing is handled by COMPLETE_TURN (sets liveMessage = null) and + // by this effect's cleanup (when not prompting). + if (conn.liveMessage != null) { + setLiveMessage(effectiveConversationId, conn.liveMessage) + } return () => { - setLiveMessage(effectiveConversationId, null) + // Don't clear liveMessage if agent is still responding — the session + // is kept via pendingCleanup, and clearing here would cause the + // SET_LIVE_MESSAGE guard to block the reconnect liveMessage on reopen. + if (connStatusRef.current !== "prompting") { + setLiveMessage(effectiveConversationId, null) + } } }, [conn.liveMessage, effectiveConversationId, setLiveMessage]) useEffect(() => { - if (!dbConversationId) return - setExternalId(dbConversationId, detail?.summary.external_id ?? null) - }, [dbConversationId, detail?.summary.external_id, setExternalId]) + if (effectiveConversationId <= 0) return + setExternalId(effectiveConversationId, detail?.summary.external_id ?? null) + }, [effectiveConversationId, detail?.summary.external_id, setExternalId]) useEffect(() => { - if (!dbConversationId) return if (!connSessionId) return - setExternalId(dbConversationId, connSessionId) - }, [connSessionId, dbConversationId, setExternalId]) + setExternalId(effectiveConversationId, connSessionId) + }, [connSessionId, effectiveConversationId, setExternalId]) const trySaveExternalId = useCallback(() => { if ( @@ -345,45 +357,6 @@ const ConversationTabView = memo(function ConversationTabView({ } }, [connSessionId, trySaveExternalId]) - useEffect(() => { - if (!dbConversationId) return - if (!detail) return - if (connStatus === "prompting") return - acknowledgePersistedDetail(dbConversationId, detail) - }, [acknowledgePersistedDetail, connStatus, dbConversationId, detail]) - - const prevConnStatusRef = useRef(connStatus) - useEffect(() => { - const wasPrompting = prevConnStatusRef.current === "prompting" - prevConnStatusRef.current = connStatus - if (!wasPrompting || connStatus === "prompting") return - - 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) - ) - } - }, [ - clearReconcileTimer, - connStatus, - effectiveConversationId, - refreshConversations, - refreshFromDb, - setSyncState, - ]) - useEffect(() => { if (connStatus === "connected" || connStatus === "prompting") { statusUpdatedRef.current = false @@ -413,8 +386,8 @@ const ConversationTabView = memo(function ConversationTabView({ signal: reloadSignal, sawLoading: false, } - refetchConversationDetail() - }, [dbConversationId, reloadSignal, refetchConversationDetail]) + refetchDetail(dbConversationId) + }, [dbConversationId, reloadSignal, refetchDetail]) useEffect(() => { const pending = pendingReloadState.current @@ -437,7 +410,19 @@ const ConversationTabView = memo(function ConversationTabView({ toast.success(t("reloaded")) }, [detailLoading, detailError, t]) - useEffect(() => clearReconcileTimer, [clearReconcileTimer]) + // Cleanup runtime data on unmount (tab close) + useEffect(() => { + mountedRef.current = true + return () => { + mountedRef.current = false + if (connStatusRef.current === "prompting") { + // Agent still responding — mark for deferred cleanup + setPendingCleanup(effectiveConversationId, true) + } else { + removeConversation(effectiveConversationId) + } + } + }, [effectiveConversationId, removeConversation, setPendingCleanup]) const handleSend = useCallback( (draft: PromptDraft, selectedModeIdArg?: string | null) => { @@ -481,22 +466,31 @@ const ConversationTabView = memo(function ConversationTabView({ createConversation(folderId, selectedAgent, title) .then((newConversationId) => { dbConvIdRef.current = newConversationId + // Set external ID on the stable virtual session (no migration needed — + // effectiveConversationId never changes, so the session stays in place) + setExternalId(effectiveConversationId, sessionIdRef.current ?? null) + trySaveExternalId() + + if (!mountedRef.current) { + // Component unmounted while creating — mark for deferred cleanup + // so the background turn_complete handler can clean up later. + setPendingCleanup(effectiveConversationId, true) + refreshConversations() + return + } + 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) @@ -514,16 +508,13 @@ const ConversationTabView = memo(function ConversationTabView({ folderId, hasPersistedConversation, lifecycleSend, - migrateConversation, refreshConversations, - refreshFromDb, selectedAgent, setExternalId, setSyncState, sharedT, tWelcome, tabId, - temporaryConversationId, trySaveExternalId, ] ) @@ -598,8 +589,8 @@ const ConversationTabView = memo(function ConversationTabView({ ] ) - const showDraftHeader = !hasPersistedConversation - const isWelcomeMode = showDraftHeader && !hasSentMessage + const showDraftHeader = !hasPersistedConversation && !hasSentMessage + const isWelcomeMode = showDraftHeader const messageListNode = ( ) @@ -735,9 +726,10 @@ const ConversationTabView = memo(function ConversationTabView({ export function ConversationDetailPanel() { const t = useTranslations("Folder.conversation") const { - acknowledgePersistedDetail, + completeTurn: runtimeCompleteTurn, getConversationIdByExternalId, - setSyncState, + getSession, + removeConversation: runtimeRemoveConversation, } = useConversationRuntime() const { folder, newConversation, conversations, refreshConversations } = useFolderContext() @@ -752,10 +744,6 @@ export function ConversationDetailPanel() { 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 @@ -765,64 +753,10 @@ export function ConversationDetailPanel() { 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 { - const detail = await refreshDetailCache(conversationId) - acknowledgePersistedDetail(conversationId, detail) - } catch (error) { - setSyncState(conversationId, "failed") - console.error( - "[ConversationDetailPanel] background detail cache refresh failed:", - error - ) - } - }) - ) - - refreshConversations() - })() - }, [acknowledgePersistedDetail, refreshConversations, setSyncState]) - - 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] - ) - + // Background turn_complete handler: for conversations not open in tabs useEffect(() => { let cancelled = false let unlisten: (() => void | Promise) | null = null - const pendingClosedConversationIds = pendingClosedConversationIdsRef.current void import("@tauri-apps/api/event") .then(({ listen }) => @@ -840,15 +774,40 @@ export function ConversationDetailPanel() { runtimeConversationId ?? summary?.id ?? null if (!matchedConversationId) return + // Check both virtual (runtime) ID and real DB ID — after + // bindConversationTab the tab stores the real DB ID while the + // runtime session may still be keyed by the virtual ID. + const dbId2 = summary?.id const isOpenInTabs = tabsRef.current.some( - (tab) => tab.conversationId === matchedConversationId + (tab) => + tab.conversationId === matchedConversationId || + (dbId2 != null && tab.conversationId === dbId2) ) if (isOpenInTabs) return - invalidateDetailCache(matchedConversationId) - setSyncState(matchedConversationId, "reconciling") + // Promote liveMessage + optimisticTurns to localTurns immediately + runtimeCompleteTurn(matchedConversationId) - scheduleClosedConversationRefresh(matchedConversationId) + // If tab was closed while agent was responding, clean up now + const session = getSession(matchedConversationId) + if (session?.pendingCleanup) { + runtimeRemoveConversation(matchedConversationId) + } + + // Update conversation status — use the DB summary (found by + // external_id above) since matchedConversationId may be a virtual + // (negative) ID that won't match any DB record. + const dbId = summary?.id ?? (matchedConversationId > 0 ? matchedConversationId : null) + if (dbId && (!summary || summary.status === "in_progress")) { + updateConversationStatus(dbId, "pending_review") + .then(() => refreshConversations()) + .catch((error: unknown) => + console.error( + "[ConversationDetailPanel] background update status:", + error + ) + ) + } }) ) .then((dispose) => { @@ -867,11 +826,6 @@ export function ConversationDetailPanel() { return () => { cancelled = true - if (pendingRefreshTimerRef.current) { - clearTimeout(pendingRefreshTimerRef.current) - pendingRefreshTimerRef.current = null - } - pendingClosedConversationIds.clear() disposeTauriListener( unlisten, "ConversationDetailPanel.backgroundRefresh" @@ -879,9 +833,10 @@ export function ConversationDetailPanel() { } }, [ getConversationIdByExternalId, - acknowledgePersistedDetail, - scheduleClosedConversationRefresh, - setSyncState, + getSession, + runtimeCompleteTurn, + runtimeRemoveConversation, + refreshConversations, ]) const hasNoTabs = tabs.length === 0 && !activeTabId diff --git a/src/components/layout/aux-panel-session-files-tab.tsx b/src/components/layout/aux-panel-session-files-tab.tsx index 7b9c1ee..1515faf 100644 --- a/src/components/layout/aux-panel-session-files-tab.tsx +++ b/src/components/layout/aux-panel-session-files-tab.tsx @@ -7,7 +7,7 @@ import { useFolderContext } from "@/contexts/folder-context" import { useTabContext } from "@/contexts/tab-context" import { useConversationRuntime } from "@/contexts/conversation-runtime-context" import { useWorkspaceContext } from "@/contexts/workspace-context" -import { useDbMessageDetail } from "@/hooks/use-db-message-detail" +import { useConversationDetail } from "@/hooks/use-conversation-detail" import { extractSessionFilesGrouped } from "@/lib/session-files" import { CommitFileAdditions, @@ -54,7 +54,7 @@ function toFolderRelativePath(filePath: string, folderPath?: string): string { function SessionFilesContent({ conversationId }: { conversationId: number }) { const t = useTranslations("Folder.sessionFiles") - const { loading } = useDbMessageDetail(conversationId) + const { loading } = useConversationDetail(conversationId) const { getTimelineTurns } = useConversationRuntime() const { openSessionFileDiff } = useWorkspaceContext() const { folder } = useFolderContext() diff --git a/src/components/message/message-list-view.tsx b/src/components/message/message-list-view.tsx index 6ecf13d..5103d9b 100644 --- a/src/components/message/message-list-view.tsx +++ b/src/components/message/message-list-view.tsx @@ -5,10 +5,9 @@ import { useConversationRuntime } from "@/contexts/conversation-runtime-context" import { ContentPartsRenderer } from "./content-parts-renderer" import { adaptMessageTurns, - type MessageGroup, + type AdaptedContentPart, type UserImageDisplay, type UserResourceDisplay, - groupAdaptedMessages, } from "@/lib/adapters/ai-elements-adapter" import { TurnStats } from "./turn-stats" import { LiveTurnStats } from "./live-turn-stats" @@ -40,9 +39,16 @@ interface MessageListViewProps { hideEmptyState?: boolean } -interface ResolvedMessageGroup extends MessageGroup { +interface ResolvedMessageGroup { + id: string + role: "user" | "assistant" | "system" + parts: AdaptedContentPart[] resources: UserResourceDisplay[] images: UserImageDisplay[] + usage?: import("@/lib/types").TurnUsage | null + duration_ms?: number | null + model?: string | null + models?: string[] } type ThreadRenderItem = @@ -186,36 +192,27 @@ export function MessageListView({ (_, index) => timelineTurns[index].phase !== "streaming" ) - // Group adapted messages per phase-chunk to prevent merging - // assistant turns across phase boundaries (e.g. persisted + streaming). - const items: ThreadRenderItem[] = [] - let chunkStart = 0 - while (chunkStart < allAdapted.length) { - const chunkPhase = timelineTurns[chunkStart].phase - let chunkEnd = chunkStart + 1 - while ( - chunkEnd < allAdapted.length && - timelineTurns[chunkEnd].phase === chunkPhase - ) { - chunkEnd++ + // Map each adapted message directly to a render item (1:1). + // Backend group_into_turns() already ensures each turn is a complete unit. + const items: ThreadRenderItem[] = allAdapted.map((msg, i) => { + const phase = timelineTurns[i].phase + const role = msg.role === "tool" ? "assistant" : msg.role + return { + key: `${phase}-${msg.id}-${i}`, + kind: "turn" as const, + group: { + id: msg.id, + role, + parts: msg.content, + resources: msg.userResources ?? [], + images: msg.userImages ?? [], + usage: msg.usage, + duration_ms: msg.duration_ms, + model: msg.model, + }, + phase, } - const chunkAdapted = allAdapted.slice(chunkStart, chunkEnd) - const groups = groupAdaptedMessages(chunkAdapted) - for (let i = 0; i < groups.length; i++) { - const group = groups[i] - items.push({ - key: `${chunkPhase}-${chunkStart}-${group.id}-${i}`, - kind: "turn", - group: { - ...group, - resources: group.userResources ?? [], - images: group.userImages ?? [], - }, - phase: chunkPhase, - }) - } - chunkStart = chunkEnd - } + }) const lastPhase = timelineTurns[timelineTurns.length - 1]?.phase ?? null if ( diff --git a/src/contexts/conversation-runtime-context.tsx b/src/contexts/conversation-runtime-context.tsx index 61aaff4..274a0c5 100644 --- a/src/contexts/conversation-runtime-context.tsx +++ b/src/contexts/conversation-runtime-context.tsx @@ -6,17 +6,15 @@ import { useContext, useMemo, useReducer, + useRef, type ReactNode, } from "react" import type { LiveMessage } from "@/contexts/acp-connections-context" +import { getFolderConversation } from "@/lib/tauri" import type { DbConversationDetail, MessageTurn } from "@/lib/types" import { inferLiveToolName } from "@/lib/tool-call-normalization" -export type ConversationSyncState = - | "idle" - | "awaiting_persist" - | "reconciling" - | "failed" +export type ConversationSyncState = "idle" | "awaiting_persist" export type ConversationTimelinePhase = "persisted" | "optimistic" | "streaming" @@ -29,15 +27,25 @@ export interface ConversationTimelineTurn { export interface ConversationRuntimeSession { conversationId: number externalId: string | null - persistedTurns: MessageTurn[] + + // DB data (cold open only) + detail: DbConversationDetail | null + detailLoading: boolean + detailError: string | null + + // Active session accumulated turns (promoted optimistic + completed streaming) + localTurns: MessageTurn[] + + // Temporary state optimisticTurns: MessageTurn[] liveMessage: LiveMessage | null + + // Sync syncState: ConversationSyncState activeTurnToken: string | null - lastHydratedAt: number | null - lastPersistedAt: number | null - persistedUpdatedAt: string | null - persistedMessageCount: number + + // Cleanup + pendingCleanup: boolean } interface ConversationRuntimeState { @@ -51,7 +59,24 @@ const initialState: ConversationRuntimeState = { } type Action = - | { type: "HYDRATE_FROM_DETAIL"; detail: DbConversationDetail } + | { + type: "FETCH_DETAIL_START" + conversationId: number + } + | { + type: "FETCH_DETAIL_SUCCESS" + conversationId: number + detail: DbConversationDetail + } + | { + type: "FETCH_DETAIL_ERROR" + conversationId: number + error: string + } + | { + type: "COMPLETE_TURN" + conversationId: number + } | { type: "APPEND_OPTIMISTIC_TURN" conversationId: number @@ -63,12 +88,6 @@ type Action = conversationId: number liveMessage: LiveMessage | null } - | { - type: "ACK_PERSISTED_DETAIL" - conversationId: number - detail: DbConversationDetail - turnToken?: string | null - } | { type: "SET_EXTERNAL_ID" conversationId: number @@ -84,6 +103,11 @@ type Action = fromConversationId: number toConversationId: number } + | { + type: "SET_PENDING_CLEANUP" + conversationId: number + pendingCleanup: boolean + } | { type: "REMOVE_CONVERSATION"; conversationId: number } | { type: "RESET" } @@ -93,15 +117,15 @@ function createEmptySession( return { conversationId, externalId: null, - persistedTurns: [], + detail: null, + detailLoading: false, + detailError: null, + localTurns: [], optimisticTurns: [], liveMessage: null, syncState: "idle", activeTurnToken: null, - lastHydratedAt: null, - lastPersistedAt: null, - persistedUpdatedAt: null, - persistedMessageCount: 0, + pendingCleanup: false, } } @@ -179,24 +203,6 @@ function buildStreamingTurnFromLiveMessage( } } -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, @@ -213,74 +219,18 @@ function upsertExternalIdIndex( return next } -function reduceHydrateDetail( +function updateSessionInState( state: ConversationRuntimeState, conversationId: number, - detail: DbConversationDetail + updater: (current: ConversationRuntimeSession) => ConversationRuntimeSession ): ConversationRuntimeState { - const current = state.byConversationId.get(conversationId) - const nextExternalId = detail.summary.external_id ?? null - const acceptSnapshot = shouldAcceptPersistedSnapshot(current, detail) - const prevPersistedTurnCount = current?.persistedTurns.length ?? 0 - const prevPersistedMessageCount = current?.persistedMessageCount ?? 0 - 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 - // Content advance: actual turns or messages grew — safe to clear - // liveMessage because persisted data now covers the streamed content. - const hasContentAdvance = - acceptSnapshot && - (detail.turns.length > prevPersistedTurnCount || - detail.summary.message_count > prevPersistedMessageCount) - // Note: updated_at changes (e.g. status update bumping the timestamp) - // are NOT treated as content advance. Only actual turns / message_count - // growth should clear liveMessage, because a metadata-only bump could - // arrive before the session file is flushed to disk. - - const nextSession: ConversationRuntimeSession = { - ...(current ?? createEmptySession(conversationId)), - externalId: nextExternalId, - persistedTurns, - liveMessage: - hasContentAdvance && current?.syncState !== "awaiting_persist" - ? null - : (current?.liveMessage ?? null), - 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 current = + state.byConversationId.get(conversationId) ?? + createEmptySession(conversationId) + const nextSession = updater(current) 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, - } + return { ...state, byConversationId: nextByConversationId } } function reducer( @@ -288,73 +238,125 @@ function reducer( action: Action ): ConversationRuntimeState { switch (action.type) { - case "HYDRATE_FROM_DETAIL": - return reduceHydrateDetail(state, action.detail.summary.id, action.detail) + case "FETCH_DETAIL_START": + return updateSessionInState(state, action.conversationId, (current) => ({ + ...current, + detailLoading: true, + detailError: null, + })) - case "APPEND_OPTIMISTIC_TURN": { + case "FETCH_DETAIL_SUCCESS": { const current = state.byConversationId.get(action.conversationId) ?? createEmptySession(action.conversationId) + const nextExternalId = action.detail.summary.external_id ?? null + + // DB data is authoritative for completed turns — always clear localTurns. + // Only preserve optimisticTurns + liveMessage if user actively sent + // a message and is awaiting agent response. + const isActivelyInteracting = + current.syncState === "awaiting_persist" + const nextSession: ConversationRuntimeSession = { + ...current, + detail: action.detail, + detailLoading: false, + detailError: null, + externalId: nextExternalId ?? current.externalId, + localTurns: [], + ...(isActivelyInteracting + ? {} + : { optimisticTurns: [], liveMessage: null }), + } + + const nextByConversationId = new Map(state.byConversationId) + nextByConversationId.set(action.conversationId, nextSession) + const nextExternalIndex = upsertExternalIdIndex( + state.conversationIdByExternalId, + current.externalId, + nextExternalId ?? current.externalId, + action.conversationId + ) + + return { + byConversationId: nextByConversationId, + conversationIdByExternalId: nextExternalIndex, + } + } + + case "FETCH_DETAIL_ERROR": + return updateSessionInState(state, action.conversationId, (current) => ({ + ...current, + detailLoading: false, + detailError: action.error, + })) + + case "COMPLETE_TURN": { + const current = state.byConversationId.get(action.conversationId) + if (!current) return state + + // Convert liveMessage to a completed MessageTurn + const streamingTurn = current.liveMessage + ? buildStreamingTurnFromLiveMessage( + current.conversationId, + current.liveMessage + ) + : null + + // Promote: optimisticTurns + streamingTurn → localTurns + const promoted = [...current.localTurns, ...current.optimisticTurns] + if (streamingTurn) promoted.push(streamingTurn) + + return updateSessionInState(state, action.conversationId, () => ({ + ...current, + localTurns: promoted, + optimisticTurns: [], + liveMessage: null, + syncState: "idle", + activeTurnToken: null, + })) + } + + case "APPEND_OPTIMISTIC_TURN": + return updateSessionInState(state, action.conversationId, (current) => ({ ...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 current = state.byConversationId.get(action.conversationId) + + // Avoid creating a ghost session when clearing liveMessage on a deleted session + if (!current && action.liveMessage === null) return state + + const session = current ?? createEmptySession(action.conversationId) // Guard: prevent stale liveMessage from ACP reconnects overriding // persisted data. When a session has no active liveMessage and no - // pending interaction (idle or reconciling without a live turn), - // a SET_LIVE_MESSAGE from a reconnected ACP connection carries - // the completed response that is already in persistedTurns. + // pending interaction (idle without a live turn), a SET_LIVE_MESSAGE + // from a reconnected ACP connection carries the completed response + // that is already in localTurns/detail.turns. // Accepting it would cause duplicate assistant text in the timeline. + // Also block during cold loading (detailLoading) — the reconnect + // liveMessage arrives before DB data, causing overlap after fetch. + const hasExistingTurns = + (session.detail?.turns.length ?? 0) > 0 || + session.localTurns.length > 0 if ( action.liveMessage !== null && - current.liveMessage === null && - current.syncState !== "awaiting_persist" && - current.persistedTurns.length > 0 + session.liveMessage === null && + session.syncState !== "awaiting_persist" && + (hasExistingTurns || session.detailLoading) ) { return state } - 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 = { + return updateSessionInState(state, action.conversationId, () => ({ ...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 } + liveMessage: action.liveMessage, + })) } case "SET_EXTERNAL_ID": { @@ -379,18 +381,11 @@ function reducer( } } - case "SET_SYNC_STATE": { - const current = - state.byConversationId.get(action.conversationId) ?? - createEmptySession(action.conversationId) - const nextSession: ConversationRuntimeSession = { + case "SET_SYNC_STATE": + return updateSessionInState(state, action.conversationId, (current) => ({ ...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 @@ -400,33 +395,20 @@ function reducer( state.byConversationId.get(action.toConversationId) ?? createEmptySession(action.toConversationId) - const preferFromSnapshot = - from.persistedTurns.length >= to.persistedTurns.length const mergedLiveMessage = to.liveMessage ?? from.liveMessage const merged: ConversationRuntimeSession = { ...to, ...from, conversationId: action.toConversationId, - persistedTurns: preferFromSnapshot - ? from.persistedTurns - : to.persistedTurns, + detail: to.detail ?? from.detail, + detailLoading: to.detailLoading || from.detailLoading, + detailError: to.detailError ?? from.detailError, + localTurns: [...from.localTurns, ...to.localTurns], optimisticTurns: [...from.optimisticTurns, ...to.optimisticTurns], liveMessage: mergedLiveMessage, 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) @@ -449,6 +431,12 @@ function reducer( } } + case "SET_PENDING_CLEANUP": + return updateSessionInState(state, action.conversationId, (current) => ({ + ...current, + pendingCleanup: action.pendingCleanup, + })) + case "REMOVE_CONVERSATION": { const current = state.byConversationId.get(action.conversationId) if (!current) return state @@ -473,7 +461,9 @@ interface ConversationRuntimeContextValue { getSession: (conversationId: number) => ConversationRuntimeSession | null getConversationIdByExternalId: (externalId: string) => number | null getTimelineTurns: (conversationId: number) => ConversationTimelineTurn[] - hydrateFromDetail: (detail: DbConversationDetail) => void + fetchDetail: (conversationId: number) => void + refetchDetail: (conversationId: number) => void + completeTurn: (conversationId: number) => void appendOptimisticTurn: ( conversationId: number, turn: MessageTurn, @@ -483,11 +473,6 @@ interface ConversationRuntimeContextValue { 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, @@ -497,6 +482,7 @@ interface ConversationRuntimeContextValue { fromConversationId: number, toConversationId: number ) => void + setPendingCleanup: (conversationId: number, pendingCleanup: boolean) => void removeConversation: (conversationId: number) => void reset: () => void } @@ -511,6 +497,9 @@ export function ConversationRuntimeProvider({ }) { const [state, dispatch] = useReducer(reducer, initialState) + const stateRef = useRef(state) + stateRef.current = state + const getSession = useCallback( (conversationId: number) => state.byConversationId.get(conversationId) ?? null, @@ -528,43 +517,98 @@ export function ConversationRuntimeProvider({ const session = state.byConversationId.get(conversationId) if (!session) return [] - const persisted: ConversationTimelineTurn[] = session.persistedTurns.map( + // Phase 1: DB historical turns + const persisted: ConversationTimelineTurn[] = ( + session.detail?.turns ?? [] + ).map((turn, index) => ({ + key: `persisted-${conversationId}-${turn.id}-${index}`, + turn, + phase: "persisted", + })) + + // Phase 2: Locally completed turns (promoted optimistic + completed streaming) + const local: ConversationTimelineTurn[] = session.localTurns.map( (turn, index) => ({ - key: `persisted-${conversationId}-${turn.id}-${index}`, + key: `local-${conversationId}-${turn.id}-${index}`, turn, phase: "persisted", }) ) + + // Phase 3: Optimistic turns (pending user messages) const optimistic: ConversationTimelineTurn[] = session.optimisticTurns.map((turn, index) => ({ key: `optimistic-${conversationId}-${turn.id}-${index}`, turn, phase: "optimistic", })) + + // Phase 4: Streaming turn (live agent response) const streamingMessage = session.liveMessage const streamingTurn = streamingMessage ? buildStreamingTurnFromLiveMessage(conversationId, streamingMessage) : null - if (!streamingTurn) { - return [...persisted, ...optimistic] - } + const result = [...persisted, ...local, ...optimistic] - return [ - ...persisted, - ...optimistic, - { + if (streamingTurn) { + result.push({ key: `streaming-${conversationId}-${streamingMessage?.id ?? "unknown"}`, turn: streamingTurn, phase: "streaming", - }, - ] + }) + } + + return result }, [state.byConversationId] ) - const hydrateFromDetail = useCallback((detail: DbConversationDetail) => { - dispatch({ type: "HYDRATE_FROM_DETAIL", detail }) + const fetchDetail = useCallback((conversationId: number) => { + const session = stateRef.current.byConversationId.get(conversationId) + if (session?.detail || session?.detailLoading) return + + // Skip fetch if session has active data (ongoing conversation) + if ( + session && + (session.optimisticTurns.length > 0 || + session.liveMessage !== null || + session.localTurns.length > 0) + ) { + return + } + + dispatch({ type: "FETCH_DETAIL_START", conversationId }) + getFolderConversation(conversationId) + .then((detail) => { + dispatch({ type: "FETCH_DETAIL_SUCCESS", conversationId, detail }) + }) + .catch((error: unknown) => { + dispatch({ + type: "FETCH_DETAIL_ERROR", + conversationId, + error: error instanceof Error ? error.message : String(error), + }) + }) + }, []) + + const refetchDetail = useCallback((conversationId: number) => { + dispatch({ type: "FETCH_DETAIL_START", conversationId }) + getFolderConversation(conversationId) + .then((detail) => { + dispatch({ type: "FETCH_DETAIL_SUCCESS", conversationId, detail }) + }) + .catch((error: unknown) => { + dispatch({ + type: "FETCH_DETAIL_ERROR", + conversationId, + error: error instanceof Error ? error.message : String(error), + }) + }) + }, []) + + const completeTurn = useCallback((conversationId: number) => { + dispatch({ type: "COMPLETE_TURN", conversationId }) }, []) const appendOptimisticTurn = useCallback( @@ -586,22 +630,6 @@ export function ConversationRuntimeProvider({ [] ) - 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 }) @@ -627,6 +655,13 @@ export function ConversationRuntimeProvider({ [] ) + const setPendingCleanup = useCallback( + (conversationId: number, pendingCleanup: boolean) => { + dispatch({ type: "SET_PENDING_CLEANUP", conversationId, pendingCleanup }) + }, + [] + ) + const removeConversation = useCallback((conversationId: number) => { dispatch({ type: "REMOVE_CONVERSATION", conversationId }) }, []) @@ -640,13 +675,15 @@ export function ConversationRuntimeProvider({ getSession, getConversationIdByExternalId, getTimelineTurns, - hydrateFromDetail, + fetchDetail, + refetchDetail, + completeTurn, appendOptimisticTurn, setLiveMessage, - acknowledgePersistedDetail, setExternalId, setSyncState, migrateConversation, + setPendingCleanup, removeConversation, reset, }), @@ -654,13 +691,15 @@ export function ConversationRuntimeProvider({ getSession, getConversationIdByExternalId, getTimelineTurns, - hydrateFromDetail, + fetchDetail, + refetchDetail, + completeTurn, appendOptimisticTurn, setLiveMessage, - acknowledgePersistedDetail, setExternalId, setSyncState, migrateConversation, + setPendingCleanup, removeConversation, reset, ] diff --git a/src/hooks/use-conversation-detail.ts b/src/hooks/use-conversation-detail.ts new file mode 100644 index 0000000..900dd30 --- /dev/null +++ b/src/hooks/use-conversation-detail.ts @@ -0,0 +1,39 @@ +"use client" + +import { useEffect } from "react" +import { useConversationRuntime } from "@/contexts/conversation-runtime-context" +import type { DbConversationDetail } from "@/lib/types" + +function isVirtualConversationId(conversationId: number): boolean { + return !Number.isFinite(conversationId) || conversationId <= 0 +} + +export function useConversationDetail(conversationId: number): { + detail: DbConversationDetail | null + loading: boolean + error: string | null +} { + const { getSession, fetchDetail } = useConversationRuntime() + const session = getSession(conversationId) + const isVirtual = isVirtualConversationId(conversationId) + + useEffect(() => { + if (isVirtual) return + if (session?.detail || session?.detailLoading) return + fetchDetail(conversationId) + }, [ + conversationId, + isVirtual, + session?.detail, + session?.detailLoading, + fetchDetail, + ]) + + return { + detail: session?.detail ?? null, + loading: session + ? session.detailLoading + : !isVirtual, + error: session?.detailError ?? null, + } +} diff --git a/src/hooks/use-db-message-detail.ts b/src/hooks/use-db-message-detail.ts deleted file mode 100644 index c325bb0..0000000 --- a/src/hooks/use-db-message-detail.ts +++ /dev/null @@ -1,208 +0,0 @@ -"use client" - -import { useCallback, useEffect, useMemo, useState } from "react" -import { getFolderConversation } from "@/lib/tauri" -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> ->() - -function publishDetail(conversationId: number, detail: DbConversationDetail) { - const listeners = detailListeners.get(conversationId) - if (!listeners || listeners.size === 0) return - for (const listener of listeners) { - listener(detail) - } -} - -function setCachedDetail(conversationId: number, detail: DbConversationDetail) { - detailCache.set(conversationId, detail) - publishDetail(conversationId, detail) -} - -function subscribeDetail( - conversationId: number, - listener: (detail: DbConversationDetail) => void -) { - let listeners = detailListeners.get(conversationId) - if (!listeners) { - listeners = new Set() - detailListeners.set(conversationId, listeners) - } - listeners.add(listener) - - return () => { - const current = detailListeners.get(conversationId) - if (!current) return - current.delete(listener) - if (current.size === 0) { - detailListeners.delete(conversationId) - } - } -} - -/** Invalidate cached detail so the next mount re-fetches from disk. */ -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 refreshDetailCache( - conversationId: number -): Promise { - detailCache.delete(conversationId) - return loadAndCacheDetail(conversationId) -} - -interface State { - key: number - detail: DbConversationDetail | null - loading: boolean - error: string | null - 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, - detail: cached ?? null, - loading: !cached, - error: null, - fetchSeq: 0, - } - }, []) - - const [state, setState] = useState(() => { - return getCachedState(conversationId) - }) - - const derivedState = - state.key === conversationId ? state : getCachedState(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 = - prev.key === conversationId ? prev : getCachedState(conversationId) - return { - ...base, - key: conversationId, - loading: true, - error: null, - fetchSeq: base.fetchSeq + 1, - } - }) - }, [conversationId, getCachedState]) - - useEffect(() => { - if (isVirtualId) return - // Skip fetch if cache already has data - if (detailCache.has(conversationId)) return - - let cancelled = false - loadAndCacheDetail(conversationId) - .then((d) => { - if (!cancelled) { - setState((prev) => - prev.key === conversationId - ? { ...prev, detail: d, loading: false, error: null } - : { - key: conversationId, - detail: d, - loading: false, - error: null, - fetchSeq: 0, - } - ) - } - }) - .catch((e) => { - if (!cancelled) { - setState((prev) => - prev.key === conversationId - ? { - ...prev, - error: e instanceof Error ? e.message : String(e), - loading: false, - } - : { - key: conversationId, - detail: null, - loading: false, - error: e instanceof Error ? e.message : String(e), - fetchSeq: 0, - } - ) - } - }) - return () => { - cancelled = true - } - }, [conversationId, derivedState.fetchSeq, isVirtualId]) - - return useMemo( - () => ({ - detail: derivedState.detail, - loading: derivedState.loading, - error: derivedState.error, - refetch, - }), - [derivedState.detail, derivedState.loading, derivedState.error, refetch] - ) -} diff --git a/src/hooks/use-message-detail.ts b/src/hooks/use-message-detail.ts deleted file mode 100644 index 05d6270..0000000 --- a/src/hooks/use-message-detail.ts +++ /dev/null @@ -1,60 +0,0 @@ -"use client" - -import { useEffect, useState } from "react" -import { getConversation } from "@/lib/tauri" -import type { AgentType, ConversationDetail } from "@/lib/types" - -interface MessageDetailState { - key: string - detail: ConversationDetail | null - loading: boolean - error: string | null -} - -function makeKey(agentType: AgentType, conversationId: string): string { - return `${agentType}:${conversationId}` -} - -export function useMessageDetail(agentType: AgentType, conversationId: string) { - const key = makeKey(agentType, conversationId) - - const [state, setState] = useState({ - key, - detail: null, - loading: true, - error: null, - }) - - // Reset when key changes (single setState instead of 4) - if (state.key !== key) { - setState({ key, detail: null, loading: true, error: null }) - } - - useEffect(() => { - let cancelled = false - getConversation(agentType, conversationId) - .then((d) => { - if (!cancelled) { - setState((prev) => ({ ...prev, detail: d, loading: false })) - } - }) - .catch((e) => { - if (!cancelled) { - setState((prev) => ({ - ...prev, - error: e instanceof Error ? e.message : String(e), - loading: false, - })) - } - }) - return () => { - cancelled = true - } - }, [agentType, conversationId]) - - return { - detail: state.detail, - loading: state.loading, - error: state.error, - } -} diff --git a/src/lib/adapters/ai-elements-adapter.ts b/src/lib/adapters/ai-elements-adapter.ts index 4cd870e..f77a629 100644 --- a/src/lib/adapters/ai-elements-adapter.ts +++ b/src/lib/adapters/ai-elements-adapter.ts @@ -746,85 +746,3 @@ export function adaptMessageTurns( adaptMessageTurn(turn, text, streamingIndices?.has(i) ?? false) ) } - -/** - * A visual message group that merges consecutive assistant/tool turns - * into a single block, split only by user or system messages. - */ -export interface MessageGroup { - id: string - role: "user" | "assistant" | "system" - parts: AdaptedContentPart[] - userResources?: UserResourceDisplay[] - userImages?: UserImageDisplay[] - usage?: TurnUsage | null - duration_ms?: number | null - model?: string | null - models?: string[] -} - -function mergeUsage( - a: TurnUsage | null | undefined, - b: TurnUsage | null | undefined -): TurnUsage | null { - if (!a && !b) return null - if (!a) return b! - if (!b) return a - return { - input_tokens: a.input_tokens + b.input_tokens, - output_tokens: a.output_tokens + b.output_tokens, - cache_creation_input_tokens: - a.cache_creation_input_tokens + b.cache_creation_input_tokens, - cache_read_input_tokens: - a.cache_read_input_tokens + b.cache_read_input_tokens, - } -} - -/** - * Group adapted messages so that consecutive assistant/tool messages - * are merged into one visual block, matching Claude Code terminal UX. - */ -export function groupAdaptedMessages( - messages: AdaptedMessage[] -): MessageGroup[] { - const groups: MessageGroup[] = [] - let currentGroup: MessageGroup | null = null - - for (const msg of messages) { - const effectiveRole = msg.role === "tool" ? "assistant" : msg.role - - if (effectiveRole === "user" || effectiveRole === "system") { - currentGroup = null - groups.push({ - id: msg.id, - role: effectiveRole, - parts: [...msg.content], - userResources: msg.userResources, - userImages: msg.userImages, - }) - } else { - if (currentGroup && currentGroup.role === "assistant") { - currentGroup.parts.push(...msg.content) - currentGroup.usage = mergeUsage(currentGroup.usage, msg.usage) - currentGroup.duration_ms = - (currentGroup.duration_ms ?? 0) + (msg.duration_ms ?? 0) - if (msg.model && !currentGroup.models?.includes(msg.model)) { - currentGroup.models = [...(currentGroup.models ?? []), msg.model] - } - } else { - currentGroup = { - id: msg.id, - role: "assistant", - parts: [...msg.content], - usage: msg.usage, - duration_ms: msg.duration_ms, - model: msg.model, - models: msg.model ? [msg.model] : [], - } - groups.push(currentGroup) - } - } - } - - return groups -} diff --git a/src/lib/types.ts b/src/lib/types.ts index 362638e..dd54b57 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -80,13 +80,6 @@ export type ContentBlock = } | { type: "thinking"; text: string } -export interface UnifiedMessage { - id: string - role: MessageRole - content: ContentBlock[] - timestamp: string -} - export type TurnRole = "user" | "assistant" | "system" export interface TurnUsage {