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:
@@ -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
@@ -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 = () => {
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user