From 715486465b5825409e39b577f3e5f80018d3060c Mon Sep 17 00:00:00 2001 From: Mathias Fredriksson Date: Mon, 9 Mar 2026 13:59:25 +0200 Subject: [PATCH] fix(site): remove duplicate image rendering in chat messages (#22826) Fixes a regression where image attachments in user chat messages were rendered twice, once inside the bubble container and once outside it. - **ConversationTimeline.tsx**: Remove 43 duplicate lines (outer image block + second fade overlay) from the `ChatMessageItem` user-message branch. - **ConversationTimeline.stories.tsx** (new): Add focused stories for `ConversationTimeline` with `play` function assertions on image thumbnail counts to guard against this class of regression. --- .../ConversationTimeline.stories.tsx | 246 ++++++++++++++++++ .../AgentDetail/ConversationTimeline.tsx | 43 --- .../chats/files/storybook-test-image | Bin 0 -> 70 bytes 3 files changed, 246 insertions(+), 43 deletions(-) create mode 100644 site/src/pages/AgentsPage/AgentDetail/ConversationTimeline.stories.tsx create mode 100644 site/static/api/experimental/chats/files/storybook-test-image diff --git a/site/src/pages/AgentsPage/AgentDetail/ConversationTimeline.stories.tsx b/site/src/pages/AgentsPage/AgentDetail/ConversationTimeline.stories.tsx new file mode 100644 index 00000000000..4d7470ee2b2 --- /dev/null +++ b/site/src/pages/AgentsPage/AgentDetail/ConversationTimeline.stories.tsx @@ -0,0 +1,246 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import type * as TypesGen from "api/typesGenerated"; +import { createRef } from "react"; +import { expect, within } from "storybook/test"; +import { ConversationTimeline } from "./ConversationTimeline"; +import { + buildParsedMessageSections, + parseMessagesWithMergedTools, +} from "./messageParsing"; + +// 1×1 solid coral (#FF6B6B) PNG encoded as base64. +const TEST_PNG_B64 = + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGP4n539HwAHFwLVF8kc1wAAAABJRU5ErkJggg=="; + +const buildSections = (messages: TypesGen.ChatMessage[]) => + buildParsedMessageSections(parseMessagesWithMergedTools(messages)); + +const baseMessage = { + chat_id: "story-chat", + created_at: "2026-03-10T00:00:00.000Z", +} as const; + +const defaultArgs: Omit< + React.ComponentProps, + "parsedSections" +> = { + isEmpty: false, + hasMoreMessages: false, + loadMoreSentinelRef: createRef(), + hasStreamOutput: false, + streamState: null, + streamTools: [], + subagentTitles: new Map(), + subagentStatusOverrides: new Map(), + isAwaitingFirstStreamChunk: false, +}; + +const meta: Meta = { + title: "pages/AgentsPage/AgentDetail/ConversationTimeline", + component: ConversationTimeline, + decorators: [ + (Story) => ( +
+ +
+ ), + ], +}; +export default meta; +type Story = StoryObj; + +/** Regression guard: a single image attachment must not be duplicated. */ +export const UserMessageWithSingleImage: Story = { + args: { + ...defaultArgs, + parsedSections: buildSections([ + { + ...baseMessage, + id: 1, + role: "user", + content: [ + { type: "text", text: "Check this screenshot" }, + { + type: "file", + media_type: "image/png", + data: TEST_PNG_B64, + }, + ], + }, + { + ...baseMessage, + id: 2, + role: "assistant", + content: [ + { + type: "text", + text: "I can see the screenshot. It looks like a settings panel.", + }, + ], + }, + ]), + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const images = canvas.getAllByRole("img", { name: "Attached image" }); + expect(images).toHaveLength(1); + }, +}; + +/** Ensures N images in yields exactly N thumbnails with no duplication. */ +export const UserMessageWithMultipleImages: Story = { + args: { + ...defaultArgs, + parsedSections: buildSections([ + { + ...baseMessage, + id: 1, + role: "user", + content: [ + { type: "text", text: "Here are three screenshots" }, + { + type: "file", + media_type: "image/png", + data: TEST_PNG_B64, + }, + { + type: "file", + media_type: "image/jpeg", + data: TEST_PNG_B64, + }, + { + type: "file", + media_type: "image/webp", + data: TEST_PNG_B64, + }, + ], + }, + ]), + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const images = canvas.getAllByRole("img", { name: "Attached image" }); + expect(images).toHaveLength(3); + }, +}; + +/** File-id images use a server URL instead of inline base64 data. */ +export const UserMessageWithFileIdImage: Story = { + args: { + ...defaultArgs, + parsedSections: buildSections([ + { + ...baseMessage, + id: 1, + role: "user", + content: [ + { type: "text", text: "Uploaded via file ID" }, + { + type: "file", + media_type: "image/png", + file_id: "storybook-test-image", + }, + ], + }, + ]), + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const images = canvas.getAllByRole("img", { name: "Attached image" }); + expect(images).toHaveLength(1); + // Verify file_id path is used, not a base64 data URI. + expect(images[0]).toHaveAttribute( + "src", + "/api/experimental/chats/files/storybook-test-image", + ); + }, +}; + +/** Text-only messages must not produce spurious image thumbnails. */ +export const UserMessageTextOnly: Story = { + args: { + ...defaultArgs, + parsedSections: buildSections([ + { + ...baseMessage, + id: 1, + role: "user", + content: [{ type: "text", text: "Just a plain text message" }], + }, + ]), + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const images = canvas.queryAllByRole("img", { name: "Attached image" }); + expect(images).toHaveLength(0); + expect(canvas.getByText("Just a plain text message")).toBeInTheDocument(); + }, +}; + +/** Assistant-side images go through renderBlockList, not the user path. */ +export const AssistantMessageWithImage: Story = { + args: { + ...defaultArgs, + parsedSections: buildSections([ + { + ...baseMessage, + id: 1, + role: "user", + content: [{ type: "text", text: "Generate an image" }], + }, + { + ...baseMessage, + id: 2, + role: "assistant", + content: [ + { type: "text", text: "Here is the generated image:" }, + { + type: "file", + media_type: "image/png", + data: TEST_PNG_B64, + }, + ], + }, + ]), + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const images = canvas.getAllByRole("img", { name: "Attached image" }); + expect(images).toHaveLength(1); + }, +}; + +/** Images and file-references coexist without interfering. */ +export const UserMessageWithImagesAndFileRefs: Story = { + args: { + ...defaultArgs, + parsedSections: buildSections([ + { + ...baseMessage, + id: 1, + role: "user", + content: [ + { type: "text", text: "Look at these files" }, + { + type: "file", + media_type: "image/png", + data: TEST_PNG_B64, + }, + { + type: "file-reference", + file_name: "src/main.go", + start_line: 10, + end_line: 25, + text: "main function", + }, + ], + }, + ]), + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const images = canvas.getAllByRole("img", { name: "Attached image" }); + expect(images).toHaveLength(1); + expect(canvas.getByText(/main\.go/)).toBeInTheDocument(); + }, +}; diff --git a/site/src/pages/AgentsPage/AgentDetail/ConversationTimeline.tsx b/site/src/pages/AgentsPage/AgentDetail/ConversationTimeline.tsx index b89d529bae6..287c1c1348b 100644 --- a/site/src/pages/AgentsPage/AgentDetail/ConversationTimeline.tsx +++ b/site/src/pages/AgentsPage/AgentDetail/ConversationTimeline.tsx @@ -435,49 +435,6 @@ const ChatMessageItem = memo<{ /> )} - {(() => { - const imageBlocks = parsed.blocks.filter( - (b): b is Extract => - b.type === "file" && b.mediaType.startsWith("image/"), - ); - if (imageBlocks.length === 0) return null; - return ( -
- {imageBlocks.map((block, i) => { - const src = block.fileId - ? `/api/experimental/chats/files/${block.fileId}` - : `data:${block.mediaType};base64,${block.data}`; - return ( - - ); - })} -
- ); - })()} - {fadeFromBottom && ( -
- )}{" "} ) : ( diff --git a/site/static/api/experimental/chats/files/storybook-test-image b/site/static/api/experimental/chats/files/storybook-test-image new file mode 100644 index 0000000000000000000000000000000000000000..8413304a104d47f4b9e8899b7a7808a21680174d GIT binary patch literal 70 zcmeAS@N?(olHy`uVBq!ia0vp^j3CUx1|;Q0k92}1TpU9x<|O}^KliUZ1G_lWRq>ND Q*MSlYp00i_>zopr0LX9<9{>OV literal 0 HcmV?d00001