From ad0fef4a2e0ea63ae00562ff10d9ebbae66c42d0 Mon Sep 17 00:00:00 2001 From: Guilhem Date: Tue, 28 Jul 2026 08:57:15 +0200 Subject: [PATCH] feat(ai-chat): recall queued/last message into the composer (#10191) * feat(ai-chat): recall queued/last message into input via ArrowUp or chip click Co-Authored-By: Claude Fable 5 * feat(ai-chat): cycle chat mode with Shift+Tab, keep full message in chip tooltip Co-Authored-By: Claude Fable 5 * fix(ai-chat): only cycle mode on Shift+Tab when the textarea is focused Co-Authored-By: Claude Fable 5 * revert(ai-chat): drop Shift+Tab mode cycling, it conflicts with browser shortcuts Co-Authored-By: Claude Fable 5 * fix(ai-chat): only recall on ArrowUp when the textarea is focused Co-Authored-By: Claude Fable 5 * fix(ai-chat): make ArrowUp recall image-aware, unnest the queued-chip controls Co-Authored-By: Claude Fable 5 * fix: align workspace banners with page content padding and round corners * fix(ai): recall attachments and context chips on ArrowUp, unnest chip buttons * fix(ai): skip synthetic auto-resume turns in ArrowUp recall boundary * fix(ai): defer recall during in-flight sends, make synthetic flag per-send * fix(ai): gate recall on send-in-flight, not loading --------- Co-authored-by: Claude Fable 5 --- .../lib/components/ForkWorkspaceBanner.svelte | 6 +- .../components/WorkspaceDraftsBanner.svelte | 6 +- .../copilot/chat/AIChatInput.svelte | 97 +++++++++++++++++++ .../copilot/chat/AIChatManager.svelte.ts | 29 +++++- .../copilot/chat/QueuedMessageChip.svelte | 97 +++++++++++-------- .../src/lib/components/copilot/chat/shared.ts | 3 + 6 files changed, 191 insertions(+), 47 deletions(-) diff --git a/frontend/src/lib/components/ForkWorkspaceBanner.svelte b/frontend/src/lib/components/ForkWorkspaceBanner.svelte index b97740b135..4a74d97241 100644 --- a/frontend/src/lib/components/ForkWorkspaceBanner.svelte +++ b/frontend/src/lib/components/ForkWorkspaceBanner.svelte @@ -163,8 +163,10 @@ {#if isFork} -
-
+ +
+
diff --git a/frontend/src/lib/components/WorkspaceDraftsBanner.svelte b/frontend/src/lib/components/WorkspaceDraftsBanner.svelte index 0a9ab91075..074cefee3a 100644 --- a/frontend/src/lib/components/WorkspaceDraftsBanner.svelte +++ b/frontend/src/lib/components/WorkspaceDraftsBanner.svelte @@ -29,8 +29,10 @@ {#if !isFork && draftCount > 0} -
-
+ +
+
diff --git a/frontend/src/lib/components/copilot/chat/AIChatInput.svelte b/frontend/src/lib/components/copilot/chat/AIChatInput.svelte index 336246b368..a377a7e370 100644 --- a/frontend/src/lib/components/copilot/chat/AIChatInput.svelte +++ b/frontend/src/lib/components/copilot/chat/AIChatInput.svelte @@ -509,6 +509,67 @@ focusInput() } + /** Copy the last sent message (all four draft lanes + context chips) into + * the composer. The conversation is left untouched — resending creates a new + * message, unlike the bubble's edit pencil which rewinds the conversation. */ + function recallLastSentMessage(): boolean { + const messages = aiChatManager.displayMessages + for (let i = messages.length - 1; i >= 0; i--) { + const message = messages[i] + if (message.role !== 'user' || message.synthetic) continue + // Images come from the stored turn, never the bubble: a provider + // rejection strips them from history while the bubble keeps its copy, + // and recalling that copy would re-attach the refused image. + const images = aiChatManager.storedImages(i) ?? [] + // Eligibility looks at the bubble, though: the last thing the user + // actually sent is the recall boundary, so a context-only turn (GLOBAL + // allows text-free sends with chips) recalls its chips, and a turn + // whose only image was provider-rejected recalls as empty — neither + // falls through and resurrects an older prompt. + if ( + !( + message.content || + message.images?.length || + message.files?.length || + message.contextElements?.length + ) + ) + continue + draft.replace({ + text: message.content, + pastes: message.pastes, + images + }) + // The recalled message stays in the transcript, so its files still + // count against the conversation budget — re-admit them instead of + // copying, or resending would blow past MAX_CONVERSATION_FILE_BYTES. + if (message.files?.length) { + const budget = + MAX_CONVERSATION_FILE_BYTES - aiChatManager.attachmentBytesExcluding(composerKey) + const { droppedAtBudget } = draft.addFiles(message.files, budget) + if (droppedAtBudget > 0) { + const mb = Math.round(MAX_CONVERSATION_FILE_BYTES / 1_000_000) + sendUserToast( + `${droppedAtBudget} file(s) not recalled — this conversation reached its ${mb}MB attachment budget.`, + true + ) + } + } + // Chips usually persist in the selection across sends, but the user may + // have removed some since — merge the message's chips back in (union, + // like dequeue) rather than replace, so chips selected since survive. + const missingContext = (message.contextElements ?? []).filter( + (c) => !selectedContext.some((s) => isSameContextElement(s, c)) + ) + if (missingContext.length > 0) { + selectedContext = [...selectedContext, ...missingContext] + } + focusInput() + return true + } + return false + } + function clickOutside(node: HTMLElement) { function handleClick(event: MouseEvent) { // An expanded image chip renders in a portal, so clicks in it land outside @@ -980,6 +1041,42 @@ if (e.key === 'Escape' && aiChatManager.loading) { e.preventDefault() aiChatManager.cancel() + } else if ( + e.key === 'ArrowUp' && + !e.defaultPrevented && + e.target instanceof HTMLTextAreaElement && + editingMessageIndex === null && + onSendRequest === undefined && + draft.isEmpty && + pendingImages === 0 && + pendingFiles === 0 && + ingestionHolds === 0 + ) { + // Shell-style recall: ArrowUp in the empty main composer pulls the + // queued message (if any, same as the queued-message chip's click/X) + // or otherwise a copy of the last sent message back into the input. + // Empty means no attachment still decoding/reading either — + // recalling over an attachments-only draft would clobber it. + // Textarea only — ArrowUp on the wrapper's buttons/badges must not + // mutate the composer; and main composer only — the edit input and + // custom-send consumers (inline widget) have their own history + // semantics. + if ( + aiChatManager.queuedMessage || + aiChatManager.queuedImages.length > 0 || + aiChatManager.queuedFiles.length > 0 || + (aiChatManager.queuedContext?.length ?? 0) > 0 + ) { + e.preventDefault() + aiChatManager.dequeueMessage() + } else if (!aiChatManager.sendInFlight && recallLastSentMessage()) { + // History recall waits for the in-flight turn: from the moment the + // composer clears, the turn's bubble, stored images and context land + // across several awaits, so recalling now would return an incomplete + // copy — or skip past the turn entirely. Dequeueing above stays + // available; the queue is composer state, not history. + e.preventDefault() + } } }} > diff --git a/frontend/src/lib/components/copilot/chat/AIChatManager.svelte.ts b/frontend/src/lib/components/copilot/chat/AIChatManager.svelte.ts index 847c0c6ffd..a1f90c546d 100644 --- a/frontend/src/lib/components/copilot/chat/AIChatManager.svelte.ts +++ b/frontend/src/lib/components/copilot/chat/AIChatManager.svelte.ts @@ -903,7 +903,7 @@ export class AIChatManager { const count = this.pendingJobNotes.length this.instructions = count === 1 ? 'A background job just finished.' : `${count} background jobs just finished.` - await this.sendRequest() + await this.sendRequest({ synthetic: true }) } catch (e) { console.error('Auto-resume after background job failed', e) } finally { @@ -2417,7 +2417,26 @@ export class AIChatManager { beforeSend?: () => Promise | void afterFirstTurnSaved?: () => Promise | void - sendRequest = async ( + /** A send is between the composer clearing and its turn being installed. + * `loading` only rises after the attachment-upkeep awaits, so consumers that + * must not read half-installed history (ArrowUp recall) need this instead. + * Counted, not boolean: a send recursively flushes queued messages, and the + * inner one finishing doesn't mean the outer is done. */ + #sendsInFlight = $state(0) + get sendInFlight(): boolean { + return this.#sendsInFlight > 0 + } + + sendRequest = async (options: Parameters[0] = {}) => { + this.#sendsInFlight++ + try { + return await this.sendRequestImpl(options) + } finally { + this.#sendsInFlight-- + } + } + + private sendRequestImpl = async ( options: { removeDiff?: boolean addBackCode?: boolean @@ -2446,6 +2465,11 @@ export class AIChatManager { * under it are released once this send installs its bubble or exits before * install. Absent on normal sends, so they never touch a resend's reservation. */ resendReservationKey?: string + /** This send was authored by the client (background-job auto-resume), not + * the user. Per-send, not read from #autoResuming: that flag stays up + * while this call recursively flushes queued messages, and those are real + * user turns. */ + synthetic?: boolean } = {} ) => { // Returns whether the input was consumed: true when it was sent as a chat @@ -2629,6 +2653,7 @@ export class AIChatManager { // lets the history's blob store persist one copy for both. images: images.length > 0 ? images : undefined, files: files.length > 0 ? files : undefined, + synthetic: options.synthetic ? true : undefined, index: this.messages.length // matching with actual messages index. not -1 because it's not yet added to the messages array } ] diff --git a/frontend/src/lib/components/copilot/chat/QueuedMessageChip.svelte b/frontend/src/lib/components/copilot/chat/QueuedMessageChip.svelte index 0e1ec12e74..f5fdc7a5d3 100644 --- a/frontend/src/lib/components/copilot/chat/QueuedMessageChip.svelte +++ b/frontend/src/lib/components/copilot/chat/QueuedMessageChip.svelte @@ -8,8 +8,9 @@ // The single message typed while a turn was streaming, waiting to be // auto-sent when the turn finishes. Rendered above the whole input stack // (session bars, context badges, textarea) so it reads as "next in the - // conversation". Pressing Enter again appends another line to it; the X - // removes it and restores its text into the input so nothing is lost. + // conversation". Pressing Enter again appends another line to it; clicking + // the chip body (its X, or ArrowUp in the empty input) removes it and + // restores its content into the input so nothing is lost. const aiChatManager = getAiChatManager() @@ -20,47 +21,61 @@ those stay visible in the composer, and repeating them would read as two selections. --> {#if aiChatManager.queuedMessage || aiChatManager.queuedImages.length > 0 || aiChatManager.queuedFiles.length > 0 || (aiChatManager.queuedContext?.length ?? 0) > 0} +
-
- {#if aiChatManager.queuedImages.length > 0} -
- {#each aiChatManager.queuedImages as image, i (i)} - {image.name - {/each} -
- {/if} - {#if aiChatManager.queuedFiles.length > 0} -
- {#each aiChatManager.queuedFiles as file, i (i)} - - - {file.name} - - {/each} -
- {/if} - {#if aiChatManager.queuedMessage} -

- {aiChatManager.queuedMessage} -

- {:else if aiChatManager.queuedImages.length === 0 && aiChatManager.queuedFiles.length === 0 && aiChatManager.queuedContext?.length} -
- {#each aiChatManager.queuedContext as element (contextElementKey(element))} - - {/each} -
- {/if} -
+ {#if aiChatManager.queuedMessage || aiChatManager.queuedImages.length > 0 || aiChatManager.queuedFiles.length > 0} + + {:else if aiChatManager.queuedContext?.length} + +
+ {#each aiChatManager.queuedContext as element (contextElementKey(element))} + + {/each} +
+ {/if}