diff --git a/frontend/src/lib/components/copilot/chat/AIChatDisplay.svelte b/frontend/src/lib/components/copilot/chat/AIChatDisplay.svelte
index 9b55bd9217..c68945695b 100644
--- a/frontend/src/lib/components/copilot/chat/AIChatDisplay.svelte
+++ b/frontend/src/lib/components/copilot/chat/AIChatDisplay.svelte
@@ -26,7 +26,7 @@
import { fade } from 'svelte/transition'
import Popover from '$lib/components/meltComponents/Popover.svelte'
import DropdownV2 from '$lib/components/DropdownV2.svelte'
- import { pendingUserAction, type DisplayMessage } from './shared'
+ import { pendingUserAction, pendingUserActionDetail, type DisplayMessage } from './shared'
import { PLAN_MODE_TEXT_COLOR, PLAN_MODE_TRIGGER_CLASS } from './planMode'
import { PLAN_MODE_MESSAGES } from './planModeMessages'
import type { ContextElement } from './context'
@@ -514,10 +514,16 @@
// 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(pendingUserAction(messages) === 'question')
+ // Gated on `loading` because a card restored from history still looks parked:
+ // its resolver left with the old page, so the composer must not advertise an
+ // answer it cannot deliver.
+ const pendingQuestionToolCallId = $derived.by(() => {
+ if (!aiChatManager.loading) {
+ return undefined
+ }
+ const pending = pendingUserActionDetail(messages)
+ return pending?.action === 'question' ? pending.toolCallId : undefined
+ })
// Get app context for display when in APP mode
const appContext = $derived.by((): SelectedContext | undefined => {
@@ -799,7 +805,8 @@ the panel, or the Escape-to-stop focus check would wrongly reject them. -->
{initialInstructions}
{onDraftChange}
showContext={aiChatManager.mode !== AIMode.GLOBAL}
- disabled={disabled || hasActiveUserQuestion}
+ {disabled}
+ {pendingQuestionToolCallId}
isFirstMessage={messages.length === 0}
/>
void
+ // tool_call_id of the askUserQuestion the turn is parked on, when it is. A
+ // plain-text draft sent from here answers it instead of queueing behind a
+ // turn that only the answer can resume.
+ pendingQuestionToolCallId?: string
}
let {
@@ -98,7 +102,8 @@
onKeyDown = undefined,
loading,
onCancel,
- onDraftChange = undefined
+ onDraftChange = undefined,
+ pendingQuestionToolCallId = undefined
}: Props = $props()
// GLOBAL-mode suggestion pool. We pick one at mount-time so each new
@@ -124,6 +129,10 @@
// Generate mode-specific placeholder
const modePlaceholder = $derived.by(() => {
+ if (pendingQuestionToolCallId !== undefined) {
+ return 'Answer the question above'
+ }
+
if (!isFirstMessage) {
return 'Ask followup'
}
@@ -182,6 +191,17 @@
onDraftChange?.(text)
})
})
+
+ // A parked askUserQuestion is a request for input, so a plain-text draft sent
+ // from the composer answers it rather than being queued behind a turn that can
+ // only resume once the question is answered. Attachments can't ride an answer,
+ // so a draft carrying them falls through to the queue and flushes on resume.
+ const questionAnsweredBySend = $derived(
+ editingMessageIndex === null && draft.text.trim() !== '' && !draft.hasAttachments
+ ? pendingQuestionToolCallId
+ : undefined
+ )
+
// Images being decoded right now. Holds off sending so a message can never go
// out without an attachment the user already dropped, and reserves cap slots
// against a concurrent drop.
@@ -674,6 +694,20 @@
if (pendingImages > 0 || pendingFiles > 0 || ingestionHolds > 0) {
return
}
+ // Read before `take()` empties the draft the id derives from, and only take
+ // once the answer is delivered — an undelivered one would leave the user
+ // with neither their text nor a resumed turn.
+ const answeredQuestionId = questionAnsweredBySend
+ if (
+ answeredQuestionId &&
+ aiChatManager.handleUserQuestionAnswer(answeredQuestionId, [
+ expanded(chatDraft(draft.text.trim(), draft.pastes))
+ ])
+ ) {
+ draft.take()
+ contextTextareaComponent?.clearForSend()
+ return
+ }
if (aiChatManager.loading) {
// Queue the message instead of silently discarding it — it is
// auto-sent when the streaming turn completes successfully.
@@ -940,7 +974,10 @@
{#snippet sendStopButton()}
- {@const isLoading = loading ?? aiChatManager.loading}
+
+ {@const isLoading = (loading ?? aiChatManager.loading) && !questionAnsweredBySend}
{@const emptyDraft = draft.isEmpty}