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 <noreply@anthropic.com>

* feat(ai-chat): cycle chat mode with Shift+Tab, keep full message in chip tooltip

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(ai-chat): only cycle mode on Shift+Tab when the textarea is focused

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* revert(ai-chat): drop Shift+Tab mode cycling, it conflicts with browser shortcuts

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(ai-chat): only recall on ArrowUp when the textarea is focused

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(ai-chat): make ArrowUp recall image-aware, unnest the queued-chip controls

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>
This commit is contained in:
Guilhem
2026-07-28 08:57:15 +02:00
committed by GitHub
parent 3b95a2d096
commit ad0fef4a2e
6 changed files with 191 additions and 47 deletions
@@ -163,8 +163,10 @@
</script>
{#if isFork}
<div class="w-full bg-blue-50 dark:bg-blue-900 text-xs rounded-b-md max-w-7xl mx-auto">
<div class="px-4 py-2">
<!-- Side padding mirrors the page content container below, so the banner
stays aligned with it instead of bleeding to the viewport edges. -->
<div class="w-full text-xs max-w-7xl mx-auto px-4 sm:px-8 pt-2">
<div class="bg-blue-50 dark:bg-blue-900 rounded-md px-4 py-2">
<div class="flex items-center justify-between">
<div class="flex items-center gap-3">
<GitFork class="w-4 h-4 text-accent" />
@@ -29,8 +29,10 @@
</script>
{#if !isFork && draftCount > 0}
<div class="w-full bg-blue-50 dark:bg-blue-900 text-xs rounded-b-md max-w-7xl mx-auto">
<div class="px-4 py-2">
<!-- Side padding mirrors the page content container below, so the banner
stays aligned with it instead of bleeding to the viewport edges. -->
<div class="w-full text-xs max-w-7xl mx-auto px-4 sm:px-8 pt-2">
<div class="bg-blue-50 dark:bg-blue-900 rounded-md px-4 py-2">
<div class="flex items-center justify-between">
<div class="flex items-center gap-3">
<Pencil class="w-4 h-4 text-accent" />
@@ -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()
}
}
}}
>
@@ -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> | void
afterFirstTurnSaved?: () => Promise<void> | 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<typeof this.sendRequestImpl>[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
}
]
@@ -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()
</script>
@@ -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}
<!-- The body and the X are sibling buttons for the same action (an X inside a
clickable chip would be a nested interactive control, invalid ARIA). -->
<div
class="mb-1 flex flex-row items-start gap-1 rounded-md bg-surface-input px-3 py-2 opacity-60"
title={aiChatManager.queuedMessage}
class="mb-1 flex flex-row items-start gap-1 rounded-md bg-surface-input px-3 py-2 opacity-60 hover:opacity-100"
>
<div class="min-w-0 grow">
{#if aiChatManager.queuedImages.length > 0}
<div class="flex flex-row flex-wrap gap-1 {aiChatManager.queuedMessage ? 'mb-1' : ''}">
{#each aiChatManager.queuedImages as image, i (i)}
<img
src={image.dataUrl}
alt={image.name ?? 'queued image'}
class="h-6 w-6 object-cover rounded border border-border-light"
/>
{/each}
</div>
{/if}
{#if aiChatManager.queuedFiles.length > 0}
<div class="flex flex-row flex-wrap gap-1 {aiChatManager.queuedMessage ? 'mb-1' : ''}">
{#each aiChatManager.queuedFiles as file, i (i)}
<span
class="flex flex-row items-center gap-1 px-1.5 rounded border border-border-light text-2xs text-secondary max-w-36"
title={file.name}
>
<FileText size={10} class="shrink-0" />
<span class="truncate min-w-0">{file.name}</span>
</span>
{/each}
</div>
{/if}
{#if aiChatManager.queuedMessage}
<p class="text-xs text-secondary whitespace-pre-wrap line-clamp-2">
{aiChatManager.queuedMessage}
</p>
{:else if aiChatManager.queuedImages.length === 0 && aiChatManager.queuedFiles.length === 0 && aiChatManager.queuedContext?.length}
<div class="flex flex-row flex-wrap gap-1">
{#each aiChatManager.queuedContext as element (contextElementKey(element))}
<ContextElementBadge contextElement={element} compact />
{/each}
</div>
{/if}
</div>
{#if aiChatManager.queuedMessage || aiChatManager.queuedImages.length > 0 || aiChatManager.queuedFiles.length > 0}
<button
type="button"
class="min-w-0 grow text-left cursor-pointer"
title={aiChatManager.queuedMessage}
aria-label="Remove queued message and put it back in the input"
onclick={() => aiChatManager.dequeueMessage()}
>
{#if aiChatManager.queuedImages.length > 0}
<div class="flex flex-row flex-wrap gap-1 {aiChatManager.queuedMessage ? 'mb-1' : ''}">
{#each aiChatManager.queuedImages as image, i (i)}
<img
src={image.dataUrl}
alt={image.name ?? 'queued image'}
class="h-6 w-6 object-cover rounded border border-border-light"
/>
{/each}
</div>
{/if}
{#if aiChatManager.queuedFiles.length > 0}
<div class="flex flex-row flex-wrap gap-1 {aiChatManager.queuedMessage ? 'mb-1' : ''}">
{#each aiChatManager.queuedFiles as file, i (i)}
<span
class="flex flex-row items-center gap-1 px-1.5 rounded border border-border-light text-2xs text-secondary max-w-36"
title={file.name}
>
<FileText size={10} class="shrink-0" />
<span class="truncate min-w-0">{file.name}</span>
</span>
{/each}
</div>
{/if}
{#if aiChatManager.queuedMessage}
<p class="text-xs text-secondary whitespace-pre-wrap line-clamp-2">
{aiChatManager.queuedMessage}
</p>
{/if}
</button>
{:else if aiChatManager.queuedContext?.length}
<!-- Context badges are interactive themselves (popover preview), so a
context-only queue gets a plain row instead of the clickable body —
nesting the badges in it would be invalid ARIA and a badge click
would dequeue out from under the opening popover. The X (and
ArrowUp in the empty input) still restores the queue. -->
<div class="min-w-0 grow flex flex-row flex-wrap gap-1">
{#each aiChatManager.queuedContext as element (contextElementKey(element))}
<ContextElementBadge contextElement={element} compact />
{/each}
</div>
{/if}
<Button
variant="subtle"
unifiedSize="xs"
@@ -478,6 +478,9 @@ export type UserDisplayMessage = BaseDisplayMessage & {
// bubble. The prompt lists them by reference; the content here is the durable
// copy, re-registered into the session file store on load for tool reads.
files?: AttachedTextFile[]
// The client authored this turn itself (background-job auto-resume), not the
// user — ArrowUp recall must skip it.
synthetic?: boolean
}
export type CreatedResourceTriggerKind =