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

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,
} 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<number | null>(conversationId)
const mountedRef = useRef(true)
const statusUpdatedRef = useRef(false)
const selectedAgentRef = useRef(selectedAgent)
const createConversationPendingRef = useRef(false)
const reconcileTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const externalIdSavedRef = useRef(false)
const sessionIdRef = useRef<string | null>(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 = (
<MessageListView
@@ -611,7 +602,7 @@ const ConversationTabView = memo(function ConversationTabView({
sessionStats={detail?.session_stats ?? null}
detailLoading={detailLoading}
detailError={detailError}
hideEmptyState={showDraftHeader}
hideEmptyState={!hasPersistedConversation || hasSentMessage}
/>
)
@@ -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<Record<string, number>>({})
const tabsRef = useRef(tabs)
const conversationsRef = useRef(conversations)
const pendingClosedConversationIdsRef = useRef<Set<number>>(new Set())
const pendingRefreshTimerRef = useRef<ReturnType<typeof setTimeout> | 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<void>) | 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

View File

@@ -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()

View File

@@ -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 (

View File

@@ -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<string, number>,
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,
]

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)
)
}
/**
* 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 }
export interface UnifiedMessage {
id: string
role: MessageRole
content: ContentBlock[]
timestamp: string
}
export type TurnRole = "user" | "assistant" | "system"
export interface TurnUsage {