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>
This commit is contained in:
Cliff Hall 2026-05-23 09:20:20 -04:00 • committed by GitHub
commit 8d6dea0af2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 1428 additions and 224 deletions

View file

@ -1,9 +1,37 @@
import { useState } from "react";
import { describe, it, expect, vi } from "vitest";
import userEvent from "@testing-library/user-event";
import type { Prompt } from "@modelcontextprotocol/sdk/types.js";
import { renderWithMantine, screen } from "../../../test/renderWithMantine";
import { PromptArgumentsForm } from "./PromptArgumentsForm";
/**
* Wrapper that owns `argumentValues` state so completion tests can
* type multi-character input naturally — the production parent
* (PromptsScreen) is what holds this state, and the form is controlled
* via its onArgumentChange callback.
*/
function StatefulForm(
props: Omit<
React.ComponentProps<typeof PromptArgumentsForm>,
"argumentValues" | "onArgumentChange"
> & { initialValues?: Record<string, string> },
) {
const { initialValues, ...rest } = props;
const [values, setValues] = useState<Record<string, string>>(
initialValues ?? {},
);
return (
<PromptArgumentsForm
{...rest}
argumentValues={values}
onArgumentChange={(name, value) =>
setValues((prev) => ({ ...prev, [name]: value }))
}
/>
);
}
const promptNoArgs: Prompt = {
name: "summarize",
description: "Summarize the given text into key points",
@ -122,7 +150,8 @@ describe("PromptArgumentsForm", () => {
renderWithMantine(
<PromptArgumentsForm
prompt={promptWithArgs}
argumentValues={{}}
// Required arg filled — otherwise the button is disabled.
argumentValues={{ text: "Hello" }}
onArgumentChange={vi.fn()}
onGetPrompt={onGetPrompt}
/>,
@ -131,6 +160,33 @@ describe("PromptArgumentsForm", () => {
expect(onGetPrompt).toHaveBeenCalledTimes(1);
});
it("disables Get Prompt until every required argument has a value", async () => {
const user = userEvent.setup();
renderWithMantine(
<StatefulForm prompt={promptWithArgs} onGetPrompt={vi.fn()} />,
);
const button = screen.getByRole("button", { name: "Get Prompt" });
expect(button).toBeDisabled();
await user.type(screen.getByPlaceholderText("Enter text..."), "hi");
expect(button).not.toBeDisabled();
});
it("allows submission when only optional arguments are blank", () => {
renderWithMantine(
<PromptArgumentsForm
prompt={promptWithArgs}
argumentValues={{ text: "Hello" }}
onArgumentChange={vi.fn()}
onGetPrompt={vi.fn()}
/>,
);
// targetLanguage is required: false, so leaving it blank should
// not disable submission.
expect(
screen.getByRole("button", { name: "Get Prompt" }),
).not.toBeDisabled();
});
it("renders without description when none is provided", () => {
renderWithMantine(
<PromptArgumentsForm
@ -143,4 +199,243 @@ describe("PromptArgumentsForm", () => {
expect(screen.getByText("code-review")).toBeInTheDocument();
expect(screen.getByText("Arguments")).toBeInTheDocument();
});
describe("completions", () => {
it("fires a completion immediately on focus before any keystroke", async () => {
const user = userEvent.setup();
const onCompleteArgument = vi
.fn<
(
argName: string,
value: string,
context: Record<string, string>,
) => Promise<string[]>
>()
.mockResolvedValue(["alpha", "alphabet"]);
renderWithMantine(
<StatefulForm
prompt={promptWithArgs}
onGetPrompt={vi.fn()}
completionsSupported
onCompleteArgument={onCompleteArgument}
/>,
);
await user.click(screen.getByRole("textbox", { name: /^text/ }));
// No debounce on focus — the call fires synchronously off the
// focus handler. A microtask is enough for the response to settle.
await new Promise((r) => setTimeout(r, 0));
expect(onCompleteArgument).toHaveBeenCalledWith("text", "", {
targetLanguage: "",
});
expect(await screen.findByText("alpha")).toBeInTheDocument();
});
it("calls onCompleteArgument (debounced) and surfaces values when supported", async () => {
const user = userEvent.setup();
const onCompleteArgument = vi
.fn<
(
argName: string,
value: string,
context: Record<string, string>,
) => Promise<string[]>
>()
.mockResolvedValue(["alpha", "alphabet"]);
renderWithMantine(
<StatefulForm
prompt={promptWithArgs}
onGetPrompt={vi.fn()}
completionsSupported
onCompleteArgument={onCompleteArgument}
/>,
);
await user.type(screen.getByRole("textbox", { name: /^text/ }), "al");
await new Promise((r) => setTimeout(r, 400));
// The last call carries the typed value and the context for the
// other (still empty) sibling.
expect(onCompleteArgument).toHaveBeenLastCalledWith("text", "al", {
targetLanguage: "",
});
expect(await screen.findByText("alpha")).toBeInTheDocument();
expect(screen.getByText("alphabet")).toBeInTheDocument();
});
it("sends every sibling argument in context, including the unset ones", async () => {
const user = userEvent.setup();
const onCompleteArgument = vi
.fn<
(
argName: string,
value: string,
context: Record<string, string>,
) => Promise<string[]>
>()
.mockResolvedValue([]);
renderWithMantine(
<StatefulForm
prompt={promptWithArgs}
initialValues={{ targetLanguage: "es" }}
onGetPrompt={vi.fn()}
completionsSupported
onCompleteArgument={onCompleteArgument}
/>,
);
await user.type(screen.getByRole("textbox", { name: /^text/ }), "h");
await new Promise((r) => setTimeout(r, 400));
// The completing arg ("text") is excluded from context; every
// other declared argument rides along — even ones still empty.
expect(onCompleteArgument).toHaveBeenLastCalledWith("text", "h", {
targetLanguage: "es",
});
});
it("captures sibling values at fire time, not at schedule time", async () => {
const user = userEvent.setup();
const onCompleteArgument = vi
.fn<
(
argName: string,
value: string,
context: Record<string, string>,
) => Promise<string[]>
>()
.mockResolvedValue([]);
renderWithMantine(
<StatefulForm
prompt={promptWithArgs}
onGetPrompt={vi.fn()}
completionsSupported
onCompleteArgument={onCompleteArgument}
/>,
);
// Type into "text" — this schedules a debounced completion call.
await user.type(screen.getByRole("textbox", { name: /^text/ }), "h");
// Before the 300ms debounce fires, update the sibling. The
// text-arg fire that lands at t=300 must see the latest sibling
// value, not the empty one captured at schedule time.
await user.type(
screen.getByRole("textbox", { name: /targetLanguage/ }),
"es",
);
await new Promise((r) => setTimeout(r, 400));
// The most recent call for "text" carries the up-to-date
// sibling value, even though it was scheduled before "es" was
// typed. (There's also a focus-fire call when the second input
// gained focus — separate stream, not asserted here.)
const textCalls = onCompleteArgument.mock.calls.filter(
([n]) => n === "text",
);
expect(textCalls.at(-1)).toEqual(["text", "h", { targetLanguage: "es" }]);
});
it("clears stale dropdown options the instant a new keystroke arrives", async () => {
const user = userEvent.setup();
const deferred: Array<{
value: string;
resolve: (values: string[]) => void;
}> = [];
const onCompleteArgument = vi.fn(
(_argName: string, value: string) =>
new Promise<string[]>((resolve) => {
deferred.push({ value, resolve });
}),
);
renderWithMantine(
<StatefulForm
prompt={promptWithArgs}
onGetPrompt={vi.fn()}
completionsSupported
onCompleteArgument={onCompleteArgument}
/>,
);
// Focus → first call (value=""). Resolve so the dropdown has
// something to show.
await user.click(screen.getByRole("textbox", { name: /^text/ }));
await new Promise((r) => setTimeout(r, 0));
expect(deferred.length).toBe(1);
deferred[0].resolve(["alpha", "alphabet"]);
expect(await screen.findByText("alpha")).toBeInTheDocument();
// Type a new character — the keystroke handler must drop the
// stale options immediately so the dropdown doesn't show
// "alpha" / "alphabet" while the next request is in flight
// (300ms debounce + network latency).
await user.type(screen.getByRole("textbox", { name: /^text/ }), "z");
expect(screen.queryByText("alpha")).not.toBeInTheDocument();
expect(screen.queryByText("alphabet")).not.toBeInTheDocument();
});
it("aborts an in-flight request when a faster keystroke arrives", async () => {
const user = userEvent.setup();
const calls: Array<{
value: string;
resolve: (values: string[]) => void;
}> = [];
const onCompleteArgument = vi.fn(
(_argName: string, value: string) =>
new Promise<string[]>((resolve) => {
calls.push({ value, resolve });
}),
);
renderWithMantine(
<StatefulForm
prompt={promptWithArgs}
onGetPrompt={vi.fn()}
completionsSupported
onCompleteArgument={onCompleteArgument}
/>,
);
// Focus fires the first call (value=""). Type "h" → second call
// after debounce. Type "i" → third call after debounce.
await user.type(screen.getByRole("textbox", { name: /^text/ }), "h");
await new Promise((r) => setTimeout(r, 350));
await user.type(screen.getByRole("textbox", { name: /^text/ }), "i");
await new Promise((r) => setTimeout(r, 350));
// Resolve the late "h" response — it should be dropped because
// the form aborted that controller when the "hi" request started.
const hi = calls.find((c) => c.value === "hi");
const h = calls.find((c) => c.value === "h");
expect(hi).toBeDefined();
expect(h).toBeDefined();
h?.resolve(["from-stale-h"]);
hi?.resolve(["from-fresh-hi"]);
await new Promise((r) => setTimeout(r, 0));
// The dropdown shows the fresh response, not the stale one.
expect(await screen.findByText("from-fresh-hi")).toBeInTheDocument();
expect(screen.queryByText("from-stale-h")).not.toBeInTheDocument();
});
it("does not call onCompleteArgument when completions are unsupported", async () => {
const user = userEvent.setup();
const onCompleteArgument = vi.fn();
renderWithMantine(
<StatefulForm
prompt={promptWithArgs}
onGetPrompt={vi.fn()}
completionsSupported={false}
onCompleteArgument={onCompleteArgument}
/>,
);
// Focus the input first, then type — neither should trigger a call.
await user.click(screen.getByPlaceholderText("Enter text..."));
await user.type(screen.getByPlaceholderText("Enter text..."), "ab");
await new Promise((r) => setTimeout(r, 400));
expect(onCompleteArgument).not.toHaveBeenCalled();
});
});
});

View file

@ -1,4 +1,13 @@
import { Button, Group, Stack, Text, TextInput, Title } from "@mantine/core";
import { useCallback, useEffect, useRef, useState } from "react";
import {
Autocomplete,
Button,
Group,
Stack,
Text,
TextInput,
Title,
} from "@mantine/core";
import type { Prompt } from "@modelcontextprotocol/sdk/types.js";
export interface PromptArgumentsFormProps {
@ -6,8 +15,27 @@ export interface PromptArgumentsFormProps {
argumentValues: Record<string, string>;
onArgumentChange: (name: string, value: string) => void;
onGetPrompt: () => void;
/**
* When provided, each keystroke in an argument input dispatches a
* (debounced) `completion/complete` request to the server and surfaces
* the returned values as a dropdown via Mantine `Autocomplete`.
* Wire to `InspectorClient.getCompletions` in the host App.
*/
onCompleteArgument?: (
argumentName: string,
argumentValue: string,
context: Record<string, string>,
) => Promise<string[]>;
/**
* Gates whether to render Autocomplete (with live completions) vs the
* plain TextInput. Typically derived from the server's
* `completions` capability.
*/
completionsSupported?: boolean;
}
const COMPLETION_DEBOUNCE_MS = 300;
const PromptTitle = Text.withProps({
fw: 700,
size: "lg",
@ -28,9 +56,135 @@ export function PromptArgumentsForm({
argumentValues,
onArgumentChange,
onGetPrompt,
onCompleteArgument,
completionsSupported = false,
}: PromptArgumentsFormProps) {
const { name, title, description, arguments: promptArguments } = prompt;
const [completions, setCompletions] = useState<Record<string, string[]>>({});
// Reset completion state whenever the active prompt changes — completions
// are keyed by argument name, and the same name could mean different
// things across prompts.
useEffect(() => {
setCompletions({});
}, [name]);
// Per-arg in-flight controller (later keystroke aborts older request).
const requestsRef = useRef<Map<string, AbortController>>(new Map());
// Per-arg debounce timer so we don't spam the server on every key.
const timersRef = useRef<Map<string, ReturnType<typeof setTimeout>>>(
new Map(),
);
useEffect(() => {
const timers = timersRef.current;
const requests = requestsRef.current;
return () => {
for (const t of timers.values()) clearTimeout(t);
timers.clear();
for (const c of requests.values()) c.abort();
requests.clear();
};
}, []);
const useAutocomplete = completionsSupported && !!onCompleteArgument;
const runCompletion = useCallback(
async (argName: string, value: string, context: Record<string, string>) => {
if (!onCompleteArgument) return;
requestsRef.current.get(argName)?.abort();
const controller = new AbortController();
requestsRef.current.set(argName, controller);
try {
const values = await onCompleteArgument(argName, value, context);
if (controller.signal.aborted) return;
setCompletions((prev) => ({ ...prev, [argName]: values }));
} catch {
if (!controller.signal.aborted) {
setCompletions((prev) => ({ ...prev, [argName]: [] }));
}
} finally {
if (requestsRef.current.get(argName) === controller) {
requestsRef.current.delete(argName);
}
}
},
[onCompleteArgument],
);
// Hold the latest argumentValues in a ref so debounced fires can read
// sibling values at *fire* time, not at schedule time. Without this,
// typing in arg A then arg B within the debounce window would ship
// A's request with B's value stuck at its pre-keystroke state.
const argumentValuesRef = useRef(argumentValues);
useEffect(() => {
argumentValuesRef.current = argumentValues;
}, [argumentValues]);
// Build the `context.arguments` payload for a completion request.
// Includes every prompt argument the user could fill in (with `""`
// for ones they haven't typed yet) except the one being completed —
// the completing arg goes in `params.argument`. Servers that
// disambiguate based on co-arguments need all of them, not just
// whatever the user has already typed.
const buildContext = useCallback(
(currentArg: string): Record<string, string> => {
const ctx: Record<string, string> = {};
for (const a of promptArguments ?? []) {
if (a.name === currentArg) continue;
ctx[a.name] = argumentValuesRef.current[a.name] ?? "";
}
return ctx;
},
[promptArguments],
);
function handleChange(argName: string, value: string) {
onArgumentChange(argName, value);
if (!useAutocomplete) return;
// Drop the previous prefix's completions so the dropdown doesn't
// show ghost suggestions from the old keystroke while the new
// request is in flight (300ms debounce + network latency). The
// fresh response repopulates the array when it arrives.
setCompletions((prev) => {
if (prev[argName] === undefined) return prev;
const next = { ...prev };
delete next[argName];
return next;
});
const existing = timersRef.current.get(argName);
if (existing) clearTimeout(existing);
const timer = setTimeout(() => {
timersRef.current.delete(argName);
// Build context at fire time so sibling values that arrived
// between schedule and fire are picked up.
void runCompletion(argName, value, buildContext(argName));
}, COMPLETION_DEBOUNCE_MS);
timersRef.current.set(argName, timer);
}
function handleFocus(argName: string) {
if (!useAutocomplete) return;
// Fire immediately so the dropdown isn't empty when the user first
// clicks in. Cancel any pending debounce so a stale keystroke
// request doesn't overwrite this fresher one.
const existing = timersRef.current.get(argName);
if (existing) {
clearTimeout(existing);
timersRef.current.delete(argName);
}
const value = argumentValuesRef.current[argName] ?? "";
void runCompletion(argName, value, buildContext(argName));
}
// Mirror ResourceTemplatePanel: every required argument must be
// filled before Get Prompt is enabled. Optional args are allowed to
// stay empty; the server will treat them as absent.
const canSubmit = (promptArguments ?? [])
.filter((a) => a.required === true)
.every((a) => (argumentValues[a.name] ?? "").length > 0);
return (
<Stack gap="md">
<PromptTitle>{title ?? name}</PromptTitle>
@ -39,24 +193,42 @@ export function PromptArgumentsForm({
<>
<Title order={4}>Arguments</Title>
<Stack gap="sm">
{promptArguments.map((arg) => (
<TextInput
key={arg.name}
label={arg.name}
withAsterisk={arg.required === true}
description={arg.description}
placeholder={formatPlaceholder(arg.name)}
value={argumentValues[arg.name] || ""}
onChange={(event) =>
onArgumentChange(arg.name, event.currentTarget.value)
}
/>
))}
{promptArguments.map((arg) =>
useAutocomplete ? (
<Autocomplete
key={arg.name}
label={arg.name}
withAsterisk={arg.required === true}
description={arg.description}
placeholder={formatPlaceholder(arg.name)}
value={argumentValues[arg.name] || ""}
data={completions[arg.name] ?? []}
// The server already filtered for the typed prefix.
// Passing options through verbatim avoids hiding valid
// suggestions client-side.
filter={({ options }) => options}
onChange={(value) => handleChange(arg.name, value)}
onFocus={() => handleFocus(arg.name)}
/>
) : (
<TextInput
key={arg.name}
label={arg.name}
withAsterisk={arg.required === true}
description={arg.description}
placeholder={formatPlaceholder(arg.name)}
value={argumentValues[arg.name] || ""}
onChange={(event) =>
handleChange(arg.name, event.currentTarget.value)
}
/>
),
)}
</Stack>
</>
)}
<Group justify="flex-end">
<Button size="sm" onClick={onGetPrompt}>
<Button size="sm" disabled={!canSubmit} onClick={onGetPrompt}>
Get Prompt
</Button>
</Group>

View file

@ -46,4 +46,21 @@ describe("PromptMessagesDisplay", () => {
await user.click(screen.getByRole("button", { name: "Copy All" }));
expect(onCopyAll).toHaveBeenCalledTimes(1);
});
it("renders a close button when onClose is provided and invokes it on click", async () => {
const user = userEvent.setup();
const onClose = vi.fn();
renderWithMantine(
<PromptMessagesDisplay messages={messages} onClose={onClose} />,
);
await user.click(screen.getByRole("button", { name: "Close messages" }));
expect(onClose).toHaveBeenCalledTimes(1);
});
it("does not render a close button when onClose is omitted", () => {
renderWithMantine(<PromptMessagesDisplay messages={messages} />);
expect(
screen.queryByRole("button", { name: "Close messages" }),
).not.toBeInTheDocument();
});
});

View file

@ -1,10 +1,24 @@
import { Button, Group, Stack, Text, Title } from "@mantine/core";
import {
Button,
CloseButton,
Group,
ScrollArea,
Stack,
Text,
Title,
} from "@mantine/core";
import type { PromptMessage } from "@modelcontextprotocol/sdk/types.js";
import { MessageBubble } from "../../elements/MessageBubble/MessageBubble";
export interface PromptMessagesDisplayProps {
messages: PromptMessage[];
onCopyAll?: () => void;
/**
* When provided, a top-left X button dismisses the panel. The host
* (`PromptsScreen`) decides what to show in its place — typically
* the prompt's argument form (if it has arguments) or the empty state.
*/
onClose?: () => void;
}
const CopyAllButton = Button.withProps({
@ -12,25 +26,70 @@ const CopyAllButton = Button.withProps({
size: "sm",
});
// Outer stack inside the PreviewCard: header stays pinned, the scroll
// region absorbs overflow. Mirrors ResourcePreviewPanel so prompts and
// resources share the same sized-to-content / cap-then-scroll behavior.
const PanelStack = Stack.withProps({
gap: "md",
miw: 0,
mih: 0,
});
const HeaderRow = Group.withProps({
justify: "space-between",
flex: "0 0 auto",
});
const HeaderLeft = Group.withProps({
gap: "xs",
wrap: "nowrap",
});
// `0 1 auto` lets the scroll region shrink (but not grow) when the card
// hits its mah. `mih: 0` is required for flex children to shrink below
// their content's intrinsic height.
const MessagesScroll = ScrollArea.withProps({
flex: "0 1 auto",
miw: 0,
mih: 0,
type: "auto",
scrollbars: "y",
offsetScrollbars: true,
});
const MessagesStack = Stack.withProps({
gap: "md",
});
export function PromptMessagesDisplay({
messages,
onCopyAll,
onClose,
}: PromptMessagesDisplayProps) {
return (
<Stack gap="md">
<Group justify="space-between">
<Title order={4}>Messages</Title>
<PanelStack>
<HeaderRow>
<HeaderLeft>
{onClose && (
<CloseButton aria-label="Close messages" onClick={onClose} />
)}
<Title order={4}>Messages</Title>
</HeaderLeft>
{onCopyAll && messages.length > 0 && (
<CopyAllButton onClick={onCopyAll}>Copy All</CopyAllButton>
)}
</Group>
{messages.length === 0 ? (
<Text c="dimmed">No messages to display</Text>
) : (
messages.map((message, index) => (
<MessageBubble key={index} index={index} message={message} />
))
)}
</Stack>
</HeaderRow>
<MessagesScroll>
<MessagesStack>
{messages.length === 0 ? (
<Text c="dimmed">No messages to display</Text>
) : (
messages.map((message, index) => (
<MessageBubble key={index} index={index} message={message} />
))
)}
</MessagesStack>
</MessagesScroll>
</PanelStack>
);
}

View file

@ -237,6 +237,23 @@ describe("ResourcePreviewPanel", () => {
).toBeInTheDocument();
});
it("renders a close button when onClose is provided and invokes it on click", async () => {
const user = userEvent.setup();
const onClose = vi.fn();
renderWithMantine(
<ResourcePreviewPanel {...baseProps} onClose={onClose} />,
);
await user.click(screen.getByRole("button", { name: "Close preview" }));
expect(onClose).toHaveBeenCalledTimes(1);
});
it("does not render a close button when onClose is omitted", () => {
renderWithMantine(<ResourcePreviewPanel {...baseProps} />);
expect(
screen.queryByRole("button", { name: "Close preview" }),
).not.toBeInTheDocument();
});
it("does not render plain-text content as markdown even with markdown-looking text", () => {
renderWithMantine(
<ResourcePreviewPanel

View file

@ -1,5 +1,6 @@
import {
Button,
CloseButton,
Flex,
Group,
ScrollArea,
@ -26,6 +27,12 @@ export interface ResourcePreviewPanelProps {
onRefresh: () => void;
onSubscribe: () => void;
onUnsubscribe: () => void;
/**
* When provided, a top-left X button dismisses the panel. The host
* (`ResourcesScreen`) decides what to show in its place — either the
* originating template form or the empty state.
*/
onClose?: () => void;
}
function toContentBlock(
@ -57,6 +64,11 @@ const HeaderRow = Group.withProps({
flex: "0 0 auto",
});
const HeaderLeft = Group.withProps({
gap: "xs",
wrap: "nowrap",
});
const UriGroup = Group.withProps({
gap: "xs",
wrap: "nowrap",
@ -159,6 +171,7 @@ export function ResourcePreviewPanel({
onRefresh,
onSubscribe,
onUnsubscribe,
onClose,
}: ResourcePreviewPanelProps) {
const { uri, annotations } = resource;
const mimeType = effectiveMime(contents[0]?.mimeType, resource);
@ -166,7 +179,12 @@ export function ResourcePreviewPanel({
return (
<PanelStack>
<HeaderRow>
<Title order={4}>Resource</Title>
<HeaderLeft>
{onClose && (
<CloseButton aria-label="Close preview" onClick={onClose} />
)}
<Title order={4}>Resource</Title>
</HeaderLeft>
<UriGroup>
<UriText>{uri}</UriText>
<CopyButton value={uri} />

View file

@ -139,6 +139,37 @@ describe("ResourceTemplatePanel", () => {
});
describe("completions", () => {
it("fires a completion immediately on focus before any keystroke", async () => {
const user = userEvent.setup();
const onCompleteArgument = vi
.fn<
(
argName: string,
value: string,
context: Record<string, string>,
) => Promise<string[]>
>()
.mockResolvedValue(["alpha", "alphabet"]);
renderWithMantine(
<ResourceTemplatePanel
template={titledTemplate}
onReadResource={vi.fn()}
completionsSupported
onCompleteArgument={onCompleteArgument}
/>,
);
await user.click(screen.getByRole("textbox", { name: "tableName" }));
await new Promise((r) => setTimeout(r, 0));
// Empty value, empty sibling — but the sibling key is still
// present so the server sees the full argument set.
expect(onCompleteArgument).toHaveBeenCalledWith("tableName", "", {
rowId: "",
});
expect(await screen.findByText("alpha")).toBeInTheDocument();
});
it("calls onCompleteArgument (debounced) and surfaces values when supported", async () => {
const user = userEvent.setup();
const onCompleteArgument = vi
@ -163,8 +194,10 @@ describe("ResourceTemplatePanel", () => {
await user.type(screen.getByRole("textbox", { name: "userId" }), "al");
// Wait past the 300ms debounce.
await new Promise((r) => setTimeout(r, 400));
expect(onCompleteArgument).toHaveBeenCalledTimes(1);
expect(onCompleteArgument).toHaveBeenCalledWith("userId", "al", {});
// user.type focuses first (firing one immediate completion) and
// then types the characters (firing the debounced one). Only the
// typed-prefix call is the one we care about here.
expect(onCompleteArgument).toHaveBeenLastCalledWith("userId", "al", {});
// Server-returned values surface in the Autocomplete dropdown.
expect(await screen.findByText("alpha")).toBeInTheDocument();
@ -212,6 +245,45 @@ describe("ResourceTemplatePanel", () => {
});
});
it("clears stale dropdown options the instant a new keystroke arrives", async () => {
const user = userEvent.setup();
const deferred: Array<{
value: string;
resolve: (values: string[]) => void;
}> = [];
const onCompleteArgument = vi.fn(
(_argName: string, value: string) =>
new Promise<string[]>((resolve) => {
deferred.push({ value, resolve });
}),
);
renderWithMantine(
<ResourceTemplatePanel
template={singleVarTemplate}
onReadResource={vi.fn()}
completionsSupported
onCompleteArgument={onCompleteArgument}
/>,
);
// Focus → first call (value=""). Resolve so the dropdown has
// something to show.
await user.click(screen.getByRole("textbox", { name: "userId" }));
await new Promise((r) => setTimeout(r, 0));
expect(deferred.length).toBe(1);
deferred[0].resolve(["alpha", "alphabet"]);
expect(await screen.findByText("alpha")).toBeInTheDocument();
// Type a new character — the keystroke handler must drop the
// stale options immediately so the dropdown doesn't show
// "alpha" / "alphabet" while the next request is in flight
// (300ms debounce + network latency).
await user.type(screen.getByRole("textbox", { name: "userId" }), "z");
expect(screen.queryByText("alpha")).not.toBeInTheDocument();
expect(screen.queryByText("alphabet")).not.toBeInTheDocument();
});
it("does not call onCompleteArgument when completions are unsupported", async () => {
const user = userEvent.setup();
const onCompleteArgument = vi.fn();

View file

@ -163,25 +163,58 @@ export function ResourceTemplatePanel({
[onCompleteArgument],
);
// Hold the latest `variables` in a ref so a debounced completion
// call reads sibling values at fire time, not at schedule time.
// Typing in A then B within the 300ms window would otherwise ship
// A's request with B's value still empty in context.
const variablesRef = useRef(variables);
useEffect(() => {
variablesRef.current = variables;
}, [variables]);
function buildContext(varName: string): Record<string, string> {
const ctx: Record<string, string> = { ...variablesRef.current };
delete ctx[varName];
return ctx;
}
function handleVariableChange(varName: string, value: string) {
setVariables((prev) => {
const next = { ...prev, [varName]: value };
if (useAutocomplete) {
// Schedule a debounced completion call. The `context` carries the
// other variables' current values so the server can disambiguate
// when one variable depends on another.
const context: Record<string, string> = { ...next };
delete context[varName];
const existing = timersRef.current.get(varName);
if (existing) clearTimeout(existing);
const timer = setTimeout(() => {
timersRef.current.delete(varName);
void runCompletion(varName, value, context);
}, COMPLETION_DEBOUNCE_MS);
timersRef.current.set(varName, timer);
}
setVariables((prev) => ({ ...prev, [varName]: value }));
if (!useAutocomplete) return;
// Drop the previous prefix's completions so the dropdown doesn't
// show ghost suggestions from the old keystroke while the new
// request is in flight (300ms debounce + network latency).
setCompletions((prev) => {
if (prev[varName] === undefined) return prev;
const next = { ...prev };
delete next[varName];
return next;
});
const existing = timersRef.current.get(varName);
if (existing) clearTimeout(existing);
const timer = setTimeout(() => {
timersRef.current.delete(varName);
// Build context at fire time so sibling updates that arrived
// between schedule and fire are picked up.
void runCompletion(varName, value, buildContext(varName));
}, COMPLETION_DEBOUNCE_MS);
timersRef.current.set(varName, timer);
}
function handleVariableFocus(varName: string) {
if (!useAutocomplete) return;
// Fire immediately so the dropdown isn't empty when the user first
// clicks in. Cancel any pending debounce for this variable so a
// stale keystroke request doesn't overwrite the fresher focus
// response. `variables` already carries every declared template
// variable (seeded with "") so the context is complete by default.
const existing = timersRef.current.get(varName);
if (existing) {
clearTimeout(existing);
timersRef.current.delete(varName);
}
const value = variablesRef.current[varName] ?? "";
void runCompletion(varName, value, buildContext(varName));
}
const canSubmit = variableNames.every((n) => variables[n]?.length > 0);
@ -217,6 +250,7 @@ export function ResourceTemplatePanel({
// substring-match what the server returned.
filter={({ options }) => options}
onChange={(value) => handleVariableChange(varName, value)}
onFocus={() => handleVariableFocus(varName)}
/>
) : (
<TextInput

View file

@ -69,7 +69,7 @@ describe("SamplingRequestPanel", () => {
).toBeInTheDocument();
expect(screen.getByText("Messages:")).toBeInTheDocument();
expect(
screen.getByText('"What is the capital of France?"'),
screen.getByText("What is the capital of France?"),
).toBeInTheDocument();
});