fix(web): settle orphan inflight bubbles via shared cancel helper

Wire cancelInflightAssistantMessages on stop so composer unlock and
message status stay consistent across sessions.
This commit is contained in:
WeiCHEN
2026-09-22 22:59:17 +08:00
parent c3639eba7d
commit db8c3dcc64
3 changed files with 60 additions and 11 deletions
+2 -10
View File
@@ -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 = () => {
+26 -1
View File
@@ -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');
});
});
+32
View File
@@ -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<T extends InflightCancelableMessage>(
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);
}