diff --git a/packages/web-shell/client/App.module.css b/packages/web-shell/client/App.module.css index c01ffd40491..f60d37ec8e0 100644 --- a/packages/web-shell/client/App.module.css +++ b/packages/web-shell/client/App.module.css @@ -307,117 +307,15 @@ padding: 0; } -.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; +/* 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; - color: var(--secondary-foreground); + padding: 4px 0; + font-size: 13px; + color: var(--muted-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; +.emptyWelcomeFooter { + padding: 12px 0 0; } diff --git a/packages/web-shell/client/App.tsx b/packages/web-shell/client/App.tsx index 8c3ba96283b..ab412725529 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,14 @@ import { copyFromLastAssistantMessage, COPY_MESSAGES, } from './utils/copyCommand'; -import { cssUrlVar } from './utils/cssUrlVar'; import { getModelDisplayName } from './utils/modelDisplay'; import { filterModelSwitchMessages } from './utils/modelSwitchMessages'; +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'; import { @@ -211,7 +213,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 +758,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 +1219,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); @@ -1329,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], @@ -1344,6 +1257,21 @@ 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); + // 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 || @@ -1403,6 +1331,17 @@ export function App({ queuedPromptsRef.current = []; setQueuedPrompts([]); drainingQueueRef.current = false; + awaitingTurnStartRef.current = false; + if (awaitingTurnStartTimerRef.current) { + 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(); @@ -1694,14 +1633,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 @@ -3130,12 +3061,19 @@ export function App({ ); useEffect(() => { - if (drainingQueueRef.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; @@ -3147,9 +3085,33 @@ export function App({ } popNextQueuedPrompt(); + // 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); + } + const TURN_START_GATE_SAFETY_MS = 2500; + awaitingTurnStartTimerRef.current = setTimeout(() => { + awaitingTurnStartRef.current = false; + awaitingTurnStartTimerRef.current = null; + // 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; let sent = false; - const timer = window.setTimeout(() => { + const timer = setTimeout(() => { + drainSubmitTimerRef.current = null; sent = true; try { handleSubmit(nextPrompt.text, nextPrompt.images); @@ -3158,16 +3120,19 @@ export function App({ drainingQueueRef.current = false; } }, 0); + drainSubmitTimerRef.current = timer; 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); + } + clearTimeout(timer); + drainSubmitTimerRef.current = null; } - window.clearTimeout(timer); drainingQueueRef.current = false; }; }, [ @@ -3180,8 +3145,39 @@ export function App({ popNextQueuedPrompt, queuedPrompts, streamingState, + drainTick, ]); + // 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]); + + // 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 @@ -3270,81 +3266,105 @@ 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 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; @@ -3911,6 +3931,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..f97d56869ee 100644 --- a/packages/web-shell/client/components/ChatEditor.module.css +++ b/packages/web-shell/client/components/ChatEditor.module.css @@ -978,6 +978,98 @@ 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 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; + 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 var(--esc-countdown-duration, 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..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'; @@ -52,6 +53,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 +62,6 @@ interface ChatEditorProps { slashCommandCategoryOrder?: CommandDisplayCategoryOrder; queuedMessages?: string[]; onPopQueuedMessages?: () => string | null; - onClearQueuedMessages?: () => boolean; currentMode?: string; currentModel?: string; chatWidthMode?: '1000' | 'wide'; @@ -849,6 +851,7 @@ export const ChatEditor = memo( onToggleShortcuts, onCancel, isRunning = false, + cancelArmed = false, disabled = false, placeholderText = 'Type a message...', commands, @@ -856,7 +859,6 @@ export const ChatEditor = memo( slashCommandCategoryOrder, queuedMessages = [], onPopQueuedMessages, - onClearQueuedMessages, currentMode = 'default', currentModel = '', chatWidthMode = '1000', @@ -888,7 +890,6 @@ export const ChatEditor = memo( slashCommandCategoryOrder, queuedMessages, onPopQueuedMessages, - onClearQueuedMessages, currentMode, onFocusFooter, dialogOpen, @@ -1550,12 +1551,21 @@ 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..4ef56b7d654 --- /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 './QueuedPromptDisplay.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..c815b111c77 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,40 @@ 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..79614fb237d 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,15 +447,12 @@ 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', '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 ?? ''}`, @@ -1011,6 +1008,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 +1061,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 +1561,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,13 +1636,11 @@ const ZH: Messages = { 'quickKeys.tab': '接受补全', 'error.unsupportedTheme': '不支持该主题。请使用 /theme light 或 /theme dark。', - 'queue.cleared': '已清空排队消息', 'queue.delete': '删除', 'queue.edit': '编辑', '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 ?? ''}`, @@ -2170,6 +2167,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 +2210,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..8a2444ef66f --- /dev/null +++ b/packages/web-shell/client/utils/escapeIntent.ts @@ -0,0 +1,53 @@ +// 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. + +/** + * 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 { + /** 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 + ); +}