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 @@