Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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