* 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:
parent
5f296589ea
commit
fc41fdc10a
6 changed files with 139 additions and 9 deletions
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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 && (
|
||||||
|
|
|
||||||
|
|
@ -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: {
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue