From 0a63bda3579a070a9967f6140f459d8a923fa274 Mon Sep 17 00:00:00 2001 From: Guilhem Lemouel Date: Tue, 8 Sep 2026 11:56:53 +0200 Subject: [PATCH] fix(ai-chat): show a flow chat message's attachments while its run is in flight A user row reads its attachments back from the job it started, and the row the composer adds on send has no job id until the conversation is refetched, so the thumbnail only appeared after a reload. Hold what the composer sent and render that on the newest user row until its job id lands. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01QN7VboDEm9HAB1t4sMxMdE --- .../conversations/flowChatViewHost.svelte.ts | 32 ++++++++++++++++--- .../messageInputContext.svelte.ts | 15 +++++++++ 2 files changed, 43 insertions(+), 4 deletions(-) diff --git a/frontend/src/lib/components/flows/conversations/flowChatViewHost.svelte.ts b/frontend/src/lib/components/flows/conversations/flowChatViewHost.svelte.ts index e2c77eb798..4541e85854 100644 --- a/frontend/src/lib/components/flows/conversations/flowChatViewHost.svelte.ts +++ b/frontend/src/lib/components/flows/conversations/flowChatViewHost.svelte.ts @@ -15,7 +15,11 @@ import type { AttachedTextFile } from '$lib/components/copilot/chat/textFileUtil import { HelpersService } from '$lib/gen' import { sendUserToast } from '$lib/toast' import { randomUUID } from '$lib/utils/uuid' -import { MessageInputsStore } from './messageInputContext.svelte' +import { + attachmentsToMessageInputs, + MessageInputsStore, + type MessageInputs +} from './messageInputContext.svelte' /** What an AI agent step reads out of `user_attachments`. */ type S3Attachment = { s3: string; filename?: string } @@ -53,13 +57,18 @@ function toDisplayMessage( showStepNames: boolean, inputs: MessageInputsStore, toolCalls: ToolCallStore, - failed: boolean + failed: boolean, + pendingInputs: MessageInputs | undefined ): DisplayMessage { switch (message.message_type) { case 'user': { // What the turn ran with, read back from its job — the message row itself // keeps only the text. Renders through the same lanes the copilot uses. - const { images, contextElements } = inputs.get(message.job_id) + // The row the composer just added has no job id yet, so until the run is + // persisted its own attachments stand in. + const { images, contextElements } = message.job_id + ? inputs.get(message.job_id) + : (pendingInputs ?? { images: [], contextElements: [] }) return { role: 'user', index: userIndex, @@ -150,6 +159,9 @@ export class FlowChatViewHost implements ChatViewHost { () => new Set(this.#manager.messages.map((m) => m.step_name).filter(Boolean)).size > 1 ) + // What the turn in flight was sent with, until its own row carries a job id. + #pendingInputs = $state(undefined) + #messageInputs = new MessageInputsStore(() => this.#options.workspace?.()) #toolCalls = new ToolCallStore(() => this.#options.workspace?.()) @@ -157,6 +169,15 @@ export class FlowChatViewHost implements ChatViewHost { let userIndex = 0 const showStepNames = this.#showStepNames const messages = this.#manager.messages + // Only the newest user row can be the one the composer just added: every earlier + // row without a job id predates the column and has no inputs to show. + let lastUserIndex = -1 + for (let i = messages.length - 1; i >= 0; i--) { + if (messages[i].message_type === 'user') { + lastUserIndex = i + break + } + } return messages.map((message, i) => toDisplayMessage( message, @@ -164,7 +185,8 @@ export class FlowChatViewHost implements ChatViewHost { showStepNames, this.#messageInputs, this.#toolCalls, - message.message_type === 'user' && turnFailed(messages, i) + message.message_type === 'user' && turnFailed(messages, i), + i === lastUserIndex ? this.#pendingInputs : undefined ) ) }) @@ -216,11 +238,13 @@ export class FlowChatViewHost implements ChatViewHost { const args = { ...(this.#options.additionalInputs?.() ?? {}) } const target = this.#options.attachmentsTarget?.() const attachments = [...(options.images ?? []), ...(options.blobs ?? [])] + this.#pendingInputs = undefined if (target && attachments.length > 0) { this.#uploading = true try { const uploaded = await this.#uploadAttachments(attachments) args[target.name] = target.multiple ? uploaded : uploaded[0] + this.#pendingInputs = attachmentsToMessageInputs(options.images ?? [], options.blobs ?? []) } catch (e) { sendUserToast( `Could not upload the attachments: ${e instanceof Error ? e.message : String(e)}`, diff --git a/frontend/src/lib/components/flows/conversations/messageInputContext.svelte.ts b/frontend/src/lib/components/flows/conversations/messageInputContext.svelte.ts index ad25db31ec..d87421f4b2 100644 --- a/frontend/src/lib/components/flows/conversations/messageInputContext.svelte.ts +++ b/frontend/src/lib/components/flows/conversations/messageInputContext.svelte.ts @@ -97,6 +97,21 @@ export function argsToMessageInputs( return images.length > 0 || contextElements.length > 0 ? { images, contextElements } : EMPTY } +/** + * The same lanes, built from what the composer just sent. The turn in flight has no + * job yet, so its row cannot read its inputs back from one; the data URLs are still + * in hand, so the thumbnails need no fetch. + */ +export function attachmentsToMessageInputs( + images: AttachedImage[], + blobs: { name: string }[] +): MessageInputs { + const contextElements = blobs.map((blob) => + createAttachedFileContextElement(blob.name, `Attached file · ${blob.name}`) + ) + return images.length > 0 || contextElements.length > 0 ? { images, contextElements } : EMPTY +} + /** * Per-conversation cache of run arguments by job id. One fetch per turn, kept only * for as long as the chat is mounted.