Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion packages/web/src/components/ChatContainer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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],
);
Expand Down
2 changes: 1 addition & 1 deletion packages/web/src/components/ChatInput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"
>
取消
停止
</button>
)}
</div>
Expand Down
30 changes: 21 additions & 9 deletions packages/web/src/components/ChatInputActionButton.tsx
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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)
Expand All @@ -55,6 +56,7 @@ export function ChatInputActionButton({
hasText,
}: ChatInputActionButtonProps) {
const voice = useVoiceInput();
const [confirmSteer, setConfirmSteer] = useState(false);
const isSendDisabled = Boolean(disabled || sendDisabled);

useEffect(() => {
Expand Down Expand Up @@ -110,8 +112,8 @@ export function ChatInputActionButton({
<button
onClick={() => onStop()}
className="flex h-11 w-11 shrink-0 items-center justify-center rounded-xl bg-conn-red-text text-[var(--cafe-surface)] transition-colors hover:bg-conn-red-hover focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-conn-red-text/40"
title="停止生成"
aria-label="Stop generation"
title="停止对话"
aria-label="停止对话"
>
<svg className="w-4 h-4" viewBox="0 0 20 20" fill="currentColor">
<rect x="4" y="4" width="12" height="12" rx="2" />
Expand All @@ -125,8 +127,8 @@ export function ChatInputActionButton({
<button
onClick={() => onStop()}
className="p-3 rounded-xl bg-conn-red-text text-[var(--cafe-surface)] hover:bg-conn-red-hover transition-colors"
title="停止生成"
aria-label="Stop generation"
title="停止对话"
aria-label="停止对话"
>
<svg className="w-5 h-5" viewBox="0 0 20 20" fill="currentColor">
<rect x="4" y="4" width="12" height="12" rx="2" />
Expand Down Expand Up @@ -164,11 +166,11 @@ export function ChatInputActionButton({
</button>
{onForceSend && (
<button
onClick={onForceSend}
onClick={() => setConfirmSteer(true)}
disabled={isSendDisabled}
className="p-2 rounded-lg text-xs text-conn-red-text hover:bg-conn-red-bg disabled:opacity-40 transition-colors"
aria-label="强制发送"
title="强制发送 — 中断当前猫猫"
aria-label="强制停止并发送此消息"
title="强制停止并发送此消息"
>
<svg className="w-4 h-4" viewBox="0 0 20 20" fill="currentColor">
<path
Expand Down Expand Up @@ -201,6 +203,16 @@ export function ChatInputActionButton({
<MicIcon className="w-5 h-5" />
</button>
)}
{confirmSteer && onForceSend && (
<SteerQueuedEntryModal
source="draft"
onCancel={() => setConfirmSteer(false)}
onConfirm={() => {
setConfirmSteer(false);
onForceSend();
}}
/>
)}
</>
);
}
14 changes: 6 additions & 8 deletions packages/web/src/components/ForceResetDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,8 @@
import { useEffect, useRef } from 'react';

/**
* F220 Phase 3 — force-reset 确认弹窗(AC-3.2 / AC-3.3)。
* 真相源:docs/features/F220-a2a-collab-reliability.md §设计稿 + assets/F220/force-reset-mock.html
* 点击不立即执行——先讲清 做什么/保留什么/何时用,确认才 onConfirm。
* LL-048:force-reset 只清运行态,绝不碰消息/历史/记忆等持久化数据(文案明示"会保留什么")。
* #1307 Stop confirmation. The implementation still calls force-reset, but
* users see one truthful all-thread Stop action rather than two names for it.
*/
interface ForceResetDialogProps {
open: boolean;
Expand Down Expand Up @@ -76,14 +74,14 @@ export function ForceResetDialog({ open, busy = false, onCancel, onConfirm }: Fo
<div
role="dialog"
aria-modal="true"
aria-label="强制重置这个对话"
aria-label="停止这个对话"
className="fixed inset-0 z-50 flex items-center justify-center px-4"
style={{ backgroundColor: 'color-mix(in srgb, var(--cafe-text) 32%, transparent)' }}
>
<div className="w-full max-w-sm rounded-2xl overflow-hidden bg-cafe-surface-elevated shadow-xl">
<div className="flex items-center justify-between px-5 py-4 border-b border-cafe">
<h3 className="text-sm font-bold" style={{ color: 'var(--cafe-text)' }}>
强制重置这个对话
停止这个对话
</h3>
<button
type="button"
Expand Down Expand Up @@ -117,7 +115,7 @@ export function ForceResetDialog({ open, busy = false, onCancel, onConfirm }: Fo
<IconInfo />
</span>
<span>
<b className="font-semibold">何时用:</b>仅在猫卡死、点「停止」也没反应时的最后手段
<b className="font-semibold">会发生什么:</b>当前对话里的所有运行都会停止;之后可以继续发送新消息
</span>
</div>
</div>
Expand All @@ -139,7 +137,7 @@ export function ForceResetDialog({ open, busy = false, onCancel, onConfirm }: Fo
className="rounded-xl px-4 py-2 text-sm font-semibold text-white transition-opacity disabled:opacity-60"
style={{ backgroundColor: 'var(--semantic-critical)' }}
>
强制重置
停止
</button>
</div>
</div>
Expand Down
27 changes: 20 additions & 7 deletions packages/web/src/components/SteerQueuedEntryModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<HTMLDivElement>(null);
const isDraft = source === 'draft';

useEffect(() => {
const handler = (e: KeyboardEvent) => {
Expand All @@ -24,16 +32,21 @@ export function SteerQueuedEntryModal({ onCancel, onConfirm }: { onCancel: () =>
>
<div ref={modalRef} className="bg-cafe-surface rounded-2xl shadow-2xl w-full max-w-[520px] mx-4 overflow-hidden">
<div className="px-6 pt-6 pb-4">
<h2 className="text-lg font-semibold text-cafe-black">Steer 这条排队消息</h2>
<p className="text-sm text-cafe-secondary mt-1">取消目标猫当前回合,并以这条消息立即重新启动。</p>
<h2 className="text-lg font-semibold text-cafe-black">Steer(强制停止并发送此消息)</h2>
<p className="text-sm text-cafe-secondary mt-1">
{isDraft
? '会停止目标当前回复,然后立即发送当前输入的消息。'
: '会停止目标当前回复,然后立即发送这条排队消息。'}
</p>
</div>

<div className="px-6 pb-5">
<div className="w-full p-4 rounded-xl border border-[var(--conn-amber-ring)] bg-[var(--conn-amber-bg)]">
<div className="text-sm font-medium text-[var(--conn-amber-text)]">⚠️ 取消当前回合并重新启动</div>
<div className="text-sm font-medium text-[var(--conn-amber-text)]">⚠️ 会停止当前回复后发送此消息</div>
<div className="text-xs text-cafe-secondary mt-1">
旧 invocation 会被取消;系统只以这条已持久化消息启动一次。取消前已经完成的回复仍会发表,不会被这次 Steer
吞掉。
{isDraft
? '这不是“追加到当前回复”;当前回复会被停止。已经完成的回复仍会保留在聊天记录中。'
: '旧回复会被停止;系统只以这条已持久化消息启动一次。已经完成的回复仍会保留在聊天记录中。'}
</div>
</div>
</div>
Expand All @@ -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"
>
确认
停止并发送
</button>
</div>
</div>
Expand Down
14 changes: 7 additions & 7 deletions packages/web/src/components/ThinkingIndicator.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,7 @@ function SquareIcon({ className }: { className?: string }) {
}

interface ThinkingIndicatorProps {
onCancel?: (threadId: string, catId?: string) => void;
onCancel?: (threadId: string) => void;
threadId?: string;
}

Expand All @@ -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;
Expand Down Expand Up @@ -163,12 +163,12 @@ export function ThinkingIndicator({ onCancel, threadId }: ThinkingIndicatorProps
<button
type="button"
data-testid="cancel-btn"
onClick={() => onCancel(effectiveThreadId, catId)}
onClick={() => onCancel(effectiveThreadId)}
className="flex items-center gap-1.5 px-4 py-2 rounded-xl text-sm font-semibold text-[var(--cafe-surface)] flex-shrink-0 transition-opacity hover:opacity-90"
style={{ backgroundColor: 'var(--semantic-warning)' }}
>
<SquareIcon className="w-3.5 h-3.5" />
取消
停止对话
</button>
)}
</div>
Expand Down Expand Up @@ -209,12 +209,12 @@ export function ThinkingIndicator({ onCancel, threadId }: ThinkingIndicatorProps
<button
type="button"
data-testid="cancel-btn"
onClick={() => onCancel(effectiveThreadId, catId)}
onClick={() => onCancel(effectiveThreadId)}
className="flex items-center gap-1.5 px-4 py-2 rounded-xl text-sm font-semibold text-[var(--cafe-surface)] flex-shrink-0 transition-opacity hover:opacity-90"
style={{ backgroundColor: 'var(--semantic-critical)' }}
>
<SquareIcon className="w-3.5 h-3.5" />
取消
停止对话
</button>
)}
</div>
Expand Down
Loading
Loading