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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QN7VboDEm9HAB1t4sMxMdE
This commit is contained in:
Guilhem Lemouel
2026-09-08 11:56:53 +02:00
co-authored by Claude Opus 5
parent 87bccb10ff
commit 0a63bda357
2 changed files with 43 additions and 4 deletions
@@ -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<MessageInputs | undefined>(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)}`,
@@ -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.