diff --git a/frontend/src/lib/components/copilot/chat/AIChatDisplay.svelte b/frontend/src/lib/components/copilot/chat/AIChatDisplay.svelte
index a267e71e8c..c3875f702f 100644
--- a/frontend/src/lib/components/copilot/chat/AIChatDisplay.svelte
+++ b/frontend/src/lib/components/copilot/chat/AIChatDisplay.svelte
@@ -14,7 +14,6 @@
Folder,
Hand,
HistoryIcon,
- Hourglass,
MousePointer2,
Plus,
TextSelect,
@@ -25,7 +24,7 @@
import { fade } from 'svelte/transition'
import Popover from '$lib/components/meltComponents/Popover.svelte'
import DropdownV2 from '$lib/components/DropdownV2.svelte'
- import { isActiveUserQuestion, type DisplayMessage } from './shared'
+ import { pendingUserAction, type DisplayMessage } from './shared'
import type { ContextElement } from './context'
import ChatQuickActions from './ChatQuickActions.svelte'
import ContextUsageIndicator from './ContextUsageIndicator.svelte'
@@ -486,24 +485,15 @@
}
})
- // "Waiting for user" detection — when the latest tool message is staged
- // for confirmation or has an unanswered askUserQuestion, the AI loop is
- // paused on the user, not on its own work. The typing-dots indicator
- // implies the AI is busy, which is misleading; surface a text pill
- // instead so users know to act on the tool above.
- const waitingForUserAction = $derived.by(() => {
- if (!aiChatManager.loading) return false
- const last = messages[messages.length - 1]
- if (!last || last.role !== 'tool') return false
- if (last.needsConfirmation && last.isLoading) return true
- if (isActiveUserQuestion(last)) return true
- return false
- })
+ // The typing-dots indicator implies the AI is busy, which is misleading while
+ // the loop is parked on the user; surface a text pill instead so users know to
+ // act on the tool above.
+ const waitingForUserAction = $derived(aiChatManager.loading && !!pendingUserAction(messages))
// While the AI is waiting on an answer to an askUserQuestion, the only valid
// input is one of the choices (or the custom answer) in the question card —
// so disable the main chat input until the question is answered or canceled.
- const hasActiveUserQuestion = $derived(isActiveUserQuestion(messages[messages.length - 1]))
+ const hasActiveUserQuestion = $derived(pendingUserAction(messages) === 'question')
// Get app context for display when in APP mode
const appContext = $derived.by((): SelectedContext | undefined => {
@@ -682,28 +672,19 @@ the panel, or the Escape-to-stop focus check would wrongly reject them. -->
showFlowPendingActionControls ? 'bottom-14' : 'bottom-2'
)}
>
- {#if waitingForUserAction}
-
-
- Waiting for your input
-
- {:else}
-
- {/if}
+
{/if}
@@ -1067,26 +1048,3 @@ the panel, or the Escape-to-stop focus check would wrongly reject them. -->
{/if}
-
-
diff --git a/frontend/src/lib/components/copilot/chat/ChatTypingIndicator.svelte b/frontend/src/lib/components/copilot/chat/ChatTypingIndicator.svelte
index 7c52de5cdf..1afe8aff4a 100644
--- a/frontend/src/lib/components/copilot/chat/ChatTypingIndicator.svelte
+++ b/frontend/src/lib/components/copilot/chat/ChatTypingIndicator.svelte
@@ -1,26 +1,37 @@
-
-
-
-
-
-
- {label ? label + ' · ' : ''}{formatElapsed(loadingElapsedMs)}
-
+
+ Waiting for your input
+
+{:else}
+
+
+
+
+
+
+ {label ? label + ' · ' : ''}{formatElapsed(elapsedMs)}
+
+{/if}
diff --git a/frontend/src/lib/components/copilot/chat/shared.test.ts b/frontend/src/lib/components/copilot/chat/shared.test.ts
index 0588d2a171..719638d700 100644
--- a/frontend/src/lib/components/copilot/chat/shared.test.ts
+++ b/frontend/src/lib/components/copilot/chat/shared.test.ts
@@ -888,6 +888,55 @@ describe('isActiveUserQuestion', () => {
})
})
+describe('pendingUserAction', () => {
+ const toolMessage = (overrides: Partial = {}): ToolDisplayMessage => ({
+ role: 'tool',
+ tool_call_id: 'call_p',
+ content: 'running',
+ isLoading: true,
+ ...overrides
+ })
+
+ const question = toolMessage({ userQuestion: { question: 'Pick one', choices: ['a'] } })
+
+ it('distinguishes an unanswered question from a staged confirmation', async () => {
+ const { pendingUserAction } = await import('./shared')
+ expect(pendingUserAction([question])).toBe('question')
+ expect(pendingUserAction([toolMessage({ needsConfirmation: true })])).toBe('confirmation')
+ })
+
+ it('is undefined for a tool the AI is running on its own', async () => {
+ const { pendingUserAction } = await import('./shared')
+ expect(pendingUserAction([toolMessage()])).toBe(undefined)
+ expect(pendingUserAction([toolMessage({ needsConfirmation: true, isLoading: false })])).toBe(
+ undefined
+ )
+ })
+
+ // A multi-tool turn creates every card before running the calls one at a time,
+ // so the blocked card is not the last message.
+ it('finds a blocked card sitting behind queued ones', async () => {
+ const { pendingUserAction } = await import('./shared')
+ expect(pendingUserAction([question, toolMessage(), toolMessage()])).toBe('question')
+ expect(pendingUserAction([toolMessage({ needsConfirmation: true }), toolMessage()])).toBe(
+ 'confirmation'
+ )
+ })
+
+ // Text emitted between two tool calls lands as an assistant card between them.
+ it('finds a blocked card behind an interleaved assistant card', async () => {
+ const { pendingUserAction } = await import('./shared')
+ const assistant: DisplayMessage = { role: 'assistant', content: 'and also…' }
+ expect(pendingUserAction([question, assistant, toolMessage()])).toBe('question')
+ })
+
+ it('stops at the previous turn rather than reviving its resolved cards', async () => {
+ const { pendingUserAction } = await import('./shared')
+ const userMessage: DisplayMessage = { role: 'user', index: 0, content: 'go on' }
+ expect(pendingUserAction([question, userMessage, toolMessage()])).toBe(undefined)
+ })
+})
+
describe('pollJobCompletion detach', () => {
function makeCallbacks() {
return {
diff --git a/frontend/src/lib/components/copilot/chat/shared.ts b/frontend/src/lib/components/copilot/chat/shared.ts
index 76a1595c7b..131392a665 100644
--- a/frontend/src/lib/components/copilot/chat/shared.ts
+++ b/frontend/src/lib/components/copilot/chat/shared.ts
@@ -650,6 +650,26 @@ export function isActiveUserQuestion(message: DisplayMessage | undefined): boole
)
}
+// The loop is parked on the user: an unanswered askUserQuestion, or a tool call
+// staged for confirmation. The manager stays `loading` through both, so anything
+// rendering progress must ask here first or it reports "the AI is working".
+export type PendingUserAction = 'question' | 'confirmation'
+
+// Scans back to the turn boundary, not just the last message: a turn's cards are
+// created up front and run one at a time, and text between two tool calls pushes
+// an assistant card between them, so the blocked card is rarely last. Only cards
+// of a live turn can match — every resolution path clears `isLoading`.
+export function pendingUserAction(messages: DisplayMessage[]): PendingUserAction | undefined {
+ for (let i = messages.length - 1; i >= 0; i--) {
+ const message = messages[i]
+ if (message.role === 'user') break
+ if (message.role !== 'tool') continue
+ if (isActiveUserQuestion(message)) return 'question'
+ if (message.needsConfirmation && message.isLoading) return 'confirmation'
+ }
+ return undefined
+}
+
// Fires after every tool call resolves, with the tool name. Lets a host (e.g.
// the sessions page) react to mutating tools — refreshing previews — without
// the tool layer knowing about the UI. Single slot; the consumer filters by name
diff --git a/frontend/src/lib/components/sessions/SessionStatusDot.svelte b/frontend/src/lib/components/sessions/SessionStatusDot.svelte
index 4c0ef4c408..646163bcaf 100644
--- a/frontend/src/lib/components/sessions/SessionStatusDot.svelte
+++ b/frontend/src/lib/components/sessions/SessionStatusDot.svelte
@@ -1,11 +1,5 @@