From 25a3e6ea7a7efb29ea3868ac0d1453d074325717 Mon Sep 17 00:00:00 2001 From: Diego Imbert <70353967+diegoimbert@users.noreply.github.com> Date: Mon, 24 Aug 2026 22:35:54 +0200 Subject: [PATCH] fix(ai-chat): keep the composer usable while a question is pending (#10816) * fix(ai-chat): keep the composer usable while a question is pending Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01D2hyAfRT2aFF7uswsdTodL * fix(ai-chat): keep a typed answer when the question's resolver is gone Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01D2hyAfRT2aFF7uswsdTodL * fix(ai-chat): only advertise the answer affordance on a live question Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01D2hyAfRT2aFF7uswsdTodL * style: trim the pending-question rationale comments to the 4-line cap Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01D2hyAfRT2aFF7uswsdTodL --------- Co-authored-by: Claude Opus 5 (1M context) --- .../copilot/chat/AIChatDisplay.svelte | 19 ++++++--- .../copilot/chat/AIChatInput.svelte | 41 ++++++++++++++++++- .../copilot/chat/AIChatManager.svelte.ts | 8 +++- .../copilot/chat/AIChatManager.test.ts | 27 ++++++++++++ .../copilot/chat/messageDraft.svelte.ts | 4 ++ .../components/copilot/chat/shared.test.ts | 14 +++++++ .../src/lib/components/copilot/chat/shared.ts | 20 ++++++--- 7 files changed, 118 insertions(+), 15 deletions(-) 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}