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({
) : (