diff --git a/packages/web/src/components/ChatContainer.tsx b/packages/web/src/components/ChatContainer.tsx index 7af62b693d..f675eaf8c1 100644 --- a/packages/web/src/components/ChatContainer.tsx +++ b/packages/web/src/components/ChatContainer.tsx @@ -812,7 +812,7 @@ export function ChatContainer({ threadId }: ChatContainerProps) { const handleStop = useCallback( (overrideThreadId?: unknown) => { const targetThreadId = typeof overrideThreadId === 'string' ? overrideThreadId : threadId; - stopHandler(cancelInvocation, targetThreadId); + void stopHandler(cancelInvocation, targetThreadId); }, [stopHandler, cancelInvocation, threadId], ); diff --git a/packages/web/src/components/ChatInput.tsx b/packages/web/src/components/ChatInput.tsx index f649a9a183..483455e548 100644 --- a/packages/web/src/components/ChatInput.tsx +++ b/packages/web/src/components/ChatInput.tsx @@ -735,7 +735,7 @@ export function ChatInput({ onClick={onStop} className="text-xs text-cafe-muted hover:text-cafe-primary transition-colors px-2 py-0.5 rounded-md hover:bg-cafe-surface-elevated flex-shrink-0" > - 取消 + 停止 )} diff --git a/packages/web/src/components/ChatInputActionButton.tsx b/packages/web/src/components/ChatInputActionButton.tsx index 9f9d5ff9f8..65434323d6 100644 --- a/packages/web/src/components/ChatInputActionButton.tsx +++ b/packages/web/src/components/ChatInputActionButton.tsx @@ -1,12 +1,13 @@ 'use client'; -import { useEffect } from 'react'; +import { useEffect, useState } from 'react'; import { useVoiceInput } from '@/hooks/useVoiceInput'; import { ExpandableProse } from './content-overflow'; import { LoadingIcon } from './icons/LoadingIcon'; import { MicIcon } from './icons/MicIcon'; import { SendIcon } from './icons/SendIcon'; import { StopRecordingIcon } from './icons/StopRecordingIcon'; +import { SteerQueuedEntryModal } from './SteerQueuedEntryModal'; interface ChatInputActionButtonProps { onTranscript: (text: string) => void; @@ -34,7 +35,7 @@ function QueueSendIcon({ className }: { className?: string }) { } /** Renders the action button states: - * 1. Stop generation (disabled + active invocation) + * 1. Stop conversation (disabled + active invocation) * 2. Stop recording * 3. Transcribing * 4. Queue send (F39: active invocation + has text) @@ -55,6 +56,7 @@ export function ChatInputActionButton({ hasText, }: ChatInputActionButtonProps) { const voice = useVoiceInput(); + const [confirmSteer, setConfirmSteer] = useState(false); const isSendDisabled = Boolean(disabled || sendDisabled); useEffect(() => { @@ -110,8 +112,8 @@ export function ChatInputActionButton({ diff --git a/packages/web/src/components/SteerQueuedEntryModal.tsx b/packages/web/src/components/SteerQueuedEntryModal.tsx index e8714123a6..89c769fc4c 100644 --- a/packages/web/src/components/SteerQueuedEntryModal.tsx +++ b/packages/web/src/components/SteerQueuedEntryModal.tsx @@ -2,8 +2,16 @@ import { useEffect, useRef } from 'react'; -export function SteerQueuedEntryModal({ onCancel, onConfirm }: { onCancel: () => void; onConfirm: () => void }) { +interface SteerQueuedEntryModalProps { + onCancel: () => void; + onConfirm: () => void; + /** A draft has not entered Queue yet; a queued entry already has a durable receipt. */ + source?: 'draft' | 'queued'; +} + +export function SteerQueuedEntryModal({ onCancel, onConfirm, source = 'queued' }: SteerQueuedEntryModalProps) { const modalRef = useRef(null); + const isDraft = source === 'draft'; useEffect(() => { const handler = (e: KeyboardEvent) => { @@ -24,16 +32,21 @@ export function SteerQueuedEntryModal({ onCancel, onConfirm }: { onCancel: () => >
-

Steer 这条排队消息

-

取消目标猫当前回合,并以这条消息立即重新启动。

+

Steer(强制停止并发送此消息)

+

+ {isDraft + ? '会停止目标当前回复,然后立即发送当前输入的消息。' + : '会停止目标当前回复,然后立即发送这条排队消息。'} +

-
⚠️ 取消当前回合并重新启动
+
⚠️ 会停止当前回复后发送此消息
- 旧 invocation 会被取消;系统只以这条已持久化消息启动一次。取消前已经完成的回复仍会发表,不会被这次 Steer - 吞掉。 + {isDraft + ? '这不是“追加到当前回复”;当前回复会被停止。已经完成的回复仍会保留在聊天记录中。' + : '旧回复会被停止;系统只以这条已持久化消息启动一次。已经完成的回复仍会保留在聊天记录中。'}
@@ -52,7 +65,7 @@ export function SteerQueuedEntryModal({ onCancel, onConfirm }: { onCancel: () => onClick={onConfirm} className="text-sm px-4 py-2 rounded-full bg-[var(--color-cocreator-primary)] text-[var(--cafe-surface)] hover:opacity-90 transition-colors" > - 确认 + 停止并发送
diff --git a/packages/web/src/components/ThinkingIndicator.tsx b/packages/web/src/components/ThinkingIndicator.tsx index d61fa51bc5..47c75ac43a 100644 --- a/packages/web/src/components/ThinkingIndicator.tsx +++ b/packages/web/src/components/ThinkingIndicator.tsx @@ -82,7 +82,7 @@ function SquareIcon({ className }: { className?: string }) { } interface ThinkingIndicatorProps { - onCancel?: (threadId: string, catId?: string) => void; + onCancel?: (threadId: string) => void; threadId?: string; } @@ -98,8 +98,8 @@ export function ThinkingIndicator({ onCancel, threadId }: ThinkingIndicatorProps useThreadLiveness(effectiveThreadId); const { getCatById } = useCatData(); - // Derive display+cancel target from the same truth source (activeInvocations) - // to avoid "显示 A、取消 B" when targetCats is stale. + // Derive the displayed cat from the active invocation rather than stale target + // selection. A user-facing stop always applies to the entire conversation. const slots = Object.values(activeInvocations ?? {}); const catId = slots.length === 1 ? slots[0]?.catId : targetCats.length === 1 ? targetCats[0] : undefined; if (!catId) return null; @@ -163,12 +163,12 @@ export function ThinkingIndicator({ onCancel, threadId }: ThinkingIndicatorProps )} @@ -209,12 +209,12 @@ export function ThinkingIndicator({ onCancel, threadId }: ThinkingIndicatorProps )} diff --git a/packages/web/src/components/ThreadExecutionBar.tsx b/packages/web/src/components/ThreadExecutionBar.tsx index 9ebba41541..8458d71350 100644 --- a/packages/web/src/components/ThreadExecutionBar.tsx +++ b/packages/web/src/components/ThreadExecutionBar.tsx @@ -37,7 +37,7 @@ interface ThreadExecutionBarProps { threadId?: string; } -/** F122B AC-B8+B9: Per-cat execution status bar with stop controls. +/** F122B AC-B8: Per-cat execution status bar. * B8/B9 polish: cat names use formatCatName() — "品种(variant)" format, colors from cat-config. */ export function ThreadExecutionBar({ threadId }: ThreadExecutionBarProps) { const currentThreadId = useChatStore((s) => s.currentThreadId); @@ -90,33 +90,20 @@ export function ThreadExecutionBar({ threadId }: ThreadExecutionBarProps) { return () => clearInterval(interval); }, [activeCats.length]); - const handleStopCat = useCallback( - async (catId: string) => { - if (!effectiveThreadId) return; - await apiFetch(`/api/threads/${effectiveThreadId}/cancel/${catId}`, { method: 'POST' }); - }, - [effectiveThreadId], - ); - - const handleStopAll = useCallback(async () => { - if (!effectiveThreadId) return; - await Promise.all(activeCats.map(({ catId }) => handleStopCat(catId))); - }, [effectiveThreadId, activeCats, handleStopCat]); - - // F220 Phase 3: 升级态判定 — 任一活跃猫疑似卡死(liveness warning)→ 入口上浮变醒目。 + // A stalled turn makes Stop more visually urgent, but does not change its + // scope: it always stops the complete thread run. const stalled = activeCats.some(({ catId, lifecycle }) => { return isStreamingTipSuppressed(catStatuses[catId], lifecycle); }); - // F220 Phase 3: 确认后调 force-reset 端点(只清运行态,LL-048 不碰持久化)→ toast → 关弹窗。 - const handleForceReset = useCallback(async () => { + const handleStopThread = useCallback(async () => { if (!effectiveThreadId) return; setResetting(true); try { await apiFetch(`/api/threads/${effectiveThreadId}/force-reset`, { method: 'POST' }); useToastStore.getState().addToast({ type: 'success', - title: '已重置', - message: '对话已解放,可以发新消息了', + title: '已停止', + message: '对话中的运行已停止,可以继续发送新消息了', duration: 4000, }); setResetDialogOpen(false); @@ -136,31 +123,20 @@ export function ThreadExecutionBar({ threadId }: ThreadExecutionBarProps) { return ( ); })} - {activeCats.length > 1 && ( - - )} - setResetDialogOpen(true)} /> + setResetDialogOpen(true)} /> setResetDialogOpen(false)} - onConfirm={handleForceReset} + onConfirm={handleStopThread} /> ); @@ -185,15 +161,13 @@ function TriangleAlertIcon({ className }: { className?: string }) { ); } -/** F220 Phase 3: force-reset 入口。默认低调(dashed-top + 灰 + 小,藏面板底); - * escalated(疑似卡死)时上浮变醒目(critical-surface 底 + 警告色)。 */ -function ForceResetEntry({ escalated, onClick }: { escalated: boolean; onClick: () => void }) { +function StopConversationEntry({ escalated, onClick }: { escalated: boolean; onClick: () => void }) { if (escalated) { return (
); @@ -213,13 +187,13 @@ function ForceResetEntry({ escalated, onClick }: { escalated: boolean; onClick:
); @@ -240,19 +214,15 @@ function getStartedAt( } function CatStatusChip({ - catId, label, color, startedAt, lifecycle, - onStop, }: { - catId: string; label: string; color: string; startedAt: number; lifecycle?: AppServerLifecycleSnapshot; - onStop: (catId: string) => void; }) { const elapsed = Math.floor((Date.now() - startedAt) / 1000); const minutes = Math.floor(elapsed / 60); @@ -274,20 +244,6 @@ function CatStatusChip({ )} {timeStr} - ); } diff --git a/packages/web/src/components/__tests__/ForceResetDialog.test.tsx b/packages/web/src/components/__tests__/ForceResetDialog.test.tsx index b7d32ee400..166694ea95 100644 --- a/packages/web/src/components/__tests__/ForceResetDialog.test.tsx +++ b/packages/web/src/components/__tests__/ForceResetDialog.test.tsx @@ -38,14 +38,14 @@ describe('ForceResetDialog', () => { expect(container.textContent).toBe(''); }); - it('renders title + three explanation rows (做什么/保留什么/何时用) when open', () => { + it('renders title + three explanation rows (做什么/保留什么/会发生什么) when open', () => { act(() => { root.render(React.createElement(ForceResetDialog, { open: true, onCancel: () => {}, onConfirm: () => {} })); }); - expect(container.textContent).toContain('强制重置这个对话'); + expect(container.textContent).toContain('停止这个对话'); expect(container.textContent).toContain('会做什么'); expect(container.textContent).toContain('会保留什么'); - expect(container.textContent).toContain('何时用'); + expect(container.textContent).toContain('会发生什么'); }); it('calls onCancel and onConfirm when respective buttons are clicked', async () => { @@ -56,7 +56,7 @@ describe('ForceResetDialog', () => { }); const buttons = Array.from(container.querySelectorAll('button')); const cancelBtn = buttons.find((b) => b.textContent?.includes('取消')) ?? null; - const confirmBtn = buttons.find((b) => b.textContent?.includes('强制重置')) ?? null; + const confirmBtn = buttons.find((b) => b.textContent?.trim() === '停止') ?? null; expect(cancelBtn).not.toBeNull(); expect(confirmBtn).not.toBeNull(); @@ -77,9 +77,9 @@ describe('ForceResetDialog', () => { React.createElement(ForceResetDialog, { open: true, busy: true, onCancel: () => {}, onConfirm: () => {} }), ); }); - const confirmBtn = Array.from(container.querySelectorAll('button')).find((b) => - b.textContent?.includes('强制重置'), - ) as HTMLButtonElement | undefined; + const confirmBtn = Array.from(container.querySelectorAll('button')).find((b) => b.textContent?.trim() === '停止') as + | HTMLButtonElement + | undefined; expect(confirmBtn?.disabled).toBe(true); }); }); diff --git a/packages/web/src/components/__tests__/ThinkingIndicator-liveness.test.ts b/packages/web/src/components/__tests__/ThinkingIndicator-liveness.test.ts index 8c91c8b70d..dcb304fde8 100644 --- a/packages/web/src/components/__tests__/ThinkingIndicator-liveness.test.ts +++ b/packages/web/src/components/__tests__/ThinkingIndicator-liveness.test.ts @@ -152,7 +152,7 @@ describe('F118 ThinkingIndicator liveness states', () => { expect(container.textContent).toContain('客户端初始化'); }); - it('cancel button calls onCancel with threadId', async () => { + it('stop button calls onCancel with the whole conversation threadId', async () => { storeState.catStatuses = { codex: 'suspected_stall' }; storeState.catInvocations = { codex: { @@ -180,7 +180,7 @@ describe('F118 ThinkingIndicator liveness states', () => { cancelBtn.click(); }); - expect(mockCancelInvocation).toHaveBeenCalledWith('thread-1', 'codex'); + expect(mockCancelInvocation).toHaveBeenCalledWith('thread-1'); }); it('renders from a single active slot even when targetCats is stale or empty', async () => { @@ -199,7 +199,7 @@ describe('F118 ThinkingIndicator liveness states', () => { expect(container.textContent).toContain('回复中'); }); - it('uses single active slot as cancel target when targetCats contains multiple stale cats', async () => { + it('uses the requested thread when targetCats contains multiple stale cats', async () => { storeState.targetCats = ['codex', 'opus']; storeState.activeInvocations = { 'inv-codex': { catId: 'codex', mode: 'execute' }, @@ -231,7 +231,7 @@ describe('F118 ThinkingIndicator liveness states', () => { cancelBtn.click(); }); - expect(mockCancelInvocation).toHaveBeenCalledWith('thread-1', 'codex'); + expect(mockCancelInvocation).toHaveBeenCalledWith('thread-1'); }); it('normal thinking state renders paw emoji (KD-9: Apple emoji preferred over Lucide SVG)', async () => { diff --git a/packages/web/src/components/__tests__/chat-input-mobile.test.ts b/packages/web/src/components/__tests__/chat-input-mobile.test.ts index 4ef61cc58c..05bf811d89 100644 --- a/packages/web/src/components/__tests__/chat-input-mobile.test.ts +++ b/packages/web/src/components/__tests__/chat-input-mobile.test.ts @@ -148,7 +148,7 @@ describe('ChatInput composer layout', () => { it('keeps the active invocation stop affordance visible while preserving hover and keyboard focus styling', () => { render({ hasActiveInvocation: true, onStop: vi.fn() }); - const stopButton = container.querySelector('button[aria-label="Stop generation"]'); + const stopButton = container.querySelector('button[aria-label="停止对话"]'); expect(stopButton?.className).not.toContain('opacity-0'); expect(stopButton?.className).toContain('bg-conn-red-text'); expect(stopButton?.className).toContain('hover:bg-conn-red-hover'); diff --git a/packages/web/src/components/__tests__/invocation-stall-cancel-invariant.test.ts b/packages/web/src/components/__tests__/invocation-stall-cancel-invariant.test.ts index 143dc39a5e..6f2a9ca61c 100644 --- a/packages/web/src/components/__tests__/invocation-stall-cancel-invariant.test.ts +++ b/packages/web/src/components/__tests__/invocation-stall-cancel-invariant.test.ts @@ -107,13 +107,13 @@ describe('Invocation stall cancel invariant', () => { // Invariant: alive_but_silent MUST have a cancel button const cancelBtn = container.querySelector('[data-testid="cancel-btn"]'); expect(cancelBtn).toBeTruthy(); - expect(cancelBtn?.textContent).toContain('取消'); + expect(cancelBtn?.textContent).toContain('停止'); }); // ───────────────────────────────────────────────────────────────────────── - // RED TEST 2: alive_but_silent cancel fires onCancel with correct args + // RED TEST 2: alive_but_silent stop ends the whole conversation // ───────────────────────────────────────────────────────────────────────── - it('alive_but_silent cancel button calls onCancel with threadId and catId', async () => { + it('alive_but_silent stop button calls onCancel with the threadId only', async () => { storeState.catStatuses = { codex: 'alive_but_silent' }; storeState.catInvocations = { codex: { @@ -144,7 +144,7 @@ describe('Invocation stall cancel invariant', () => { cancelBtn.click(); }); - expect(mockCancelInvocation).toHaveBeenCalledWith('thread-1', 'codex'); + expect(mockCancelInvocation).toHaveBeenCalledWith('thread-1'); }); }); @@ -179,7 +179,7 @@ describe('ChatInput active invocation banner cancel invariant (structural)', () // INVARIANT: banner MUST contain a cancel button gated on onStop expect(bannerBlock).toContain('data-testid="banner-cancel-btn"'); expect(bannerBlock).toContain('onStop'); - expect(bannerBlock).toContain('取消'); + expect(bannerBlock).toContain('停止'); }); it('banner cancel button is gated on onStop (not always visible)', async () => { diff --git a/packages/web/src/components/__tests__/mid-invocation-inject.test.ts b/packages/web/src/components/__tests__/mid-invocation-inject.test.ts index dfcc9df0af..04a333c3e3 100644 --- a/packages/web/src/components/__tests__/mid-invocation-inject.test.ts +++ b/packages/web/src/components/__tests__/mid-invocation-inject.test.ts @@ -64,7 +64,7 @@ describe('F24: mid-invocation message injection', () => { ); }); - const stopBtn = container.querySelector('button[aria-label="Stop generation"]'); + const stopBtn = container.querySelector('button[aria-label="停止对话"]'); const micBtn = container.querySelector('button[aria-label*="voice input"]'); expect(stopBtn).not.toBeNull(); expect(micBtn).not.toBeNull(); @@ -87,7 +87,7 @@ describe('F24: mid-invocation message injection', () => { ); }); - const stopBtn = container.querySelector('button[aria-label="Stop generation"]'); + const stopBtn = container.querySelector('button[aria-label="停止对话"]'); const sendBtn = container.querySelector('button[aria-label="Send message"]'); expect(stopBtn).not.toBeNull(); expect(sendBtn).not.toBeNull(); @@ -133,7 +133,7 @@ describe('F24: mid-invocation message injection', () => { ); }); - const stopBtns = container.querySelectorAll('button[aria-label="Stop generation"]'); + const stopBtns = container.querySelectorAll('button[aria-label="停止对话"]'); const sendBtn = container.querySelector('button[aria-label="Send message"]'); // When disabled=true, only the primary (large) Stop button should exist expect(stopBtns.length).toBe(1); diff --git a/packages/web/src/components/__tests__/queue-panel-steer.test.ts b/packages/web/src/components/__tests__/queue-panel-steer.test.ts index 9e00c16063..caa4cd5642 100644 --- a/packages/web/src/components/__tests__/queue-panel-steer.test.ts +++ b/packages/web/src/components/__tests__/queue-panel-steer.test.ts @@ -145,9 +145,9 @@ describe('QueuePanel steer (F047)', () => { expect(steerBtn).not.toBeNull(); act(() => steerBtn?.click()); - expect(container.textContent).toContain('取消当前回合'); - expect(container.textContent).toContain('以这条消息立即重新启动'); - expect(container.textContent).toContain('取消前已经完成的回复仍会发表'); + expect(container.textContent).toContain('Steer(强制停止并发送此消息)'); + expect(container.textContent).toContain('会停止当前回复后发送此消息'); + expect(container.textContent).toContain('旧回复会被停止'); expect(container.textContent).not.toContain('提到队首'); }); diff --git a/packages/web/src/components/__tests__/stop-event-payload.test.ts b/packages/web/src/components/__tests__/stop-event-payload.test.ts index 96d2d97cac..ecaeac8359 100644 --- a/packages/web/src/components/__tests__/stop-event-payload.test.ts +++ b/packages/web/src/components/__tests__/stop-event-payload.test.ts @@ -68,7 +68,7 @@ describe('Stop event payload regression', () => { ); }); - const stopBtn = container.querySelector('button[aria-label="Stop generation"]'); + const stopBtn = container.querySelector('button[aria-label="停止对话"]'); expect(stopBtn).toBeTruthy(); act(() => { diff --git a/packages/web/src/components/__tests__/thread-execution-bar-force-reset.test.tsx b/packages/web/src/components/__tests__/thread-execution-bar-force-reset.test.tsx index 85cdb188af..9d7c964f23 100644 --- a/packages/web/src/components/__tests__/thread-execution-bar-force-reset.test.tsx +++ b/packages/web/src/components/__tests__/thread-execution-bar-force-reset.test.tsx @@ -54,7 +54,7 @@ function setSilentActive(catId: string) { }); } -describe('ThreadExecutionBar force-reset (F220 Phase 3)', () => { +describe('ThreadExecutionBar stop conversation (#1307)', () => { let container: HTMLDivElement; let root: Root; @@ -77,34 +77,34 @@ describe('ThreadExecutionBar force-reset (F220 Phase 3)', () => { vi.clearAllMocks(); }); - it('renders a force-reset entry when a cat is running (情境化, 非常驻)', () => { + it('renders a stop-conversation entry when a cat is running', () => { setActive('opus', 'streaming'); act(() => { root.render(React.createElement(ThreadExecutionBar, { threadId: 'thread-a' })); }); - const entry = container.querySelector('[data-testid="force-reset-entry"]'); + const entry = container.querySelector('[data-testid="thread-stop-entry"]'); expect(entry).not.toBeNull(); - expect(container.textContent).toContain('强制重置'); + expect(container.textContent).toContain('停止对话'); }); - it('clicking the entry opens the confirm dialog; confirming calls the force-reset endpoint + toast', async () => { + it('confirming stop ends the whole conversation through the durable reset endpoint + toast', async () => { setActive('opus', 'streaming'); act(() => { root.render(React.createElement(ThreadExecutionBar, { threadId: 'thread-a' })); }); - const entry = container.querySelector('[data-testid="force-reset-entry"]') as HTMLButtonElement; + const entry = container.querySelector('[data-testid="thread-stop-entry"]') as HTMLButtonElement; await act(async () => { entry.click(); }); // dialog 打开 - expect(container.textContent).toContain('强制重置这个对话'); + expect(container.textContent).toContain('停止这个对话'); expect(container.textContent).toContain('会保留什么'); - // 点弹窗里的"强制重置"确认按钮(精确文本,区别于入口的"卡住了?强制重置") - const confirmBtn = Array.from(container.querySelectorAll('button')).find( - (b) => b.textContent?.trim() === '强制重置', - ) as HTMLButtonElement | undefined; + // 点弹窗里的“停止”确认按钮(精确文本,区别于入口“停止对话”) + const confirmBtn = Array.from(container.querySelectorAll('button')).find((b) => b.textContent?.trim() === '停止') as + | HTMLButtonElement + | undefined; expect(confirmBtn).not.toBeUndefined(); await act(async () => { confirmBtn?.click(); @@ -119,7 +119,7 @@ describe('ThreadExecutionBar force-reset (F220 Phase 3)', () => { act(() => { root.render(React.createElement(ThreadExecutionBar, { threadId: 'thread-a' })); }); - const entry = container.querySelector('[data-testid="force-reset-entry"]'); + const entry = container.querySelector('[data-testid="thread-stop-entry"]'); expect(entry?.getAttribute('data-escalated')).toBe('true'); }); @@ -128,7 +128,7 @@ describe('ThreadExecutionBar force-reset (F220 Phase 3)', () => { act(() => { root.render(React.createElement(ThreadExecutionBar, { threadId: 'thread-a' })); }); - const entry = container.querySelector('[data-testid="force-reset-entry"]'); + const entry = container.querySelector('[data-testid="thread-stop-entry"]'); expect(entry?.getAttribute('data-escalated')).toBe('false'); }); @@ -137,7 +137,7 @@ describe('ThreadExecutionBar force-reset (F220 Phase 3)', () => { act(() => { root.render(React.createElement(ThreadExecutionBar, { threadId: 'thread-a' })); }); - const entry = container.querySelector('[data-testid="force-reset-entry"]'); + const entry = container.querySelector('[data-testid="thread-stop-entry"]'); expect(entry?.getAttribute('data-escalated')).toBe('true'); }); diff --git a/packages/web/src/components/__tests__/thread-execution-bar.test.ts b/packages/web/src/components/__tests__/thread-execution-bar.test.ts index 8fe2f8ebf9..0c823a0960 100644 --- a/packages/web/src/components/__tests__/thread-execution-bar.test.ts +++ b/packages/web/src/components/__tests__/thread-execution-bar.test.ts @@ -214,6 +214,7 @@ describe('ThreadExecutionBar (F122B AC-B8 + B8/B9 polish)', () => { expect(container.querySelector('[data-app-server-stalled="true"]')).not.toBeNull(); expect(container.textContent).toContain('可能在等待模型'); - expect(container.querySelector('[aria-label="Stop 缅因猫"]')).not.toBeNull(); + expect(container.querySelector('[aria-label="Stop 缅因猫"]')).toBeNull(); + expect(container.querySelector('[data-testid="thread-stop-entry"]')).not.toBeNull(); }); }); diff --git a/packages/web/src/components/__tests__/thread-liveness-chrome.test.tsx b/packages/web/src/components/__tests__/thread-liveness-chrome.test.tsx index 489109d8af..1a54388d63 100644 --- a/packages/web/src/components/__tests__/thread-liveness-chrome.test.tsx +++ b/packages/web/src/components/__tests__/thread-liveness-chrome.test.tsx @@ -101,7 +101,7 @@ describe('thread-scoped liveness chrome', () => { expect(container.textContent).toContain('布偶猫(Opus 4.7)'); }); - it('ThreadExecutionBar cancel uses the requested thread id', async () => { + it('ThreadExecutionBar stop uses the requested thread id', async () => { useChatStore.setState({ currentThreadId: 'thread-a', activeInvocations: {}, @@ -120,16 +120,22 @@ describe('thread-scoped liveness chrome', () => { root.render(React.createElement(ThreadExecutionBar, { threadId: 'thread-b' })); }); - const stopButton = container.querySelector( - 'button[aria-label="Stop 布偶猫(Opus 4.7)"]', - ) as HTMLButtonElement | null; + const stopButton = container.querySelector('[data-testid="thread-stop-entry"]') as HTMLButtonElement | null; expect(stopButton).not.toBeNull(); await act(async () => { stopButton?.click(); }); - expect(mocks.apiFetch).toHaveBeenCalledWith('/api/threads/thread-b/cancel/opus', { method: 'POST' }); + const confirmButton = Array.from(container.querySelectorAll('button')).find( + (button) => button.textContent?.trim() === '停止', + ) as HTMLButtonElement | undefined; + expect(confirmButton).not.toBeUndefined(); + await act(async () => { + confirmButton?.click(); + }); + + expect(mocks.apiFetch).toHaveBeenCalledWith('/api/threads/thread-b/force-reset', { method: 'POST' }); }); it('ThinkingIndicator shows the requested thread spawning cat during A2A handoff windows', () => { diff --git a/packages/web/src/components/concierge/ConciergePanel.tsx b/packages/web/src/components/concierge/ConciergePanel.tsx index b57c76d0c5..87f593a063 100644 --- a/packages/web/src/components/concierge/ConciergePanel.tsx +++ b/packages/web/src/components/concierge/ConciergePanel.tsx @@ -216,15 +216,15 @@ export function ConciergePanel() { [behaviorEnabled, setBehaviorEnabled], ); - // F229 UX: cancel/stop in-progress invocation via scoped per-cat cancel (F122B AC-B9). - // Uses /cancel/:catId (scoped to the duty cat) instead of /force-reset (whole-thread nuclear). - // dutyCatId comes from useConciergeQueue which polls activeInvocations during in_progress. + // #1307: every user-facing Stop ends the whole conversation. The concierge + // card can name the duty cat, but it must not secretly perform a narrower + // operation than the chat-level Stop control. const [cancelLoading, setCancelLoading] = useState(false); const handleCancel = useCallback(async () => { - if (!threadId || !queueStatus.dutyCatId || cancelLoading) return; + if (!threadId || cancelLoading) return; setCancelLoading(true); try { - const res = await apiFetch(`/api/threads/${threadId}/cancel/${queueStatus.dutyCatId}`, { + const res = await apiFetch(`/api/threads/${threadId}/force-reset`, { method: 'POST', }); if (res.ok) { @@ -235,7 +235,7 @@ export function ConciergePanel() { } finally { setCancelLoading(false); } - }, [threadId, queueStatus.dutyCatId, cancelLoading, setInvocationStatus]); + }, [threadId, cancelLoading, setInvocationStatus]); const handleSend = useCallback(async () => { const text = inputValue.trim(); @@ -549,8 +549,8 @@ export function ConciergePanel() { )} diff --git a/packages/web/src/components/concierge/__tests__/concierge-components.test.tsx b/packages/web/src/components/concierge/__tests__/concierge-components.test.tsx index 613c605b86..8c61ec72b2 100644 --- a/packages/web/src/components/concierge/__tests__/concierge-components.test.tsx +++ b/packages/web/src/components/concierge/__tests__/concierge-components.test.tsx @@ -1654,9 +1654,9 @@ describe('Block 9: Cancel button during processing', () => { await render(); await flushEffects(); - const cancelBtn = container.querySelector('button[aria-label="停止回复"]'); + const cancelBtn = container.querySelector('button[aria-label="停止对话"]'); expect(cancelBtn).not.toBeNull(); - expect(cancelBtn?.textContent).toBe('停止'); + expect(cancelBtn?.textContent).toBe('停止对话'); }); it('cancel button NOT present when idle', async () => { @@ -1682,10 +1682,10 @@ describe('Block 9: Cancel button during processing', () => { await render(); await flushEffects(); - expect(container.querySelector('button[aria-label="停止回复"]')).toBeNull(); + expect(container.querySelector('button[aria-label="停止对话"]')).toBeNull(); }); - it('cancel button calls per-cat cancel API (not force-reset) and transitions to idle', async () => { + it('stop button calls the whole-conversation stop API and transitions to idle', async () => { useConciergeStore.setState({ configLoaded: true, surfaceState: 'bubble', @@ -1695,7 +1695,7 @@ describe('Block 9: Cancel button during processing', () => { }); mockApiFetch.mockImplementation((url: string) => { - if (url.includes('/cancel/gemini25')) { + if (url.includes('/force-reset')) { return Promise.resolve({ ok: true, status: 200, @@ -1726,7 +1726,7 @@ describe('Block 9: Cancel button during processing', () => { await render(); await flushEffects(); - const cancelBtn = container.querySelector('button[aria-label="停止回复"]') as HTMLButtonElement; + const cancelBtn = container.querySelector('button[aria-label="停止对话"]') as HTMLButtonElement; expect(cancelBtn).not.toBeNull(); // Click cancel @@ -1736,18 +1736,12 @@ describe('Block 9: Cancel button during processing', () => { }); await flushEffects(); - // Verify per-cat cancel was called (NOT force-reset) - const cancelCalls = mockApiFetch.mock.calls.filter( - (args) => typeof args[0] === 'string' && args[0].includes('/cancel/gemini25'), - ); - expect(cancelCalls.length).toBe(1); - expect(cancelCalls[0][0]).toBe('/api/threads/thread-cancel-3/cancel/gemini25'); - - // Must NOT have called force-reset + // Stop is identical to the durable whole-conversation reset operation. const resetCalls = mockApiFetch.mock.calls.filter( (args) => typeof args[0] === 'string' && args[0].includes('/force-reset'), ); - expect(resetCalls.length).toBe(0); + expect(resetCalls.length).toBe(1); + expect(resetCalls[0][0]).toBe('/api/threads/thread-cancel-3/force-reset'); // Status should transition to idle expect(useConciergeStore.getState().invocationStatus).toBe('idle'); @@ -1777,6 +1771,6 @@ describe('Block 9: Cancel button during processing', () => { await flushEffects(); // Pending shows "发送中" but no cancel button (message hasn't been accepted yet) - expect(container.querySelector('button[aria-label="停止回复"]')).toBeNull(); + expect(container.querySelector('button[aria-label="停止对话"]')).toBeNull(); }); }); diff --git a/packages/web/src/hooks/__tests__/useAgentMessages-loading.test.ts b/packages/web/src/hooks/__tests__/useAgentMessages-loading.test.ts index cce152cadb..79e90a7c19 100644 --- a/packages/web/src/hooks/__tests__/useAgentMessages-loading.test.ts +++ b/packages/web/src/hooks/__tests__/useAgentMessages-loading.test.ts @@ -18,6 +18,7 @@ const mockClearCatStatuses = vi.fn(); const mockSetCatInvocation = vi.fn(); const mockSetMessageUsage = vi.fn(); const mockRequestStreamCatchUp = vi.fn(); +const mockGetStoreState = vi.fn(); const mockAddMessageToThread = vi.fn(); const mockClearThreadActiveInvocation = vi.fn(); @@ -88,7 +89,7 @@ const storeState = { let captured: ReturnType | undefined; vi.mock('@/stores/chatStore', () => { - const useChatStoreMock = Object.assign(() => storeState, { getState: () => storeState }); + const useChatStoreMock = Object.assign(() => storeState, { getState: () => mockGetStoreState() }); return { useChatStore: useChatStoreMock, }; @@ -131,6 +132,8 @@ describe('useAgentMessages loading lifecycle', () => { mockClearCatStatuses.mockClear(); mockSetCatInvocation.mockClear(); mockSetMessageUsage.mockClear(); + mockGetStoreState.mockReset(); + mockGetStoreState.mockReturnValue(storeState); mockAddMessageToThread.mockClear(); mockClearThreadActiveInvocation.mockClear(); @@ -337,7 +340,69 @@ describe('useAgentMessages loading lifecycle', () => { expect(mockSetStreaming).not.toHaveBeenCalled(); }); - it('stopping a background thread derives catId from the TARGET thread slots', () => { + it('keeps local running state when whole-thread Stop is rejected', async () => { + const cancelInvocation = vi.fn(async () => false); + mockGetThreadState.mockReturnValue({ + messages: [ + { + id: 'still-running', + type: 'assistant', + catId: 'opus', + content: 'running', + isStreaming: true, + timestamp: Date.now(), + }, + ], + }); + + act(() => { + root.render(React.createElement(Harness)); + }); + await act(async () => { + await captured?.handleStop(cancelInvocation, 'thread-1'); + }); + + expect(cancelInvocation).toHaveBeenCalledWith('thread-1', undefined); + expect(mockClearAllActiveInvocations).not.toHaveBeenCalled(); + expect(mockSetLoading).not.toHaveBeenCalledWith(false); + expect(mockSetStreaming).not.toHaveBeenCalledWith('still-running', false); + }); + + it('keeps a newly active thread running when an earlier Stop resolves after navigation', async () => { + let resolveCancel: ((accepted: boolean) => void) | undefined; + const cancelInvocation = vi.fn( + () => + new Promise((resolve) => { + resolveCancel = resolve; + }), + ); + mockGetThreadState.mockReturnValue({ messages: [] }); + + act(() => { + root.render(React.createElement(Harness)); + }); + + let stopping: ReturnType['handleStop']> | undefined; + const stoppingThreadSnapshot = { ...storeState, currentThreadId: 'thread-1' }; + const newlyActiveThreadSnapshot = { ...storeState, currentThreadId: 'thread-2' }; + mockGetStoreState.mockReset(); + mockGetStoreState.mockReturnValue(stoppingThreadSnapshot); + await act(async () => { + stopping = captured?.handleStop(cancelInvocation, 'thread-1'); + // The user navigates to another Thread while force-reset is in flight; + // Zustand publishes a fresh state snapshot for that new active Thread. + mockGetStoreState.mockReturnValue(newlyActiveThreadSnapshot); + resolveCancel?.(true); + await stopping; + }); + + expect(mockResetThreadInvocationState).toHaveBeenCalledWith('thread-1'); + expect(mockClearAllActiveInvocations).not.toHaveBeenCalled(); + expect(mockSetLoading).not.toHaveBeenCalledWith(false); + expect(mockClearCatStatuses).not.toHaveBeenCalled(); + }); + + it('stopping a background thread stops the full target-thread run rather than deriving one cat slot', () => { const cancelInvocation = vi.fn(); storeState.activeInvocations = { 'inv-active': { catId: 'codex', mode: 'execute' }, @@ -382,7 +447,7 @@ describe('useAgentMessages loading lifecycle', () => { captured?.handleStop(cancelInvocation, 'thread-2'); }); - expect(cancelInvocation).toHaveBeenCalledWith('thread-2', 'opus'); + expect(cancelInvocation).toHaveBeenCalledWith('thread-2', undefined); expect(mockResetThreadInvocationState).toHaveBeenCalledWith('thread-2'); }); diff --git a/packages/web/src/hooks/__tests__/useSocket-thread-guard.test.ts b/packages/web/src/hooks/__tests__/useSocket-thread-guard.test.ts index a988775b35..80271322a6 100644 --- a/packages/web/src/hooks/__tests__/useSocket-thread-guard.test.ts +++ b/packages/web/src/hooks/__tests__/useSocket-thread-guard.test.ts @@ -155,8 +155,17 @@ const GUIDE_FLOW: OrchestrationFlow = { /** * Minimal wrapper component to mount the useSocket hook with controlled threadId. */ -function HookWrapper({ callbacks, threadId }: { callbacks: SocketCallbacks; threadId: string }) { - useSocket(callbacks, threadId); +function HookWrapper({ + callbacks, + threadId, + onReady, +}: { + callbacks: SocketCallbacks; + threadId: string; + onReady?: (socket: ReturnType) => void; +}) { + const socket = useSocket(callbacks, threadId); + React.useEffect(() => onReady?.(socket), [onReady, socket]); return null; } @@ -286,6 +295,33 @@ describe('useSocket thread guard (P1 regression: cross-thread event leakage)', ( }); }); + it('reports an all-thread Stop request failure instead of leaving an unhandled rejection', async () => { + const callbacks: SocketCallbacks = { onMessage: vi.fn() }; + let cancelInvocation: ReturnType['cancelInvocation'] | undefined; + + await act(async () => { + root.render( + React.createElement(HookWrapper, { + callbacks, + threadId: 'thread-A', + onReady: (socket) => { + cancelInvocation = socket.cancelInvocation; + }, + }), + ); + }); + expect(cancelInvocation).toBeTypeOf('function'); + mockApiFetch.mockReset(); + mockApiFetch.mockRejectedValueOnce(new Error('network unavailable')); + + await act(async () => { + await cancelInvocation?.('thread-A'); + }); + + expect(mockApiFetch).toHaveBeenCalledWith('/api/threads/thread-A/force-reset', { method: 'POST' }); + expect(mockAddToast).toHaveBeenCalledWith(expect.objectContaining({ type: 'error', title: '停止失败' })); + }); + it('intent_mode from OTHER thread routes to background path, not callback', () => { const onIntentMode = vi.fn(); const callbacks: SocketCallbacks = { diff --git a/packages/web/src/hooks/useAgentMessages.ts b/packages/web/src/hooks/useAgentMessages.ts index 1ca080c126..6aa7e261c3 100644 --- a/packages/web/src/hooks/useAgentMessages.ts +++ b/packages/web/src/hooks/useAgentMessages.ts @@ -6389,48 +6389,58 @@ export function useAgentMessages() { ); const handleStop = useCallback( - (cancelFn: (threadId: string, catId?: string) => void, threadId: string) => { - const store = useChatStore.getState(); - // When exactly one cat is active, cancel only that cat to avoid - // thread-level cancelAll accidentally killing other cats. - const activeSlots = Object.values(store.getThreadState(threadId).activeInvocations ?? {}); - const singleCatId = activeSlots.length === 1 ? activeSlots[0]?.catId : undefined; - cancelFn(threadId, singleCatId); - clearPendingCallbacksForThread(threadId); - const isActiveThreadStop = threadId === store.currentThreadId; - - if (!isActiveThreadStop) { - clearDoneTimeout(threadId); - const threadState = store.getThreadState(threadId); - for (const message of threadState.messages) { - if (message.type === 'assistant' && message.isStreaming) { - store.setThreadMessageStreaming(threadId, message.id, false); + (cancelFn: (threadId: string, catId?: string) => boolean | void | Promise, threadId: string) => { + // #1307: a conversation-level Stop is always a full-thread stop. Per-cat + // cancellation remains an internal control-plane primitive and Steer uses + // its dedicated, explicitly destructive path. + const clearStoppedThread = () => { + // Force-reset is async. Resolve the current snapshot only after the request + // succeeds so a navigation during the request cannot clean a newly active thread. + const store = useChatStore.getState(); + clearPendingCallbacksForThread(threadId); + const isActiveThreadStop = threadId === store.currentThreadId; + + if (!isActiveThreadStop) { + clearDoneTimeout(threadId); + const threadState = store.getThreadState(threadId); + for (const message of threadState.messages) { + if (message.type === 'assistant' && message.isStreaming) { + store.setThreadMessageStreaming(threadId, message.id, false); + } } + store.resetThreadInvocationState(threadId); + // Codex review P2 — split-pane / background stop must also clear the stopped + // thread's suppression markers; otherwise switching back later sees stale + // replacement state and shouldSuppressLateStreamChunk drops legitimate text. + clearReplacedInvocationsForThread(threadId); + return; } - store.resetThreadInvocationState(threadId); - // Codex review P2 — split-pane / background stop must also clear the stopped - // thread's suppression markers; otherwise switching back later sees stale - // replacement state and shouldSuppressLateStreamChunk drops legitimate text. + + clearDoneTimeout(threadId); + setLoading(false); + // F108: stop clears all invocation slots (user cancel-all) + clearAllActiveInvocations(); + setIntentMode(null); + clearCatStatuses(); + // Stop all active streams + for (const ref of getAllActiveValues()) { + setStreaming(ref.id, false); + } + clearAllActive(); + // F173 A.12 砚砚 round 5 — handleStop is an EXPLICIT cancel by the user, so it's + // legitimate to clear suppression for the stopped thread. (Background-stop branch + // above also clears for the same reason.) This is invocation-lifecycle aligned: + // user's stop = invocation explicitly ended = suppression no longer relevant. clearReplacedInvocationsForThread(threadId); - return; - } + }; - clearDoneTimeout(threadId); - setLoading(false); - // F108: stop clears all invocation slots (user cancel-all) - clearAllActiveInvocations(); - setIntentMode(null); - clearCatStatuses(); - // Stop all active streams - for (const ref of getAllActiveValues()) { - setStreaming(ref.id, false); + const requested = cancelFn(threadId, undefined); + if (requested && typeof (requested as Promise).then === 'function') { + return (requested as Promise).then((accepted) => { + if (accepted !== false) clearStoppedThread(); + }); } - clearAllActive(); - // F173 A.12 砚砚 round 5 — handleStop is an EXPLICIT cancel by the user, so it's - // legitimate to clear suppression for the stopped thread. (Background-stop branch - // above also clears for the same reason.) This is invocation-lifecycle aligned: - // user's stop = invocation explicitly ended = suppression no longer relevant. - clearReplacedInvocationsForThread(threadId); + if (requested !== false) clearStoppedThread(); }, [ setLoading, diff --git a/packages/web/src/hooks/useSocket.ts b/packages/web/src/hooks/useSocket.ts index 0f770df1fc..f6b62fb512 100644 --- a/packages/web/src/hooks/useSocket.ts +++ b/packages/web/src/hooks/useSocket.ts @@ -1262,15 +1262,34 @@ export function useSocket(callbacks: SocketCallbacks, threadId?: string, foregro }); }, [threadId, storeThreadId]); - const cancelInvocation = useCallback((tid: string, catId?: string) => { + const cancelInvocation = useCallback(async (tid: string, catId?: string): Promise => { + // #1307: the visible conversation Stop is the durable all-thread stop + // endpoint. A socket cancel-all clears live slots, but cannot on its own + // recover every persisted/orphaned running record that force-reset owns. + if (!catId) { + try { + const response = await apiFetch(`/api/threads/${encodeURIComponent(tid)}/force-reset`, { method: 'POST' }); + if (!response.ok) throw new Error(`stop request failed (${response.status})`); + return true; + } catch { + useToastStore.getState().addToast({ + type: 'error', + title: '停止失败', + message: '未能停止对话中的运行,请稍后重试。', + duration: 5000, + }); + return false; + } + } const clientInstanceId = cancelClientInstanceIdRef.current; - if (!clientInstanceId) return; + if (!clientInstanceId) return false; emitExplicitCancel(socketRef.current, { threadId: tid, ...(catId ? { catId } : {}), clientInstanceId, actionId: newCancelIdentity('action'), }); + return true; }, []); return { socketRef, joinRoom, leaveRoom, syncRooms, cancelInvocation, socketConnected };