mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-08-18 16:02:10 +00:00
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:
@@ -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 =
|
||||
|
||||
Reference in New Issue
Block a user