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}
+