diff --git a/console/web/src/components/chat/ChatView.tsx b/console/web/src/components/chat/ChatView.tsx index 78b6b37f4..1dbcfb7e8 100644 --- a/console/web/src/components/chat/ChatView.tsx +++ b/console/web/src/components/chat/ChatView.tsx @@ -179,6 +179,20 @@ export function ChatView({ const serverWorking = conversation.status === 'working' const streamingIndicator = isStreaming || serverWorking + // Stop requested but not yet finalized server-side. Disables the stop button + // until status-changed flips the indicator off. The ref is the dedupe guard: + // synchronous (two clicks in one frame can't both pass, unlike closure + // state) and readable outside a state updater (React forbids side effects + // inside updaters — Strict Mode double-invokes them). + const [stopping, setStopping] = useState(false) + const stopRequestedRef = useRef(false) + useEffect(() => { + if (!streamingIndicator) { + stopRequestedRef.current = false + setStopping(false) + } + }, [streamingIndicator]) + // Messages queued mid-stream (MOT-3837): shown above the composer until the // harness drains them into the transcript. Each draft carries the predicted // entry id of its eventual transcript row. @@ -1278,8 +1292,17 @@ export function ChatView({ ) const handleStop = useCallback(() => { + if (stopRequestedRef.current) return + stopRequestedRef.current = true + setStopping(true) abortRef.current?.abort() - void backend.abortRun?.(sessionId).catch(() => {}) + // Re-enable the button if the stop RPC fails while the server still + // reports working — otherwise `stopping` never clears (the reset + // effect waits on the indicator) and the user can't retry. + void backend.abortRun?.(sessionId).catch(() => { + stopRequestedRef.current = false + setStopping(false) + }) }, [backend, sessionId]) // Rescue a parked stream loop: the session hit a terminal error server-side @@ -1729,6 +1752,7 @@ export function ChatView({ onTextChange={handleComposerTextChange} onSubmit={handleSubmit} onStop={handleStop} + stopping={stopping} queuedForEdit={backend.editQueued ? queuedForEdit : undefined} onEditQueued={backend.editQueued ? handleEditQueued : undefined} onBrowseChange={setBrowsedQueuedId} diff --git a/console/web/src/components/chat/Composer.tsx b/console/web/src/components/chat/Composer.tsx index 8ad2dced3..194fef0a5 100644 --- a/console/web/src/components/chat/Composer.tsx +++ b/console/web/src/components/chat/Composer.tsx @@ -4,7 +4,7 @@ import { $getRoot, type LexicalEditor, } from 'lexical' -import { ArrowUp, Square } from 'lucide-react' +import { ArrowUp, Loader2, Square } from 'lucide-react' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { PermissionModePicker } from '@/components/permissions/PermissionModePicker' import type { PermissionMode } from '@/lib/backend/approval-settings' @@ -87,6 +87,8 @@ interface ComposerProps { onPermissionModeChange: (next: PermissionMode) => void onSubmit: (payload: ComposerSubmitPayload) => void onStop?: () => void + /** A stop was requested and the server hasn't finalized the turn yet. */ + stopping?: boolean isStreaming?: boolean /** * When true, the editor stays unlocked while streaming: a submit queues the @@ -160,6 +162,7 @@ export function Composer({ onPermissionModeChange, onSubmit, onStop, + stopping, isStreaming, queueWhileStreaming, blocked, @@ -389,10 +392,15 @@ export function Composer({ ) : (