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.
This commit is contained in:
Mathias Fredriksson
2026-03-09 13:59:25 +02:00
committed by GitHub
parent e205a3493d
commit 715486465b
3 changed files with 246 additions and 43 deletions
@@ -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<typeof ConversationTimeline>,
"parsedSections"
> = {
isEmpty: false,
hasMoreMessages: false,
loadMoreSentinelRef: createRef<HTMLDivElement>(),
hasStreamOutput: false,
streamState: null,
streamTools: [],
subagentTitles: new Map(),
subagentStatusOverrides: new Map(),
isAwaitingFirstStreamChunk: false,
};
const meta: Meta<typeof ConversationTimeline> = {
title: "pages/AgentsPage/AgentDetail/ConversationTimeline",
component: ConversationTimeline,
decorators: [
(Story) => (
<div className="mx-auto w-full max-w-3xl py-6">
<Story />
</div>
),
],
};
export default meta;
type Story = StoryObj<typeof ConversationTimeline>;
/** 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();
},
};
@@ -435,49 +435,6 @@ const ChatMessageItem = memo<{
/>
)}
</div>
{(() => {
const imageBlocks = parsed.blocks.filter(
(b): b is Extract<RenderBlock, { type: "file" }> =>
b.type === "file" && b.mediaType.startsWith("image/"),
);
if (imageBlocks.length === 0) return null;
return (
<div className="mt-2 flex flex-wrap gap-2">
{imageBlocks.map((block, i) => {
const src = block.fileId
? `/api/experimental/chats/files/${block.fileId}`
: `data:${block.mediaType};base64,${block.data}`;
return (
<button
key={`user-file-${i}`}
type="button"
aria-label="View image"
className="inline-block rounded-md border-0 bg-transparent p-0"
onClick={(e) => {
e.stopPropagation();
setPreviewImage(src);
}}
>
<ImageThumbnail
previewUrl={src}
name="Attached image"
className="cursor-pointer transition-opacity hover:opacity-80"
/>
</button>
);
})}
</div>
);
})()}
{fadeFromBottom && (
<div
className="pointer-events-none absolute inset-x-0 bottom-0 h-1/2 max-h-12"
style={{
background:
"linear-gradient(to top, hsl(var(--surface-secondary)), transparent)",
}}
/>
)}{" "}
</MessageContent>
</Message>
) : (
Binary file not shown.

After

Width:  |  Height:  |  Size: 70 B