Skip to content
Draft
Show file tree
Hide file tree
Changes from 4 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