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}