feat(apps): render tool.icons on ToolListItem and ToolDetailPanel (#1264) (#1293)

* feat(apps): render tool.icons on ToolListItem (#1264)

Add icon rendering to ToolListItem so the Tools sidebar matches the
Apps sidebar treatment. Falls back gracefully when tool.icons is absent.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(stories): use #228be6 in ToolListItem WithIcon data URL

The source SVG had %23228be6 pre-encoded, but encodeURIComponent then
re-escaped the % to %25, leaving fill="%23228be6" in the decoded SVG
and making the icon render black instead of Mantine blue.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(apps): render tool.icons on ToolDetailPanel (#1264)

Show the tool icon next to the title in the detail panel, matching the
header icon size used in AppsScreen (24x24, fit: contain). Adds a
WithIcon story and tests for icon-present/icon-absent states.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Cliff Hall 2026-05-09 15:45:53 -04:00 • committed by GitHub
commit fc41fdc10a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 139 additions and 9 deletions

View file

@ -71,6 +71,26 @@ const longQueryTool: Tool = {
}, },
}; };
const ICON_DATA_URL =
"data:image/svg+xml;utf8," +
encodeURIComponent(
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="#228be6"><circle cx="12" cy="12" r="10"/></svg>',
);
const iconedTool: Tool = {
name: "send_message",
title: "Send Message",
description: "Sends a message to the recipient",
icons: [{ src: ICON_DATA_URL }],
inputSchema: {
type: "object",
properties: {
message: { type: "string", description: "The message to send" },
},
required: ["message"],
},
};
const batchProcessTool: Tool = { const batchProcessTool: Tool = {
name: "batch_process", name: "batch_process",
description: "Processes items in batch", description: "Processes items in batch",
@ -94,6 +114,12 @@ export const MultipleParams: Story = {
}, },
}; };
export const WithIcon: Story = {
args: {
tool: iconedTool,
},
};
export const WithAnnotations: Story = { export const WithAnnotations: Story = {
args: { args: {
tool: deleteRecordsTool, tool: deleteRecordsTool,

View file

@ -27,6 +27,18 @@ const titledTool: Tool = {
}, },
}; };
const ICON_SRC = "data:image/svg+xml,%3Csvg/%3E";
const iconedTool: Tool = {
name: "send_message",
title: "Send Message",
icons: [{ src: ICON_SRC }],
inputSchema: {
type: "object",
properties: {},
},
};
const annotatedTool: Tool = { const annotatedTool: Tool = {
name: "delete_records", name: "delete_records",
description: "Deletes records", description: "Deletes records",
@ -66,6 +78,17 @@ describe("ToolDetailPanel", () => {
).toBeInTheDocument(); ).toBeInTheDocument();
}); });
it("renders the first icon when tool.icons is present", () => {
renderWithMantine(<ToolDetailPanel {...baseProps} tool={iconedTool} />);
const img = screen.getByRole("presentation");
expect(img).toHaveAttribute("src", ICON_SRC);
});
it("does not render an icon when tool.icons is missing", () => {
renderWithMantine(<ToolDetailPanel {...baseProps} tool={simpleTool} />);
expect(screen.queryByRole("presentation")).not.toBeInTheDocument();
});
it("does not render the description when none is provided", () => { it("does not render the description when none is provided", () => {
renderWithMantine(<ToolDetailPanel {...baseProps} tool={simpleTool} />); renderWithMantine(<ToolDetailPanel {...baseProps} tool={simpleTool} />);
expect( expect(

View file

@ -1,4 +1,4 @@
import { Button, Divider, Group, Stack, Text } from "@mantine/core"; import { Button, Divider, Group, Image, Stack, Text } from "@mantine/core";
import type { import type {
ProgressNotification, ProgressNotification,
Tool, Tool,
@ -24,6 +24,19 @@ export interface ToolDetailPanelProps {
onCancel: () => void; onCancel: () => void;
} }
const TitleRow = Group.withProps({
gap: "sm",
wrap: "nowrap",
align: "center",
miw: 0,
});
const ToolIcon = Image.withProps({
w: 24,
h: 24,
fit: "contain",
});
const ToolTitle = Text.withProps({ const ToolTitle = Text.withProps({
fw: 700, fw: 700,
size: "lg", size: "lg",
@ -59,11 +72,15 @@ export function ToolDetailPanel({
onExecute, onExecute,
onCancel, onCancel,
}: ToolDetailPanelProps) { }: ToolDetailPanelProps) {
const { name, title, description, annotations, inputSchema } = tool; const { name, title, description, icons, annotations, inputSchema } = tool;
const iconSrc = icons?.[0]?.src;
return ( return (
<Stack gap="md" miw={0}> <Stack gap="md" miw={0}>
<ToolTitle>{resolveDisplayLabel(name, title)}</ToolTitle> <TitleRow>
{iconSrc && <ToolIcon src={iconSrc} alt="" />}
<ToolTitle>{resolveDisplayLabel(name, title)}</ToolTitle>
</TitleRow>
{hasAnyAnnotation(annotations) && annotations && ( {hasAnyAnnotation(annotations) && annotations && (
<Group gap="xs"> <Group gap="xs">
{annotations.readOnlyHint && ( {annotations.readOnlyHint && (

View file

@ -25,6 +25,19 @@ const weatherToolWithTitle: Tool = {
inputSchema: { type: "object" }, inputSchema: { type: "object" },
}; };
const ICON_DATA_URL =
"data:image/svg+xml;utf8," +
encodeURIComponent(
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="#228be6"><circle cx="12" cy="12" r="10"/></svg>',
);
const weatherToolWithIcon: Tool = {
name: "get_weather",
title: "Get Weather",
icons: [{ src: ICON_DATA_URL }],
inputSchema: { type: "object" },
};
export const Default: Story = { export const Default: Story = {
args: { args: {
tool: weatherTool, tool: weatherTool,
@ -46,6 +59,13 @@ export const WithTitle: Story = {
}, },
}; };
export const WithIcon: Story = {
args: {
tool: weatherToolWithIcon,
selected: false,
},
};
export const LongName: Story = { export const LongName: Story = {
args: { args: {
tool: { tool: {

View file

@ -9,6 +9,8 @@ const tool: Tool = {
inputSchema: { type: "object" }, inputSchema: { type: "object" },
}; };
const ICON_SRC = "data:image/svg+xml,%3Csvg/%3E";
describe("ToolListItem", () => { describe("ToolListItem", () => {
it("renders the name when title is missing", () => { it("renders the name when title is missing", () => {
renderWithMantine( renderWithMantine(
@ -43,4 +45,23 @@ describe("ToolListItem", () => {
renderWithMantine(<ToolListItem tool={tool} selected onClick={() => {}} />); renderWithMantine(<ToolListItem tool={tool} selected onClick={() => {}} />);
expect(screen.getByRole("button")).toBeInTheDocument(); expect(screen.getByRole("button")).toBeInTheDocument();
}); });
it("renders the first icon when tool.icons is present", () => {
renderWithMantine(
<ToolListItem
tool={{ ...tool, icons: [{ src: ICON_SRC }] }}
selected={false}
onClick={() => {}}
/>,
);
const img = screen.getByRole("presentation");
expect(img).toHaveAttribute("src", ICON_SRC);
});
it("does not render an icon when tool.icons is missing", () => {
renderWithMantine(
<ToolListItem tool={tool} selected={false} onClick={() => {}} />,
);
expect(screen.queryByRole("presentation")).not.toBeInTheDocument();
});
}); });

View file

@ -1,4 +1,4 @@
import { Stack, Text, UnstyledButton } from "@mantine/core"; import { Group, Image, Stack, Text, UnstyledButton } from "@mantine/core";
import type { Tool } from "@modelcontextprotocol/sdk/types.js"; import type { Tool } from "@modelcontextprotocol/sdk/types.js";
import { resolveDisplayLabel } from "../../../utils/toolUtils"; import { resolveDisplayLabel } from "../../../utils/toolUtils";
@ -19,8 +19,28 @@ const ItemSubLabel = Text.withProps({
truncate: true, truncate: true,
}); });
const ItemBody = Stack.withProps({
gap: 2,
flex: 1,
miw: 0,
});
const Row = Group.withProps({
gap: "sm",
wrap: "nowrap",
align: "flex-start",
});
const ToolIcon = Image.withProps({
w: 20,
h: 20,
fit: "contain",
});
export function ToolListItem({ tool, selected, onClick }: ToolListItemProps) { export function ToolListItem({ tool, selected, onClick }: ToolListItemProps) {
const { name, title } = tool; const { name, title, icons } = tool;
const iconSrc = icons?.[0]?.src;
return ( return (
<UnstyledButton <UnstyledButton
w="100%" w="100%"
@ -29,10 +49,13 @@ export function ToolListItem({ tool, selected, onClick }: ToolListItemProps) {
bg={selected ? "var(--mantine-primary-color-light)" : undefined} bg={selected ? "var(--mantine-primary-color-light)" : undefined}
onClick={onClick} onClick={onClick}
> >
<Stack gap={2}> <Row>
<ItemLabel>{resolveDisplayLabel(name, title)}</ItemLabel> {iconSrc && <ToolIcon src={iconSrc} alt="" />}
{title && <ItemSubLabel>{name}</ItemSubLabel>} <ItemBody>
</Stack> <ItemLabel>{resolveDisplayLabel(name, title)}</ItemLabel>
{title && <ItemSubLabel>{name}</ItemSubLabel>}
</ItemBody>
</Row>
</UnstyledButton> </UnstyledButton>
); );
} }