From bc2b4613f75d3a0a4b0e30f26725d8ff0dfb6bf7 Mon Sep 17 00:00:00 2001 From: matevip Date: Mon, 24 Aug 2026 23:17:48 -0400 Subject: [PATCH] fix(ui): restore chat state after conversation resume (#618) --- mateclaw-ui/src/utils/chatRouteHydration.ts | 25 +++++ mateclaw-ui/src/views/ChatConsole.vue | 92 ++++++++++++------- .../chatConsoleRouteHydration.test.ts | 42 +++++++++ 3 files changed, 128 insertions(+), 31 deletions(-) diff --git a/mateclaw-ui/src/utils/chatRouteHydration.ts b/mateclaw-ui/src/utils/chatRouteHydration.ts index 748218cf..3a3dd5f4 100644 --- a/mateclaw-ui/src/utils/chatRouteHydration.ts +++ b/mateclaw-ui/src/utils/chatRouteHydration.ts @@ -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, + } +} diff --git a/mateclaw-ui/src/views/ChatConsole.vue b/mateclaw-ui/src/views/ChatConsole.vue index be4357e5..24419820 100644 --- a/mateclaw-ui/src/views/ChatConsole.vue +++ b/mateclaw-ui/src/views/ChatConsole.vue @@ -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 guard:await 返回后确认仍是当前会话 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 = '') { // 用户之后回到 A:pollActivity / 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 guard:await 返回后确认仍是当前会话,否则丢弃 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 approvals:恢复刷新后丢失的审批卡片(RFC-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) { diff --git a/mateclaw-ui/src/views/__tests__/chatConsoleRouteHydration.test.ts b/mateclaw-ui/src/views/__tests__/chatConsoleRouteHydration.test.ts index b5f2f080..96bdcbe8 100644 --- a/mateclaw-ui/src/views/__tests__/chatConsoleRouteHydration.test.ts +++ b/mateclaw-ui/src/views/__tests__/chatConsoleRouteHydration.test.ts @@ -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, + }) + }) +})