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
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -94,6 +114,12 @@ export const MultipleParams: Story = {
},
};

export const WithIcon: Story = {
args: {
tool: iconedTool,
},
};

export const WithAnnotations: Story = {
args: {
tool: deleteRecordsTool,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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(
Expand Down
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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",
Expand Down Expand Up @@ -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 && (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -46,6 +59,13 @@ export const WithTitle: Story = {
},
};

export const WithIcon: Story = {
args: {
tool: weatherToolWithIcon,
selected: false,
},
};

export const LongName: Story = {
args: {
tool: {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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();
});
});
35 changes: 29 additions & 6 deletions clients/web/src/components/groups/ToolListItem/ToolListItem.tsx
Original file line number Diff line number Diff line change
@@ -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";

Expand All @@ -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%"
Expand All @@ -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>
);
}