继续重构会话消息处理逻辑

This commit is contained in:
xintaofei
2026-03-12 18:34:34 +08:00
parent 4e49e2f16a
commit bd5456423f
9 changed files with 452 additions and 779 deletions

View File

@@ -29,11 +29,7 @@ import {
updateConversationStatus, updateConversationStatus,
} from "@/lib/tauri" } from "@/lib/tauri"
import { useConversationRuntime } from "@/contexts/conversation-runtime-context" import { useConversationRuntime } from "@/contexts/conversation-runtime-context"
import { import { useConversationDetail } from "@/hooks/use-conversation-detail"
invalidateDetailCache,
refreshDetailCache,
useDbMessageDetail,
} from "@/hooks/use-db-message-detail"
import { import {
extractUserImagesFromDraft, extractUserImagesFromDraft,
extractUserResourcesFromDraft, extractUserResourcesFromDraft,
@@ -143,17 +139,21 @@ const ConversationTabView = memo(function ConversationTabView({
const { bindConversationTab } = useTabContext() const { bindConversationTab } = useTabContext()
const { setSessionStats } = useSessionStats() const { setSessionStats } = useSessionStats()
const { const {
acknowledgePersistedDetail,
appendOptimisticTurn, appendOptimisticTurn,
migrateConversation, completeTurn,
refetchDetail,
removeConversation,
setExternalId, setExternalId,
setLiveMessage, setLiveMessage,
setPendingCleanup,
setSyncState, setSyncState,
} = useConversationRuntime() } = useConversationRuntime()
const temporaryConversationId = useMemo( // Stable runtime session key — set once at mount, never changes.
() => buildVirtualConversationId(`draft-${tabId}`), // For new conversations this is a virtual (negative) ID; for existing
[tabId] // conversations opened from the sidebar it equals the real DB ID.
const [effectiveConversationId] = useState(
() => conversationId ?? buildVirtualConversationId(`draft-${tabId}`)
) )
const [createdConversationId, setCreatedConversationId] = useState< const [createdConversationId, setCreatedConversationId] = useState<
number | null number | null
@@ -173,7 +173,11 @@ const ConversationTabView = memo(function ConversationTabView({
const hasPersistedConversation = dbConversationId != null const hasPersistedConversation = dbConversationId != null
const canAutoConnect = const canAutoConnect =
hasPersistedConversation || (agentsLoaded && usableAgentCount > 0) 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 latestReloadSignal = useRef(reloadSignal)
const pendingReloadState = useRef<{ const pendingReloadState = useRef<{
@@ -181,10 +185,10 @@ const ConversationTabView = memo(function ConversationTabView({
sawLoading: boolean sawLoading: boolean
} | null>(null) } | null>(null)
const dbConvIdRef = useRef<number | null>(conversationId) const dbConvIdRef = useRef<number | null>(conversationId)
const mountedRef = useRef(true)
const statusUpdatedRef = useRef(false) const statusUpdatedRef = useRef(false)
const selectedAgentRef = useRef(selectedAgent) const selectedAgentRef = useRef(selectedAgent)
const createConversationPendingRef = useRef(false) const createConversationPendingRef = useRef(false)
const reconcileTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const externalIdSavedRef = useRef(false) const externalIdSavedRef = useRef(false)
const sessionIdRef = useRef<string | null>(null) const sessionIdRef = useRef<string | null>(null)
@@ -200,8 +204,7 @@ const ConversationTabView = memo(function ConversationTabView({
detail, detail,
loading: detailLoading, loading: detailLoading,
error: detailError, error: detailError,
refetch: refetchConversationDetail, } = useConversationDetail(effectiveConversationId)
} = useDbMessageDetail(effectiveConversationId)
useEffect(() => { useEffect(() => {
if (!isActive) return if (!isActive) return
@@ -271,56 +274,65 @@ const ConversationTabView = memo(function ConversationTabView({
return conn.modes?.current_mode_id ?? connectionModes[0]?.id ?? null return conn.modes?.current_mode_id ?? connectionModes[0]?.id ?? null
}, [conn.modes?.current_mode_id, connectionModes, modeId]) }, [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(() => { useEffect(() => {
if (connSessionId) { if (connSessionId) {
sessionIdRef.current = connSessionId sessionIdRef.current = connSessionId
} }
}, [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(() => { 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 () => { return () => {
// 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) setLiveMessage(effectiveConversationId, null)
} }
}
}, [conn.liveMessage, effectiveConversationId, setLiveMessage]) }, [conn.liveMessage, effectiveConversationId, setLiveMessage])
useEffect(() => { useEffect(() => {
if (!dbConversationId) return if (effectiveConversationId <= 0) return
setExternalId(dbConversationId, detail?.summary.external_id ?? null) setExternalId(effectiveConversationId, detail?.summary.external_id ?? null)
}, [dbConversationId, detail?.summary.external_id, setExternalId]) }, [effectiveConversationId, detail?.summary.external_id, setExternalId])
useEffect(() => { useEffect(() => {
if (!dbConversationId) return
if (!connSessionId) return if (!connSessionId) return
setExternalId(dbConversationId, connSessionId) setExternalId(effectiveConversationId, connSessionId)
}, [connSessionId, dbConversationId, setExternalId]) }, [connSessionId, effectiveConversationId, setExternalId])
const trySaveExternalId = useCallback(() => { const trySaveExternalId = useCallback(() => {
if ( if (
@@ -345,45 +357,6 @@ const ConversationTabView = memo(function ConversationTabView({
} }
}, [connSessionId, trySaveExternalId]) }, [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(() => { useEffect(() => {
if (connStatus === "connected" || connStatus === "prompting") { if (connStatus === "connected" || connStatus === "prompting") {
statusUpdatedRef.current = false statusUpdatedRef.current = false
@@ -413,8 +386,8 @@ const ConversationTabView = memo(function ConversationTabView({
signal: reloadSignal, signal: reloadSignal,
sawLoading: false, sawLoading: false,
} }
refetchConversationDetail() refetchDetail(dbConversationId)
}, [dbConversationId, reloadSignal, refetchConversationDetail]) }, [dbConversationId, reloadSignal, refetchDetail])
useEffect(() => { useEffect(() => {
const pending = pendingReloadState.current const pending = pendingReloadState.current
@@ -437,7 +410,19 @@ const ConversationTabView = memo(function ConversationTabView({
toast.success(t("reloaded")) toast.success(t("reloaded"))
}, [detailLoading, detailError, t]) }, [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( const handleSend = useCallback(
(draft: PromptDraft, selectedModeIdArg?: string | null) => { (draft: PromptDraft, selectedModeIdArg?: string | null) => {
@@ -481,22 +466,31 @@ const ConversationTabView = memo(function ConversationTabView({
createConversation(folderId, selectedAgent, title) createConversation(folderId, selectedAgent, title)
.then((newConversationId) => { .then((newConversationId) => {
dbConvIdRef.current = 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) setCreatedConversationId(newConversationId)
migrateConversation(temporaryConversationId, newConversationId)
setExternalId(newConversationId, sessionIdRef.current ?? null)
bindConversationTab(tabId, newConversationId, selectedAgent, title) bindConversationTab(tabId, newConversationId, selectedAgent, title)
moveMessageInputDraft( moveMessageInputDraft(
buildNewConversationDraftStorageKey({ folderId }), buildNewConversationDraftStorageKey({ folderId }),
buildConversationDraftStorageKey(selectedAgent, newConversationId) buildConversationDraftStorageKey(selectedAgent, newConversationId)
) )
trySaveExternalId()
statusUpdatedRef.current = false statusUpdatedRef.current = false
updateConversationStatus(newConversationId, "in_progress") updateConversationStatus(newConversationId, "in_progress")
.then(() => refreshConversations()) .then(() => refreshConversations())
.catch((e: unknown) => .catch((e: unknown) =>
console.error("[ConversationTabView] update status:", e) console.error("[ConversationTabView] update status:", e)
) )
void refreshFromDb(newConversationId)
}) })
.catch((e: unknown) => .catch((e: unknown) =>
console.error("[ConversationTabView] create conversation:", e) console.error("[ConversationTabView] create conversation:", e)
@@ -514,16 +508,13 @@ const ConversationTabView = memo(function ConversationTabView({
folderId, folderId,
hasPersistedConversation, hasPersistedConversation,
lifecycleSend, lifecycleSend,
migrateConversation,
refreshConversations, refreshConversations,
refreshFromDb,
selectedAgent, selectedAgent,
setExternalId, setExternalId,
setSyncState, setSyncState,
sharedT, sharedT,
tWelcome, tWelcome,
tabId, tabId,
temporaryConversationId,
trySaveExternalId, trySaveExternalId,
] ]
) )
@@ -598,8 +589,8 @@ const ConversationTabView = memo(function ConversationTabView({
] ]
) )
const showDraftHeader = !hasPersistedConversation const showDraftHeader = !hasPersistedConversation && !hasSentMessage
const isWelcomeMode = showDraftHeader && !hasSentMessage const isWelcomeMode = showDraftHeader
const messageListNode = ( const messageListNode = (
<MessageListView <MessageListView
@@ -611,7 +602,7 @@ const ConversationTabView = memo(function ConversationTabView({
sessionStats={detail?.session_stats ?? null} sessionStats={detail?.session_stats ?? null}
detailLoading={detailLoading} detailLoading={detailLoading}
detailError={detailError} detailError={detailError}
hideEmptyState={showDraftHeader} hideEmptyState={!hasPersistedConversation || hasSentMessage}
/> />
) )
@@ -735,9 +726,10 @@ const ConversationTabView = memo(function ConversationTabView({
export function ConversationDetailPanel() { export function ConversationDetailPanel() {
const t = useTranslations("Folder.conversation") const t = useTranslations("Folder.conversation")
const { const {
acknowledgePersistedDetail, completeTurn: runtimeCompleteTurn,
getConversationIdByExternalId, getConversationIdByExternalId,
setSyncState, getSession,
removeConversation: runtimeRemoveConversation,
} = useConversationRuntime() } = useConversationRuntime()
const { folder, newConversation, conversations, refreshConversations } = const { folder, newConversation, conversations, refreshConversations } =
useFolderContext() useFolderContext()
@@ -752,10 +744,6 @@ export function ConversationDetailPanel() {
const [reloadByTabId, setReloadByTabId] = useState<Record<string, number>>({}) const [reloadByTabId, setReloadByTabId] = useState<Record<string, number>>({})
const tabsRef = useRef(tabs) const tabsRef = useRef(tabs)
const conversationsRef = useRef(conversations) const conversationsRef = useRef(conversations)
const pendingClosedConversationIdsRef = useRef<Set<number>>(new Set())
const pendingRefreshTimerRef = useRef<ReturnType<typeof setTimeout> | null>(
null
)
useEffect(() => { useEffect(() => {
tabsRef.current = tabs tabsRef.current = tabs
@@ -765,64 +753,10 @@ export function ConversationDetailPanel() {
conversationsRef.current = conversations conversationsRef.current = conversations
}, [conversations]) }, [conversations])
const flushClosedConversationRefresh = useCallback(() => { // Background turn_complete handler: for conversations not open in tabs
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]
)
useEffect(() => { useEffect(() => {
let cancelled = false let cancelled = false
let unlisten: (() => void | Promise<void>) | null = null let unlisten: (() => void | Promise<void>) | null = null
const pendingClosedConversationIds = pendingClosedConversationIdsRef.current
void import("@tauri-apps/api/event") void import("@tauri-apps/api/event")
.then(({ listen }) => .then(({ listen }) =>
@@ -840,15 +774,40 @@ export function ConversationDetailPanel() {
runtimeConversationId ?? summary?.id ?? null runtimeConversationId ?? summary?.id ?? null
if (!matchedConversationId) return 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( const isOpenInTabs = tabsRef.current.some(
(tab) => tab.conversationId === matchedConversationId (tab) =>
tab.conversationId === matchedConversationId ||
(dbId2 != null && tab.conversationId === dbId2)
) )
if (isOpenInTabs) return if (isOpenInTabs) return
invalidateDetailCache(matchedConversationId) // Promote liveMessage + optimisticTurns to localTurns immediately
setSyncState(matchedConversationId, "reconciling") 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) => { .then((dispose) => {
@@ -867,11 +826,6 @@ export function ConversationDetailPanel() {
return () => { return () => {
cancelled = true cancelled = true
if (pendingRefreshTimerRef.current) {
clearTimeout(pendingRefreshTimerRef.current)
pendingRefreshTimerRef.current = null
}
pendingClosedConversationIds.clear()
disposeTauriListener( disposeTauriListener(
unlisten, unlisten,
"ConversationDetailPanel.backgroundRefresh" "ConversationDetailPanel.backgroundRefresh"
@@ -879,9 +833,10 @@ export function ConversationDetailPanel() {
} }
}, [ }, [
getConversationIdByExternalId, getConversationIdByExternalId,
acknowledgePersistedDetail, getSession,
scheduleClosedConversationRefresh, runtimeCompleteTurn,
setSyncState, runtimeRemoveConversation,
refreshConversations,
]) ])
const hasNoTabs = tabs.length === 0 && !activeTabId const hasNoTabs = tabs.length === 0 && !activeTabId

View File

@@ -7,7 +7,7 @@ import { useFolderContext } from "@/contexts/folder-context"
import { useTabContext } from "@/contexts/tab-context" import { useTabContext } from "@/contexts/tab-context"
import { useConversationRuntime } from "@/contexts/conversation-runtime-context" import { useConversationRuntime } from "@/contexts/conversation-runtime-context"
import { useWorkspaceContext } from "@/contexts/workspace-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 { extractSessionFilesGrouped } from "@/lib/session-files"
import { import {
CommitFileAdditions, CommitFileAdditions,
@@ -54,7 +54,7 @@ function toFolderRelativePath(filePath: string, folderPath?: string): string {
function SessionFilesContent({ conversationId }: { conversationId: number }) { function SessionFilesContent({ conversationId }: { conversationId: number }) {
const t = useTranslations("Folder.sessionFiles") const t = useTranslations("Folder.sessionFiles")
const { loading } = useDbMessageDetail(conversationId) const { loading } = useConversationDetail(conversationId)
const { getTimelineTurns } = useConversationRuntime() const { getTimelineTurns } = useConversationRuntime()
const { openSessionFileDiff } = useWorkspaceContext() const { openSessionFileDiff } = useWorkspaceContext()
const { folder } = useFolderContext() const { folder } = useFolderContext()

View File

@@ -5,10 +5,9 @@ import { useConversationRuntime } from "@/contexts/conversation-runtime-context"
import { ContentPartsRenderer } from "./content-parts-renderer" import { ContentPartsRenderer } from "./content-parts-renderer"
import { import {
adaptMessageTurns, adaptMessageTurns,
type MessageGroup, type AdaptedContentPart,
type UserImageDisplay, type UserImageDisplay,
type UserResourceDisplay, type UserResourceDisplay,
groupAdaptedMessages,
} from "@/lib/adapters/ai-elements-adapter" } from "@/lib/adapters/ai-elements-adapter"
import { TurnStats } from "./turn-stats" import { TurnStats } from "./turn-stats"
import { LiveTurnStats } from "./live-turn-stats" import { LiveTurnStats } from "./live-turn-stats"
@@ -40,9 +39,16 @@ interface MessageListViewProps {
hideEmptyState?: boolean hideEmptyState?: boolean
} }
interface ResolvedMessageGroup extends MessageGroup { interface ResolvedMessageGroup {
id: string
role: "user" | "assistant" | "system"
parts: AdaptedContentPart[]
resources: UserResourceDisplay[] resources: UserResourceDisplay[]
images: UserImageDisplay[] images: UserImageDisplay[]
usage?: import("@/lib/types").TurnUsage | null
duration_ms?: number | null
model?: string | null
models?: string[]
} }
type ThreadRenderItem = type ThreadRenderItem =
@@ -186,36 +192,27 @@ export function MessageListView({
(_, index) => timelineTurns[index].phase !== "streaming" (_, index) => timelineTurns[index].phase !== "streaming"
) )
// Group adapted messages per phase-chunk to prevent merging // Map each adapted message directly to a render item (1:1).
// assistant turns across phase boundaries (e.g. persisted + streaming). // Backend group_into_turns() already ensures each turn is a complete unit.
const items: ThreadRenderItem[] = [] const items: ThreadRenderItem[] = allAdapted.map((msg, i) => {
let chunkStart = 0 const phase = timelineTurns[i].phase
while (chunkStart < allAdapted.length) { const role = msg.role === "tool" ? "assistant" : msg.role
const chunkPhase = timelineTurns[chunkStart].phase return {
let chunkEnd = chunkStart + 1 key: `${phase}-${msg.id}-${i}`,
while ( kind: "turn" as const,
chunkEnd < allAdapted.length &&
timelineTurns[chunkEnd].phase === chunkPhase
) {
chunkEnd++
}
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: {
...group, id: msg.id,
resources: group.userResources ?? [], role,
images: group.userImages ?? [], parts: msg.content,
resources: msg.userResources ?? [],
images: msg.userImages ?? [],
usage: msg.usage,
duration_ms: msg.duration_ms,
model: msg.model,
}, },
phase: chunkPhase, phase,
}
}) })
}
chunkStart = chunkEnd
}
const lastPhase = timelineTurns[timelineTurns.length - 1]?.phase ?? null const lastPhase = timelineTurns[timelineTurns.length - 1]?.phase ?? null
if ( if (

View File

@@ -6,17 +6,15 @@ import {
useContext, useContext,
useMemo, useMemo,
useReducer, useReducer,
useRef,
type ReactNode, type ReactNode,
} from "react" } from "react"
import type { LiveMessage } from "@/contexts/acp-connections-context" import type { LiveMessage } from "@/contexts/acp-connections-context"
import { getFolderConversation } from "@/lib/tauri"
import type { DbConversationDetail, MessageTurn } from "@/lib/types" import type { DbConversationDetail, MessageTurn } from "@/lib/types"
import { inferLiveToolName } from "@/lib/tool-call-normalization" import { inferLiveToolName } from "@/lib/tool-call-normalization"
export type ConversationSyncState = export type ConversationSyncState = "idle" | "awaiting_persist"
| "idle"
| "awaiting_persist"
| "reconciling"
| "failed"
export type ConversationTimelinePhase = "persisted" | "optimistic" | "streaming" export type ConversationTimelinePhase = "persisted" | "optimistic" | "streaming"
@@ -29,15 +27,25 @@ export interface ConversationTimelineTurn {
export interface ConversationRuntimeSession { export interface ConversationRuntimeSession {
conversationId: number conversationId: number
externalId: string | null 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[] optimisticTurns: MessageTurn[]
liveMessage: LiveMessage | null liveMessage: LiveMessage | null
// Sync
syncState: ConversationSyncState syncState: ConversationSyncState
activeTurnToken: string | null activeTurnToken: string | null
lastHydratedAt: number | null
lastPersistedAt: number | null // Cleanup
persistedUpdatedAt: string | null pendingCleanup: boolean
persistedMessageCount: number
} }
interface ConversationRuntimeState { interface ConversationRuntimeState {
@@ -51,7 +59,24 @@ const initialState: ConversationRuntimeState = {
} }
type Action = 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" type: "APPEND_OPTIMISTIC_TURN"
conversationId: number conversationId: number
@@ -63,12 +88,6 @@ type Action =
conversationId: number conversationId: number
liveMessage: LiveMessage | null liveMessage: LiveMessage | null
} }
| {
type: "ACK_PERSISTED_DETAIL"
conversationId: number
detail: DbConversationDetail
turnToken?: string | null
}
| { | {
type: "SET_EXTERNAL_ID" type: "SET_EXTERNAL_ID"
conversationId: number conversationId: number
@@ -84,6 +103,11 @@ type Action =
fromConversationId: number fromConversationId: number
toConversationId: number toConversationId: number
} }
| {
type: "SET_PENDING_CLEANUP"
conversationId: number
pendingCleanup: boolean
}
| { type: "REMOVE_CONVERSATION"; conversationId: number } | { type: "REMOVE_CONVERSATION"; conversationId: number }
| { type: "RESET" } | { type: "RESET" }
@@ -93,15 +117,15 @@ function createEmptySession(
return { return {
conversationId, conversationId,
externalId: null, externalId: null,
persistedTurns: [], detail: null,
detailLoading: false,
detailError: null,
localTurns: [],
optimisticTurns: [], optimisticTurns: [],
liveMessage: null, liveMessage: null,
syncState: "idle", syncState: "idle",
activeTurnToken: null, activeTurnToken: null,
lastHydratedAt: null, pendingCleanup: false,
lastPersistedAt: null,
persistedUpdatedAt: null,
persistedMessageCount: 0,
} }
} }
@@ -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( function upsertExternalIdIndex(
index: Map<string, number>, index: Map<string, number>,
previousExternalId: string | null, previousExternalId: string | null,
@@ -213,68 +219,63 @@ function upsertExternalIdIndex(
return next return next
} }
function reduceHydrateDetail( function updateSessionInState(
state: ConversationRuntimeState, state: ConversationRuntimeState,
conversationId: number, conversationId: number,
detail: DbConversationDetail updater: (current: ConversationRuntimeSession) => ConversationRuntimeSession
): ConversationRuntimeState { ): ConversationRuntimeState {
const current = state.byConversationId.get(conversationId) const current =
const nextExternalId = detail.summary.external_id ?? null state.byConversationId.get(conversationId) ??
const acceptSnapshot = shouldAcceptPersistedSnapshot(current, detail) createEmptySession(conversationId)
const prevPersistedTurnCount = current?.persistedTurns.length ?? 0 const nextSession = updater(current)
const prevPersistedMessageCount = current?.persistedMessageCount ?? 0 const nextByConversationId = new Map(state.byConversationId)
const optimisticTurns = current?.optimisticTurns ?? [] nextByConversationId.set(conversationId, nextSession)
const persistedTurns = acceptSnapshot return { ...state, byConversationId: nextByConversationId }
? detail.turns }
: (current?.persistedTurns ?? [])
const nextPersistedUpdatedAt = acceptSnapshot function reducer(
? (detail.summary.updated_at ?? null) state: ConversationRuntimeState,
: (current?.persistedUpdatedAt ?? null) action: Action
const nextPersistedMessageCount = acceptSnapshot ): ConversationRuntimeState {
? detail.summary.message_count switch (action.type) {
: (current?.persistedMessageCount ?? 0) case "FETCH_DETAIL_START":
const shouldDropOptimistic = return updateSessionInState(state, action.conversationId, (current) => ({
optimisticTurns.length > 0 && ...current,
persistedTurns.length >= (current?.persistedTurns.length ?? 0) + 1 detailLoading: true,
// Content advance: actual turns or messages grew — safe to clear detailError: null,
// liveMessage because persisted data now covers the streamed content. }))
const hasContentAdvance =
acceptSnapshot && case "FETCH_DETAIL_SUCCESS": {
(detail.turns.length > prevPersistedTurnCount || const current =
detail.summary.message_count > prevPersistedMessageCount) state.byConversationId.get(action.conversationId) ??
// Note: updated_at changes (e.g. status update bumping the timestamp) createEmptySession(action.conversationId)
// are NOT treated as content advance. Only actual turns / message_count const nextExternalId = action.detail.summary.external_id ?? null
// growth should clear liveMessage, because a metadata-only bump could
// arrive before the session file is flushed to disk. // 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 = { const nextSession: ConversationRuntimeSession = {
...(current ?? createEmptySession(conversationId)), ...current,
externalId: nextExternalId, detail: action.detail,
persistedTurns, detailLoading: false,
liveMessage: detailError: null,
hasContentAdvance && current?.syncState !== "awaiting_persist" externalId: nextExternalId ?? current.externalId,
? null localTurns: [],
: (current?.liveMessage ?? null), ...(isActivelyInteracting
optimisticTurns: shouldDropOptimistic ? [] : optimisticTurns, ? {}
syncState: shouldDropOptimistic ? "idle" : (current?.syncState ?? "idle"), : { optimisticTurns: [], liveMessage: null }),
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) const nextByConversationId = new Map(state.byConversationId)
nextByConversationId.set(conversationId, nextSession) nextByConversationId.set(action.conversationId, nextSession)
const nextExternalIndex = upsertExternalIdIndex( const nextExternalIndex = upsertExternalIdIndex(
state.conversationIdByExternalId, state.conversationIdByExternalId,
current?.externalId ?? null, current.externalId,
nextExternalId, nextExternalId ?? current.externalId,
conversationId action.conversationId
) )
return { return {
@@ -283,78 +284,79 @@ function reduceHydrateDetail(
} }
} }
function reducer( case "FETCH_DETAIL_ERROR":
state: ConversationRuntimeState, return updateSessionInState(state, action.conversationId, (current) => ({
action: Action ...current,
): ConversationRuntimeState { detailLoading: false,
switch (action.type) { detailError: action.error,
case "HYDRATE_FROM_DETAIL": }))
return reduceHydrateDetail(state, action.detail.summary.id, action.detail)
case "APPEND_OPTIMISTIC_TURN": { case "COMPLETE_TURN": {
const current = const current = state.byConversationId.get(action.conversationId)
state.byConversationId.get(action.conversationId) ?? if (!current) return state
createEmptySession(action.conversationId)
const nextSession: ConversationRuntimeSession = { // 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, ...current,
optimisticTurns: [...current.optimisticTurns, action.turn], optimisticTurns: [...current.optimisticTurns, action.turn],
syncState: "awaiting_persist", syncState: "awaiting_persist",
activeTurnToken: action.turnToken, activeTurnToken: action.turnToken,
} }))
const nextByConversationId = new Map(state.byConversationId)
nextByConversationId.set(action.conversationId, nextSession)
return { ...state, byConversationId: nextByConversationId }
}
case "SET_LIVE_MESSAGE": { case "SET_LIVE_MESSAGE": {
const current = const current = state.byConversationId.get(action.conversationId)
state.byConversationId.get(action.conversationId) ??
createEmptySession(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 // Guard: prevent stale liveMessage from ACP reconnects overriding
// persisted data. When a session has no active liveMessage and no // persisted data. When a session has no active liveMessage and no
// pending interaction (idle or reconciling without a live turn), // pending interaction (idle without a live turn), a SET_LIVE_MESSAGE
// a SET_LIVE_MESSAGE from a reconnected ACP connection carries // from a reconnected ACP connection carries the completed response
// the completed response that is already in persistedTurns. // that is already in localTurns/detail.turns.
// Accepting it would cause duplicate assistant text in the timeline. // 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 ( if (
action.liveMessage !== null && action.liveMessage !== null &&
current.liveMessage === null && session.liveMessage === null &&
current.syncState !== "awaiting_persist" && session.syncState !== "awaiting_persist" &&
current.persistedTurns.length > 0 (hasExistingTurns || session.detailLoading)
) { ) {
return state return state
} }
const nextSession: ConversationRuntimeSession = { return updateSessionInState(state, action.conversationId, () => ({
...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, ...session,
syncState: "idle", liveMessage: action.liveMessage,
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": { case "SET_EXTERNAL_ID": {
@@ -379,18 +381,11 @@ function reducer(
} }
} }
case "SET_SYNC_STATE": { case "SET_SYNC_STATE":
const current = return updateSessionInState(state, action.conversationId, (current) => ({
state.byConversationId.get(action.conversationId) ??
createEmptySession(action.conversationId)
const nextSession: ConversationRuntimeSession = {
...current, ...current,
syncState: action.syncState, syncState: action.syncState,
} }))
const nextByConversationId = new Map(state.byConversationId)
nextByConversationId.set(action.conversationId, nextSession)
return { ...state, byConversationId: nextByConversationId }
}
case "MIGRATE_CONVERSATION": { case "MIGRATE_CONVERSATION": {
if (action.fromConversationId === action.toConversationId) return state if (action.fromConversationId === action.toConversationId) return state
@@ -400,33 +395,20 @@ function reducer(
state.byConversationId.get(action.toConversationId) ?? state.byConversationId.get(action.toConversationId) ??
createEmptySession(action.toConversationId) createEmptySession(action.toConversationId)
const preferFromSnapshot =
from.persistedTurns.length >= to.persistedTurns.length
const mergedLiveMessage = to.liveMessage ?? from.liveMessage const mergedLiveMessage = to.liveMessage ?? from.liveMessage
const merged: ConversationRuntimeSession = { const merged: ConversationRuntimeSession = {
...to, ...to,
...from, ...from,
conversationId: action.toConversationId, conversationId: action.toConversationId,
persistedTurns: preferFromSnapshot detail: to.detail ?? from.detail,
? from.persistedTurns detailLoading: to.detailLoading || from.detailLoading,
: to.persistedTurns, detailError: to.detailError ?? from.detailError,
localTurns: [...from.localTurns, ...to.localTurns],
optimisticTurns: [...from.optimisticTurns, ...to.optimisticTurns], optimisticTurns: [...from.optimisticTurns, ...to.optimisticTurns],
liveMessage: mergedLiveMessage, liveMessage: mergedLiveMessage,
syncState: to.syncState !== "idle" ? to.syncState : from.syncState, syncState: to.syncState !== "idle" ? to.syncState : from.syncState,
activeTurnToken: to.activeTurnToken ?? from.activeTurnToken, 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) 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": { case "REMOVE_CONVERSATION": {
const current = state.byConversationId.get(action.conversationId) const current = state.byConversationId.get(action.conversationId)
if (!current) return state if (!current) return state
@@ -473,7 +461,9 @@ interface ConversationRuntimeContextValue {
getSession: (conversationId: number) => ConversationRuntimeSession | null getSession: (conversationId: number) => ConversationRuntimeSession | null
getConversationIdByExternalId: (externalId: string) => number | null getConversationIdByExternalId: (externalId: string) => number | null
getTimelineTurns: (conversationId: number) => ConversationTimelineTurn[] getTimelineTurns: (conversationId: number) => ConversationTimelineTurn[]
hydrateFromDetail: (detail: DbConversationDetail) => void fetchDetail: (conversationId: number) => void
refetchDetail: (conversationId: number) => void
completeTurn: (conversationId: number) => void
appendOptimisticTurn: ( appendOptimisticTurn: (
conversationId: number, conversationId: number,
turn: MessageTurn, turn: MessageTurn,
@@ -483,11 +473,6 @@ interface ConversationRuntimeContextValue {
conversationId: number, conversationId: number,
liveMessage: LiveMessage | null liveMessage: LiveMessage | null
) => void ) => void
acknowledgePersistedDetail: (
conversationId: number,
detail: DbConversationDetail,
turnToken?: string | null
) => void
setExternalId: (conversationId: number, externalId: string | null) => void setExternalId: (conversationId: number, externalId: string | null) => void
setSyncState: ( setSyncState: (
conversationId: number, conversationId: number,
@@ -497,6 +482,7 @@ interface ConversationRuntimeContextValue {
fromConversationId: number, fromConversationId: number,
toConversationId: number toConversationId: number
) => void ) => void
setPendingCleanup: (conversationId: number, pendingCleanup: boolean) => void
removeConversation: (conversationId: number) => void removeConversation: (conversationId: number) => void
reset: () => void reset: () => void
} }
@@ -511,6 +497,9 @@ export function ConversationRuntimeProvider({
}) { }) {
const [state, dispatch] = useReducer(reducer, initialState) const [state, dispatch] = useReducer(reducer, initialState)
const stateRef = useRef(state)
stateRef.current = state
const getSession = useCallback( const getSession = useCallback(
(conversationId: number) => (conversationId: number) =>
state.byConversationId.get(conversationId) ?? null, state.byConversationId.get(conversationId) ?? null,
@@ -528,43 +517,98 @@ export function ConversationRuntimeProvider({
const session = state.byConversationId.get(conversationId) const session = state.byConversationId.get(conversationId)
if (!session) return [] if (!session) return []
const persisted: ConversationTimelineTurn[] = session.persistedTurns.map( // Phase 1: DB historical turns
(turn, index) => ({ const persisted: ConversationTimelineTurn[] = (
session.detail?.turns ?? []
).map((turn, index) => ({
key: `persisted-${conversationId}-${turn.id}-${index}`, key: `persisted-${conversationId}-${turn.id}-${index}`,
turn, turn,
phase: "persisted", phase: "persisted",
}))
// Phase 2: Locally completed turns (promoted optimistic + completed streaming)
const local: ConversationTimelineTurn[] = session.localTurns.map(
(turn, index) => ({
key: `local-${conversationId}-${turn.id}-${index}`,
turn,
phase: "persisted",
}) })
) )
// Phase 3: Optimistic turns (pending user messages)
const optimistic: ConversationTimelineTurn[] = const optimistic: ConversationTimelineTurn[] =
session.optimisticTurns.map((turn, index) => ({ session.optimisticTurns.map((turn, index) => ({
key: `optimistic-${conversationId}-${turn.id}-${index}`, key: `optimistic-${conversationId}-${turn.id}-${index}`,
turn, turn,
phase: "optimistic", phase: "optimistic",
})) }))
// Phase 4: Streaming turn (live agent response)
const streamingMessage = session.liveMessage const streamingMessage = session.liveMessage
const streamingTurn = streamingMessage const streamingTurn = streamingMessage
? buildStreamingTurnFromLiveMessage(conversationId, streamingMessage) ? buildStreamingTurnFromLiveMessage(conversationId, streamingMessage)
: null : null
if (!streamingTurn) { const result = [...persisted, ...local, ...optimistic]
return [...persisted, ...optimistic]
}
return [ if (streamingTurn) {
...persisted, result.push({
...optimistic,
{
key: `streaming-${conversationId}-${streamingMessage?.id ?? "unknown"}`, key: `streaming-${conversationId}-${streamingMessage?.id ?? "unknown"}`,
turn: streamingTurn, turn: streamingTurn,
phase: "streaming", phase: "streaming",
}, })
] }
return result
}, },
[state.byConversationId] [state.byConversationId]
) )
const hydrateFromDetail = useCallback((detail: DbConversationDetail) => { const fetchDetail = useCallback((conversationId: number) => {
dispatch({ type: "HYDRATE_FROM_DETAIL", detail }) 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( 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( const setExternalId = useCallback(
(conversationId: number, externalId: string | null) => { (conversationId: number, externalId: string | null) => {
dispatch({ type: "SET_EXTERNAL_ID", conversationId, externalId }) 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) => { const removeConversation = useCallback((conversationId: number) => {
dispatch({ type: "REMOVE_CONVERSATION", conversationId }) dispatch({ type: "REMOVE_CONVERSATION", conversationId })
}, []) }, [])
@@ -640,13 +675,15 @@ export function ConversationRuntimeProvider({
getSession, getSession,
getConversationIdByExternalId, getConversationIdByExternalId,
getTimelineTurns, getTimelineTurns,
hydrateFromDetail, fetchDetail,
refetchDetail,
completeTurn,
appendOptimisticTurn, appendOptimisticTurn,
setLiveMessage, setLiveMessage,
acknowledgePersistedDetail,
setExternalId, setExternalId,
setSyncState, setSyncState,
migrateConversation, migrateConversation,
setPendingCleanup,
removeConversation, removeConversation,
reset, reset,
}), }),
@@ -654,13 +691,15 @@ export function ConversationRuntimeProvider({
getSession, getSession,
getConversationIdByExternalId, getConversationIdByExternalId,
getTimelineTurns, getTimelineTurns,
hydrateFromDetail, fetchDetail,
refetchDetail,
completeTurn,
appendOptimisticTurn, appendOptimisticTurn,
setLiveMessage, setLiveMessage,
acknowledgePersistedDetail,
setExternalId, setExternalId,
setSyncState, setSyncState,
migrateConversation, migrateConversation,
setPendingCleanup,
removeConversation, removeConversation,
reset, reset,
] ]

View File

@@ -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,
}
}

View File

@@ -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<number, DbConversationDetail>()
const detailInFlight = new Map<number, Promise<DbConversationDetail>>()
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<DbConversationDetail> {
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<DbConversationDetail> {
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<State>(() => {
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]
)
}

View File

@@ -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<MessageDetailState>({
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,
}
}

View File

@@ -746,85 +746,3 @@ export function adaptMessageTurns(
adaptMessageTurn(turn, text, streamingIndices?.has(i) ?? false) 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
}

View File

@@ -80,13 +80,6 @@ export type ContentBlock =
} }
| { type: "thinking"; text: string } | { type: "thinking"; text: string }
export interface UnifiedMessage {
id: string
role: MessageRole
content: ContentBlock[]
timestamp: string
}
export type TurnRole = "user" | "assistant" | "system" export type TurnRole = "user" | "assistant" | "system"
export interface TurnUsage { export interface TurnUsage {