fix: preserve uploaded image filenames in chat history (#159216)

* fix: preserve uploaded image filenames in chat history

* test: cover image filenames in queued chat inputs

* test(gateway): expect preserved inline image filename
This commit is contained in:
Peter Steinberger
2026-09-27 00:20:58 +00:00
committed by GitHub
parent 5fd7c3d95e
commit d9c7aa507d
12 changed files with 244 additions and 143 deletions
+1
View File
@@ -406,6 +406,7 @@ pasted-text attachments. Older history without origin metadata recognizes
`text/plain` attachments named `pasted-text-<digits>.txt` as pasted text.
Uploaded attachments keep their original filenames on download, including spaces and Unicode characters.
Uploaded images also retain their names in accessible transcript image and image-viewer labels after sending and reloading; descriptive alt text takes precedence when provided.
Select **Open** on an uploaded text attachment to read it directly in the **Files** side
panel. Plain-text attachments, CSV, and JSON preserve line breaks and indentation. Markdown attachments render as documents
with interactive code blocks. When an open attachment refreshes with unchanged
+33 -23
View File
@@ -270,34 +270,28 @@ describe("composer attachment origin", () => {
describe("persistInboundImagesForTranscript", () => {
it("preserves original mixed-media order in claim-only transcript facts", async () => {
const fileName = "bands café 雪 🦞.png";
saveMediaBufferMock.mockResolvedValueOnce({
id: "video",
path: "/media/inbound/video.mp4",
size: 100,
contentType: "video/mp4",
});
const parsed = await parseMessageWithAttachments("Compare these", [
{ fileName: "video.mp4", mimeType: "video/mp4", content: GENERIC_MP4, durationMs: 2_000 },
pngAttachment({ fileName }),
]);
saveMediaBufferMock.mockResolvedValueOnce({
id: "inline",
path: "/media/inbound/inline.jpg",
path: "/media/inbound/inline.png",
size: 5,
contentType: "image/jpeg",
contentType: "image/png",
});
const result = await persistInboundImagesForTranscript({
images: [
{
type: "image",
data: "aGVsbG8=",
mimeType: "image/jpeg",
sourceIndex: 1,
},
],
images: parsed.images,
offloadedRefs: [
{
mediaRef: "https://signed.example/private-video",
id: "video",
path: "/media/inbound/video.mp4",
kind: "video",
mimeType: "video/mp4",
label: "video.mp4",
sizeBytes: 100,
durationMs: 2_000,
sourceIndex: 0,
},
{ ...parsed.offloadedRefs[0]!, mediaRef: "https://signed.example/private-video" },
],
log: { warn: vi.fn() },
logContext: "test",
@@ -310,17 +304,33 @@ describe("persistInboundImagesForTranscript", () => {
contentType: "video/mp4",
kind: "video",
fileName: "video.mp4",
sizeBytes: 100,
sizeBytes: Buffer.from(GENERIC_MP4, "base64").length,
durationMs: 2_000,
hydrationSuppressed: true,
},
{
url: "media://inbound/inline",
contentType: "image/jpeg",
contentType: "image/png",
kind: "image",
fileName,
sizeBytes: 5,
},
]);
expect(saveMediaBufferMock).toHaveBeenLastCalledWith(
Buffer.from(PNG_1x1, "base64"),
"image/png",
"inbound",
undefined,
fileName,
);
const persisted = buildPersistedUserTurnMessage({
text: parsed.message,
media: result.entries.map((entry) => entry.fact),
});
expect(readPersistedMediaFacts(persisted)?.map((fact) => fact.fileName)).toEqual([
"video.mp4",
fileName,
]);
expect(result.omission).toBe("none");
const durable = JSON.stringify(result.entries.map((entry) => entry.fact));
expect(durable).not.toContain("/media/");
+11 -1
View File
@@ -35,6 +35,7 @@ export type ChatImageContent = {
type: "image";
data: string;
mimeType: string;
fileName?: string;
sourceIndex: number;
};
@@ -165,6 +166,8 @@ export async function persistInboundImagesForTranscript(params: {
Buffer.from(image.data, "base64"),
image.mimeType,
"inbound",
undefined,
image.fileName,
);
const trusted = assertSavedMedia(saved, `inline image ${image.sourceIndex + 1}`);
entries.push({
@@ -176,6 +179,7 @@ export async function persistInboundImagesForTranscript(params: {
url: trusted.mediaRef,
contentType: saved.contentType ?? image.mimeType,
kind: "image",
...(image.fileName ? { fileName: image.fileName } : {}),
sizeBytes: saved.size,
},
});
@@ -408,7 +412,13 @@ export async function parseMessageWithAttachments(
(opts?.imageStorage !== "inline" && sizeBytes > ATTACHMENT_OFFLOAD_THRESHOLD_BYTES);
if (!shouldOffload) {
images.push({ type: "image", data: b64, mimeType: finalMime, sourceIndex: idx });
images.push({
type: "image",
data: b64,
mimeType: finalMime,
...(att.fileName ? { fileName: att.fileName } : {}),
sourceIndex: idx,
});
imageOrder.push("inline");
continue;
}
@@ -3535,7 +3535,7 @@ describe("gateway server chat", () => {
expect(responses[0]?.ok).toBe(true);
await waitForFast(() => expect(captured).toBeDefined(), FAST_WAIT_OPTS);
expect(captured?.replyOptions?.images).toEqual([
{ type: "image", data: pngB64, mimeType: "image/png", sourceIndex: 0 },
{ type: "image", data: pngB64, mimeType: "image/png", sourceIndex: 0, fileName: "dot.png" },
]);
expect(captured?.ctx?.media).toEqual([
expect.objectContaining({
+1
View File
@@ -133,6 +133,7 @@ describe("initial user message handoff", () => {
{
type: "image",
url: imageDataUrl,
fileName: "image.png",
source: { type: "url", url: imageDataUrl },
},
],
@@ -541,6 +541,7 @@ suite.define(() => {
await expect.poll(() => userImage.getAttribute("src")).toMatch(/^data:image\/png;base64,/u);
await expectDecodedThumbnail(userImage, 180);
const initialImageSrc = await userImage.getAttribute("src");
const initialImageAlt = await userImage.getAttribute("alt");
const captureThumbnail = () =>
page.video()
? takeControlUiElementScreenshot(page, userImage, [userImage])
@@ -633,6 +634,17 @@ suite.define(() => {
expect(await userImage.getAttribute("data-initial-image-node")).toBe("true");
expect((await captureThumbnail()).equals(initialPixels)).toBe(true);
await captureUiProof(suite, page, "initial-image-canonical-ready.png");
await userRow.locator(".chat-message-image-button").click();
const lightbox = page.locator("openclaw-image-lightbox");
const dialog = lightbox.getByRole("dialog");
await dialog.waitFor({ state: "visible" });
await captureUiProof(suite, page, "initial-image-filename-preview.png", {
surface: dialog,
content: [lightbox.locator("img.image")],
});
expect(initialImageAlt).toBe("pixel.png");
expect(await userImage.getAttribute("alt")).toBe("pixel.png");
expect(await dialog.getAttribute("aria-label")).toBe("Image preview: pixel.png");
} finally {
releaseMedia();
}
@@ -0,0 +1,153 @@
// @vitest-environment node
import { expectDefined } from "@openclaw/normalization-core";
import { afterEach, describe, expect, it } from "vitest";
import { createRequireRecord } from "../../../../test/helpers/record.js";
import type { ChatQueueItem, MessageGroup } from "../../lib/chat/chat-types.ts";
import { buildCachedChatItems, resetChatThreadState } from "./chat-thread.ts";
type ChatItemsProps = Parameters<typeof buildCachedChatItems>[0];
const requireRecord = createRequireRecord("record", "expected-non-array-record");
afterEach(() => resetChatThreadState("queued-inputs"));
function messageGroups(overrides: Partial<ChatItemsProps>): MessageGroup[] {
return buildCachedChatItems({
paneId: "queued-inputs",
sessionKey: "main",
runId: null,
messages: [],
toolMessages: [],
streamSegments: [],
stream: null,
streamStartedAt: null,
showToolCalls: true,
...overrides,
}).filter((item) => item.kind === "group");
}
function queuedSend(
id: string,
text: string,
createdAt: number,
sendState: ChatQueueItem["sendState"],
overrides: Partial<ChatQueueItem> = {},
): ChatQueueItem {
return { id, text, createdAt, sendState, ...overrides };
}
function groupAt(groups: readonly MessageGroup[], index: number): MessageGroup {
return expectDefined(groups[index], `message group ${index}`);
}
function messageRecord(group: MessageGroup): Record<string, unknown> {
return requireRecord(group.messages[0]?.message);
}
describe("queued transcript inputs", () => {
it("renders submitted queued sends as user turns before chat.send ACK", () => {
const groups = messageGroups({
messages: [{ role: "assistant", content: "Ready.", timestamp: 1 }],
queue: [
queuedSend("pending-send-1", "first visible send", 2, "sending", {
sendSubmittedAtMs: 10,
sender: { id: "alice@example.com", name: "Alice Example" },
}),
],
});
expect(groups.map((group) => group.role)).toEqual(["assistant", "user"]);
expect(groupAt(groups, 1).sender).toEqual({
id: "alice@example.com",
name: "Alice Example",
});
expect(messageRecord(groupAt(groups, 1)).content).toStrictEqual([
{ type: "text", text: "first visible send" },
]);
});
it("renders reply metadata on queued user turns before chat.send ACK", () => {
const groups = messageGroups({
messages: [{ role: "assistant", content: "Ready.", timestamp: 1 }],
queue: [
queuedSend("pending-send-1", "follow up", 2, "sending", {
replyToId: "transcript-123",
sendSubmittedAtMs: 10,
}),
],
});
expect(groupAt(groups, 1).messages[0]?.message).toMatchObject({
__openclaw: { replyToId: "transcript-123" },
});
});
it("keeps restored in-flight sends visible without process-local timing", () => {
const restored = {
id: "restored-send-1",
text: "stay visible across reconnect",
createdAt: 2,
sendAttempts: 1,
};
expect(
messageGroups({
queue: [
{ ...restored, sendAttempts: 0, sendSubmittedAtMs: 10, sendState: "waiting-reconnect" },
],
}),
).toStrictEqual([]);
for (const sendState of ["waiting-reconnect", "sending"] as const) {
const groups = messageGroups({ queue: [{ ...restored, sendState }] });
expect(groups).toHaveLength(1);
expect(messageRecord(groupAt(groups, 0)).content).toStrictEqual([
{ type: "text", text: "stay visible across reconnect" },
]);
}
});
it("keeps steerable queued sends out of the thread until sending starts", () => {
const queued = {
id: "pending-send-1",
text: "wait above the composer",
createdAt: 2,
sendSubmittedAtMs: 10,
};
expect(messageGroups({ queue: [{ ...queued, sendState: "waiting-idle" }] })).toStrictEqual([]);
const groups = messageGroups({ queue: [{ ...queued, sendState: "sending" }] });
expect(groups).toHaveLength(1);
expect(messageRecord(groupAt(groups, 0)).content).toStrictEqual([
{ type: "text", text: "wait above the composer" },
]);
});
it("renders submitted queued attachment sends with attachment blocks before chat.send ACK", () => {
const groups = messageGroups({
queue: [
queuedSend("pending-attachment-send-1", "see attached", 2, "sending", {
sendSubmittedAtMs: 10,
attachments: [
{
id: "attachment-1",
mimeType: "image/png",
fileName: "screenshot.png",
previewUrl: "/media/screenshot.png",
},
],
}),
],
});
expect(groups).toHaveLength(1);
expect(messageRecord(groupAt(groups, 0)).content).toStrictEqual([
{ type: "text", text: "see attached" },
{
type: "image",
url: "/media/screenshot.png",
fileName: "screenshot.png",
source: { type: "url", url: "/media/screenshot.png" },
},
]);
});
});
-106
View File
@@ -3777,112 +3777,6 @@ describe("buildCachedChatItems", () => {
});
});
it("renders submitted queued sends as user turns before chat.send ACK", () => {
const groups = messageGroups({
messages: [assistantMessage("Ready.", 1)],
queue: [
queuedSend("pending-send-1", "first visible send", 2, "sending", {
sendSubmittedAtMs: 10,
sender: { id: "alice@example.com", name: "Alice Example" },
}),
],
});
expect(groups.map((group) => group.role)).toEqual(["assistant", "user"]);
expect(groupAt(groups, 1).sender).toEqual({
id: "alice@example.com",
name: "Alice Example",
});
expect(messageRecord(groupAt(groups, 1)).content).toStrictEqual([
{ type: "text", text: "first visible send" },
]);
});
it("renders reply metadata on queued user turns before chat.send ACK", () => {
const groups = messageGroups({
messages: [assistantMessage("Ready.", 1)],
queue: [
queuedSend("pending-send-1", "follow up", 2, "sending", {
replyToId: "transcript-123",
sendSubmittedAtMs: 10,
}),
],
});
expect(groupAt(groups, 1).messages[0]?.message).toMatchObject({
__openclaw: { replyToId: "transcript-123" },
});
});
it("keeps restored in-flight sends visible without process-local timing", () => {
const restored = {
id: "restored-send-1",
text: "stay visible across reconnect",
createdAt: 2,
sendAttempts: 1,
};
expect(
messageGroups({
queue: [
{ ...restored, sendAttempts: 0, sendSubmittedAtMs: 10, sendState: "waiting-reconnect" },
],
}),
).toStrictEqual([]);
for (const sendState of ["waiting-reconnect", "sending"] as const) {
const groups = messageGroups({ queue: [{ ...restored, sendState }] });
expect(groups).toHaveLength(1);
expect(messageRecord(groupAt(groups, 0)).content).toStrictEqual([
{ type: "text", text: "stay visible across reconnect" },
]);
}
});
it("keeps steerable queued sends out of the thread until sending starts", () => {
const queued = {
id: "pending-send-1",
text: "wait above the composer",
createdAt: 2,
sendSubmittedAtMs: 10,
};
expect(messageGroups({ queue: [{ ...queued, sendState: "waiting-idle" }] })).toStrictEqual([]);
const groups = messageGroups({ queue: [{ ...queued, sendState: "sending" }] });
expect(groups).toHaveLength(1);
expect(messageRecord(groupAt(groups, 0)).content).toStrictEqual([
{ type: "text", text: "wait above the composer" },
]);
});
it("renders submitted queued attachment sends with attachment blocks before chat.send ACK", () => {
const groups = messageGroups({
queue: [
queuedSend("pending-attachment-send-1", "see attached", 2, "sending", {
sendSubmittedAtMs: 10,
attachments: [
{
id: "attachment-1",
mimeType: "image/png",
fileName: "screenshot.png",
previewUrl: "/media/screenshot.png",
},
],
}),
],
});
expect(groups).toHaveLength(1);
expect(messageRecord(groupAt(groups, 0)).content).toStrictEqual([
{ type: "text", text: "see attached" },
{
type: "image",
url: "/media/screenshot.png",
source: { type: "url", url: "/media/screenshot.png" },
},
]);
});
it("does not collapse pending sends with matching history text", () => {
const groups = messageGroups({
messages: [userMessage("same prompt", 1)],
@@ -87,10 +87,15 @@ describe("message image gallery loading", () => {
).toBe(expectedSrc);
const tile = container.querySelector<HTMLButtonElement>(".chat-message-image-button");
expect(tile).not.toBeNull();
expect(tile?.getAttribute("aria-label")).toBe(`Open image ${filename}`);
expect(container.querySelector(".chat-message-image")?.getAttribute("alt")).toBe(filename);
tile!.click();
await opened.promise;
expect(onOpenImage).toHaveBeenCalledWith(
expect.objectContaining({ src: new URL(expectedSrc, window.location.href).href }),
expect.objectContaining({
src: new URL(expectedSrc, window.location.href).href,
title: filename,
}),
);
});
@@ -257,7 +262,7 @@ describe("message image gallery loading", () => {
renderMessageImages(
[
{ url: "data:image/png;base64,cG5n", alt: "First image" },
{ url: source, alt: "Managed neighbor" },
{ url: source, alt: "Managed neighbor", fileName: "neighbor.png" },
],
{ onOpenImage, onRequestUpdate },
),
@@ -323,7 +328,7 @@ describe("message image gallery loading", () => {
renderMessageImages(
[
{ url: "data:image/png;base64,cG5n", alt: "First image" },
{ url: localSource, alt: "Local neighbor" },
{ url: localSource, fileName: "Local neighbor.png" },
],
{ onOpenImage, onRequestUpdate, sessionKey: "main", resourceBasePath: "/openclaw" },
),
@@ -366,7 +371,7 @@ describe("message image gallery loading", () => {
if (removeOwner) {
expect(result).toBeNull();
} else {
expect(result?.title).toBe("Local neighbor");
expect(result?.title).toBe("Local neighbor.png");
const url = new URL(result!.src, window.location.href);
expect(url.pathname).toBe("/openclaw/__openclaw__/assistant-media");
expect(url.searchParams.get("source")).toBe(localSource);
@@ -49,6 +49,10 @@ function isInlineImageSource(source: string | undefined): source is string {
return source?.startsWith("data:image/") === true || source?.startsWith("blob:") === true;
}
function imageTitle(image: ImageBlock): string {
return image.alt?.trim() || image.fileName?.trim() || t("chat.imageLightbox.untitled");
}
class MessageImageResourceDirective extends AsyncDirective {
private image: ImageBlock | undefined;
private options: ImageRenderOptions | undefined;
@@ -289,7 +293,7 @@ class MessageImageResourceDirective extends AsyncDirective {
previewUrl: string | undefined,
opts: ImageRenderOptions | undefined,
) {
const title = img.alt?.trim() || t("chat.imageLightbox.untitled");
const title = imageTitle(img);
return this.renderImageFrame(
img,
html`
@@ -440,7 +444,7 @@ function openMessageImage(
previewUrl: string,
opts: ImageRenderOptions | undefined,
) {
const title = img.alt?.trim() || t("chat.imageLightbox.untitled");
const title = imageTitle(img);
const requestVersion = opts?.onRequestOpenImage?.();
const images = opts?.galleryImages;
const index = images?.indexOf(img) ?? -1;
@@ -551,7 +555,7 @@ async function loadGalleryImage(
}
return {
src: safeSrc,
title: image.alt?.trim() || t("chat.imageLightbox.untitled"),
title: imageTitle(image),
width: image.width,
height: image.height,
release,
@@ -101,9 +101,10 @@ async function createCanonicalImageTranscript(
text: "Cached text",
createdAt: 1_000,
runId: "canonical-image-send",
attachments: inlineUrls.map((dataUrl) => ({
attachments: inlineUrls.map((dataUrl, index) => ({
id: crypto.randomUUID(),
mimeType: "image/png",
fileName: `image-${index}.png`,
dataUrl,
})),
};
@@ -124,10 +125,16 @@ async function createCanonicalImageTranscript(
} else if (origin === "submitted") {
reduceChatSessionProjection(owner, { type: "sendPending", runId: input.runId, message: local });
}
const media = Array.from({ length: Math.max(...factIndexes) + 1 }, (_, index) =>
factIndexes.includes(index)
? { path: `media://inbound/${crypto.randomUUID()}.png`, contentType: "image/png" }
: null,
const media: Array<{ path: string; contentType: string; fileName?: string } | null> = Array.from(
{ length: Math.max(...factIndexes) + 1 },
(_, index) =>
factIndexes.includes(index)
? {
path: `media://inbound/${crypto.randomUUID()}.png`,
contentType: "image/png",
fileName: `image-${factIndexes.indexOf(index)}.png`,
}
: null,
);
const props = {
...threadProps(
@@ -241,6 +248,7 @@ describe("canonical image presentation handoff", () => {
async (origin) => {
const fixture = await createCanonicalImageTranscript(undefined, undefined, origin);
const displayed = expectDefined(fixture.displayed[0], "displayed inline image");
expect(displayed.alt).toBe("image-0.png");
if (origin === "initial receipt") {
fixture.publish();
expectSameImageNodes(fixture.images(), [displayed]);
@@ -258,6 +266,7 @@ describe("canonical image presentation handoff", () => {
expect(displayed.getAttribute("src")).toContain(
encodeURIComponent(expectDefined(fixture.media[0], "canonical media fact").path),
);
expect(displayed.alt).toBe("image-0.png");
fixture.renderPane();
expectSameImageNodes(fixture.images(), [displayed]);
displayed.dispatchEvent(new Event("load"));
@@ -13,6 +13,7 @@ type UserChatMessageContentBlock = {
type: string;
text?: string;
url?: string;
fileName?: string;
source?: unknown;
attachment?: {
url: string;
@@ -48,6 +49,7 @@ function buildUserChatMessageContentBlocks(
blocks.push({
type: "image",
url: previewUrl,
...(attachment.fileName ? { fileName: attachment.fileName } : {}),
source: { type: "url", url: previewUrl },
});
continue;