继续重构会话消息处理逻辑
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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,
|
||||
]
|
||||
|
||||
39
src/hooks/use-conversation-detail.ts
Normal file
39
src/hooks/use-conversation-detail.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
@@ -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]
|
||||
)
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user