Skip to content

Commit c61e15f

Browse files
cliffhallclaude
andcommitted
Port resource preview UX improvements to PromptsScreen (#1328)
Brings the prompt-fetching flow up to parity with the resource preview work from #1326: - Cap the argument-form pane at maw=40% so a bare input + button doesn't stretch across wide displays. - Auto-fetch no-argument prompts the moment they're picked from the sidebar (handleSelectPrompt routes them straight to onGetPrompt({})), and hide the form once the user clicks Get Prompt — the result panel takes the same fixed-height column the resource preview uses. - Apply the PreviewCard variant=preview pattern: card sizes to content but caps at viewport, PromptMessagesDisplay pins its header (flex 0 0 auto) and lets the inner ScrollArea (flex 0 1 auto, mih 0) absorb overflow. - Tag getPromptState with the prompt name in App.tsx so the screen can ignore a stale result whose name no longer matches the selection. - MessageBubble now routes each content block through ContentViewer (markdown for text via mimeType="text/markdown", image/audio/resource via existing ContentViewer branches). Non-renderable block types (tool_use, tool_result) are filtered out; the role-label header keeps the bubble visible regardless. - PromptArgumentsForm gains onCompleteArgument + completionsSupported; when both are set, each input becomes Mantine Autocomplete with the same per-arg AbortController + per-arg debounce timer pattern as ResourceTemplatePanel. PromptsScreen + InspectorView + App.tsx wire the callback to inspectorClient.getCompletions with a ref/prompt envelope. Closes #1328. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 6678313 commit c61e15f

10 files changed

Lines changed: 678 additions & 208 deletions

File tree

clients/web/src/App.tsx

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -455,13 +455,21 @@ function App() {
455455
const onGetPrompt = useCallback(
456456
async (name: string, args: Record<string, string>) => {
457457
if (!inspectorClient) return;
458-
setGetPromptState({ status: "pending" });
458+
// Tag the in-flight + final state with the prompt name so the
459+
// PromptsScreen can guard against showing a stale result for a
460+
// prompt the user has already navigated away from.
461+
setGetPromptState({ status: "pending", promptName: name });
459462
try {
460463
const invocation = await inspectorClient.getPrompt(name, args);
461-
setGetPromptState({ status: "ok", result: invocation.result });
464+
setGetPromptState({
465+
status: "ok",
466+
promptName: name,
467+
result: invocation.result,
468+
});
462469
} catch (err) {
463470
setGetPromptState({
464471
status: "error",
472+
promptName: name,
465473
error: err instanceof Error ? err.message : String(err),
466474
});
467475
}

clients/web/src/components/elements/MessageBubble/MessageBubble.test.tsx

Lines changed: 37 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -7,17 +7,29 @@ import { renderWithMantine, screen } from "../../../test/renderWithMantine";
77
import { MessageBubble } from "./MessageBubble";
88

99
describe("MessageBubble", () => {
10-
it("renders a text sampling message", () => {
10+
it("renders a text sampling message as markdown", () => {
1111
const message: SamplingMessage = {
1212
role: "user",
1313
content: { type: "text", text: "hello" },
1414
};
1515
renderWithMantine(<MessageBubble index={0} message={message} />);
1616
expect(screen.getByText("[0] role: user")).toBeInTheDocument();
17-
expect(screen.getByText('"hello"')).toBeInTheDocument();
17+
expect(screen.getByText("hello")).toBeInTheDocument();
1818
});
1919

20-
it("renders a copy button when there is text", () => {
20+
it("renders markdown formatting in prompt text", () => {
21+
const message: PromptMessage = {
22+
role: "assistant",
23+
content: { type: "text", text: "# Heading\n\nSome **bold** text" },
24+
};
25+
renderWithMantine(<MessageBubble index={0} message={message} />);
26+
expect(
27+
screen.getByRole("heading", { level: 1, name: "Heading" }),
28+
).toBeInTheDocument();
29+
expect(screen.getByText("bold")).toBeInTheDocument();
30+
});
31+
32+
it("renders a copy button for text content via ContentViewer copyable", () => {
2133
const message: SamplingMessage = {
2234
role: "user",
2335
content: { type: "text", text: "hello" },
@@ -52,7 +64,7 @@ describe("MessageBubble", () => {
5264
);
5365
});
5466

55-
it("renders embedded resource text from a prompt message array", () => {
67+
it("renders embedded resource text from a prompt message", () => {
5668
const message: PromptMessage = {
5769
role: "user",
5870
content: {
@@ -61,7 +73,7 @@ describe("MessageBubble", () => {
6173
},
6274
};
6375
renderWithMantine(<MessageBubble index={3} message={message} />);
64-
expect(screen.getByText('"embedded"')).toBeInTheDocument();
76+
expect(screen.getByText("embedded")).toBeInTheDocument();
6577
});
6678

6779
it("renders blob resource placeholder", () => {
@@ -77,24 +89,39 @@ describe("MessageBubble", () => {
7789
},
7890
};
7991
renderWithMantine(<MessageBubble index={4} message={message} />);
80-
expect(screen.getByText('"[resource: file:///b]"')).toBeInTheDocument();
92+
expect(screen.getByText("[blob: file:///b]")).toBeInTheDocument();
8193
});
8294

8395
it("renders resource_link content", () => {
8496
const message = {
8597
role: "user",
86-
content: { type: "resource_link", uri: "ui://app" },
98+
content: { type: "resource_link", uri: "ui://app", name: "Cool App" },
8799
} as unknown as PromptMessage;
88100
renderWithMantine(<MessageBubble index={5} message={message} />);
89-
expect(screen.getByText('"[resource: ui://app]"')).toBeInTheDocument();
101+
expect(screen.getByText("Cool App")).toBeInTheDocument();
90102
});
91103

92-
it("renders fallback for unknown content types", () => {
104+
it("still renders the role label for unknown content types", () => {
93105
const message = {
94106
role: "user",
95107
content: { type: "weird" },
96108
} as unknown as SamplingMessage;
97109
renderWithMantine(<MessageBubble index={6} message={message} />);
98-
expect(screen.getByText('"[weird]"')).toBeInTheDocument();
110+
// ContentViewer returns null for unknown block types; the bubble's
111+
// role-label header still renders so the message isn't invisible.
112+
expect(screen.getByText("[6] role: user")).toBeInTheDocument();
113+
});
114+
115+
it("renders multiple content blocks from an array", () => {
116+
const message: PromptMessage = {
117+
role: "user",
118+
content: [
119+
{ type: "text", text: "first" },
120+
{ type: "text", text: "second" },
121+
] as unknown as PromptMessage["content"],
122+
};
123+
renderWithMantine(<MessageBubble index={7} message={message} />);
124+
expect(screen.getByText("first")).toBeInTheDocument();
125+
expect(screen.getByText("second")).toBeInTheDocument();
99126
});
100127
});

clients/web/src/components/elements/MessageBubble/MessageBubble.tsx

Lines changed: 42 additions & 67 deletions
Original file line numberDiff line numberDiff line change
@@ -1,72 +1,47 @@
1-
import { Group, Image, Paper, Stack, Text } from "@mantine/core";
1+
import { Group, Paper, Stack, Text } from "@mantine/core";
22
import type {
3+
ContentBlock,
34
PromptMessage,
45
SamplingMessage,
56
} from "@modelcontextprotocol/sdk/types.js";
6-
import { CopyButton } from "../CopyButton/CopyButton";
7+
import { ContentViewer } from "../ContentViewer/ContentViewer";
78

89
export interface MessageBubbleProps {
910
index: number;
1011
message: SamplingMessage | PromptMessage;
1112
}
1213

13-
function buildDataUri(mimeType: string, data: string): string {
14-
return `data:${mimeType};base64,${data}`;
15-
}
16-
1714
function formatRoleLabel(index: number, role: string): string {
1815
return `[${index}] role: ${role}`;
1916
}
2017

21-
function formatQuotedContent(content: string): string {
22-
return `"${content}"`;
23-
}
18+
// PromptMessage/SamplingMessage content unions in the SDK are wider than
19+
// ContentBlock (they admit tool_use, tool_result, etc. for the agent
20+
// messages flowing into prompts). ContentViewer renders only the visual
21+
// subset; everything else is silently dropped here. The bubble's role
22+
// header keeps an empty message from being invisible.
23+
const RENDERABLE_TYPES = new Set([
24+
"text",
25+
"image",
26+
"audio",
27+
"resource",
28+
"resource_link",
29+
]);
2430

25-
interface ContentBlockRendered {
26-
text: string;
27-
imageUri?: string;
28-
audioUri?: string;
29-
audioMime?: string;
31+
function isRenderableBlock(block: unknown): block is ContentBlock {
32+
if (typeof block !== "object" || block === null) return false;
33+
const t = (block as { type?: string }).type;
34+
return typeof t === "string" && RENDERABLE_TYPES.has(t);
3035
}
3136

32-
function extractContent(
33-
message: SamplingMessage | PromptMessage,
34-
): ContentBlockRendered {
35-
const content = message.content;
36-
const blocks = Array.isArray(content) ? content : [content];
37-
let text = "";
38-
let imageUri: string | undefined;
39-
let audioUri: string | undefined;
40-
let audioMime: string | undefined;
41-
42-
for (const block of blocks) {
43-
switch (block.type) {
44-
case "text":
45-
text += block.text;
46-
break;
47-
case "image":
48-
imageUri = buildDataUri(block.mimeType, block.data);
49-
break;
50-
case "audio":
51-
audioUri = buildDataUri(block.mimeType, block.data);
52-
audioMime = block.mimeType;
53-
break;
54-
case "resource":
55-
text +=
56-
"text" in block.resource
57-
? block.resource.text
58-
: `[resource: ${block.resource.uri}]`;
59-
break;
60-
case "resource_link":
61-
text += `[resource: ${block.uri}]`;
62-
break;
63-
default:
64-
text += `[${block.type}]`;
65-
break;
66-
}
67-
}
68-
69-
return { text, imageUri, audioUri, audioMime };
37+
// Prompt content blocks don't carry a mimeType on the text variant
38+
// (SDK `TextContent` is just `{ type: "text", text }`). Render text as
39+
// markdown by default so prompt prose with code fences, lists, and links
40+
// looks like prose rather than a preformatted dump. Image / audio blocks
41+
// already carry mimeType; ContentViewer routes them itself.
42+
function effectiveMimeForBlock(block: ContentBlock): string | undefined {
43+
if (block.type === "text") return "text/markdown";
44+
return undefined;
7045
}
7146

7247
const BubbleContainer = Paper.withProps({
@@ -81,29 +56,29 @@ const RoleLabel = Text.withProps({
8156
ff: "monospace",
8257
});
8358

84-
const PreviewImage = Image.withProps({
85-
maw: 300,
86-
radius: "sm",
87-
mt: "xs",
59+
const HeaderRow = Group.withProps({
60+
justify: "space-between",
8861
});
8962

9063
export function MessageBubble({ index, message }: MessageBubbleProps) {
91-
const { text, imageUri, audioUri, audioMime } = extractContent(message);
64+
const content = message.content;
65+
const rawBlocks = Array.isArray(content) ? content : [content];
66+
const blocks = rawBlocks.filter(isRenderableBlock);
9267

9368
return (
9469
<BubbleContainer>
9570
<Stack gap="xs">
96-
<Group justify="space-between">
71+
<HeaderRow>
9772
<RoleLabel>{formatRoleLabel(index, message.role)}</RoleLabel>
98-
{text && <CopyButton value={text} />}
99-
</Group>
100-
{text && <Text size="sm">{formatQuotedContent(text)}</Text>}
101-
{imageUri && <PreviewImage src={imageUri} />}
102-
{audioUri && (
103-
<audio controls>
104-
<source src={audioUri} type={audioMime} />
105-
</audio>
106-
)}
73+
</HeaderRow>
74+
{blocks.map((block, blockIndex) => (
75+
<ContentViewer
76+
key={blockIndex}
77+
block={block}
78+
mimeType={effectiveMimeForBlock(block)}
79+
copyable
80+
/>
81+
))}
10782
</Stack>
10883
</BubbleContainer>
10984
);

clients/web/src/components/groups/PromptArgumentsForm/PromptArgumentsForm.test.tsx

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,37 @@
1+
import { useState } from "react";
12
import { describe, it, expect, vi } from "vitest";
23
import userEvent from "@testing-library/user-event";
34
import type { Prompt } from "@modelcontextprotocol/sdk/types.js";
45
import { renderWithMantine, screen } from "../../../test/renderWithMantine";
56
import { PromptArgumentsForm } from "./PromptArgumentsForm";
67

8+
/**
9+
* Wrapper that owns `argumentValues` state so completion tests can
10+
* type multi-character input naturally — the production parent
11+
* (PromptsScreen) is what holds this state, and the form is controlled
12+
* via its onArgumentChange callback.
13+
*/
14+
function StatefulForm(
15+
props: Omit<
16+
React.ComponentProps<typeof PromptArgumentsForm>,
17+
"argumentValues" | "onArgumentChange"
18+
> & { initialValues?: Record<string, string> },
19+
) {
20+
const { initialValues, ...rest } = props;
21+
const [values, setValues] = useState<Record<string, string>>(
22+
initialValues ?? {},
23+
);
24+
return (
25+
<PromptArgumentsForm
26+
{...rest}
27+
argumentValues={values}
28+
onArgumentChange={(name, value) =>
29+
setValues((prev) => ({ ...prev, [name]: value }))
30+
}
31+
/>
32+
);
33+
}
34+
735
const promptNoArgs: Prompt = {
836
name: "summarize",
937
description: "Summarize the given text into key points",
@@ -143,4 +171,81 @@ describe("PromptArgumentsForm", () => {
143171
expect(screen.getByText("code-review")).toBeInTheDocument();
144172
expect(screen.getByText("Arguments")).toBeInTheDocument();
145173
});
174+
175+
describe("completions", () => {
176+
it("calls onCompleteArgument (debounced) and surfaces values when supported", async () => {
177+
const user = userEvent.setup();
178+
const onCompleteArgument = vi
179+
.fn<
180+
(
181+
argName: string,
182+
value: string,
183+
context: Record<string, string>,
184+
) => Promise<string[]>
185+
>()
186+
.mockResolvedValue(["alpha", "alphabet"]);
187+
188+
renderWithMantine(
189+
<StatefulForm
190+
prompt={promptWithArgs}
191+
onGetPrompt={vi.fn()}
192+
completionsSupported
193+
onCompleteArgument={onCompleteArgument}
194+
/>,
195+
);
196+
197+
await user.type(screen.getByRole("textbox", { name: /^text/ }), "al");
198+
await new Promise((r) => setTimeout(r, 400));
199+
expect(onCompleteArgument).toHaveBeenCalled();
200+
expect(onCompleteArgument).toHaveBeenLastCalledWith("text", "al", {});
201+
expect(await screen.findByText("alpha")).toBeInTheDocument();
202+
expect(screen.getByText("alphabet")).toBeInTheDocument();
203+
});
204+
205+
it("passes sibling argument values as completion context", async () => {
206+
const user = userEvent.setup();
207+
const onCompleteArgument = vi
208+
.fn<
209+
(
210+
argName: string,
211+
value: string,
212+
context: Record<string, string>,
213+
) => Promise<string[]>
214+
>()
215+
.mockResolvedValue([]);
216+
217+
renderWithMantine(
218+
<StatefulForm
219+
prompt={promptWithArgs}
220+
initialValues={{ targetLanguage: "es" }}
221+
onGetPrompt={vi.fn()}
222+
completionsSupported
223+
onCompleteArgument={onCompleteArgument}
224+
/>,
225+
);
226+
227+
await user.type(screen.getByRole("textbox", { name: /^text/ }), "h");
228+
await new Promise((r) => setTimeout(r, 400));
229+
// The completing arg ("text") is excluded from context; siblings ride along.
230+
expect(onCompleteArgument).toHaveBeenLastCalledWith("text", "h", {
231+
targetLanguage: "es",
232+
});
233+
});
234+
235+
it("does not call onCompleteArgument when completions are unsupported", async () => {
236+
const user = userEvent.setup();
237+
const onCompleteArgument = vi.fn();
238+
renderWithMantine(
239+
<StatefulForm
240+
prompt={promptWithArgs}
241+
onGetPrompt={vi.fn()}
242+
completionsSupported={false}
243+
onCompleteArgument={onCompleteArgument}
244+
/>,
245+
);
246+
await user.type(screen.getByPlaceholderText("Enter text..."), "ab");
247+
await new Promise((r) => setTimeout(r, 400));
248+
expect(onCompleteArgument).not.toHaveBeenCalled();
249+
});
250+
});
146251
});

0 commit comments

Comments
 (0)