Skip to content
17 changes: 13 additions & 4 deletions clients/web/src/App.css
Original file line number Diff line number Diff line change
Expand Up @@ -110,13 +110,22 @@
background-color: var(--mantine-primary-color-light);
}

/* ── Select dropdown / option (dark mode) ──────────────────────── */

[data-mantine-color-scheme="dark"] .mantine-Select-dropdown {
/* ── Select / Autocomplete dropdown / option (dark mode) ─────────
*
* Select and Autocomplete each render with their own static selector
* (`mantine-Select-*` / `mantine-Autocomplete-*`) — they share the
* Combobox primitive but don't emit a shared `mantine-Combobox-*`
* class on the dropdown root. List both so the argument-completion
* Autocompletes get the same gray surface + primary-light hover as
* the filter dropdowns. */

[data-mantine-color-scheme="dark"] .mantine-Select-dropdown,
[data-mantine-color-scheme="dark"] .mantine-Autocomplete-dropdown {
background-color: var(--mantine-color-gray-8);
}

[data-mantine-color-scheme="dark"] .mantine-Select-option:hover {
[data-mantine-color-scheme="dark"] .mantine-Select-option:hover,
[data-mantine-color-scheme="dark"] .mantine-Autocomplete-option:hover {
background-color: var(--mantine-primary-color-light);
}

Expand Down
12 changes: 10 additions & 2 deletions clients/web/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -455,13 +455,21 @@ function App() {
const onGetPrompt = useCallback(
async (name: string, args: Record<string, string>) => {
if (!inspectorClient) return;
setGetPromptState({ status: "pending" });
// Tag the in-flight + final state with the prompt name so the
// PromptsScreen can guard against showing a stale result for a
// prompt the user has already navigated away from.
setGetPromptState({ status: "pending", promptName: name });
try {
const invocation = await inspectorClient.getPrompt(name, args);
setGetPromptState({ status: "ok", result: invocation.result });
setGetPromptState({
status: "ok",
promptName: name,
result: invocation.result,
});
} catch (err) {
setGetPromptState({
status: "error",
promptName: name,
error: err instanceof Error ? err.message : String(err),
});
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,17 +7,29 @@ import { renderWithMantine, screen } from "../../../test/renderWithMantine";
import { MessageBubble } from "./MessageBubble";

describe("MessageBubble", () => {
it("renders a text sampling message", () => {
it("renders a text sampling message as markdown", () => {
const message: SamplingMessage = {
role: "user",
content: { type: "text", text: "hello" },
};
renderWithMantine(<MessageBubble index={0} message={message} />);
expect(screen.getByText("[0] role: user")).toBeInTheDocument();
expect(screen.getByText('"hello"')).toBeInTheDocument();
expect(screen.getByText("hello")).toBeInTheDocument();
});

it("renders a copy button when there is text", () => {
it("renders markdown formatting in prompt text", () => {
const message: PromptMessage = {
role: "assistant",
content: { type: "text", text: "# Heading\n\nSome **bold** text" },
};
renderWithMantine(<MessageBubble index={0} message={message} />);
expect(
screen.getByRole("heading", { level: 1, name: "Heading" }),
).toBeInTheDocument();
expect(screen.getByText("bold")).toBeInTheDocument();
});

it("renders a copy button for text content via ContentViewer copyable", () => {
const message: SamplingMessage = {
role: "user",
content: { type: "text", text: "hello" },
Expand Down Expand Up @@ -52,7 +64,7 @@ describe("MessageBubble", () => {
);
});

it("renders embedded resource text from a prompt message array", () => {
it("renders embedded resource text from a prompt message", () => {
const message: PromptMessage = {
role: "user",
content: {
Expand All @@ -61,7 +73,7 @@ describe("MessageBubble", () => {
},
};
renderWithMantine(<MessageBubble index={3} message={message} />);
expect(screen.getByText('"embedded"')).toBeInTheDocument();
expect(screen.getByText("embedded")).toBeInTheDocument();
});

it("renders blob resource placeholder", () => {
Expand All @@ -77,24 +89,39 @@ describe("MessageBubble", () => {
},
};
renderWithMantine(<MessageBubble index={4} message={message} />);
expect(screen.getByText('"[resource: file:///b]"')).toBeInTheDocument();
expect(screen.getByText("[blob: file:///b]")).toBeInTheDocument();
});

it("renders resource_link content", () => {
const message = {
role: "user",
content: { type: "resource_link", uri: "ui://app" },
content: { type: "resource_link", uri: "ui://app", name: "Cool App" },
} as unknown as PromptMessage;
renderWithMantine(<MessageBubble index={5} message={message} />);
expect(screen.getByText('"[resource: ui://app]"')).toBeInTheDocument();
expect(screen.getByText("Cool App")).toBeInTheDocument();
});

it("renders fallback for unknown content types", () => {
it("still renders the role label for unknown content types", () => {
const message = {
role: "user",
content: { type: "weird" },
} as unknown as SamplingMessage;
renderWithMantine(<MessageBubble index={6} message={message} />);
expect(screen.getByText('"[weird]"')).toBeInTheDocument();
// ContentViewer returns null for unknown block types; the bubble's
// role-label header still renders so the message isn't invisible.
expect(screen.getByText("[6] role: user")).toBeInTheDocument();
});

it("renders multiple content blocks from an array", () => {
const message: PromptMessage = {
role: "user",
content: [
{ type: "text", text: "first" },
{ type: "text", text: "second" },
] as unknown as PromptMessage["content"],
};
renderWithMantine(<MessageBubble index={7} message={message} />);
expect(screen.getByText("first")).toBeInTheDocument();
expect(screen.getByText("second")).toBeInTheDocument();
});
});
118 changes: 51 additions & 67 deletions clients/web/src/components/elements/MessageBubble/MessageBubble.tsx
Original file line number Diff line number Diff line change
@@ -1,72 +1,56 @@
import { Group, Image, Paper, Stack, Text } from "@mantine/core";
import { Group, Paper, Stack, Text } from "@mantine/core";
import type {
ContentBlock,
PromptMessage,
SamplingMessage,
} from "@modelcontextprotocol/sdk/types.js";
import { CopyButton } from "../CopyButton/CopyButton";
import { ContentViewer } from "../ContentViewer/ContentViewer";

export interface MessageBubbleProps {
index: number;
message: SamplingMessage | PromptMessage;
}

function buildDataUri(mimeType: string, data: string): string {
return `data:${mimeType};base64,${data}`;
}

function formatRoleLabel(index: number, role: string): string {
return `[${index}] role: ${role}`;
}

function formatQuotedContent(content: string): string {
return `"${content}"`;
}
// PromptMessage/SamplingMessage content unions in the SDK are wider than
// ContentBlock (they admit tool_use, tool_result, etc. for the agent
// messages flowing into prompts). ContentViewer renders only the visual
// subset; everything else is silently dropped here. The bubble's role
// header keeps an empty message from being invisible.
const RENDERABLE_TYPES = new Set([
"text",
"image",
"audio",
"resource",
"resource_link",
]);

interface ContentBlockRendered {
text: string;
imageUri?: string;
audioUri?: string;
audioMime?: string;
function isRenderableBlock(block: unknown): block is ContentBlock {
if (typeof block !== "object" || block === null) return false;
const t = (block as { type?: string }).type;
return typeof t === "string" && RENDERABLE_TYPES.has(t);
}

function extractContent(
message: SamplingMessage | PromptMessage,
): ContentBlockRendered {
const content = message.content;
const blocks = Array.isArray(content) ? content : [content];
let text = "";
let imageUri: string | undefined;
let audioUri: string | undefined;
let audioMime: string | undefined;

for (const block of blocks) {
switch (block.type) {
case "text":
text += block.text;
break;
case "image":
imageUri = buildDataUri(block.mimeType, block.data);
break;
case "audio":
audioUri = buildDataUri(block.mimeType, block.data);
audioMime = block.mimeType;
break;
case "resource":
text +=
"text" in block.resource
? block.resource.text
: `[resource: ${block.resource.uri}]`;
break;
case "resource_link":
text += `[resource: ${block.uri}]`;
break;
default:
text += `[${block.type}]`;
break;
}
}

return { text, imageUri, audioUri, audioMime };
// Prompt content blocks don't carry a mimeType on the text variant
// (SDK `TextContent` is just `{ type: "text", text }`). Render text as
// markdown by default so prompt prose with code fences, lists, and links
// looks like prose rather than a preformatted dump. Image / audio blocks
// already carry mimeType; ContentViewer routes them itself.
//
// Caveat: this is unconditional — a server that emits a raw shell
// snippet, log line, or string containing `#` / `_` / backticks will
// have it transformed. Most prompts are prose so the trade-off is
// worth it, but this differs from the resource side (where
// ResourcePreviewPanel only promotes to markdown when the server
// supplies `text/markdown` or the URI suffix matches). If the MCP
// spec ever adds a per-block mimeType for prompt messages, switch
// back to opt-in rendering here.
function effectiveMimeForBlock(block: ContentBlock): string | undefined {
if (block.type === "text") return "text/markdown";
return undefined;
}

const BubbleContainer = Paper.withProps({
Expand All @@ -81,29 +65,29 @@ const RoleLabel = Text.withProps({
ff: "monospace",
});

const PreviewImage = Image.withProps({
maw: 300,
radius: "sm",
mt: "xs",
const HeaderRow = Group.withProps({
justify: "space-between",
});

export function MessageBubble({ index, message }: MessageBubbleProps) {
const { text, imageUri, audioUri, audioMime } = extractContent(message);
const content = message.content;
const rawBlocks = Array.isArray(content) ? content : [content];
const blocks = rawBlocks.filter(isRenderableBlock);

return (
<BubbleContainer>
<Stack gap="xs">
<Group justify="space-between">
<HeaderRow>
<RoleLabel>{formatRoleLabel(index, message.role)}</RoleLabel>
{text && <CopyButton value={text} />}
</Group>
{text && <Text size="sm">{formatQuotedContent(text)}</Text>}
{imageUri && <PreviewImage src={imageUri} />}
{audioUri && (
<audio controls>
<source src={audioUri} type={audioMime} />
</audio>
)}
</HeaderRow>
{blocks.map((block, blockIndex) => (
<ContentViewer
key={blockIndex}
block={block}
mimeType={effectiveMimeForBlock(block)}
copyable
/>
))}
</Stack>
</BubbleContainer>
);
Expand Down
Loading
Loading