fix(web): unlock chat send when orphan streaming_status remains

Composer stop/send was gated only on message status; after squad
terminal, stale streaming bubbles kept 停止当前生成. Gate on live
turn/task-run runtime and settle orphans on explicit stop.
This commit is contained in:
WeiCHEN
2026-09-22 22:58:39 +08:00
parent 5c222d18e6
commit c3639eba7d
4 changed files with 141 additions and 39 deletions
+20 -30
View File
@@ -392,20 +392,20 @@ async function waitForSquadCompletion(
if (verdict.action === 'reject') {
fail(`squad observation rejected: ${verdict.reason}`);
}
if (verdict.action === 'accept') {
return Date.now() - startedAt;
}
await expect(assistant).toHaveAttribute('data-chat-status', ANY_TERMINAL_STATUS_RE, {
timeout: timeoutMs,
});
verdict = await observe();
if (verdict.action === 'reject') {
fail(`squad observation rejected: ${verdict.reason}`);
}
if (verdict.action !== 'accept') {
await expect(assistant).toHaveAttribute('data-chat-status', ANY_TERMINAL_STATUS_RE, {
timeout: timeoutMs,
});
verdict = await observe();
if (verdict.action === 'reject') {
fail(`squad observation rejected: ${verdict.reason}`);
}
if (verdict.action !== 'accept') {
fail(`squad terminal not accepted: ${verdict.reason}`);
}
fail(`squad terminal not accepted: ${verdict.reason}`);
}
// Terminal assistant status can race ahead of InputArea isStreaming; drain stop/send.
await ensureChatComposerIdle(page, Math.min(60_000, Math.max(5_000, timeoutMs)));
return Date.now() - startedAt;
}
@@ -521,25 +521,15 @@ async function readReviewerIntakeDuty(page: Page, docId?: string): Promise<strin
}
async function ensureChatComposerIdle(page: Page, timeoutMs = 60_000) {
// Squad terminal can land while InputArea isStreaming is still true; the
// composer then swaps "发送 (Enter)" for "停止当前生成" and blocks the next turn.
// Product fix: orphan streaming_status no longer locks send once runtime is idle.
// Still click stop if present, then wait for the send affordance.
const stopBtn = page.getByRole('button', { name: '停止当前生成' });
await expect
.poll(
async () => {
if (await stopBtn.isVisible().catch(() => false)) {
await stopBtn.click();
}
const stopLeft = await stopBtn.count();
const streamingHint = await page
.getByText('生成中,点击停止按钮可中止当前回复')
.count();
const sendReady = await page.getByRole('button', { name: /发送 \(Enter\)/ }).count();
return stopLeft === 0 && streamingHint === 0 && sendReady > 0;
},
{ timeout: timeoutMs, intervals: [200, 500, 1000] },
)
.toBe(true);
if (await stopBtn.isVisible().catch(() => false)) {
await stopBtn.click();
}
await expect(page.getByRole('button', { name: /发送 \(Enter\)/ })).toBeVisible({
timeout: timeoutMs,
});
}
async function sendChat(page: Page, text: string) {
+29 -9
View File
@@ -11,6 +11,10 @@ import {
} from './lib/createProjectSubmitGuard';
import { mapApiMembersToProjectMembers } from './lib/projectMemberAdapter';
import { resolveCurrentDocRole } from './lib/currentDocRole';
import {
COMPOSER_INFLIGHT_STATUSES,
resolveChatComposerStreaming,
} from './lib/chatComposerStreaming';
import {
decideModificationProposal,
} from './api/docgov';
@@ -113,6 +117,7 @@ import { fetchChatCategories, fetchChatDates } from './api/chatWindow';
import { ChatCategoryStat, ChatDateStat, QuotedMessage } from './types/workbench';
import { saveFile } from './data/fileStore';
import { cancelTaskRun, getTaskRun } from './api/taskRuns';
import { cancelInflightAssistantMessages } from './lib/squadCompletionObservation';
import { useRecoveredTaskPhases } from './hooks/useRecoveredTaskPhases';
export {
@@ -1150,14 +1155,21 @@ export default function App() {
setActiveQuotes((prev) => prev.filter((_, i) => i !== index));
};
// 当前会话是否存在正在等待/流式/自动重试中的助手气泡 (驱动 InputArea 停止态)
const isStreamingActive = useMemo(() => {
return currentSessionMessages.some(
(m) =>
m.role === 'assistant' &&
(m.status === 'pending' || m.status === 'streaming_status' || m.status === 'auto_retrying')
);
}, [currentSessionMessages]);
// 当前会话是否存在正在等待/流式/自动重试中的助手气泡 (驱动 InputArea 停止态)。
// 孤儿 streaming_status(任务已终态但气泡未愈合)不得继续锁住发送。
const isStreamingActive = useMemo(
() =>
resolveChatComposerStreaming(currentSessionMessages, {
turnLive: turnRuntime.isThinking,
taskRunLive: taskRunStream.isThinking || Boolean(taskRunStream.activeTaskRunId),
}),
[
currentSessionMessages,
turnRuntime.isThinking,
taskRunStream.isThinking,
taskRunStream.activeTaskRunId,
],
);
// 处理交互卡片动作流转 (驳回门禁确认、撤回、取消与终审联动)
const handleCardAction = useCardActions({
@@ -1203,7 +1215,7 @@ export default function App() {
applyControlledInvalidationPlan,
});
// 用户主动停止:仅中止当前 session 的活跃 turn
// 用户主动停止:仅中止当前 session 的活跃 turn,并结算仍挂在 inflight 的孤儿气泡。
const handleStopStreaming = () => {
turnRuntime.abortActiveSessionTurns();
taskRunStream.abortSession(activeSessionId);
@@ -1212,6 +1224,14 @@ 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,
),
);
};
const handleLogout = () => {
+52
View File
@@ -0,0 +1,52 @@
import assert from 'node:assert/strict';
import { describe, it } from 'node:test';
import { resolveChatComposerStreaming } from './chatComposerStreaming';
describe('resolveChatComposerStreaming', () => {
it('is false when no inflight assistant bubbles', () => {
assert.equal(
resolveChatComposerStreaming(
[
{ role: 'user', status: 'completed' },
{ role: 'assistant', status: 'completed' },
],
{ turnLive: true, taskRunLive: true },
),
false,
);
});
it('is true when inflight bubble and live turn', () => {
assert.equal(
resolveChatComposerStreaming(
[{ role: 'assistant', status: 'streaming_status' }],
{ turnLive: true, taskRunLive: false },
),
true,
);
});
it('is true when inflight bubble and live task run', () => {
assert.equal(
resolveChatComposerStreaming(
[{ role: 'assistant', status: 'pending' }],
{ turnLive: false, taskRunLive: true },
),
true,
);
});
it('is false for orphan streaming_status after runtime cleared', () => {
assert.equal(
resolveChatComposerStreaming(
[
{ role: 'assistant', status: 'streaming_status' },
{ role: 'user', status: 'completed' },
{ role: 'assistant', status: 'completed' },
],
{ turnLive: false, taskRunLive: false },
),
false,
);
});
});
+40
View File
@@ -0,0 +1,40 @@
/** Message statuses that historically locked the chat composer send button. */
export const COMPOSER_INFLIGHT_STATUSES = [
'pending',
'streaming_status',
'auto_retrying',
] as const;
export type ComposerInflightStatus = (typeof COMPOSER_INFLIGHT_STATUSES)[number];
export type ComposerStreamingMessage = {
role?: string;
status?: string;
};
export type ComposerStreamingRuntime = {
/** True when a turn AbortController / thinking flag is live for this session. */
turnLive: boolean;
/** True when a task-run stream is live (thinking or retained run id). */
taskRunLive: boolean;
};
/**
* Whether InputArea should show stop / block send.
*
* Orphan assistant bubbles can stay in streaming_status after the squad run
* already finished (live runtime cleared). Those must not keep locking send.
*/
export function resolveChatComposerStreaming(
messages: readonly ComposerStreamingMessage[],
runtime: ComposerStreamingRuntime,
): boolean {
const hasInflightBubble = messages.some(
(m) =>
m.role === 'assistant' &&
typeof m.status === 'string' &&
(COMPOSER_INFLIGHT_STATUSES as readonly string[]).includes(m.status),
);
if (!hasInflightBubble) return false;
return runtime.turnLive || runtime.taskRunLive;
}