diff --git a/web/e2e/document-revision-runtime.spec.ts b/web/e2e/document-revision-runtime.spec.ts index 05789337..aa4b85a9 100644 --- a/web/e2e/document-revision-runtime.spec.ts +++ b/web/e2e/document-revision-runtime.spec.ts @@ -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 { - 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) { diff --git a/web/src/App.tsx b/web/src/App.tsx index 534fb5bc..00a3b3dd 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -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 = () => { diff --git a/web/src/lib/chatComposerStreaming.test.ts b/web/src/lib/chatComposerStreaming.test.ts new file mode 100644 index 00000000..e97faa53 --- /dev/null +++ b/web/src/lib/chatComposerStreaming.test.ts @@ -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, + ); + }); +}); diff --git a/web/src/lib/chatComposerStreaming.ts b/web/src/lib/chatComposerStreaming.ts new file mode 100644 index 00000000..f26dc1be --- /dev/null +++ b/web/src/lib/chatComposerStreaming.ts @@ -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; +}