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 conversationId: string
} }
type StreamStatusLike = string | null | undefined
export function resolveRouteHydrationQuery(options: { export function resolveRouteHydrationQuery(options: {
routeAgentId?: string routeAgentId?: string
routeConversationId?: string routeConversationId?: string
@ -93,3 +95,26 @@ export function buildChatRouteQuery(options: {
...runQuery, ...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 { reconcileMessages, extractMessages } from '@/utils/messageReconcile'
import { import {
buildChatRouteQuery, buildChatRouteQuery,
decideConversationResume,
readLegacyWorkerRouteContext, readLegacyWorkerRouteContext,
readTeamRunRouteQuery, readTeamRunRouteQuery,
resolveConversationAgentSelection, resolveConversationAgentSelection,
@ -1357,10 +1358,17 @@ onActivated(async () => {
}, 1000) }, 1000)
// 登出已改为 window.location.href(刷新页面),所以切回时不会有跨用户残留 // 登出已改为 window.location.href(刷新页面),所以切回时不会有跨用户残留
if (currentConversationId.value && !isEphemeralConversation(currentConversationId.value)) { if (currentConversationId.value && !isEphemeralConversation(currentConversationId.value)) {
const cid = currentConversationId.value
try { try {
const statusRes: any = await conversationApi.getStatus(currentConversationId.value) const statusRes: any = await conversationApi.getStatus(cid)
if (currentConversationId.value && statusRes.data?.streamStatus === 'running') { if (currentConversationId.value !== cid) return
await reconnectStream(currentConversationId.value) const running = statusRes.data?.streamStatus === 'running'
await refreshCurrentConversationMessages(cid, {
allowWhileGenerating: !running,
preserveGeneratingStatus: running,
})
if (currentConversationId.value === cid && running) {
await reconnectStream(cid)
} }
} catch { } 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 (!conversationId) return
if (isGenerating.value) return if (isGenerating.value && !options.allowWhileGenerating) return
if (streamPhase.value === 'awaiting_approval') return if (streamPhase.value === 'awaiting_approval') return
try { try {
const res: any = await conversationApi.listMessages(conversationId, workerTranscriptMessageParams()) const res: any = await conversationApi.listMessages(conversationId, workerTranscriptMessageParams())
// Stale guard:await 返回后确认仍是当前会话 // Stale guard:await 返回后确认仍是当前会话
if (currentConversationId.value !== conversationId) return if (currentConversationId.value !== conversationId) return
// 二次 isGenerating 检查:如果 await 期间用户已发新消息,不覆盖本地状态 // 二次 isGenerating 检查:如果 await 期间用户已发新消息,不覆盖本地状态
if (isGenerating.value) return if (isGenerating.value && !options.allowWhileGenerating) return
const fetched = extractMessages(res).messages.map((msg: Message) => normalizeMessage(msg)) const fetched = extractMessages(res).messages.map((msg: Message) => normalizeMessage(msg, options.preserveGeneratingStatus))
// 严格过滤:只保留 conversationId 完全匹配的本地消息,orphan(空 conversationId)直接丢弃 // 严格过滤:只保留 conversationId 完全匹配的本地消息,orphan(空 conversationId)直接丢弃
const currentMessages = messages.value.filter( const currentMessages = messages.value.filter(
(m: any) => m.conversationId === conversationId (m: any) => m.conversationId === conversationId
@ -1722,17 +1733,29 @@ async function hydrateStateFromRoute() {
// 会话不在已加载列表中(可能来自 Sessions 页面跳转),尝试加载消息 // 会话不在已加载列表中(可能来自 Sessions 页面跳转),尝试加载消息
currentConversationId.value = conversationId currentConversationId.value = conversationId
messages.value = [] messages.value = []
let liveStreamStatus = ''
try { 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()) const res: any = await conversationApi.listMessages(conversationId, workerTranscriptMessageParams())
if (currentConversationId.value !== conversationId) return 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 { } catch {
// 消息加载失败,保持空 // 消息加载失败,保持空
} }
try { try {
if (currentConversationId.value !== conversationId) return if (currentConversationId.value === conversationId && resume.shouldReconnectStream) {
const statusRes: any = await conversationApi.getStatus(conversationId)
if (currentConversationId.value === conversationId && statusRes.data?.streamStatus === 'running') {
await reconnectStream(conversationId) await reconnectStream(conversationId)
} }
} catch { } catch {
@ -1763,8 +1786,14 @@ async function selectConversation(conv: Conversation, routeAgentId = '') {
// 用户之后回到 A:pollActivity / selectConversation 的 /status 探测会自动 reconnect 接回实时流; // 用户之后回到 A:pollActivity / selectConversation 的 /status 探测会自动 reconnect 接回实时流;
// 若 A 已完成,refreshCurrentConversationMessages 会从 DB 拉完整结果。 // 若 A 已完成,refreshCurrentConversationMessages 会从 DB 拉完整结果。
// 点同一个会话则完全不 reset,避免打断正在观察的流。 // 点同一个会话则完全不 reset,避免打断正在观察的流。
const switchingAway = currentConversationId.value !== conv.conversationId const previousConversationId = currentConversationId.value
if (switchingAway) { const switchingAway = previousConversationId !== conv.conversationId
const initialResume = decideConversationResume({
currentConversationId: previousConversationId,
targetConversationId: conv.conversationId,
snapshotStreamStatus: conv.streamStatus,
})
if (initialResume.shouldResetLocalStream) {
resetForNewConversation() resetForNewConversation()
messageListRef.value?.resetScrollLock() messageListRef.value?.resetScrollLock()
} }
@ -1791,13 +1820,27 @@ async function selectConversation(conv: Conversation, routeAgentId = '') {
markConversationViewed(conv.conversationId, conv.lastActiveTime) markConversationViewed(conv.conversationId, conv.lastActiveTime)
const requestedConvId = conv.conversationId const requestedConvId = conv.conversationId
try { 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()) const res: any = await conversationApi.listMessages(requestedConvId, workerTranscriptMessageParams())
// Stale guard:await 返回后确认仍是当前会话,否则丢弃 // Stale guard:await 返回后确认仍是当前会话,否则丢弃
if (currentConversationId.value !== requestedConvId) return if (currentConversationId.value !== requestedConvId) return
// 点同一个会话时,若已有 SSE 在跑就不要覆盖本地消息状态 // 点同一个会话且已有真实 SSE 在跑时,不用历史快照覆盖本地流式片段;
if (switchingAway || !isGenerating.value) { // 若后端已 idle,则允许快照把 stale generating 历史恢复成终态。
const convRunning = conv.streamStatus === 'running' if (switchingAway || !isGenerating.value || !resume.shouldReconnectStream) {
messages.value = extractMessages(res).messages.map((msg: Message) => normalizeMessage(msg, convRunning)) messages.value = extractMessages(res).messages.map((msg: Message) => normalizeMessage(msg, resume.shouldReconnectStream))
} }
// Hydrate pending approvals:恢复刷新后丢失的审批卡片(RFC-067 §4.9) // Hydrate pending approvals:恢复刷新后丢失的审批卡片(RFC-067 §4.9)
@ -1884,20 +1927,7 @@ async function selectConversation(conv: Conversation, routeAgentId = '') {
// hydration 失败不影响正常使用 // hydration 失败不影响正常使用
} }
// 决定是否重连 SSE: if (currentConversationId.value === requestedConvId && resume.shouldReconnectStream) {
// - 快照 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) {
await reconnectStream(requestedConvId) await reconnectStream(requestedConvId)
} }
} catch (e) { } catch (e) {

View File

@ -2,6 +2,7 @@
import { describe, expect, it } from 'vitest' import { describe, expect, it } from 'vitest'
import { import {
buildChatRouteQuery, buildChatRouteQuery,
decideConversationResume,
readLegacyWorkerRouteContext, readLegacyWorkerRouteContext,
readTeamRunRouteQuery, readTeamRunRouteQuery,
resolveConversationAgentSelection, resolveConversationAgentSelection,
@ -134,3 +135,44 @@ describe('legacy worker route context', () => {
expect(readLegacyWorkerRouteContext('team-task-legacy', { taskId: fullQuery.taskId })).toBeNull() 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,
})
})
})