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