Skip to content

Commit 3102f96

Browse files
authored
Merge pull request #5548 from IBM/5101-group-prompts
fix(ui-rewrite): group prompts by MCP server on the Prompts page
2 parents 9f69b83 + 4a46fc4 commit 3102f96

15 files changed

Lines changed: 412 additions & 173 deletions

File tree

client/e2e/resources.spec.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -425,16 +425,18 @@ test.describe("Resources page", () => {
425425

426426
test("card group disappears from grid when its only resource is deleted", async ({ page }) => {
427427
const SOLO = makeResource("lone_resource", "lone-gateway");
428+
let resourceDeleted = false;
428429

429430
await page.route("**/resources?*", async (route) => {
430431
await route.fulfill({
431432
status: 200,
432433
contentType: "application/json",
433-
body: JSON.stringify([SOLO, RESOURCE_A1]),
434+
body: JSON.stringify(resourceDeleted ? [RESOURCE_A1] : [SOLO, RESOURCE_A1]),
434435
});
435436
});
436437
await page.route(`**/resources/${SOLO.id}`, async (route) => {
437438
if (route.request().method() === "DELETE") {
439+
resourceDeleted = true;
438440
await route.fulfill({ status: 204 });
439441
} else {
440442
await route.fallback();

client/e2e/tools.spec.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -474,16 +474,18 @@ test.describe("Tools page", () => {
474474

475475
test("card group disappears from grid when its only tool is deleted", async ({ page }) => {
476476
const SOLO = makeTool("lone_tool", "lone-gateway");
477+
let toolDeleted = false;
477478

478479
await page.route("**/tools?*", async (route) => {
479480
await route.fulfill({
480481
status: 200,
481482
contentType: "application/json",
482-
body: JSON.stringify([SOLO, TOOL_A1]),
483+
body: JSON.stringify(toolDeleted ? [TOOL_A1] : [SOLO, TOOL_A1]),
483484
});
484485
});
485486
await page.route(`**/tools/${SOLO.id}`, async (route) => {
486487
if (route.request().method() === "DELETE") {
488+
toolDeleted = true;
487489
await route.fulfill({ status: 204 });
488490
} else {
489491
await route.fallback();
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
import { describe, it, expect } from "vitest";
2+
import { render, screen } from "@testing-library/react";
3+
import { CardTag } from "./card-tag";
4+
5+
describe("CardTag", () => {
6+
it("renders a plain chip with no tooltip trigger by default", () => {
7+
render(<CardTag>label</CardTag>);
8+
9+
const tag = screen.getByText("label");
10+
expect(tag).toBeInTheDocument();
11+
// No tooltip => not wrapped in a focusable trigger.
12+
expect(tag.closest("button")).toBeNull();
13+
});
14+
15+
it("applies the neutral variant classes", () => {
16+
render(<CardTag variant="neutral">mime</CardTag>);
17+
18+
expect(screen.getByText("mime")).toHaveClass("bg-neutral-100");
19+
});
20+
21+
it("exposes an accessible tooltip when tooltip text is provided", async () => {
22+
render(<CardTag tooltip="More info">chip</CardTag>);
23+
24+
const trigger = screen.getByText("chip").closest("button");
25+
expect(trigger).not.toBeNull();
26+
27+
// Radix opens the tooltip on focus, giving keyboard users the hint.
28+
trigger!.focus();
29+
expect(await screen.findByRole("tooltip")).toHaveTextContent("More info");
30+
});
31+
});
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
import * as React from "react";
2+
import { cva, type VariantProps } from "class-variance-authority";
3+
4+
import { cn } from "@/lib/utils";
5+
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
6+
7+
const cardTagVariants = cva(
8+
"inline-flex items-center rounded px-1.5 py-1 text-[10px] font-medium leading-none",
9+
{
10+
variants: {
11+
variant: {
12+
default: "bg-tool-badge-bg text-white",
13+
neutral: "bg-neutral-100 text-neutral-700 dark:bg-neutral-800 dark:text-white",
14+
},
15+
},
16+
defaultVariants: {
17+
variant: "default",
18+
},
19+
},
20+
);
21+
22+
export interface CardTagProps
23+
extends React.HTMLAttributes<HTMLSpanElement>, VariantProps<typeof cardTagVariants> {
24+
/** Optional text shown in an accessible tooltip on hover/focus. */
25+
tooltip?: string | null;
26+
}
27+
28+
/**
29+
* A compact tag chip used inside the tool/resource/prompt cards. Matches the
30+
* Figma card-tag style (a small squared chip, not the pill-shaped `Badge`).
31+
* When `tooltip` is provided the chip becomes an accessible tooltip trigger, so
32+
* the hint works for keyboard and touch users instead of relying on native
33+
* `title`.
34+
*/
35+
export function CardTag({ className, variant, tooltip, children, ...props }: CardTagProps) {
36+
const tag = (
37+
<span className={cn(cardTagVariants({ variant }), className)} {...props}>
38+
{children}
39+
</span>
40+
);
41+
42+
if (!tooltip) {
43+
return tag;
44+
}
45+
46+
return (
47+
<TooltipProvider>
48+
<Tooltip>
49+
<TooltipTrigger className="rounded focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring">
50+
{tag}
51+
</TooltipTrigger>
52+
<TooltipContent>{tooltip}</TooltipContent>
53+
</Tooltip>
54+
</TooltipProvider>
55+
);
56+
}
57+
58+
export { cardTagVariants };

client/src/i18n/locales/en-US/prompts.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@
44
"prompts.error.loading": "Error loading prompts",
55
"prompts.add.title": "Add prompts",
66
"prompts.add.description": "Connect a MCP server to load prompts automatically, or build a reusable template with named arguments.",
7+
"prompts.restPromptsGroup": "REST prompts",
78
"prompts.card.moreOptionsFor": "More options for {name}",
8-
"prompts.card.viewDetails": "View details"
9+
"prompts.card.viewDetails": "View details",
10+
"prompts.card.morePromptsTitle": "{count, plural, one {# more prompt} other {# more prompts}}"
911
}

client/src/i18n/locales/es-ES/prompts.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@
44
"prompts.error.loading": "Error al cargar los prompts",
55
"prompts.add.title": "Añadir prompts",
66
"prompts.add.description": "Conecte un servidor MCP para cargar prompts automáticamente, o cree una plantilla reutilizable con argumentos con nombre.",
7+
"prompts.restPromptsGroup": "Prompts REST",
78
"prompts.card.moreOptionsFor": "Más opciones para {name}",
8-
"prompts.card.viewDetails": "Ver detalles"
9+
"prompts.card.viewDetails": "Ver detalles",
10+
"prompts.card.morePromptsTitle": "{count, plural, one {# prompt más} other {# prompts más}}"
911
}

client/src/i18n/locales/pt-BR/prompts.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@
44
"prompts.error.loading": "Erro ao carregar prompts",
55
"prompts.add.title": "Adicionar prompts",
66
"prompts.add.description": "Conecte um servidor MCP para carregar prompts automaticamente ou crie um modelo reutilizável com argumentos nomeados.",
7+
"prompts.restPromptsGroup": "Prompts REST",
78
"prompts.card.moreOptionsFor": "Mais opções para {name}",
8-
"prompts.card.viewDetails": "Ver detalhes"
9+
"prompts.card.viewDetails": "Ver detalhes",
10+
"prompts.card.morePromptsTitle": "{count, plural, one {mais # prompt} other {mais # prompts}}"
911
}

client/src/pages/CreateServer.test.tsx

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -178,6 +178,11 @@ describe("CreateServer", () => {
178178
});
179179
});
180180

181+
// Wait a tick to ensure the useEffect that sets selectedComponents has run
182+
await act(async () => {
183+
await new Promise((resolve) => setTimeout(resolve, 0));
184+
});
185+
181186
await act(async () => {
182187
componentMockState.capturedProps?.onSuccess({
183188
name: "Updated GH repo tasks",

client/src/pages/Prompts.test.tsx

Lines changed: 85 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -62,38 +62,64 @@ describe("Prompts", () => {
6262
expect(screen.getByText("Loading prompts, please wait...")).toBeInTheDocument();
6363
});
6464

65-
it("renders populated prompt cards with label fallbacks, tag and argument badges, filtered descriptions, and actions", async () => {
65+
it("groups prompts from the same MCP server into one card with prompt-name badges", async () => {
6666
const user = userEvent.setup();
6767
const prompts: Prompt[] = [
6868
createMockPrompt({
6969
id: "prompt-display-name",
7070
name: "summarize",
7171
displayName: "Summarize document",
7272
originalName: "summarize_document",
73-
description: "Summarizes uploaded documents.",
74-
tags: [{ id: "tag-summary", label: "summary" }],
75-
arguments: [
76-
{ name: "topic", required: true },
77-
{ name: "tone", required: false },
78-
],
73+
gatewayId: "gw-github",
74+
gatewaySlug: "gh-repo-tasks",
7975
}),
8076
createMockPrompt({
8177
id: "prompt-original-name",
8278
name: "translate",
8379
displayName: "",
8480
originalName: "translate_text",
85-
description: "None",
86-
tags: [{ id: "tag-language", label: "language" }],
87-
arguments: [{ name: "locale", required: true }],
81+
gatewayId: "gw-github",
82+
gatewaySlug: "gh-repo-tasks",
8883
}),
84+
];
85+
86+
server.use(http.get("/prompts", () => HttpResponse.json({ prompts })));
87+
88+
renderWithProviders(<Prompts />);
89+
90+
await waitFor(() => {
91+
expect(screen.getByText("gh-repo-tasks")).toBeInTheDocument();
92+
});
93+
94+
// A single group card holds both prompts as name badges.
95+
const groupCard = getPromptCard("gh-repo-tasks");
96+
expect(within(groupCard).getByText("Summarize document")).toBeInTheDocument();
97+
expect(within(groupCard).getByText("translate_text")).toBeInTheDocument();
98+
99+
// Only one "More options" trigger for the whole group.
100+
expect(screen.getAllByRole("button", { name: /More options for/i })).toHaveLength(1);
101+
102+
await user.click(screen.getByRole("button", { name: "More options for gh-repo-tasks" }));
103+
expect(await screen.findByRole("menuitem", { name: "View details" })).toBeInTheDocument();
104+
});
105+
106+
it("collapses gateway-less prompts into a single REST prompts card", async () => {
107+
const prompts: Prompt[] = [
89108
createMockPrompt({
90-
id: "prompt-name",
109+
id: "prompt-local",
110+
name: "doc_processor",
111+
displayName: "doc-processor",
112+
originalName: "doc_processor",
113+
gatewayId: null,
114+
gatewaySlug: null,
115+
}),
116+
createMockPrompt({
117+
id: "prompt-fallback",
91118
name: "fallback_prompt",
92119
displayName: undefined,
93120
originalName: undefined,
94-
description: " ",
95-
tags: [],
96-
arguments: [],
121+
gatewayId: null,
122+
gatewaySlug: null,
97123
}),
98124
];
99125

@@ -102,25 +128,56 @@ describe("Prompts", () => {
102128
renderWithProviders(<Prompts />);
103129

104130
await waitFor(() => {
105-
expect(screen.getByText("Summarize document")).toBeInTheDocument();
131+
expect(screen.getByText("REST prompts")).toBeInTheDocument();
106132
});
107133

108-
expect(screen.getByText("translate_text")).toBeInTheDocument();
109-
expect(screen.getByText("fallback_prompt")).toBeInTheDocument();
110-
expect(screen.getByText("Summarizes uploaded documents.")).toBeInTheDocument();
111-
expect(screen.queryByText("None")).not.toBeInTheDocument();
134+
// Both gateway-less prompts share one card, shown as name badges.
135+
const restCard = getPromptCard("REST prompts");
136+
expect(within(restCard).getByText("doc-processor")).toBeInTheDocument();
137+
expect(within(restCard).getByText("fallback_prompt")).toBeInTheDocument();
112138

113-
const summarizeCard = getPromptCard("Summarize document");
114-
expect(within(summarizeCard).getByText("summary")).toBeInTheDocument();
115-
expect(within(summarizeCard).getByText("topic")).toBeInTheDocument();
116-
expect(within(summarizeCard).getByText("tone")).toBeInTheDocument();
139+
// A single "More options" trigger for the whole REST group.
140+
expect(screen.getAllByRole("button", { name: /More options for/i })).toHaveLength(1);
141+
});
117142

118-
const originalNameCard = getPromptCard("translate_text");
119-
expect(within(originalNameCard).getByText("language")).toBeInTheDocument();
120-
expect(within(originalNameCard).getByText("locale")).toBeInTheDocument();
143+
it("truncates a large group to a +N overflow badge and uses descriptions as badge tooltips", async () => {
144+
// 10 prompts in one gateway group: 8 visible + a "+2" overflow badge.
145+
const prompts: Prompt[] = Array.from({ length: 10 }, (_, i) =>
146+
createMockPrompt({
147+
id: `prompt-${i}`,
148+
name: `prompt_${i}`,
149+
displayName: `Prompt ${i}`,
150+
originalName: `prompt_${i}`,
151+
gatewayId: "gw-bulk",
152+
gatewaySlug: "bulk-gateway",
153+
description: i === 0 ? "First prompt description." : "None",
154+
}),
155+
);
121156

122-
await user.click(screen.getByRole("button", { name: "More options for Summarize document" }));
123-
expect(await screen.findByRole("menuitem", { name: "View details" })).toBeInTheDocument();
157+
server.use(http.get("/prompts", () => HttpResponse.json({ prompts })));
158+
159+
renderWithProviders(<Prompts />);
160+
161+
await waitFor(() => {
162+
expect(screen.getByText("bulk-gateway")).toBeInTheDocument();
163+
});
164+
165+
const card = getPromptCard("bulk-gateway");
166+
expect(within(card).getByText("Prompt 0")).toBeInTheDocument();
167+
expect(within(card).getByText("Prompt 7")).toBeInTheDocument();
168+
// 9th and 10th prompts are hidden behind the overflow badge.
169+
expect(within(card).queryByText("Prompt 8")).not.toBeInTheDocument();
170+
expect(within(card).getByText("+2")).toBeInTheDocument();
171+
172+
// A real description becomes an accessible tooltip; filtered "None" descriptions do not.
173+
const describedTrigger = within(card).getByText("Prompt 0").closest("button");
174+
expect(describedTrigger).not.toBeNull();
175+
// Radix opens the tooltip on focus, giving keyboard users the description.
176+
describedTrigger?.focus();
177+
expect(await screen.findByRole("tooltip")).toHaveTextContent("First prompt description.");
178+
179+
// "Prompt 1" has description "None", so it renders as a plain tag with no tooltip trigger.
180+
expect(within(card).getByText("Prompt 1").closest("button")).toBeNull();
124181
});
125182

126183
it("renders error state when prompts fail to load", async () => {

0 commit comments

Comments
 (0)