Skip to content
Open
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
32 changes: 31 additions & 1 deletion client/src/components/AppsTab.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import { getToolUiResourceUri } from "@modelcontextprotocol/ext-apps/app-bridge"
import AppRenderer from "./AppRenderer";
import ListPane from "./ListPane";
import IconDisplay, { WithIcons } from "./IconDisplay";
import SchemaFieldDescription from "./SchemaFieldDescription";
import { Label } from "@/components/ui/label";
import { Checkbox } from "@/components/ui/checkbox";
import {
Expand Down Expand Up @@ -411,6 +412,12 @@ const AppsTab = ({
key,
inputSchema,
);
const descriptionId = `${key}-description`;
const showFieldDescription =
!!prop.description &&
(prop.type === "string" ||
prop.type === "number" ||
prop.type === "integer");

return (
<div key={key} className="space-y-2">
Expand Down Expand Up @@ -496,7 +503,14 @@ const AppsTab = ({
});
}}
>
<SelectTrigger id={key}>
<SelectTrigger
id={key}
aria-describedby={
showFieldDescription
? descriptionId
: undefined
}
>
<SelectValue
placeholder={
prop.description ||
Expand All @@ -519,6 +533,11 @@ const AppsTab = ({
<Textarea
id={key}
placeholder={prop.description}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not quite sure we need still need a description as a placeholder. After this PR, the description is shown below and empty areas display it twice.

Image

aria-describedby={
showFieldDescription
? descriptionId
: undefined
}
value={
params[key] === undefined
? ""
Expand Down Expand Up @@ -564,6 +583,11 @@ const AppsTab = ({
type="number"
id={key}
placeholder={prop.description}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

the same for input placeholders

Image

aria-describedby={
showFieldDescription
? descriptionId
: undefined
}
value={
params[key] === undefined
? ""
Expand Down Expand Up @@ -606,6 +630,12 @@ const AppsTab = ({
}}
/>
)}
{showFieldDescription && (
<SchemaFieldDescription
id={descriptionId}
description={prop.description}
/>
)}
</div>
</div>
);
Expand Down
22 changes: 22 additions & 0 deletions client/src/components/SchemaFieldDescription.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
interface SchemaFieldDescriptionProps {
id: string;
description?: string;
}

export default function SchemaFieldDescription({
id,
description,
}: SchemaFieldDescriptionProps) {
if (!description) {
return null;
}

return (
<p
id={id}
className="mt-1 text-xs text-muted-foreground whitespace-pre-wrap"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We could use the same text size as the placeholder so the text is easier to see.

Suggested change
className="mt-1 text-xs text-muted-foreground whitespace-pre-wrap"
className="mt-1 text-sm text-muted-foreground whitespace-pre-wrap"

>
{description}
</p>
);
}
29 changes: 28 additions & 1 deletion client/src/components/ToolsTab.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ import { useEffect, useState, useRef } from "react";
import ListPane from "./ListPane";
import JsonView from "./JsonView";
import ToolResults from "./ToolResults";
import SchemaFieldDescription from "./SchemaFieldDescription";
import { useToast } from "@/lib/hooks/useToast";
import useCopy from "@/lib/hooks/useCopy";
import IconDisplay, { WithIcons } from "./IconDisplay";
Expand Down Expand Up @@ -354,6 +355,12 @@ const ToolsTab = ({
const inputSchema =
selectedTool.inputSchema as JsonSchemaType;
const required = isPropertyRequired(key, inputSchema);
const descriptionId = `${key}-description`;
const showFieldDescription =
!!prop.description &&
(prop.type === "string" ||
prop.type === "number" ||
prop.type === "integer");
return (
<div key={key}>
<div className="flex justify-between">
Expand Down Expand Up @@ -447,7 +454,15 @@ const ToolsTab = ({
}
}}
>
<SelectTrigger id={key} className="mt-1">
<SelectTrigger
id={key}
className="mt-1"
aria-describedby={
showFieldDescription
? descriptionId
: undefined
}
>
<SelectValue
placeholder={
prop.description || "Select an option"
Expand All @@ -467,6 +482,9 @@ const ToolsTab = ({
id={key}
name={key}
placeholder={prop.description}
aria-describedby={
showFieldDescription ? descriptionId : undefined
}
value={
params[key] === undefined
? ""
Expand Down Expand Up @@ -522,6 +540,9 @@ const ToolsTab = ({
id={key}
name={key}
placeholder={prop.description}
aria-describedby={
showFieldDescription ? descriptionId : undefined
}
value={
params[key] === undefined
? ""
Expand Down Expand Up @@ -576,6 +597,12 @@ const ToolsTab = ({
/>
</div>
)}
{showFieldDescription && (
<SchemaFieldDescription
id={descriptionId}
description={prop.description}
/>
)}
</div>
</div>
);
Expand Down
31 changes: 31 additions & 0 deletions client/src/components/__tests__/AppsTab.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -198,6 +198,37 @@ describe("AppsTab", () => {
expect(screen.getByText("Tool: fieldsApp")).toBeInTheDocument();
});

it("should keep input field descriptions visible after values are filled", () => {
const toolWithDescription: Tool = {
name: "describedApp",
inputSchema: {
type: "object",
properties: {
query: {
type: "string",
description: "Search phrase to render",
},
},
},
_meta: { ui: { resourceUri: "ui://described" } },
} as Tool & { _meta?: { ui?: { resourceUri?: string } } };

renderAppsTab({
tools: [toolWithDescription],
});

fireEvent.click(screen.getByText("describedApp"));

const input = screen.getByLabelText("query");
expect(input).toHaveAttribute("aria-describedby", "query-description");
expect(screen.getByText("Search phrase to render")).toBeVisible();

fireEvent.change(input, { target: { value: "filled value" } });

expect(input).toHaveValue("filled value");
expect(screen.getByText("Search phrase to render")).toBeVisible();
});

it("should close app renderer when close button is clicked", async () => {
renderAppsTab({
tools: [mockAppTool],
Expand Down
30 changes: 30 additions & 0 deletions client/src/components/__tests__/ToolsTab.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1015,6 +1015,36 @@ describe("ToolsTab", () => {
expect(screen.queryByRole("combobox")).not.toBeInTheDocument();
expect(screen.getByRole("textbox")).toBeInTheDocument();
});

it("should keep string parameter descriptions visible after input is filled", () => {
const toolWithStringParam: Tool = {
name: "stringTool",
description: "Tool with regular string parameter",
inputSchema: {
type: "object" as const,
properties: {
text: {
type: "string" as const,
description: "Some text input",
},
},
},
};

renderToolsTab({
tools: [toolWithStringParam],
selectedTool: toolWithStringParam,
});

const input = screen.getByRole("textbox");
expect(input).toHaveAttribute("aria-describedby", "text-description");
expect(screen.getByText("Some text input")).toBeVisible();

fireEvent.change(input, { target: { value: "filled value" } });

expect(input).toHaveValue("filled value");
expect(screen.getByText("Some text input")).toBeVisible();
});
});

describe("JSON Validation Integration", () => {
Expand Down