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 = {
name: "batch_process",
description: "Processes items in batch",
@ -94,6 +114,12 @@ export const MultipleParams: Story = {
},
};
export const WithIcon: Story = {
args: {
tool: iconedTool,
},
};
export const WithAnnotations: Story = {
args: {
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 = {
name: "delete_records",
description: "Deletes records",
@ -66,6 +78,17 @@ describe("ToolDetailPanel", () => {
).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", () => {
renderWithMantine(<ToolDetailPanel {...baseProps} tool={simpleTool} />);
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 {
ProgressNotification,
Tool,
@ -24,6 +24,19 @@ export interface ToolDetailPanelProps {
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({
fw: 700,
size: "lg",
@ -59,11 +72,15 @@ export function ToolDetailPanel({
onExecute,
onCancel,
}: ToolDetailPanelProps) {
const { name, title, description, annotations, inputSchema } = tool;
const { name, title, description, icons, annotations, inputSchema } = tool;
const iconSrc = icons?.[0]?.src;
return (
<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 && (
<Group gap="xs">
{annotations.readOnlyHint && (

View file

@ -25,6 +25,19 @@ const weatherToolWithTitle: Tool = {
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 = {
args: {
tool: weatherTool,
@ -46,6 +59,13 @@ export const WithTitle: Story = {
},
};
export const WithIcon: Story = {
args: {
tool: weatherToolWithIcon,
selected: false,
},
};
export const LongName: Story = {
args: {
tool: {

View file

@ -9,6 +9,8 @@ const tool: Tool = {
inputSchema: { type: "object" },
};
const ICON_SRC = "data:image/svg+xml,%3Csvg/%3E";
describe("ToolListItem", () => {
it("renders the name when title is missing", () => {
renderWithMantine(
@ -43,4 +45,23 @@ describe("ToolListItem", () => {
renderWithMantine(<ToolListItem tool={tool} selected onClick={() => {}} />);
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 { resolveDisplayLabel } from "../../../utils/toolUtils";
@ -19,8 +19,28 @@ const ItemSubLabel = Text.withProps({
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) {
const { name, title } = tool;
const { name, title, icons } = tool;
const iconSrc = icons?.[0]?.src;
return (
<UnstyledButton
w="100%"
@ -29,10 +49,13 @@ export function ToolListItem({ tool, selected, onClick }: ToolListItemProps) {
bg={selected ? "var(--mantine-primary-color-light)" : undefined}
onClick={onClick}
>
<Stack gap={2}>
<ItemLabel>{resolveDisplayLabel(name, title)}</ItemLabel>
{title && <ItemSubLabel>{name}</ItemSubLabel>}
</Stack>
<Row>
{iconSrc && <ToolIcon src={iconSrc} alt="" />}
<ItemBody>
<ItemLabel>{resolveDisplayLabel(name, title)}</ItemLabel>
{title && <ItemSubLabel>{name}</ItemSubLabel>}
</ItemBody>
</Row>
</UnstyledButton>
);
}