fix(ui): restore chat state after conversation resume (#618)

This commit is contained in:
matevip 2026-08-24 23:17:48 -04:00
parent 987bc2001a
commit bc2b4613f7
3 changed files with 128 additions and 31 deletions

View File

@ -8,6 +8,8 @@ interface RouteHydrationConversation {
conversationId: string
}
type StreamStatusLike = string | null | undefined
export function resolveRouteHydrationQuery(options: {
routeAgentId?: string
routeConversationId?: string
@ -93,3 +95,26 @@ export function buildChatRouteQuery(options: {
...runQuery,
}
}
export function decideConversationResume(options: {
currentConversationId?: string
targetConversationId: string
snapshotStreamStatus?: StreamStatusLike
liveStreamStatus?: StreamStatusLike
}): {
shouldResetLocalStream: boolean
shouldRefreshMessages: boolean
shouldReconnectStream: boolean
} {
const switchingConversation = options.currentConversationId !== options.targetConversationId
const hasLiveStatus = typeof options.liveStreamStatus === 'string' && options.liveStreamStatus.length > 0
const running = hasLiveStatus
? options.liveStreamStatus === 'running'
: options.snapshotStreamStatus === 'running'
return {
shouldResetLocalStream: switchingConversation,
shouldRefreshMessages: true,
shouldReconnectStream: running,
}
}

View File

@ -320,6 +320,7 @@ import { reconstructErrorInfo } from '@/types/chatError'
import { reconcileMessages, extractMessages } from '@/utils/messageReconcile'
import {
buildChatRouteQuery,
decideConversationResume,
readLegacyWorkerRouteContext,
readTeamRunRouteQuery,
resolveConversationAgentSelection,
@ -1357,10 +1358,17 @@ onActivated(async () => {
}, 1000)
// window.location.href
if (currentConversationId.value && !isEphemeralConversation(currentConversationId.value)) {
const cid = currentConversationId.value
try {
const statusRes: any = await conversationApi.getStatus(currentConversationId.value)
if (currentConversationId.value && statusRes.data?.streamStatus === 'running') {
await reconnectStream(currentConversationId.value)
const statusRes: any = await conversationApi.getStatus(cid)
if (currentConversationId.value !== cid) return
const running = statusRes.data?.streamStatus === 'running'
await refreshCurrentConversationMessages(cid, {
allowWhileGenerating: !running,
preserveGeneratingStatus: running,
})
if (currentConversationId.value === cid && running) {
await reconnectStream(cid)
}
} catch {
//
@ -1681,17 +1689,20 @@ async function loadConversations() {
}
}
async function refreshCurrentConversationMessages(conversationId: string) {
async function refreshCurrentConversationMessages(
conversationId: string,
options: { allowWhileGenerating?: boolean; preserveGeneratingStatus?: boolean } = {},
) {
if (!conversationId) return
if (isGenerating.value) return
if (isGenerating.value && !options.allowWhileGenerating) return
if (streamPhase.value === 'awaiting_approval') return
try {
const res: any = await conversationApi.listMessages(conversationId, workerTranscriptMessageParams())
// Stale guardawait
if (currentConversationId.value !== conversationId) return
// isGenerating await
if (isGenerating.value) return
const fetched = extractMessages(res).messages.map((msg: Message) => normalizeMessage(msg))
if (isGenerating.value && !options.allowWhileGenerating) return
const fetched = extractMessages(res).messages.map((msg: Message) => normalizeMessage(msg, options.preserveGeneratingStatus))
// conversationId orphan conversationId
const currentMessages = messages.value.filter(
(m: any) => m.conversationId === conversationId
@ -1722,17 +1733,29 @@ async function hydrateStateFromRoute() {
// Sessions
currentConversationId.value = conversationId
messages.value = []
let liveStreamStatus = ''
try {
if (currentConversationId.value !== conversationId) return
const statusRes: any = await conversationApi.getStatus(conversationId)
liveStreamStatus = statusRes.data?.streamStatus || ''
} catch {
//
}
const resume = decideConversationResume({
currentConversationId: conversationId,
targetConversationId: conversationId,
liveStreamStatus,
})
try {
if (currentConversationId.value !== conversationId) return
const res: any = await conversationApi.listMessages(conversationId, workerTranscriptMessageParams())
if (currentConversationId.value !== conversationId) return
messages.value = extractMessages(res).messages.map((msg: Message) => normalizeMessage(msg, true))
messages.value = extractMessages(res).messages.map((msg: Message) => normalizeMessage(msg, resume.shouldReconnectStream))
} catch {
//
}
try {
if (currentConversationId.value !== conversationId) return
const statusRes: any = await conversationApi.getStatus(conversationId)
if (currentConversationId.value === conversationId && statusRes.data?.streamStatus === 'running') {
if (currentConversationId.value === conversationId && resume.shouldReconnectStream) {
await reconnectStream(conversationId)
}
} catch {
@ -1763,8 +1786,14 @@ async function selectConversation(conv: Conversation, routeAgentId = '') {
// ApollActivity / selectConversation /status reconnect
// A refreshCurrentConversationMessages DB
// reset
const switchingAway = currentConversationId.value !== conv.conversationId
if (switchingAway) {
const previousConversationId = currentConversationId.value
const switchingAway = previousConversationId !== conv.conversationId
const initialResume = decideConversationResume({
currentConversationId: previousConversationId,
targetConversationId: conv.conversationId,
snapshotStreamStatus: conv.streamStatus,
})
if (initialResume.shouldResetLocalStream) {
resetForNewConversation()
messageListRef.value?.resetScrollLock()
}
@ -1791,13 +1820,27 @@ async function selectConversation(conv: Conversation, routeAgentId = '') {
markConversationViewed(conv.conversationId, conv.lastActiveTime)
const requestedConvId = conv.conversationId
try {
let liveStreamStatus = ''
try {
const statusRes: any = await conversationApi.getStatus(requestedConvId)
if (currentConversationId.value !== requestedConvId) return
liveStreamStatus = statusRes?.data?.streamStatus || ''
} catch {
// 退
}
const resume = decideConversationResume({
currentConversationId: previousConversationId,
targetConversationId: requestedConvId,
snapshotStreamStatus: conv.streamStatus,
liveStreamStatus,
})
const res: any = await conversationApi.listMessages(requestedConvId, workerTranscriptMessageParams())
// Stale guardawait
if (currentConversationId.value !== requestedConvId) return
// SSE
if (switchingAway || !isGenerating.value) {
const convRunning = conv.streamStatus === 'running'
messages.value = extractMessages(res).messages.map((msg: Message) => normalizeMessage(msg, convRunning))
// SSE
// idle stale generating
if (switchingAway || !isGenerating.value || !resume.shouldReconnectStream) {
messages.value = extractMessages(res).messages.map((msg: Message) => normalizeMessage(msg, resume.shouldReconnectStream))
}
// Hydrate pending approvalsRFC-067 §4.9
@ -1884,20 +1927,7 @@ async function selectConversation(conv: Conversation, routeAgentId = '') {
// hydration 使
}
// SSE
// - streamStatus==='running'
// -
let shouldReconnect = conv.streamStatus === 'running'
if (!shouldReconnect) {
try {
const statusRes: any = await conversationApi.getStatus(requestedConvId)
if (currentConversationId.value !== requestedConvId) return
shouldReconnect = statusRes?.data?.streamStatus === 'running'
} catch {
//
}
}
if (currentConversationId.value === requestedConvId && shouldReconnect) {
if (currentConversationId.value === requestedConvId && resume.shouldReconnectStream) {
await reconnectStream(requestedConvId)
}
} catch (e) {

View File

@ -2,6 +2,7 @@
import { describe, expect, it } from 'vitest'
import {
buildChatRouteQuery,
decideConversationResume,
readLegacyWorkerRouteContext,
readTeamRunRouteQuery,
resolveConversationAgentSelection,
@ -134,3 +135,44 @@ describe('legacy worker route context', () => {
expect(readLegacyWorkerRouteContext('team-task-legacy', { taskId: fullQuery.taskId })).toBeNull()
})
})
describe('decideConversationResume', () => {
it('refreshes history when returning to the same inactive conversation', () => {
expect(decideConversationResume({
currentConversationId: 'conv-long-task',
targetConversationId: 'conv-long-task',
snapshotStreamStatus: 'idle',
liveStreamStatus: 'idle',
})).toEqual({
shouldResetLocalStream: false,
shouldRefreshMessages: true,
shouldReconnectStream: false,
})
})
it('refreshes persisted history before reconnecting a running conversation', () => {
expect(decideConversationResume({
currentConversationId: 'conv-long-task',
targetConversationId: 'conv-long-task',
snapshotStreamStatus: 'idle',
liveStreamStatus: 'running',
})).toEqual({
shouldResetLocalStream: false,
shouldRefreshMessages: true,
shouldReconnectStream: true,
})
})
it('trusts live idle status over a stale running sidebar snapshot', () => {
expect(decideConversationResume({
currentConversationId: 'conv-long-task',
targetConversationId: 'conv-long-task',
snapshotStreamStatus: 'running',
liveStreamStatus: 'idle',
})).toEqual({
shouldResetLocalStream: false,
shouldRefreshMessages: true,
shouldReconnectStream: false,
})
})
})