From db8c3dcc641fe77b7b13c4ad6ea339298b7b2f3d Mon Sep 17 00:00:00 2001 From: WeiCHEN Date: Tue, 22 Sep 2026 22:59:17 +0800 Subject: [PATCH] fix(web): settle orphan inflight bubbles via shared cancel helper Wire cancelInflightAssistantMessages on stop so composer unlock and message status stay consistent across sessions. --- web/src/App.tsx | 12 ++----- .../lib/squadCompletionObservation.test.ts | 27 +++++++++++++++- web/src/lib/squadCompletionObservation.ts | 32 +++++++++++++++++++ 3 files changed, 60 insertions(+), 11 deletions(-) diff --git a/web/src/App.tsx b/web/src/App.tsx index 00a3b3dd..bc69aec5 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -12,7 +12,6 @@ import { import { mapApiMembersToProjectMembers } from './lib/projectMemberAdapter'; import { resolveCurrentDocRole } from './lib/currentDocRole'; import { - COMPOSER_INFLIGHT_STATUSES, resolveChatComposerStreaming, } from './lib/chatComposerStreaming'; import { @@ -1215,7 +1214,7 @@ export default function App() { applyControlledInvalidationPlan, }); - // 用户主动停止:仅中止当前 session 的活跃 turn,并结算仍挂在 inflight 的孤儿气泡。 + // 用户主动停止:中止当前 session 活跃 turn,并结算仍挂在 inflight 的孤儿气泡。 const handleStopStreaming = () => { turnRuntime.abortActiveSessionTurns(); taskRunStream.abortSession(activeSessionId); @@ -1224,14 +1223,7 @@ export default function App() { void cancelTaskRun(runId).catch(() => undefined); taskRunStream.setRunId(activeSessionId, null); } - const inflight = COMPOSER_INFLIGHT_STATUSES as readonly string[]; - setMessages((prev) => - prev.map((m) => - m.role === 'assistant' && m.status && inflight.includes(m.status) - ? { ...m, status: 'cancelled' as const } - : m, - ), - ); + setMessages((prev) => cancelInflightAssistantMessages(prev, activeSessionId)); }; const handleLogout = () => { diff --git a/web/src/lib/squadCompletionObservation.test.ts b/web/src/lib/squadCompletionObservation.test.ts index 0b229a71..40880424 100644 --- a/web/src/lib/squadCompletionObservation.test.ts +++ b/web/src/lib/squadCompletionObservation.test.ts @@ -1,6 +1,9 @@ import { describe, it } from 'node:test'; import assert from 'node:assert/strict'; -import { reduceSquadObservation } from './squadCompletionObservation'; +import { + cancelInflightAssistantMessages, + reduceSquadObservation, +} from './squadCompletionObservation'; const turn = { userPromptMatched: true, @@ -59,4 +62,26 @@ describe('squadCompletionObservation', () => { assert.equal(verdict.action, 'wait'); assert.equal(verdict.sawInflight, true); }); + + it('cancelInflightAssistantMessages settles only this-session assistant inflight', () => { + const next = cancelInflightAssistantMessages( + [ + { + sessionId: 's1', + role: 'assistant', + status: 'streaming_status', + livePhaseLabel: '任务执行中…', + }, + { sessionId: 's1', role: 'user', status: 'completed' }, + { sessionId: 's2', role: 'assistant', status: 'streaming_status' }, + { sessionId: 's1', role: 'assistant', status: 'completed' }, + ], + 's1', + ); + assert.equal(next[0].status, 'cancelled'); + assert.equal(next[0].livePhaseLabel, undefined); + assert.equal(next[1].status, 'completed'); + assert.equal(next[2].status, 'streaming_status'); + assert.equal(next[3].status, 'completed'); + }); }); diff --git a/web/src/lib/squadCompletionObservation.ts b/web/src/lib/squadCompletionObservation.ts index 3c8c1a06..23d84153 100644 --- a/web/src/lib/squadCompletionObservation.ts +++ b/web/src/lib/squadCompletionObservation.ts @@ -30,6 +30,38 @@ export function isInflightChatStatus(status: string): boolean { return (INFLIGHT_CHAT_STATUSES as readonly string[]).includes(status); } +type InflightCancelableMessage = { + sessionId?: string; + role: string; + status: string; + livePhaseLabel?: string; +}; + +/** + * Stop/abort alone can leave assistant bubbles in pending/streaming_status, + * which keeps InputArea locked on "停止当前生成". Mark those as cancelled so + * the composer returns to send-ready without page.evaluate. + */ +export function cancelInflightAssistantMessages( + messages: T[], + sessionId: string, +): T[] { + return messages.map((message) => { + if ( + message.sessionId === sessionId && + message.role === 'assistant' && + isInflightChatStatus(message.status) + ) { + return { + ...message, + status: 'cancelled', + livePhaseLabel: undefined, + }; + } + return message; + }); +} + export function isSuccessTerminalChatStatus(status: string): boolean { return (SUCCESS_TERMINAL_CHAT_STATUSES as readonly string[]).includes(status); }