From 860fb40f9284ef62e7aed45358e428af42cc4e61 Mon Sep 17 00:00:00 2001 From: carffuca Date: Mon, 29 Jun 2026 10:33:01 +0800 Subject: [PATCH 1/3] fix(web-shell): prevent queued-prompt loss from drain race MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The auto-drain effect popped a queued prompt, called setQueuedPrompts, then submitted via setTimeout(0). Because the daemon flips streamingState asynchronously, the setState re-render could re-run the effect and pop a second prompt before the first registered as streaming — both submitted back-to-back and the first was lost. Arm an "awaiting turn start" gate synchronously at pop so the re-run is blocked until streamingState goes non-idle, released by a dedicated effect with a safety-net timer for a prompt that never streams (e.g. a queued slash command). Cleanup no longer cancels/re-queues the pending submit while the gate is armed. --- packages/web-shell/client/App.tsx | 65 +++++++++++++++++++++++++++---- 1 file changed, 57 insertions(+), 8 deletions(-) diff --git a/packages/web-shell/client/App.tsx b/packages/web-shell/client/App.tsx index 8c3ba96283b..6946ee418f9 100644 --- a/packages/web-shell/client/App.tsx +++ b/packages/web-shell/client/App.tsx @@ -1344,6 +1344,14 @@ export function App({ const queuedPromptsRef = useRef([]); const nextQueuedPromptIdRef = useRef(1); const drainingQueueRef = useRef(false); + // After a drained prompt is submitted, block the next drain until its turn has + // actually started. `streamingState` flips asynchronously (daemon round-trip), + // so without this gate a second queued prompt fires in the window before the + // first registers as streaming — both land back-to-back and the first is lost. + const awaitingTurnStartRef = useRef(false); + const awaitingTurnStartTimerRef = useRef | null>(null); const dialogOpen = showResumeDialog || showDeleteDialog || @@ -1403,6 +1411,11 @@ export function App({ queuedPromptsRef.current = []; setQueuedPrompts([]); drainingQueueRef.current = false; + awaitingTurnStartRef.current = false; + if (awaitingTurnStartTimerRef.current) { + clearTimeout(awaitingTurnStartTimerRef.current); + awaitingTurnStartTimerRef.current = null; + } midTurnEnqueueAbortRef.current?.abort(); midTurnEnqueueAbortRef.current = null; btwAbortControllerRef.current?.abort(); @@ -3131,6 +3144,7 @@ export function App({ useEffect(() => { if (drainingQueueRef.current) return; + if (awaitingTurnStartRef.current) return; if (!connected) return; if (streamingState !== 'idle') return; if (interactionBlocked) return; @@ -3147,6 +3161,27 @@ export function App({ } popNextQueuedPrompt(); + // Arm the gate SYNCHRONOUSLY, before the setState in popNextQueuedPrompt + // triggers a re-render: the daemon flips `streamingState` asynchronously, so + // without this the effect re-runs in the same tick and pops a second prompt + // before the first registers as streaming — both fire back-to-back and the + // first is lost. Cleared once this prompt's turn starts (streamingState + // effect), with a safety-net timer for a prompt that never streams (e.g. a + // queued slash command). + awaitingTurnStartRef.current = true; + if (awaitingTurnStartTimerRef.current) { + clearTimeout(awaitingTurnStartTimerRef.current); + } + const TURN_START_GATE_SAFETY_MS = 2500; + awaitingTurnStartTimerRef.current = setTimeout(() => { + awaitingTurnStartRef.current = false; + awaitingTurnStartTimerRef.current = null; + // Opening the gate touched only a ref. Nudge a re-render (same queue + // contents) so the drain effect re-evaluates and picks up anything still + // queued behind a prompt that never streamed (e.g. a local command). + setQueuedPrompts((prev) => [...prev]); + }, TURN_START_GATE_SAFETY_MS); + drainingQueueRef.current = true; let sent = false; const timer = window.setTimeout(() => { @@ -3159,15 +3194,16 @@ export function App({ } }, 0); return () => { - if (!sent) { - // Cleanup ran before timeout fired — put the prompt back at the - // front of the queue so it's not lost. This can happen when any - // dependency (e.g. handleSubmit, streamingState) changes between - // popNextQueuedPrompt() and the setTimeout firing. - queuedPromptsRef.current = [nextPrompt, ...queuedPromptsRef.current]; - setQueuedPrompts(queuedPromptsRef.current); + // While the gate is armed the re-run is already blocked, so let the + // pending submit fire — don't cancel it or re-queue. Only when unarmed + // (a genuine dependency change before submit) restore the prompt. + if (!awaitingTurnStartRef.current) { + if (!sent) { + queuedPromptsRef.current = [nextPrompt, ...queuedPromptsRef.current]; + setQueuedPrompts(queuedPromptsRef.current); + } + window.clearTimeout(timer); } - window.clearTimeout(timer); drainingQueueRef.current = false; }; }, [ @@ -3182,6 +3218,19 @@ export function App({ streamingState, ]); + // The drained prompt's turn has started — release the drain gate. From here + // the `streamingState !== 'idle'` guard holds the next prompt until this turn + // settles, so the queue advances one turn at a time. + useEffect(() => { + if (streamingState !== 'idle') { + awaitingTurnStartRef.current = false; + if (awaitingTurnStartTimerRef.current) { + clearTimeout(awaitingTurnStartTimerRef.current); + awaitingTurnStartTimerRef.current = null; + } + } + }, [streamingState]); + const handleConfirm = useCallback( (id: string, selectedOption: string, answers?: Record) => { sessionActions From e7b5bc8b889f877523c743f2d19200716e781b41 Mon Sep 17 00:00:00 2001 From: carffuca Date: Mon, 29 Jun 2026 10:33:25 +0800 Subject: [PATCH 2/3] feat(web-shell): friendlier Esc interruption + queued-prompt UX --- packages/web-shell/client/App.module.css | 9 + packages/web-shell/client/App.tsx | 316 ++++++++---------- .../client/components/ChatEditor.module.css | 91 +++++ .../client/components/ChatEditor.tsx | 42 ++- .../client/components/MessageItem.tsx | 6 + .../components/QueuedPromptDisplay.test.tsx | 92 +++++ .../client/components/QueuedPromptDisplay.tsx | 114 +++++++ .../client/components/StatusBar.module.css | 5 - .../web-shell/client/components/StatusBar.tsx | 75 ++--- .../messages/SystemMessage.module.css | 14 + .../messages/SystemMessage.test.tsx | 61 ++++ .../components/messages/SystemMessage.tsx | 9 + .../web-shell/client/hooks/useComposerCore.ts | 10 +- packages/web-shell/client/i18n.tsx | 10 +- .../client/utils/escapeIntent.test.ts | 77 +++++ .../web-shell/client/utils/escapeIntent.ts | 45 +++ .../web-shell/client/utils/queueDrain.test.ts | 34 ++ packages/web-shell/client/utils/queueDrain.ts | 38 +++ 18 files changed, 808 insertions(+), 240 deletions(-) create mode 100644 packages/web-shell/client/components/QueuedPromptDisplay.test.tsx create mode 100644 packages/web-shell/client/components/QueuedPromptDisplay.tsx create mode 100644 packages/web-shell/client/components/messages/SystemMessage.test.tsx create mode 100644 packages/web-shell/client/utils/escapeIntent.test.ts create mode 100644 packages/web-shell/client/utils/escapeIntent.ts create mode 100644 packages/web-shell/client/utils/queueDrain.test.ts create mode 100644 packages/web-shell/client/utils/queueDrain.ts diff --git a/packages/web-shell/client/App.module.css b/packages/web-shell/client/App.module.css index c01ffd40491..01623711d7e 100644 --- a/packages/web-shell/client/App.module.css +++ b/packages/web-shell/client/App.module.css @@ -307,6 +307,15 @@ padding: 0; } +/* Esc-clear hint, shown in the composer's top status slot (where the streaming + loader sits) — the two never coexist, so it stays clear of the queue. */ +.escClearStatus { + flex-shrink: 0; + padding: 4px 0; + font-size: 13px; + color: var(--muted-foreground); +} + .emptyWelcomeFooter { padding: 12px 0 0; } diff --git a/packages/web-shell/client/App.tsx b/packages/web-shell/client/App.tsx index 6946ee418f9..d37c036cafc 100644 --- a/packages/web-shell/client/App.tsx +++ b/packages/web-shell/client/App.tsx @@ -32,6 +32,7 @@ import type { import { extractPendingPermission } from './adapters/transcriptAdapter'; import { removeInjectedFromQueue } from './midTurnDedup'; import { MessageList, type MessageListHandle } from './components/MessageList'; +import { QueuedPromptDisplay } from './components/QueuedPromptDisplay'; import { extractVoiceModels, type VoiceModelOption } from './voice/voiceModels'; import { ChatEditor, @@ -80,10 +81,6 @@ import { useAnimationFrameValue } from './hooks/useAnimationFrameValue'; import { useBackgroundTasks } from './hooks/useBackgroundTasks'; import { useMessages } from './hooks/useMessages'; import { useShallowMemo, useStableArray } from './hooks/useShallowMemo'; -import deleteIconUrl from './assets/icons/delete.svg'; -import editIconUrl from './assets/icons/edit.svg'; -import insertIconUrl from './assets/icons/insert.svg'; -import queueIconUrl from './assets/icons/queue.svg'; import { I18nProvider, getTranslator, @@ -96,9 +93,10 @@ import { copyFromLastAssistantMessage, COPY_MESSAGES, } from './utils/copyCommand'; -import { cssUrlVar } from './utils/cssUrlVar'; import { getModelDisplayName } from './utils/modelDisplay'; import { filterModelSwitchMessages } from './utils/modelSwitchMessages'; +import { decideEscapeIntent } from './utils/escapeIntent'; +import { canDrainQueue } from './utils/queueDrain'; import type { SkillInfo } from './completions/slashCompletion'; import { collectSystemInfo } from './utils/systemInfo'; import { @@ -211,7 +209,6 @@ function TodoContextsProvider({ } const MODES_CYCLE = DAEMON_APPROVAL_MODES; -const MAX_QUEUED_PROMPT_PREVIEW_CHARS = 240; const MAX_TOASTS = 4; const COMPACT_MODE_SETTING_KEY = 'ui.compactMode'; const HIDE_TIPS_SETTING_KEY = 'ui.hideTips'; @@ -757,102 +754,6 @@ function translateCopyMessage( return message; } -function QueuedPromptDisplay({ - prompts, - t, - onDelete, - onInsert, - onEdit, -}: { - prompts: readonly QueuedPrompt[]; - t: ReturnType; - onDelete: (id: number) => void; - onInsert: (id: number) => void; - onEdit: (id: number) => void; -}) { - if (prompts.length === 0) return null; - - return ( -
- {prompts.map((prompt) => { - const normalizedPreview = prompt.text.replace(/\s+/g, ' ').trim(); - const preview = - normalizedPreview.length > MAX_QUEUED_PROMPT_PREVIEW_CHARS - ? `${normalizedPreview.slice(0, MAX_QUEUED_PROMPT_PREVIEW_CHARS)}...` - : normalizedPreview; - const imageCount = prompt.images?.length ?? 0; - // A command (/… or !…) can't be inserted into the running turn — insert - // injects raw text the model would see literally, never running the - // command. Show the action disabled so it stays visible but inert. - const isCommand = isCommandPrompt(prompt.text); - return ( -
-
- ); - })} -
{t('queue.footer')}
-
- ); -} - export function App({ onSessionIdChange, theme: providedTheme, @@ -1314,7 +1215,11 @@ export function App({ const [agentsDialogMode, setAgentsDialogMode] = useState(null); const [escapeHintVisible, setEscapeHintVisible] = useState(false); - const escPressCountRef = useRef(0); + // Whether the first Esc has armed a stream cancellation; the composer's send + // button shows an "Esc again to stop" affordance while true. + const [cancelArmed, setCancelArmed] = useState(false); + // Which action the pending second Esc would perform, or null when idle. + const escArmedActionRef = useRef<'cancel' | 'clear' | null>(null); const escapeTimerRef = useRef | null>(null); const [tasksDialogMessage, setTasksDialogMessage] = useState(null); @@ -1352,6 +1257,13 @@ export function App({ const awaitingTurnStartTimerRef = useRef | null>(null); + // The pending setTimeout(0) submit of a drained prompt. Tracked so a session + // switch or unmount can cancel it — the drain cleanup deliberately leaves it + // running while the gate is armed (for the benign re-render storm), which + // would otherwise dispatch a stale prompt into the wrong/torn-down session. + const drainSubmitTimerRef = useRef | null>( + null, + ); const dialogOpen = showResumeDialog || showDeleteDialog || @@ -1416,6 +1328,12 @@ export function App({ clearTimeout(awaitingTurnStartTimerRef.current); awaitingTurnStartTimerRef.current = null; } + // Cancel a still-pending drained submit so it can't fire into the new + // session (the drain cleanup leaves it running while the gate is armed). + if (drainSubmitTimerRef.current) { + clearTimeout(drainSubmitTimerRef.current); + drainSubmitTimerRef.current = null; + } midTurnEnqueueAbortRef.current?.abort(); midTurnEnqueueAbortRef.current = null; btwAbortControllerRef.current?.abort(); @@ -1707,14 +1625,6 @@ export function App({ [popQueuedPromptForEdit], ); - const clearQueuedPrompts = useCallback((): boolean => { - if (queuedPromptsRef.current.length === 0) return false; - queuedPromptsRef.current = []; - setQueuedPrompts([]); - store.dispatch([{ type: 'status', text: t('queue.cleared') }]); - return true; - }, [store, t]); - // When the daemon drains queued messages into the running turn it emits // `mid_turn_message_injected` (one frame per tool batch). Drop the matching // (text-only) entries from the local queue so the idle-time drain doesn't ALSO @@ -3143,13 +3053,19 @@ export function App({ ); useEffect(() => { - if (drainingQueueRef.current) return; - if (awaitingTurnStartRef.current) return; - if (!connected) return; - if (streamingState !== 'idle') return; - if (interactionBlocked) return; - if (pendingApproval) return; - if (queuedPrompts.length === 0) return; + if ( + !canDrainQueue({ + draining: drainingQueueRef.current, + awaitingTurnStart: awaitingTurnStartRef.current, + connected, + streaming: streamingState !== 'idle', + interactionBlocked, + pendingApproval: !!pendingApproval, + queueLength: queuedPrompts.length, + }) + ) { + return; + } const nextPrompt = peekNextQueuedPrompt(); if (!nextPrompt) return; @@ -3184,7 +3100,8 @@ export function App({ drainingQueueRef.current = true; let sent = false; - const timer = window.setTimeout(() => { + const timer = setTimeout(() => { + drainSubmitTimerRef.current = null; sent = true; try { handleSubmit(nextPrompt.text, nextPrompt.images); @@ -3193,6 +3110,7 @@ export function App({ drainingQueueRef.current = false; } }, 0); + drainSubmitTimerRef.current = timer; return () => { // While the gate is armed the re-run is already blocked, so let the // pending submit fire — don't cancel it or re-queue. Only when unarmed @@ -3202,7 +3120,8 @@ export function App({ queuedPromptsRef.current = [nextPrompt, ...queuedPromptsRef.current]; setQueuedPrompts(queuedPromptsRef.current); } - window.clearTimeout(timer); + clearTimeout(timer); + drainSubmitTimerRef.current = null; } drainingQueueRef.current = false; }; @@ -3231,6 +3150,23 @@ export function App({ } }, [streamingState]); + // On unmount, cancel both pending drain timers so neither the safety-net + // re-render (up to 2.5s) nor a still-pending submit fires on a torn-down + // component / dispatches into a dead session. + useEffect( + () => () => { + if (awaitingTurnStartTimerRef.current) { + clearTimeout(awaitingTurnStartTimerRef.current); + awaitingTurnStartTimerRef.current = null; + } + if (drainSubmitTimerRef.current) { + clearTimeout(drainSubmitTimerRef.current); + drainSubmitTimerRef.current = null; + } + }, + [], + ); + const handleConfirm = useCallback( (id: string, selectedOption: string, answers?: Record) => { sessionActions @@ -3319,81 +3255,107 @@ export function App({ t, ]); + const resetEscapeState = useCallback(() => { + escArmedActionRef.current = null; + setEscapeHintVisible(false); + setCancelArmed(false); + if (escapeTimerRef.current) { + clearTimeout(escapeTimerRef.current); + escapeTimerRef.current = null; + } + }, []); + + // The Esc handler reads live state, but its global keydown listener must mount + // ONCE: streamingState flips among 'waiting'/'responding'/'thinking' mid-turn, + // and if it were an effect dep each flip would tear the listener down and run + // resetEscapeState(), wiping a half-armed two-press cancel. Read live values + // through a ref so the listener stays put across re-renders. + const escLiveRef = useRef({ + streamingState, + pendingApproval, + interactionBlocked, + handleCancel, + handleCycleMode, + }); + escLiveRef.current = { + streamingState, + pendingApproval, + interactionBlocked, + handleCancel, + handleCycleMode, + }; + + // Clear a half-armed two-press whenever the streaming/idle boundary flips — the + // relevant action (cancel vs clear) changes with it, so a leftover arm is now + // stale. Keyed on the boolean, so intra-turn sub-state flips don't reset it. + const escStreamingBoundary = streamingState !== 'idle'; useEffect(() => { - const resetEscapeState = () => { - escPressCountRef.current = 0; - setEscapeHintVisible(false); - if (escapeTimerRef.current) { - clearTimeout(escapeTimerRef.current); - escapeTimerRef.current = null; - } + resetEscapeState(); + }, [escStreamingBoundary, resetEscapeState]); + + useEffect(() => { + // Arm a two-press action: the first Esc shows the affordance and starts a + // confirm window; a second Esc within it confirms, any other key resets it. + const ESC_CANCEL_CONFIRM_WINDOW_MS = 2000; + const ESC_CLEAR_CONFIRM_WINDOW_MS = 500; + const armEscape = (action: 'cancel' | 'clear', windowMs: number) => { + escArmedActionRef.current = action; + if (action === 'cancel') setCancelArmed(true); + else setEscapeHintVisible(true); + if (escapeTimerRef.current) clearTimeout(escapeTimerRef.current); + escapeTimerRef.current = setTimeout(resetEscapeState, windowMs); }; const onKeyDown = (e: KeyboardEvent) => { if (e.defaultPrevented || e.isComposing) return; + const live = escLiveRef.current; if (e.key !== 'Escape') { - if (escPressCountRef.current > 0) { + if (escArmedActionRef.current !== null) { resetEscapeState(); } - if (e.key === 'Tab' && e.shiftKey && !interactionBlocked) { + if (e.key === 'Tab' && e.shiftKey && !live.interactionBlocked) { e.preventDefault(); - handleCycleMode(); + live.handleCycleMode(); } return; } - if (pendingApproval || interactionBlocked) return; - - if (clearQueuedPrompts()) { - e.preventDefault(); - resetEscapeState(); - return; - } - - if (editorRef.current?.hasInput()) { - e.preventDefault(); - if (escPressCountRef.current === 0) { - escPressCountRef.current = 1; - setEscapeHintVisible(true); - if (escapeTimerRef.current) { - clearTimeout(escapeTimerRef.current); - } - escapeTimerRef.current = setTimeout(() => { - resetEscapeState(); - }, 500); - } else { + // Streaming takes priority over clearing text (queued prompts stay intact + // and drain after the turn settles); see decideEscapeIntent for the rules. + const intent = decideEscapeIntent({ + blocked: !!live.pendingApproval || live.interactionBlocked, + streaming: live.streamingState !== 'idle', + hasInput: !!editorRef.current?.hasInput(), + armed: escArmedActionRef.current, + }); + if (intent.kind === 'ignore') return; + e.preventDefault(); + switch (intent.kind) { + case 'cancel': + live.handleCancel(); + resetEscapeState(); + break; + case 'clear': editorRef.current?.clear(); resetEscapeState(); - } - return; - } - - if (streamingState !== 'idle') { - e.preventDefault(); - handleCancel(); - resetEscapeState(); - return; + break; + case 'arm': + armEscape( + intent.action, + intent.action === 'cancel' + ? ESC_CANCEL_CONFIRM_WINDOW_MS + : ESC_CLEAR_CONFIRM_WINDOW_MS, + ); + break; } }; window.addEventListener('keydown', onKeyDown); return () => { window.removeEventListener('keydown', onKeyDown); - escPressCountRef.current = 0; - setEscapeHintVisible(false); - if (escapeTimerRef.current) { - clearTimeout(escapeTimerRef.current); - escapeTimerRef.current = null; - } + resetEscapeState(); }; - }, [ - streamingState, - handleCancel, - handleCycleMode, - pendingApproval, - interactionBlocked, - clearQueuedPrompts, - ]); + }, [resetEscapeState]); const isDisabled = !connected || connection.catchingUp; @@ -3960,6 +3922,11 @@ export function App({ )}
+ {escapeHintVisible && streamingState === 'idle' && ( +
+ {t('editor.escClearHint')} +
+ )} ) : ( setShowApprovalModeDialog((v) => !v)} onSelectModel={() => setModelDialogMode((v) => (v ? null : 'main')) diff --git a/packages/web-shell/client/components/ChatEditor.module.css b/packages/web-shell/client/components/ChatEditor.module.css index bf5d365b581..51a7dbf41ec 100644 --- a/packages/web-shell/client/components/ChatEditor.module.css +++ b/packages/web-shell/client/components/ChatEditor.module.css @@ -978,6 +978,97 @@ background: currentColor; } +/* Armed-to-cancel state: first Esc primes the stop, the button shows an "Esc" + hint, a subtle pulse, and a depleting ring counting down the confirm window + until the second press confirms or the window lapses. */ +.sendBtnArmed, +.sendBtnArmed:not(:disabled) { + position: relative; + border-radius: 50%; + background: var(--error-color, #e06c75); + animation: sendBtnArmedPulse 1s ease-in-out infinite; +} + +/* Registered so the conic angle can animate smoothly. */ +@property --esc-countdown { + syntax: ''; + inherits: false; + initial-value: 100%; +} + +/* Countdown ring around the armed button. Its duration matches + ESC_CANCEL_CONFIRM_WINDOW_MS in App.tsx — keep the two in sync. */ +.sendBtnArmed::after { + content: ''; + position: absolute; + inset: -4px; + border-radius: 50%; + background: conic-gradient( + var(--error-color, #e06c75) var(--esc-countdown), + transparent 0 + ); + -webkit-mask: radial-gradient( + farthest-side, + transparent calc(100% - 2px), + #000 calc(100% - 2px) + ); + mask: radial-gradient( + farthest-side, + transparent calc(100% - 2px), + #000 calc(100% - 2px) + ); + animation: escCountdown 2000ms linear forwards; + pointer-events: none; +} + +@keyframes escCountdown { + to { + --esc-countdown: 0%; + } +} + +@media (prefers-reduced-motion: reduce) { + .sendBtnArmed, + .sendBtnArmed:not(:disabled) { + animation: none; + } + + .sendBtnArmed::after { + animation: none; + --esc-countdown: 100%; + } +} + +.escLabel { + font-size: 11px; + font-weight: 600; + line-height: 1; + letter-spacing: 0.02em; +} + +/* Visually hidden but exposed to assistive tech (for aria-live announcements). */ +.srOnly { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; +} + +@keyframes sendBtnArmedPulse { + 0%, + 100% { + opacity: 1; + } + 50% { + opacity: 0.6; + } +} + .images { display: flex; gap: 6px; diff --git a/packages/web-shell/client/components/ChatEditor.tsx b/packages/web-shell/client/components/ChatEditor.tsx index 281e9d7a36c..bacbae238e4 100644 --- a/packages/web-shell/client/components/ChatEditor.tsx +++ b/packages/web-shell/client/components/ChatEditor.tsx @@ -52,6 +52,8 @@ interface ChatEditorProps { onToggleShortcuts?: () => void; onCancel?: () => void; isRunning?: boolean; + /** First Esc armed a cancel — the send button shows an "Esc to stop" hint. */ + cancelArmed?: boolean; disabled?: boolean; placeholderText?: string; commands: CommandInfo[]; @@ -59,7 +61,6 @@ interface ChatEditorProps { slashCommandCategoryOrder?: CommandDisplayCategoryOrder; queuedMessages?: string[]; onPopQueuedMessages?: () => string | null; - onClearQueuedMessages?: () => boolean; currentMode?: string; currentModel?: string; chatWidthMode?: '1000' | 'wide'; @@ -849,6 +850,7 @@ export const ChatEditor = memo( onToggleShortcuts, onCancel, isRunning = false, + cancelArmed = false, disabled = false, placeholderText = 'Type a message...', commands, @@ -856,7 +858,6 @@ export const ChatEditor = memo( slashCommandCategoryOrder, queuedMessages = [], onPopQueuedMessages, - onClearQueuedMessages, currentMode = 'default', currentModel = '', chatWidthMode = '1000', @@ -888,7 +889,6 @@ export const ChatEditor = memo( slashCommandCategoryOrder, queuedMessages, onPopQueuedMessages, - onClearQueuedMessages, currentMode, onFocusFooter, dialogOpen, @@ -1550,7 +1550,9 @@ export const ChatEditor = memo( + + {isRunning && cancelArmed ? t('stream.cancelArmed') : ''} +
diff --git a/packages/web-shell/client/components/MessageItem.tsx b/packages/web-shell/client/components/MessageItem.tsx index f0485cac056..d11aea11c89 100644 --- a/packages/web-shell/client/components/MessageItem.tsx +++ b/packages/web-shell/client/components/MessageItem.tsx @@ -160,6 +160,12 @@ export const MessageItem = memo(function MessageItem({ ); } + // The cancellation marker is a right-aligned, full-width turn-terminal row; a + // hover timestamp would overlap its text, so render it without the wrapper. + if (message.role === 'system' && message.source === 'prompt_cancelled') { + return safeBody; + } + return ( = []; + +function render(node: React.ReactNode): HTMLElement { + const container = document.createElement('div'); + document.body.appendChild(container); + const root = createRoot(container); + act(() => root.render(node)); + mounted.push({ root, container }); + return container; +} + +afterEach(() => { + for (const { root, container } of mounted.splice(0)) { + act(() => root.unmount()); + container.remove(); + } +}); + +function setup( + overrides: Partial> = {}, +) { + const handlers = { + onDelete: vi.fn(), + onInsert: vi.fn(), + onEdit: vi.fn(), + }; + const prompts: QueuedPromptView[] = overrides.prompts + ? [...overrides.prompts] + : [ + { id: 1, text: '排队消息一' }, + { id: 2, text: '排队消息二' }, + ]; + const container = render( + , + ); + return { container, handlers }; +} + +describe('QueuedPromptDisplay', () => { + it('renders nothing when the queue is empty', () => { + const { container } = setup({ prompts: [] }); + expect(container.textContent).toBe(''); + }); + + it('lists each queued prompt', () => { + const { container } = setup(); + expect(container.textContent).toContain('排队消息一'); + expect(container.textContent).toContain('排队消息二'); + }); + + it('passes the prompt id to per-row delete', () => { + const { container, handlers } = setup({ + prompts: [{ id: 42, text: 'only one' }], + }); + const del = [...container.querySelectorAll('button')].find( + (b) => b.getAttribute('aria-label') === t('queue.delete'), + ); + act(() => del!.dispatchEvent(new MouseEvent('click', { bubbles: true }))); + expect(handlers.onDelete).toHaveBeenCalledWith(42); + }); + + it('disables insert for a command prompt', () => { + const { container } = setup({ + prompts: [{ id: 1, text: '/help me' }], + }); + const insert = [...container.querySelectorAll('button')].find((b) => + (b.textContent || '').includes(t('queue.insert')), + ); + expect(insert).toBeTruthy(); + expect((insert as HTMLButtonElement).disabled).toBe(true); + }); +}); diff --git a/packages/web-shell/client/components/QueuedPromptDisplay.tsx b/packages/web-shell/client/components/QueuedPromptDisplay.tsx new file mode 100644 index 00000000000..c940601fee0 --- /dev/null +++ b/packages/web-shell/client/components/QueuedPromptDisplay.tsx @@ -0,0 +1,114 @@ +import type { PromptImage } from '../adapters/promptTypes'; +import { getTranslator } from '../i18n'; +import { isCommandPrompt } from '../utils/localCommandQueue'; +import { cssUrlVar } from '../utils/cssUrlVar'; +import deleteIconUrl from '../assets/icons/delete.svg'; +import editIconUrl from '../assets/icons/edit.svg'; +import insertIconUrl from '../assets/icons/insert.svg'; +import queueIconUrl from '../assets/icons/queue.svg'; +import styles from '../App.module.css'; + +const MAX_QUEUED_PROMPT_PREVIEW_CHARS = 240; + +export interface QueuedPromptView { + id: number; + text: string; + images?: PromptImage[]; +} + +interface QueuedPromptDisplayProps { + prompts: readonly QueuedPromptView[]; + t: ReturnType; + onDelete: (id: number) => void; + onInsert: (id: number) => void; + onEdit: (id: number) => void; +} + +export function QueuedPromptDisplay({ + prompts, + t, + onDelete, + onInsert, + onEdit, +}: QueuedPromptDisplayProps) { + if (prompts.length === 0) return null; + + return ( +
+ {prompts.map((prompt) => { + const normalizedPreview = prompt.text.replace(/\s+/g, ' ').trim(); + const preview = + normalizedPreview.length > MAX_QUEUED_PROMPT_PREVIEW_CHARS + ? `${normalizedPreview.slice(0, MAX_QUEUED_PROMPT_PREVIEW_CHARS)}...` + : normalizedPreview; + const imageCount = prompt.images?.length ?? 0; + // A command (/… or !…) can't be inserted into the running turn — insert + // injects raw text the model would see literally, never running the + // command. Show the action disabled so it stays visible but inert. + const isCommand = isCommandPrompt(prompt.text); + return ( +
+
+ ); + })} +
+ ); +} diff --git a/packages/web-shell/client/components/StatusBar.module.css b/packages/web-shell/client/components/StatusBar.module.css index 8a933167d28..223d6e37f8a 100644 --- a/packages/web-shell/client/components/StatusBar.module.css +++ b/packages/web-shell/client/components/StatusBar.module.css @@ -130,11 +130,6 @@ text-decoration: underline; } -.escapeHint { - color: var(--muted-foreground); - font-size: 13px; -} - .model { color: var(--muted-foreground); font-size: 12px; diff --git a/packages/web-shell/client/components/StatusBar.tsx b/packages/web-shell/client/components/StatusBar.tsx index 380e6b01f4f..21f1c86b510 100644 --- a/packages/web-shell/client/components/StatusBar.tsx +++ b/packages/web-shell/client/components/StatusBar.tsx @@ -40,7 +40,6 @@ function getModeIndicator( } interface StatusBarProps { - escapeHint?: boolean; onSelectMode: () => void; /** Open the model picker so the model can be chosen with the mouse. */ onSelectModel: () => void; @@ -159,7 +158,6 @@ function formatGoalElapsed(ms: number): string { export const StatusBar = forwardRef( function StatusBar( { - escapeHint, onSelectMode, onSelectModel, onShowContext, @@ -197,15 +195,14 @@ export const StatusBar = forwardRef( }, [activeGoal]); const taskPillLabel = useMemo(() => getTaskPillLabel(tasks, t), [tasks, t]); - const hasLeftPrefix = - !!escapeHint || (!compact && (connected || !!modeIndicator)); + const hasLeftPrefix = !compact && (connected || !!modeIndicator); const goalElapsed = activeGoal ? formatGoalElapsed(Date.now() - activeGoal.setAt) : ''; const goalLabel = activeGoal ? `◎ ${t('goal.statusActive')}${goalElapsed ? ` (${goalElapsed})` : ''}` : ''; - const hasLeftContent = !!escapeHint || !!taskPillLabel || !compact; + const hasLeftContent = !!taskPillLabel || !compact; const hasRightContent = (!compact && !!currentModel) || (!compact && contextWindow > 0 && tokenCount > 0) || @@ -277,52 +274,42 @@ export const StatusBar = forwardRef( )} - {escapeHint ? ( - - {t('editor.escClearHint')} - - ) : ( + {modeIndicator && !compact && ( + + )} + {!compact && ( <> - {modeIndicator && !compact && ( + {onToggleShortcuts ? ( - )} - {!compact && ( - <> - {onToggleShortcuts ? ( - - ) : ( - {t('status.shortcuts')} - )} - + ) : ( + {t('status.shortcuts')} )} )} diff --git a/packages/web-shell/client/components/messages/SystemMessage.module.css b/packages/web-shell/client/components/messages/SystemMessage.module.css index ac4a33721f0..663890d6dd8 100644 --- a/packages/web-shell/client/components/messages/SystemMessage.module.css +++ b/packages/web-shell/client/components/messages/SystemMessage.module.css @@ -102,3 +102,17 @@ .retryButton:hover { color: var(--foreground, #fff); } + +/* Right-aligned, subtle marker for a user-cancelled (ESC) stream — reads as a + user-side action and keeps the same divider treatment as a turn status row. */ +.cancelled { + display: flex; + justify-content: flex-end; + width: 100%; + border-bottom: 1px solid color-mix(in srgb, var(--border) 58%, transparent); + padding-bottom: 10px; + margin-bottom: 5px; + font-size: 14px; + line-height: 1.4; + color: var(--muted-foreground); +} diff --git a/packages/web-shell/client/components/messages/SystemMessage.test.tsx b/packages/web-shell/client/components/messages/SystemMessage.test.tsx new file mode 100644 index 00000000000..828c8f365dd --- /dev/null +++ b/packages/web-shell/client/components/messages/SystemMessage.test.tsx @@ -0,0 +1,61 @@ +// @vitest-environment jsdom +import { afterEach, describe, expect, it } from 'vitest'; +import { act, type ReactNode } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { I18nProvider } from '../../i18n'; +import { SystemMessage } from './SystemMessage'; + +( + globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean } +).IS_REACT_ACT_ENVIRONMENT = true; + +const mounted: Array<{ root: Root; container: HTMLElement }> = []; + +afterEach(() => { + for (const { root, container } of mounted.splice(0)) { + act(() => root.unmount()); + container.remove(); + } +}); + +function render(node: ReactNode): HTMLElement { + const container = document.createElement('div'); + document.body.appendChild(container); + const root = createRoot(container); + act(() => { + root.render({node}); + }); + mounted.push({ root, container }); + return container; +} + +describe('SystemMessage — prompt_cancelled marker', () => { + it('renders the user-cancelled marker as a status region', () => { + const container = render( + , + ); + const status = container.querySelector('[role="status"]'); + expect(status).not.toBeNull(); + expect(status?.textContent).toBe('You cancelled this request'); + }); + + it('ignores message content when rendering the cancelled marker', () => { + const container = render( + , + ); + expect(container.textContent).toBe('You cancelled this request'); + expect(container.textContent).not.toContain('raw daemon text'); + }); + + it('renders a normal message without the status marker for other sources', () => { + const container = render( + , + ); + expect(container.querySelector('[role="status"]')).toBeNull(); + expect(container.textContent).toContain('a plain note'); + }); +}); diff --git a/packages/web-shell/client/components/messages/SystemMessage.tsx b/packages/web-shell/client/components/messages/SystemMessage.tsx index 9b256439f56..d9c205b7364 100644 --- a/packages/web-shell/client/components/messages/SystemMessage.tsx +++ b/packages/web-shell/client/components/messages/SystemMessage.tsx @@ -38,6 +38,15 @@ export const SystemMessage = memo(function SystemMessage({ onRetryClick, }: SystemMessageProps) { const { t } = useI18n(); + // The user ESC-cancelled a live stream. Render it right-aligned and subtle — + // a user-initiated stop reads as belonging to the user side of the transcript. + if (source === 'prompt_cancelled') { + return ( +
+ {t('turn.stopped')} +
+ ); + } const contextUsage = variant === 'info' ? parseContextUsageMessage(content) : null; if (contextUsage) { diff --git a/packages/web-shell/client/hooks/useComposerCore.ts b/packages/web-shell/client/hooks/useComposerCore.ts index 642707e956a..9d798096f51 100644 --- a/packages/web-shell/client/hooks/useComposerCore.ts +++ b/packages/web-shell/client/hooks/useComposerCore.ts @@ -754,7 +754,6 @@ export interface UseComposerCoreOptions { slashCommandCategoryOrder?: CommandDisplayCategoryOrder; queuedMessages?: string[]; onPopQueuedMessages?: () => string | null; - onClearQueuedMessages?: () => boolean; currentMode?: string; onFocusFooter?: () => boolean; dialogOpen?: boolean; @@ -888,7 +887,6 @@ export function useComposerCore( slashCommandCategoryOrder, queuedMessages = [], onPopQueuedMessages, - onClearQueuedMessages, currentMode = 'default', onFocusFooter, dialogOpen = false, @@ -925,8 +923,6 @@ export function useComposerCore( queuedMessagesRef.current = queuedMessages; const onPopQueuedMessagesRef = useRef(onPopQueuedMessages); onPopQueuedMessagesRef.current = onPopQueuedMessages; - const onClearQueuedMessagesRef = useRef(onClearQueuedMessages); - onClearQueuedMessagesRef.current = onClearQueuedMessages; const followupStateRef = useRef(followupState); followupStateRef.current = followupState; const onAcceptFollowupRef = useRef(onAcceptFollowup); @@ -1440,8 +1436,10 @@ export function useComposerCore( setShellMode(false); return true; } - if (queuedMessagesRef.current.length === 0) return false; - return onClearQueuedMessagesRef.current?.() ?? false; + // Don't clear the queue on Escape — let it fall through to the + // window handler, where Escape cancels the in-flight turn (queued + // prompts are preserved and drain once it settles). + return false; }, }, { diff --git a/packages/web-shell/client/i18n.tsx b/packages/web-shell/client/i18n.tsx index a273b91b0a7..82362a67c50 100644 --- a/packages/web-shell/client/i18n.tsx +++ b/packages/web-shell/client/i18n.tsx @@ -372,7 +372,7 @@ const EN: Messages = { '↑↓ switch fields · ⌘/Ctrl+Enter to save · Esc to menu', 'dialog.footer.search': 'Type to search · Enter to commit · Esc to clear', 'dialog.footer.select': 'Enter to select', - 'editor.escClearHint': 'Press Esc again to clear.', + 'editor.escClearHint': 'Press Esc again to clear', 'editor.hintCommands': 'commands', 'editor.hintFiles': 'files', 'editor.hintNext': 'next', @@ -447,7 +447,6 @@ const EN: Messages = { 'quickKeys.tab': 'Accept completion', 'error.unsupportedTheme': 'Unsupported theme. Use /theme light or /theme dark.', - 'queue.cleared': 'Queued messages cleared', 'queue.delete': 'Delete', 'queue.edit': 'Edit', 'queue.insert': 'Insert', @@ -1011,6 +1010,7 @@ const EN: Messages = { 'status.modeHint': '(shift + tab or click to switch)', 'status.shortcuts': '? for shortcuts', 'stream.cancel': 'esc to cancel', + 'stream.cancelArmed': 'Press Esc again to stop', 'stream.tokens': (v) => `${v?.count ?? 0} tokens`, 'theme.current': (v) => `current: ${v?.theme ?? ''}`, 'theme.auto': 'Auto', @@ -1063,6 +1063,7 @@ const EN: Messages = { const n = v?.count ?? 0; return `${n} thought${n === 1 ? '' : 's'}`; }, + 'turn.stopped': 'You cancelled this request', 'message.renderError': 'This message could not be displayed.', 'tasks.title': 'Background tasks', 'tasks.empty': 'No tasks currently running', @@ -1562,7 +1563,7 @@ const ZH: Messages = { 'dialog.footer.saveMenu': '↑↓ 切换输入框 · ⌘/Ctrl+Enter 保存 · Esc 返回菜单', 'dialog.footer.search': '输入搜索 · Enter 确认 · Esc 清空', 'dialog.footer.select': 'Enter 选择', - 'editor.escClearHint': '再按一次 Esc 清空输入。', + 'editor.escClearHint': '再按一次 Esc 清空', 'editor.hintCommands': '命令', 'editor.hintFiles': '文件', 'editor.hintNext': '下一条', @@ -1637,7 +1638,6 @@ const ZH: Messages = { 'quickKeys.tab': '接受补全', 'error.unsupportedTheme': '不支持该主题。请使用 /theme light 或 /theme dark。', - 'queue.cleared': '已清空排队消息', 'queue.delete': '删除', 'queue.edit': '编辑', 'queue.insert': '插入', @@ -2170,6 +2170,7 @@ const ZH: Messages = { 'status.modeHint': '(shift + tab 或点击切换)', 'status.shortcuts': '? 查看快捷键', 'stream.cancel': 'esc 取消', + 'stream.cancelArmed': '再按一次 Esc 停止', 'stream.tokens': (v) => `${v?.count ?? 0} tokens`, 'theme.current': (v) => `当前:${v?.theme ?? ''}`, 'theme.auto': '自动', @@ -2212,6 +2213,7 @@ const ZH: Messages = { 'turn.executionSteps': (v) => `${v?.count ?? 0} 步`, 'turn.toolCalls': (v) => `工具 ${v?.count ?? 0} 次`, 'turn.thinkingCount': (v) => `思考 ${v?.count ?? 0} 次`, + 'turn.stopped': '你已取消请求', 'message.renderError': '此消息无法显示。', 'tasks.title': '后台任务', 'tasks.empty': '当前没有运行中的任务', diff --git a/packages/web-shell/client/utils/escapeIntent.test.ts b/packages/web-shell/client/utils/escapeIntent.test.ts new file mode 100644 index 00000000000..4d72ac0d8ce --- /dev/null +++ b/packages/web-shell/client/utils/escapeIntent.test.ts @@ -0,0 +1,77 @@ +import { describe, expect, it } from 'vitest'; +import { decideEscapeIntent, type EscapeContext } from './escapeIntent'; + +const base: EscapeContext = { + blocked: false, + streaming: false, + hasInput: false, + armed: null, +}; + +describe('decideEscapeIntent', () => { + it('ignores Escape while blocked, even with a stream or input', () => { + expect(decideEscapeIntent({ ...base, blocked: true })).toEqual({ + kind: 'ignore', + }); + expect( + decideEscapeIntent({ + ...base, + blocked: true, + streaming: true, + hasInput: true, + armed: 'cancel', + }), + ).toEqual({ kind: 'ignore' }); + }); + + it('arms cancel on the first Esc while streaming', () => { + expect(decideEscapeIntent({ ...base, streaming: true })).toEqual({ + kind: 'arm', + action: 'cancel', + }); + }); + + it('confirms cancel on the second Esc while streaming', () => { + expect( + decideEscapeIntent({ ...base, streaming: true, armed: 'cancel' }), + ).toEqual({ kind: 'cancel' }); + }); + + it('re-arms cancel (not clear) when a clear-armed press lands while streaming', () => { + expect( + decideEscapeIntent({ ...base, streaming: true, armed: 'clear' }), + ).toEqual({ kind: 'arm', action: 'cancel' }); + }); + + it('prioritises streaming over composer text', () => { + expect( + decideEscapeIntent({ ...base, streaming: true, hasInput: true }), + ).toEqual({ kind: 'arm', action: 'cancel' }); + }); + + it('arms clear on the first Esc with text and no stream', () => { + expect(decideEscapeIntent({ ...base, hasInput: true })).toEqual({ + kind: 'arm', + action: 'clear', + }); + }); + + it('confirms clear on the second Esc with text', () => { + expect( + decideEscapeIntent({ ...base, hasInput: true, armed: 'clear' }), + ).toEqual({ kind: 'clear' }); + }); + + it('re-arms clear when a stale cancel-armed press lands with text', () => { + expect( + decideEscapeIntent({ ...base, hasInput: true, armed: 'cancel' }), + ).toEqual({ kind: 'arm', action: 'clear' }); + }); + + it('ignores Escape with no stream and no text', () => { + expect(decideEscapeIntent(base)).toEqual({ kind: 'ignore' }); + expect(decideEscapeIntent({ ...base, armed: 'clear' })).toEqual({ + kind: 'ignore', + }); + }); +}); diff --git a/packages/web-shell/client/utils/escapeIntent.ts b/packages/web-shell/client/utils/escapeIntent.ts new file mode 100644 index 00000000000..3c3866d4ff5 --- /dev/null +++ b/packages/web-shell/client/utils/escapeIntent.ts @@ -0,0 +1,45 @@ +// Pure decision logic for the composer's two-press Escape behaviour, extracted +// from App's keydown listener so the priority + confirm rules can be tested +// without mounting the whole app. The listener owns the side effects (timers, +// cancel/clear handlers); this module only decides what a press means. + +export type EscArmedAction = 'cancel' | 'clear'; + +export interface EscapeContext { + /** A pending approval or blocking dialog swallows Escape entirely. */ + blocked: boolean; + /** A turn is in flight (streamingState !== 'idle'). */ + streaming: boolean; + /** The composer currently has text that could be cleared. */ + hasInput: boolean; + /** Which action the previous Escape armed, or null when idle. */ + armed: EscArmedAction | null; +} + +export type EscapeIntent = + | { kind: 'cancel' } // confirmed second press: stop the stream + | { kind: 'clear' } // confirmed second press: clear the composer + | { kind: 'arm'; action: EscArmedAction } // first press: show the affordance + | { kind: 'ignore' }; // nothing to act on + +/** + * Decide what an Escape press means. Streaming takes priority over clearing + * text (stopping a live turn is what the user most wants), and each action is a + * two-press confirm: the first press arms, a matching second press confirms. A + * press armed for the wrong action (e.g. clear-armed while now streaming) + * re-arms the action that currently applies rather than confirming. + */ +export function decideEscapeIntent(ctx: EscapeContext): EscapeIntent { + if (ctx.blocked) return { kind: 'ignore' }; + if (ctx.streaming) { + return ctx.armed === 'cancel' + ? { kind: 'cancel' } + : { kind: 'arm', action: 'cancel' }; + } + if (ctx.hasInput) { + return ctx.armed === 'clear' + ? { kind: 'clear' } + : { kind: 'arm', action: 'clear' }; + } + return { kind: 'ignore' }; +} diff --git a/packages/web-shell/client/utils/queueDrain.test.ts b/packages/web-shell/client/utils/queueDrain.test.ts new file mode 100644 index 00000000000..e6cf1346d00 --- /dev/null +++ b/packages/web-shell/client/utils/queueDrain.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from 'vitest'; +import { canDrainQueue, type QueueDrainGate } from './queueDrain'; + +// A gate where every condition is satisfied — the next prompt should drain. +const ready: QueueDrainGate = { + draining: false, + awaitingTurnStart: false, + connected: true, + streaming: false, + interactionBlocked: false, + pendingApproval: false, + queueLength: 1, +}; + +describe('canDrainQueue', () => { + it('drains when every condition is satisfied', () => { + expect(canDrainQueue(ready)).toBe(true); + }); + + it('holds the queue when it is empty', () => { + expect(canDrainQueue({ ...ready, queueLength: 0 })).toBe(false); + }); + + it.each([ + ['a drain is already in flight', { draining: true }], + ['waiting for the prior turn to start', { awaitingTurnStart: true }], + ['the connection is down', { connected: false }], + ['a turn is streaming', { streaming: true }], + ['interaction is blocked', { interactionBlocked: true }], + ['a tool approval is pending', { pendingApproval: true }], + ] as const)('holds the queue when %s', (_label, override) => { + expect(canDrainQueue({ ...ready, ...override })).toBe(false); + }); +}); diff --git a/packages/web-shell/client/utils/queueDrain.ts b/packages/web-shell/client/utils/queueDrain.ts new file mode 100644 index 00000000000..a05505889ff --- /dev/null +++ b/packages/web-shell/client/utils/queueDrain.ts @@ -0,0 +1,38 @@ +// Pure gate for the queued-prompt auto-drain, extracted from App's drain effect +// so the "may I drain the next prompt right now?" conditions are a named, +// tested contract. This covers the boolean gate only — the effect still owns +// the timing (arming the turn-start gate, the setTimeout submit). The race that +// gate guards against is inherently effect-level and is verified separately. + +export interface QueueDrainGate { + /** A drain is already in flight this tick. */ + draining: boolean; + /** Waiting for the previously drained prompt's turn to start. */ + awaitingTurnStart: boolean; + /** The daemon connection is live. */ + connected: boolean; + /** A turn is in flight (streamingState !== 'idle'). */ + streaming: boolean; + /** Some interaction (dialog, catch-up) is blocking input. */ + interactionBlocked: boolean; + /** A tool approval is pending. */ + pendingApproval: boolean; + /** Number of prompts currently queued. */ + queueLength: number; +} + +/** + * Whether the next queued prompt may be auto-drained into a new turn right now. + * Every condition must hold; any one being unmet holds the queue. + */ +export function canDrainQueue(gate: QueueDrainGate): boolean { + return ( + !gate.draining && + !gate.awaitingTurnStart && + gate.connected && + !gate.streaming && + !gate.interactionBlocked && + !gate.pendingApproval && + gate.queueLength > 0 + ); +} From f1d5d12967a52747913b8056d85c87bc27758189 Mon Sep 17 00:00:00 2001 From: carffuca Date: Tue, 30 Jun 2026 09:27:11 +0800 Subject: [PATCH 3/3] refactor(web-shell): tidy Esc/queue code per review Behavior-preserving cleanups addressing review feedback on the Esc-interruption and queued-prompt changes: - Remove the now-dead queue.footer i18n key (EN + ZH) and the unreferenced .queuedHint CSS, orphaned when the Esc-clears-queue behavior was dropped. - Co-locate the queued-prompt styles in QueuedPromptDisplay.module.css instead of reaching into the parent App.module.css. - Make the Esc confirm-window constants the single source of truth: export them from escapeIntent.ts and drive the countdown-ring duration from one of them via a CSS custom property. - Nudge the queue-drain safety net with a dedicated tick counter instead of cloning queuedPrompts, so it no longer re-renders the composer for a no-op. - Drop a redundant !compact guard in StatusBar left over from flattening a ternary. - Document the pop/gate-arm ordering invariant in the drain effect. --- packages/web-shell/client/App.module.css | 111 ------------------ packages/web-shell/client/App.tsx | 37 +++--- .../client/components/ChatEditor.module.css | 7 +- .../client/components/ChatEditor.tsx | 8 ++ .../components/QueuedPromptDisplay.module.css | 106 +++++++++++++++++ .../client/components/QueuedPromptDisplay.tsx | 2 +- .../web-shell/client/components/StatusBar.tsx | 4 +- packages/web-shell/client/i18n.tsx | 3 - .../web-shell/client/utils/escapeIntent.ts | 8 ++ 9 files changed, 151 insertions(+), 135 deletions(-) create mode 100644 packages/web-shell/client/components/QueuedPromptDisplay.module.css diff --git a/packages/web-shell/client/App.module.css b/packages/web-shell/client/App.module.css index 01623711d7e..f60d37ec8e0 100644 --- a/packages/web-shell/client/App.module.css +++ b/packages/web-shell/client/App.module.css @@ -319,114 +319,3 @@ .emptyWelcomeFooter { padding: 12px 0 0; } - -.queuedPrompts { - display: flex; - flex-direction: column; - gap: 6px; - width: calc(100% - 32px); - margin: 0 auto -8px; - padding: 12px; - border: 1px solid var(--border); - border-radius: 12px 12px 0 0; - background: var(--background); - color: var(--foreground); - font-family: var(--font-sans, system-ui, sans-serif); - font-size: 14px; - line-height: 22px; -} - -.queuedPrompt { - display: flex; - align-items: center; - gap: 5px; - min-width: 0; - max-width: 100%; - min-height: 28px; - color: var(--foreground); -} - -.queuedPrompt + .queuedPrompt { - border-top: 0; -} - -.queuedPromptIcon { - display: inline-flex; - width: 20px; - height: 20px; - align-items: center; - justify-content: center; - flex-shrink: 0; - color: var(--secondary-foreground); -} - -.queuedPromptMaskIcon, -.queuedPromptActionIcon { - display: inline-block; - flex-shrink: 0; - background: currentColor; - mask: var(--queued-icon-url) center / contain no-repeat; - -webkit-mask: var(--queued-icon-url) center / contain no-repeat; -} - -.queuedPromptMaskIcon { - width: 14px; - height: 14px; -} - -.queuedPromptText { - min-width: 0; - flex: 1; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -.queuedPromptActions { - position: relative; - display: inline-flex; - align-items: center; - gap: 10px; - flex-shrink: 0; - color: var(--secondary-foreground); -} - -.queuedPromptAction { - display: inline-flex; - align-items: center; - gap: 4px; - min-height: 28px; - appearance: none; - border: 0; - border-radius: 6px; - background: transparent; - color: var(--secondary-foreground); - cursor: pointer; - font: inherit; - font-size: 14px; - line-height: 22px; - padding: 3px 6px; -} - -.queuedPromptActionIcon { - width: 14px; - height: 14px; -} - -.queuedPromptAction:hover { - background: var(--accent); - color: var(--foreground); -} - -.queuedPromptAction:disabled { - opacity: 0.4; - cursor: not-allowed; -} - -.queuedPromptAction:disabled:hover { - color: var(--secondary-foreground); -} - -.queuedHint { - display: none; -} diff --git a/packages/web-shell/client/App.tsx b/packages/web-shell/client/App.tsx index d37c036cafc..ab412725529 100644 --- a/packages/web-shell/client/App.tsx +++ b/packages/web-shell/client/App.tsx @@ -95,7 +95,11 @@ import { } from './utils/copyCommand'; import { getModelDisplayName } from './utils/modelDisplay'; import { filterModelSwitchMessages } from './utils/modelSwitchMessages'; -import { decideEscapeIntent } from './utils/escapeIntent'; +import { + decideEscapeIntent, + ESC_CANCEL_CONFIRM_WINDOW_MS, + ESC_CLEAR_CONFIRM_WINDOW_MS, +} from './utils/escapeIntent'; import { canDrainQueue } from './utils/queueDrain'; import type { SkillInfo } from './completions/slashCompletion'; import { collectSystemInfo } from './utils/systemInfo'; @@ -1234,6 +1238,10 @@ export function App({ const sessionDisplayName = connection.displayName; const [currentMode, setCurrentMode] = useState('default'); const [queuedPrompts, setQueuedPrompts] = useState([]); + // A bump-only signal to re-run the drain effect without changing queuedPrompts + // identity (which would needlessly invalidate queuedTexts and re-render the + // composer). Used by the turn-start safety net below. + const [drainTick, setDrainTick] = useState(0); const queuedTexts = useMemo( () => queuedPrompts.map((prompt) => prompt.text), [queuedPrompts], @@ -3077,13 +3085,14 @@ export function App({ } popNextQueuedPrompt(); - // Arm the gate SYNCHRONOUSLY, before the setState in popNextQueuedPrompt - // triggers a re-render: the daemon flips `streamingState` asynchronously, so - // without this the effect re-runs in the same tick and pops a second prompt - // before the first registers as streaming — both fire back-to-back and the - // first is lost. Cleared once this prompt's turn starts (streamingState - // effect), with a safety-net timer for a prompt that never streams (e.g. a - // queued slash command). + // Arm the gate SYNCHRONOUSLY here, immediately after the pop — the daemon + // flips `streamingState` asynchronously, so otherwise this effect re-runs in + // the same tick (via the pop's setState) and pops a second prompt before the + // first registers as streaming, losing the first. Keep every guard ABOVE the + // pop: an early return between the pop and this line would strand the popped + // prompt (dequeued but never submitted or re-queued). Cleared once this + // prompt's turn starts (streamingState effect); a safety-net timer covers a + // prompt that never streams (e.g. a queued slash command). awaitingTurnStartRef.current = true; if (awaitingTurnStartTimerRef.current) { clearTimeout(awaitingTurnStartTimerRef.current); @@ -3092,10 +3101,11 @@ export function App({ awaitingTurnStartTimerRef.current = setTimeout(() => { awaitingTurnStartRef.current = false; awaitingTurnStartTimerRef.current = null; - // Opening the gate touched only a ref. Nudge a re-render (same queue - // contents) so the drain effect re-evaluates and picks up anything still - // queued behind a prompt that never streamed (e.g. a local command). - setQueuedPrompts((prev) => [...prev]); + // Opening the gate touched only a ref. Bump a dedicated tick so the drain + // effect re-evaluates and picks up anything still queued behind a prompt + // that never streamed (e.g. a local command) — without changing the queue + // identity, which would re-render the composer for a no-op data change. + setDrainTick((t) => t + 1); }, TURN_START_GATE_SAFETY_MS); drainingQueueRef.current = true; @@ -3135,6 +3145,7 @@ export function App({ popNextQueuedPrompt, queuedPrompts, streamingState, + drainTick, ]); // The drained prompt's turn has started — release the drain gate. From here @@ -3296,8 +3307,6 @@ export function App({ useEffect(() => { // Arm a two-press action: the first Esc shows the affordance and starts a // confirm window; a second Esc within it confirms, any other key resets it. - const ESC_CANCEL_CONFIRM_WINDOW_MS = 2000; - const ESC_CLEAR_CONFIRM_WINDOW_MS = 500; const armEscape = (action: 'cancel' | 'clear', windowMs: number) => { escArmedActionRef.current = action; if (action === 'cancel') setCancelArmed(true); diff --git a/packages/web-shell/client/components/ChatEditor.module.css b/packages/web-shell/client/components/ChatEditor.module.css index 51a7dbf41ec..f97d56869ee 100644 --- a/packages/web-shell/client/components/ChatEditor.module.css +++ b/packages/web-shell/client/components/ChatEditor.module.css @@ -996,8 +996,9 @@ initial-value: 100%; } -/* Countdown ring around the armed button. Its duration matches - ESC_CANCEL_CONFIRM_WINDOW_MS in App.tsx — keep the two in sync. */ +/* Countdown ring around the armed button. Its duration comes from + --esc-countdown-duration, set by ChatEditor from ESC_CANCEL_CONFIRM_WINDOW_MS, + so the JS confirm window is the single source of truth (fallback below). */ .sendBtnArmed::after { content: ''; position: absolute; @@ -1017,7 +1018,7 @@ transparent calc(100% - 2px), #000 calc(100% - 2px) ); - animation: escCountdown 2000ms linear forwards; + animation: escCountdown var(--esc-countdown-duration, 2000ms) linear forwards; pointer-events: none; } diff --git a/packages/web-shell/client/components/ChatEditor.tsx b/packages/web-shell/client/components/ChatEditor.tsx index bacbae238e4..17d87d432f6 100644 --- a/packages/web-shell/client/components/ChatEditor.tsx +++ b/packages/web-shell/client/components/ChatEditor.tsx @@ -32,6 +32,7 @@ import { } from '../hooks/useComposerCore'; import { ModeIcon } from './ModeIcon'; import { getModelDisplayName } from '../utils/modelDisplay'; +import { ESC_CANCEL_CONFIRM_WINDOW_MS } from '../utils/escapeIntent'; import { VoiceButton } from '../voice/VoiceButton'; import styles from './ChatEditor.module.css'; @@ -1558,6 +1559,13 @@ export const ChatEditor = memo( disabled={ isRunning ? !onCancel : core.disabled || !core.hasContent } + style={ + isRunning && cancelArmed + ? ({ + '--esc-countdown-duration': `${ESC_CANCEL_CONFIRM_WINDOW_MS}ms`, + } as CSSProperties) + : undefined + } onClick={(e) => { e.stopPropagation(); if (isRunning) { diff --git a/packages/web-shell/client/components/QueuedPromptDisplay.module.css b/packages/web-shell/client/components/QueuedPromptDisplay.module.css new file mode 100644 index 00000000000..c4d963dbcc8 --- /dev/null +++ b/packages/web-shell/client/components/QueuedPromptDisplay.module.css @@ -0,0 +1,106 @@ +.queuedPrompts { + display: flex; + flex-direction: column; + gap: 6px; + width: calc(100% - 32px); + margin: 0 auto -8px; + padding: 12px; + border: 1px solid var(--border); + border-radius: 12px 12px 0 0; + background: var(--background); + color: var(--foreground); + font-family: var(--font-sans, system-ui, sans-serif); + font-size: 14px; + line-height: 22px; +} + +.queuedPrompt { + display: flex; + align-items: center; + gap: 5px; + min-width: 0; + max-width: 100%; + min-height: 28px; + color: var(--foreground); +} + +.queuedPrompt + .queuedPrompt { + border-top: 0; +} + +.queuedPromptIcon { + display: inline-flex; + width: 20px; + height: 20px; + align-items: center; + justify-content: center; + flex-shrink: 0; + color: var(--secondary-foreground); +} + +.queuedPromptMaskIcon, +.queuedPromptActionIcon { + display: inline-block; + flex-shrink: 0; + background: currentColor; + mask: var(--queued-icon-url) center / contain no-repeat; + -webkit-mask: var(--queued-icon-url) center / contain no-repeat; +} + +.queuedPromptMaskIcon { + width: 14px; + height: 14px; +} + +.queuedPromptText { + min-width: 0; + flex: 1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.queuedPromptActions { + position: relative; + display: inline-flex; + align-items: center; + gap: 10px; + flex-shrink: 0; + color: var(--secondary-foreground); +} + +.queuedPromptAction { + display: inline-flex; + align-items: center; + gap: 4px; + min-height: 28px; + appearance: none; + border: 0; + border-radius: 6px; + background: transparent; + color: var(--secondary-foreground); + cursor: pointer; + font: inherit; + font-size: 14px; + line-height: 22px; + padding: 3px 6px; +} + +.queuedPromptActionIcon { + width: 14px; + height: 14px; +} + +.queuedPromptAction:hover { + background: var(--accent); + color: var(--foreground); +} + +.queuedPromptAction:disabled { + opacity: 0.4; + cursor: not-allowed; +} + +.queuedPromptAction:disabled:hover { + color: var(--secondary-foreground); +} diff --git a/packages/web-shell/client/components/QueuedPromptDisplay.tsx b/packages/web-shell/client/components/QueuedPromptDisplay.tsx index c940601fee0..4ef56b7d654 100644 --- a/packages/web-shell/client/components/QueuedPromptDisplay.tsx +++ b/packages/web-shell/client/components/QueuedPromptDisplay.tsx @@ -6,7 +6,7 @@ import deleteIconUrl from '../assets/icons/delete.svg'; import editIconUrl from '../assets/icons/edit.svg'; import insertIconUrl from '../assets/icons/insert.svg'; import queueIconUrl from '../assets/icons/queue.svg'; -import styles from '../App.module.css'; +import styles from './QueuedPromptDisplay.module.css'; const MAX_QUEUED_PROMPT_PREVIEW_CHARS = 240; diff --git a/packages/web-shell/client/components/StatusBar.tsx b/packages/web-shell/client/components/StatusBar.tsx index 21f1c86b510..c815b111c77 100644 --- a/packages/web-shell/client/components/StatusBar.tsx +++ b/packages/web-shell/client/components/StatusBar.tsx @@ -289,9 +289,7 @@ export const StatusBar = forwardRef( > {modeIndicator.label} - {!compact && ( - {t('status.modeHint')} - )} + {t('status.modeHint')} )} {!compact && ( diff --git a/packages/web-shell/client/i18n.tsx b/packages/web-shell/client/i18n.tsx index 82362a67c50..79614fb237d 100644 --- a/packages/web-shell/client/i18n.tsx +++ b/packages/web-shell/client/i18n.tsx @@ -453,8 +453,6 @@ const EN: Messages = { 'queue.insertCommandDisabled': "Commands can't be inserted into the running turn; they run after it finishes.", 'queue.insertFailed': 'Failed to insert queued message', - 'queue.footer': - 'Press ↑ to edit the latest queued message · Esc to clear queue', 'queue.imageCount': (v) => `(+${v?.count ?? 0} images)`, 'queue.more': (v) => `... (+${v?.count ?? 0} more)`, 'midTurn.inserted': (v) => `Inserted message: ${v?.message ?? ''}`, @@ -1643,7 +1641,6 @@ const ZH: Messages = { 'queue.insert': '插入', 'queue.insertCommandDisabled': '命令无法插入当前回合,会在回合结束后执行。', 'queue.insertFailed': '插入排队消息失败', - 'queue.footer': '按 ↑ 编辑最后一条排队消息 · Esc 清空队列', 'queue.imageCount': (v) => `(+${v?.count ?? 0} 张图片)`, 'queue.more': (v) => `...(还有 ${v?.count ?? 0} 条)`, 'midTurn.inserted': (v) => `已插入消息:${v?.message ?? ''}`, diff --git a/packages/web-shell/client/utils/escapeIntent.ts b/packages/web-shell/client/utils/escapeIntent.ts index 3c3866d4ff5..8a2444ef66f 100644 --- a/packages/web-shell/client/utils/escapeIntent.ts +++ b/packages/web-shell/client/utils/escapeIntent.ts @@ -3,6 +3,14 @@ // without mounting the whole app. The listener owns the side effects (timers, // cancel/clear handlers); this module only decides what a press means. +/** + * Confirm windows for the two-press Escape gesture, in milliseconds. The cancel + * window also drives the countdown ring's animation duration (passed to CSS as a + * custom property), so it stays the single source of truth for that timing. + */ +export const ESC_CANCEL_CONFIRM_WINDOW_MS = 2000; +export const ESC_CLEAR_CONFIRM_WINDOW_MS = 500; + export type EscArmedAction = 'cancel' | 'clear'; export interface EscapeContext {