Skip to content

Commit 8d6dea0

Browse files
cliffhallclaude
andauthored
Port resource preview UX improvements to PromptsScreen (#1328) (#1331)
* 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> * Add close (X) buttons to ResourcePreviewPanel and PromptMessagesDisplay A top-left CloseButton dismisses the preview panel. The host screen decides what to fall back to: - ResourcesScreen remembers the originating template URI when the user reads from a template form (originatingTemplateUri); closing the preview restores the template form. Plain-resource reads (sidebar selection) just empty the selection and return to the empty state. Picking a different template / resource from the sidebar clears the back-trail. - PromptsScreen flips submittedFor back to undefined when the closed prompt has arguments — the argument form re-renders with the user's values preserved so they can edit and re-submit. No-arg prompts have nothing to fall back to, so the selection is dropped and the empty state appears. The X button is hidden when the host omits onClose, so callers that don't want a dismiss control keep their existing rendering. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * Add close button to pending and error preview states The OK-state preview already had its X button (inside the panel component itself). Pending and error states were inline cards in renderReadState / renderPreview with no way to dismiss them, so a user who submitted bad input to a resource template or prompt was stuck staring at the error until they picked a different sidebar item. Adds a top-left CloseButton row to both states on both screens, wired to the same handleClosePreview handler — so the template form or argument form re-appears on close just like it does from the OK state. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * Fire prompt completions on focus and send every sibling in context Two fixes to the PromptArgumentsForm autocomplete: - Focusing an argument input now fires completion/complete immediately (handleFocus) — the dropdown is populated as soon as the user clicks in, not only after they start typing. Any in-flight debounce timer for the same arg is cancelled so a stale keystroke request can't overwrite the fresh focus response. - The completion context now includes every declared prompt argument (with "" for ones the user hasn't typed into yet), minus the one being completed. Previously the context only carried args the user had touched, so servers that disambiguate based on co-arguments couldn't see the full picture on the first keystroke. Tests cover both: a focus-only path that fires before any keystroke, and a typing path that asserts the empty sibling is sent through. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * Fire resource-template completions on focus Mirrors the prompt-side change: focusing a template variable input now fires completion/complete immediately so the dropdown populates the moment the user clicks in, rather than waiting for the first keystroke + 300ms debounce. Any pending debounce timer for the same variable is cancelled first so a stale keystroke response can't overwrite the focus response. The sibling-context coverage was already correct here — `variables` is seeded with empty strings for every declared template variable at mount and on template switch, so the context payload always carries the full variable set minus the one being completed. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * Style Autocomplete dropdowns to match the rest of the app Two fixes so the completion dropdowns on prompt-argument and resource-template inputs look like every other dropdown in the app: - App.css: the dark-mode dropdown / option-hover rules used to target .mantine-Select-* only, so the Autocomplete dropdowns shipped with Mantine's default surface color and a different hover background. Re-scoped to .mantine-Combobox-* so every Combobox-built input (Select, Autocomplete, MultiSelect, …) inherits the same styling. - theme/Autocomplete.ts: new theme module setting `radius: "md"` so Autocomplete matches the default Select / TextInput corner radius. Wired through theme/index.ts and theme/theme.ts. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * Fix dropdown styling — Select and Autocomplete need separate selectors The previous commit re-scoped the dark-mode dropdown rules from `.mantine-Select-*` to `.mantine-Combobox-*` on the assumption that both Select and Autocomplete share a Combobox class on the dropdown root. They don't — Mantine's Combobox factory takes `__staticSelector` from the parent component, so Select emits `mantine-Select-dropdown` and Autocomplete emits `mantine-Autocomplete-dropdown` with no shared `Combobox` class on that element. The `.mantine-Combobox-*` selector matched nothing, so the TaskControls filter (and every other Select) lost its gray surface and reverted to Mantine's default brown-ish dark background. List both selectors so Select dropdowns keep their styling and Autocomplete dropdowns pick it up. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * Address review feedback: canSubmit, fire-time context, re-click guard - PromptArgumentsForm: add a canSubmit guard that disables Get Prompt until every required argument has a value, matching the resource template form's symmetry. Optional args may stay blank. - Both forms: capture sibling context inside the debounce callback, not at schedule time. Hold the latest argumentValues / variables in a ref + sync via useEffect, and call buildContext() at fire time. Without this, typing in arg A then arg B within the 300ms window would ship A's request with B's pre-keystroke value. - PromptsScreen.handleSelectPrompt: early-return when the user re-clicks the already-selected prompt — sidebar is for navigation, ✕ is for dismiss. Previously a re-click wiped form values and re-fired the auto-fetch for no-arg prompts. - MessageBubble: expand the comment on effectiveMimeForBlock to flag the unconditional markdown promotion as a known asymmetry with the resource side (which only promotes when the server signals it), pending a per-block mimeType in the spec. - New tests: canSubmit disabled / enabled transitions, fire-time context capture across siblings, abort-path verification (a stale in-flight response must not overwrite the fresh one), and the no-op re-click guard on PromptsScreen. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * Address follow-up review nits: ghost suggestions, dead previewActive branch - Clear `completions[name]` at the top of handleChange in both PromptArgumentsForm and ResourceTemplatePanel so the dropdown doesn't show stale options from the previous prefix during the 300ms debounce + network window. - Simplify previewActive in PromptsScreen to drop the unreachable `!getPromptState?.promptName` fallback. App.tsx tags every prompt state transition with `promptName`, so the fallback was dead code that obscured the actual runtime invariant. - Add a focused test for each form that asserts stale dropdown options vanish the instant a new keystroke arrives. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 6678313 commit 8d6dea0

21 files changed

Lines changed: 1428 additions & 224 deletions

File tree

clients/web/src/App.css

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -110,13 +110,22 @@
110110
background-color: var(--mantine-primary-color-light);
111111
}
112112

113-
/* ── Select dropdown / option (dark mode) ──────────────────────── */
114-
115-
[data-mantine-color-scheme="dark"] .mantine-Select-dropdown {
113+
/* ── Select / Autocomplete dropdown / option (dark mode) ─────────
114+
*
115+
* Select and Autocomplete each render with their own static selector
116+
* (`mantine-Select-*` / `mantine-Autocomplete-*`) — they share the
117+
* Combobox primitive but don't emit a shared `mantine-Combobox-*`
118+
* class on the dropdown root. List both so the argument-completion
119+
* Autocompletes get the same gray surface + primary-light hover as
120+
* the filter dropdowns. */
121+
122+
[data-mantine-color-scheme="dark"] .mantine-Select-dropdown,
123+
[data-mantine-color-scheme="dark"] .mantine-Autocomplete-dropdown {
116124
background-color: var(--mantine-color-gray-8);
117125
}
118126

119-
[data-mantine-color-scheme="dark"] .mantine-Select-option:hover {
127+
[data-mantine-color-scheme="dark"] .mantine-Select-option:hover,
128+
[data-mantine-color-scheme="dark"] .mantine-Autocomplete-option:hover {
120129
background-color: var(--mantine-primary-color-light);
121130
}
122131

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: 51 additions & 67 deletions
Original file line numberDiff line numberDiff line change
@@ -1,72 +1,56 @@
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+
//
43+
// Caveat: this is unconditional — a server that emits a raw shell
44+
// snippet, log line, or string containing `#` / `_` / backticks will
45+
// have it transformed. Most prompts are prose so the trade-off is
46+
// worth it, but this differs from the resource side (where
47+
// ResourcePreviewPanel only promotes to markdown when the server
48+
// supplies `text/markdown` or the URI suffix matches). If the MCP
49+
// spec ever adds a per-block mimeType for prompt messages, switch
50+
// back to opt-in rendering here.
51+
function effectiveMimeForBlock(block: ContentBlock): string | undefined {
52+
if (block.type === "text") return "text/markdown";
53+
return undefined;
7054
}
7155

7256
const BubbleContainer = Paper.withProps({
@@ -81,29 +65,29 @@ const RoleLabel = Text.withProps({
8165
ff: "monospace",
8266
});
8367

84-
const PreviewImage = Image.withProps({
85-
maw: 300,
86-
radius: "sm",
87-
mt: "xs",
68+
const HeaderRow = Group.withProps({
69+
justify: "space-between",
8870
});
8971

9072
export function MessageBubble({ index, message }: MessageBubbleProps) {
91-
const { text, imageUri, audioUri, audioMime } = extractContent(message);
73+
const content = message.content;
74+
const rawBlocks = Array.isArray(content) ? content : [content];
75+
const blocks = rawBlocks.filter(isRenderableBlock);
9276

9377
return (
9478
<BubbleContainer>
9579
<Stack gap="xs">
96-
<Group justify="space-between">
80+
<HeaderRow>
9781
<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-
)}
82+
</HeaderRow>
83+
{blocks.map((block, blockIndex) => (
84+
<ContentViewer
85+
key={blockIndex}
86+
block={block}
87+
mimeType={effectiveMimeForBlock(block)}
88+
copyable
89+
/>
90+
))}
10791
</Stack>
10892
</BubbleContainer>
10993
);

0 commit comments

Comments
 (0)