feat(web): render pending sampling/elicitation requests (#1407) (#1411)

* feat(web): render pending sampling/elicitation requests (#1407)

Server-initiated sampling and elicitation requests never reached the v2
web UI: the InspectorClient queued them and returned a Promise that only
resolved on a user response, but nothing subscribed to the pending-change
events or rendered the (already-built) request components. A tool that
triggered one spun the Execute button forever.

Wire the queues to the UI:
- Add usePendingClientRequests() (core/react) subscribing to
  pendingSamplesChange / pendingElicitationsChange and returning the live
  arrays. Expose getPendingSamples() / getPendingElicitations() on
  InspectorClientProtocol (and the test fake) so hooks can read them.
- Add PendingClientRequestModal, an App-level modal that surfaces one
  queued request at a time (sampling-first) via the existing
  SamplingRequestPanel / ElicitationFormPanel / ElicitationUrlPanel. It is
  non-dismissable so the blocked call only resolves via an explicit action.
- Wire it in App.tsx: responding/rejecting resolves the client's handler
  Promise, which unblocks the originating call and clears the spinner.

Also fold in two issues surfaced while verifying:
- SchemaForm rendered an `array` + `items.enum` (multi-select enum) as a
  JSON textarea because only `items.anyOf` had a branch; add a MultiSelect
  branch with enumNames label support.
- ToolsScreen's result lives in App and outlived the screen's local
  selection (reset on tab-switch unmount), leaving a result with no
  selected tool. Clear the result on unmount for a clean slate on return.
  Prompts/Resources gate their preview on selection, so they are unaffected.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(elicitation): support explicit decline; honest queue label; review polish

Address @claude review on #1411:
- Add an explicit "Decline" action to ElicitationFormPanel so the spec's
  decline (refusal) is distinguishable from cancel (dismissal); the JSDoc
  claimed accept/decline/cancel but only accept/cancel were wired. Wire the
  modal body to emit { action: "decline" }.
- Replace the misleading "1 of N" queue label (the leading 1 never changed
  since the modal always shows the head) with an "N pending" hint, hidden when
  nothing else is queued.
- Make the dead useMemo honest: derive the head request inside the memo from
  the source arrays so it actually caches (the inline activeElicitation had a
  fresh identity every render).
- Document the URL elicitation accept-on-open semantic with a comment pointing
  at the follow-up (#1415).
- Add an App-level round-trip test driving pendingSamplesChange end-to-end
  (modal opens, respond() fires, empty event closes it).

Follow-ups filed: #1415 (URL two-step completion flow), #1416 (form required-
field submit gate + double-submit guard).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(elicitation): include untouched schema defaults in submitted content

The schema form displays a field's default via resolveValue(), but onChange
only writes a field into `values` once the user edits it. So an elicitation
submitted with default-only fields left untouched dropped them entirely —
v2 sent just the manually-edited fields while v1 sent defaults too (reported
with the everything server: firstLine/integer/number/enum defaults missing).

Add collectSchemaDefaults() to jsonUtils (recurses nested objects, omits
fields with no default) and seed ElicitationFormModalBody's values state with
it via a lazy initializer. The modal body is keyed by request id, so it
re-seeds per request; user edits still override seeded defaults via the
existing onChange merge.

Tests: collectSchemaDefaults unit tests (field types, nested, empty) in
jsonUtils.test; a modal test asserting an untouched-default form still submits
its defaults in content.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(forms): seed schema defaults for tool-call and app-launch forms too

Extend the elicitation defaults fix (collectSchemaDefaults) to the other two
SchemaForm consumers that own form state and submit it: ToolsScreen (tool
execute args) and AppsScreen (app launch args). Both reset formValues to {} on
selection, so default-only fields the user never edited were dropped from the
submitted args — same root cause as the elicitation modal.

Seed formValues from the selected tool/app inputSchema defaults on select.
User edits still override via the existing onFormChange merge.

(InlineElicitationRequest has no consumers wired up, so it's left as-is.)

Tests: ToolsScreen sends a tool's default on Execute without editing;
AppsScreen sends an app's default on Open App without editing.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(elicitation): gate form submit on required fields; guard double-submit (#1416)

Fold the #1416 hardening into this PR:

- Gate the elicitation form's Submit button on required fields. Promote the
  hasMissingRequiredFields helper out of AppDetailPanel into jsonUtils (shared)
  and use it in ElicitationFormPanel so Submit is disabled until every required
  field has a value.
- Guard against double-submit. Add an optional `busy` prop to the sampling,
  elicitation-form, and elicitation-url panels that disables their responding
  actions. PendingClientRequestModal's bodies use a new ref-guarded
  useRespondOnce() hook: the first action dispatches and locks the panel; later
  clicks no-op. This stops a fast double-click from calling respond()/reject()
  twice (which throws and produced an unhandled rejection). Copy URL stays
  enabled — it doesn't resolve the request.

Tests: hasMissingRequiredFields unit tests; ElicitationFormPanel submit-gate
and busy-lock tests; a modal double-click-resolves-once test.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(tools): cap detail/result panels at viewport and scroll internally

The Tools screen's detail and result columns sized to their content and bled
past the bottom of the viewport on long output (e.g. the env dump or a long
elicitation result). Cap each column at the screen's available height and let
its panel scroll internally instead.

- ToolsScreen: drop `align: flex-start` and wrap each column in a stretched
  `ContentPane` (Flex column, capped by `mah` = viewport − header − xl*2). The
  pane's definite height is what lets the card's inner ScrollArea know how far
  to shrink — a bare `mah` on the card does not (the card sizes to content, so
  the scroll region renders full-height and overflow:hidden just clips it,
  unreachable). Mirrors the proven Prompts/Resources preview panes.
- ToolResultPanel: pinned header + inner ScrollArea (flex 0 1 auto, mih 0).
- ToolDetailPanel: pinned title/annotations header, scrolling body
  (description + form + progress), pinned Execute footer.

Audited the other screens: Prompts/Resources already use this pattern,
ServerList and the four list panels (Logging/Network/History/Tasks) already cap
via ScrollArea.Autosize. Only AppsScreen's app-input form had the same gap —
fixed it the same way: AppDetailPanel now scrolls internally and the card uses
variant="preview" so the running app's iframe and a long form both stay within
the viewport.

Verified in-browser against the everything server: the get-env result now
stops at the viewport bottom with margin and scrolls within the card
(clientHeight 502 vs scrollHeight 1931), header pinned.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test(remote): use sync pino destinations in logging tests to fix teardown race

CI flaked with an uncaught `ENOENT: ... /tmp/inspector-log-unknown-level-*/app.log`
that failed an unrelated later test. The remote-logging integration tests create
`pino.destination(logFile)` loggers (async sonic-boom streams) and rmSync the temp
dir in finally/afterEach without closing the stream — a buffered background flush
then fires after the dir is gone and throws an uncaught error, which Vitest counts
as a run failure even though all tests pass.

Make those destinations `sync: true` so the write lands during the request instead
of on a later flush, removing the race. Test-only; unrelated to this PR's feature
work but it was blocking the PR's CI.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Cliff Hall 2026-06-02 17:52:20 -04:00 • committed by GitHub
commit 8f6a2e0f78
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 1678 additions and 147 deletions

View file

@ -0,0 +1,117 @@
import { AppShell } from "@mantine/core";
import type { Meta, StoryObj } from "@storybook/react-vite";
import { expect, fn, userEvent, within } from "storybook/test";
import type {
CreateMessageRequestParams,
ElicitRequestFormParams,
} from "@modelcontextprotocol/sdk/types.js";
import {
PendingClientRequestModal,
type PendingClientRequestModalProps,
} from "./PendingClientRequestModal";
const samplingRequest: CreateMessageRequestParams = {
messages: [
{
role: "user",
content: {
type: "text",
text: "Summarize the latest changes in this repository.",
},
},
],
maxTokens: 1024,
};
const formRequest: ElicitRequestFormParams = {
message: "Please provide your database connection details.",
requestedSchema: {
type: "object",
properties: {
host: { type: "string", title: "Host" },
port: { type: "string", title: "Port" },
},
},
};
function InteractiveRender(args: PendingClientRequestModalProps) {
return (
<AppShell>
<AppShell.Main>
<PendingClientRequestModal {...args} />
</AppShell.Main>
</AppShell>
);
}
const meta: Meta<typeof PendingClientRequestModal> = {
title: "Groups/PendingClientRequestModal",
component: PendingClientRequestModal,
parameters: { layout: "fullscreen" },
render: InteractiveRender,
args: {
serverName: "Everything Server",
queuePosition: "1 of 1",
onSamplingRespond: fn(),
onSamplingReject: fn(),
onElicitationRespond: fn(),
},
};
export default meta;
type Story = StoryObj<typeof PendingClientRequestModal>;
export const Sampling: Story = {
args: {
request: { kind: "sampling", id: "sampling-1", request: samplingRequest },
},
play: async ({ canvasElement, args }) => {
const body = within(canvasElement.ownerDocument.body);
await body.findByText("Sampling Request");
expect(
body.getByText("The server is requesting an LLM completion."),
).toBeInTheDocument();
await userEvent.click(body.getByRole("button", { name: "Auto-respond" }));
expect(args.onSamplingRespond).toHaveBeenCalled();
},
};
export const ElicitationForm: Story = {
args: {
request: {
kind: "elicitation-form",
id: "elicitation-1",
request: formRequest,
},
},
play: async ({ canvasElement, args }) => {
const body = within(canvasElement.ownerDocument.body);
await body.findByText("Elicitation Request");
expect(
body.getByText(/Please provide your database connection details/),
).toBeInTheDocument();
await userEvent.click(body.getByRole("button", { name: "Submit" }));
expect(args.onElicitationRespond).toHaveBeenCalledWith({
action: "accept",
content: {},
});
},
};
export const ElicitationUrl: Story = {
args: {
request: {
kind: "elicitation-url",
id: "elicitation-2",
message: "Authorize access in your browser to continue.",
url: "https://example.com/authorize?token=abc123",
},
},
play: async ({ canvasElement }) => {
const body = within(canvasElement.ownerDocument.body);
await body.findByText("Elicitation Request");
expect(
body.getByText("https://example.com/authorize?token=abc123"),
).toBeInTheDocument();
},
};

View file

@ -0,0 +1,259 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import userEvent from "@testing-library/user-event";
import type {
CreateMessageRequestParams,
ElicitRequestFormParams,
} from "@modelcontextprotocol/sdk/types.js";
import { renderWithMantine, screen } from "../../../test/renderWithMantine";
import {
PendingClientRequestModal,
type PendingClientRequestContent,
} from "./PendingClientRequestModal";
const samplingRequest: CreateMessageRequestParams = {
messages: [
{
role: "user",
content: { type: "text", text: "What is the capital of France?" },
},
],
maxTokens: 1024,
};
const formRequest: ElicitRequestFormParams = {
message: "Please provide your name.",
requestedSchema: {
type: "object",
properties: { name: { type: "string", title: "Name" } },
},
};
const samplingContent: PendingClientRequestContent = {
kind: "sampling",
id: "sampling-1",
request: samplingRequest,
};
const formContent: PendingClientRequestContent = {
kind: "elicitation-form",
id: "elicitation-1",
request: formRequest,
};
const urlContent: PendingClientRequestContent = {
kind: "elicitation-url",
id: "elicitation-2",
message: "Authorize access in your browser.",
url: "https://example.com/authorize",
};
const baseProps = {
serverName: "Everything Server",
queuePosition: "1 of 1",
onSamplingRespond: vi.fn(),
onSamplingReject: vi.fn(),
onElicitationRespond: vi.fn(),
};
beforeEach(() => {
vi.clearAllMocks();
});
describe("PendingClientRequestModal", () => {
it("renders nothing when there is no active request", () => {
renderWithMantine(
<PendingClientRequestModal {...baseProps} request={null} />,
);
expect(screen.queryByText("Sampling Request")).not.toBeInTheDocument();
expect(screen.queryByText("Elicitation Request")).not.toBeInTheDocument();
});
it("renders the sampling title, queue position, and panel", () => {
renderWithMantine(
<PendingClientRequestModal {...baseProps} request={samplingContent} />,
);
expect(screen.getByText("Sampling Request")).toBeInTheDocument();
expect(screen.getByText("1 of 1")).toBeInTheDocument();
expect(
screen.getByText("The server is requesting an LLM completion."),
).toBeInTheDocument();
});
it("auto-responds with a stub sampling result", async () => {
const user = userEvent.setup();
renderWithMantine(
<PendingClientRequestModal {...baseProps} request={samplingContent} />,
);
await user.click(screen.getByRole("button", { name: "Auto-respond" }));
expect(baseProps.onSamplingRespond).toHaveBeenCalledWith({
model: "stub-model",
stopReason: "endTurn",
role: "assistant",
content: { type: "text", text: "" },
});
});
it("sends the edited sampling draft", async () => {
const user = userEvent.setup();
renderWithMantine(
<PendingClientRequestModal {...baseProps} request={samplingContent} />,
);
const textarea = screen
.getAllByRole("textbox")
.find((el) => el.tagName === "TEXTAREA") as HTMLTextAreaElement;
await user.type(textarea, "Paris");
await user.click(screen.getByRole("button", { name: "Send Response" }));
expect(baseProps.onSamplingRespond).toHaveBeenCalledWith(
expect.objectContaining({
content: { type: "text", text: "Paris" },
}),
);
});
it("rejects the sampling request", async () => {
const user = userEvent.setup();
renderWithMantine(
<PendingClientRequestModal {...baseProps} request={samplingContent} />,
);
await user.click(screen.getByRole("button", { name: "Reject" }));
expect(baseProps.onSamplingReject).toHaveBeenCalledTimes(1);
});
it("resolves a request only once when the action is double-clicked", async () => {
const user = userEvent.setup();
renderWithMantine(
<PendingClientRequestModal {...baseProps} request={samplingContent} />,
);
const autoRespond = screen.getByRole("button", { name: "Auto-respond" });
await user.click(autoRespond);
// After the first dispatch the actions lock (busy); a second click no-ops.
await user.click(autoRespond);
expect(baseProps.onSamplingRespond).toHaveBeenCalledTimes(1);
expect(
screen.getByRole("button", { name: "Send Response" }),
).toBeDisabled();
expect(screen.getByRole("button", { name: "Reject" })).toBeDisabled();
});
it("renders the elicitation form with the server name warning", () => {
renderWithMantine(
<PendingClientRequestModal {...baseProps} request={formContent} />,
);
expect(screen.getByText("Elicitation Request")).toBeInTheDocument();
expect(screen.getByText(/Please provide your name/)).toBeInTheDocument();
expect(screen.getByText(/Everything Server/)).toBeInTheDocument();
});
it("accepts a form elicitation on submit", async () => {
const user = userEvent.setup();
renderWithMantine(
<PendingClientRequestModal {...baseProps} request={formContent} />,
);
await user.click(screen.getByRole("button", { name: "Submit" }));
expect(baseProps.onElicitationRespond).toHaveBeenCalledWith({
action: "accept",
content: {},
});
});
it("includes untouched schema defaults in the submitted content", async () => {
const user = userEvent.setup();
const formWithDefaults: PendingClientRequestContent = {
kind: "elicitation-form",
id: "elicitation-defaults",
request: {
message: "Confirm your preferences.",
requestedSchema: {
type: "object",
properties: {
firstLine: {
type: "string",
title: "First line",
default: "It was a dark and stormy night.",
},
integer: { type: "integer", title: "Integer", default: 42 },
name: { type: "string", title: "Name" },
},
},
},
};
renderWithMantine(
<PendingClientRequestModal {...baseProps} request={formWithDefaults} />,
);
// Submit without touching any field: the default-only fields must still be
// sent (the v1-parity bug this guards against dropped them).
await user.click(screen.getByRole("button", { name: "Submit" }));
expect(baseProps.onElicitationRespond).toHaveBeenCalledWith({
action: "accept",
content: {
firstLine: "It was a dark and stormy night.",
integer: 42,
},
});
});
it("declines a form elicitation", async () => {
const user = userEvent.setup();
renderWithMantine(
<PendingClientRequestModal {...baseProps} request={formContent} />,
);
await user.click(screen.getByRole("button", { name: "Decline" }));
expect(baseProps.onElicitationRespond).toHaveBeenCalledWith({
action: "decline",
});
});
it("cancels a form elicitation", async () => {
const user = userEvent.setup();
renderWithMantine(
<PendingClientRequestModal {...baseProps} request={formContent} />,
);
await user.click(screen.getByRole("button", { name: "Cancel" }));
expect(baseProps.onElicitationRespond).toHaveBeenCalledWith({
action: "cancel",
});
});
it("opens the URL and accepts a URL elicitation", async () => {
const user = userEvent.setup();
const openSpy = vi.spyOn(window, "open").mockReturnValue(null);
renderWithMantine(
<PendingClientRequestModal {...baseProps} request={urlContent} />,
);
await user.click(screen.getByRole("button", { name: "Open in Browser" }));
expect(openSpy).toHaveBeenCalledWith(
"https://example.com/authorize",
"_blank",
"noopener,noreferrer",
);
expect(baseProps.onElicitationRespond).toHaveBeenCalledWith({
action: "accept",
});
openSpy.mockRestore();
});
it("copies the URL to the clipboard", async () => {
const user = userEvent.setup();
const writeText = vi.fn().mockResolvedValue(undefined);
Object.defineProperty(navigator, "clipboard", {
value: { writeText },
configurable: true,
});
renderWithMantine(
<PendingClientRequestModal {...baseProps} request={urlContent} />,
);
await user.click(screen.getByRole("button", { name: "Copy URL" }));
expect(writeText).toHaveBeenCalledWith("https://example.com/authorize");
});
it("cancels a URL elicitation", async () => {
const user = userEvent.setup();
renderWithMantine(
<PendingClientRequestModal {...baseProps} request={urlContent} />,
);
await user.click(screen.getByRole("button", { name: "Cancel" }));
expect(baseProps.onElicitationRespond).toHaveBeenCalledWith({
action: "cancel",
});
});
});

View file

@ -0,0 +1,259 @@
import { useRef, useState } from "react";
import { Group, Modal, Text } from "@mantine/core";
import type {
CreateMessageRequestParams,
CreateMessageResult,
ElicitRequestFormParams,
ElicitResult,
} from "@modelcontextprotocol/sdk/types.js";
import { SamplingRequestPanel } from "../SamplingRequestPanel/SamplingRequestPanel";
import { ElicitationFormPanel } from "../ElicitationFormPanel/ElicitationFormPanel";
import { ElicitationUrlPanel } from "../ElicitationUrlPanel/ElicitationUrlPanel";
import {
collectSchemaDefaults,
type JsonSchemaType,
} from "../../../utils/jsonUtils";
/**
* The server-initiated request currently shown in the modal. `id` is the
* client-side request id; it keys the modal body so per-request draft state
* resets when the active request changes.
*/
export type PendingClientRequestContent =
| { kind: "sampling"; id: string; request: CreateMessageRequestParams }
| { kind: "elicitation-form"; id: string; request: ElicitRequestFormParams }
| { kind: "elicitation-url"; id: string; message: string; url: string };
export interface PendingClientRequestModalProps {
/** The active request to display, or null when nothing is pending. */
request: PendingClientRequestContent | null;
/** Display name of the connected server (shown in elicitation warnings). */
serverName: string;
/**
* Count label for the still-pending requests, e.g. "3 pending". The modal
* always shows the head of the queue, so this is a remaining-count hint, not
* a navigable position. Empty string hides the label (nothing else queued).
*/
queuePosition: string;
/** Resolve a sampling request with the given (drafted or auto) result. */
onSamplingRespond: (result: CreateMessageResult) => void;
/** Reject the active sampling request. */
onSamplingReject: () => void;
/** Resolve an elicitation request (accept/decline/cancel). */
onElicitationRespond: (result: ElicitResult) => void;
}
const TitleRow = Group.withProps({
justify: "space-between",
gap: "md",
w: "100%",
wrap: "nowrap",
});
const TitleText = Text.withProps({ fw: 600 });
const QueueLabel = Text.withProps({ size: "xs", c: "dimmed" });
/**
* The stub result pre-filled into a sampling draft. "Auto-respond" sends this
* as-is; "Send Response" sends whatever the user edited it into.
*/
function createDefaultSamplingResult(): CreateMessageResult {
return {
model: "stub-model",
stopReason: "endTurn",
role: "assistant",
content: { type: "text", text: "" },
};
}
function titleFor(content: PendingClientRequestContent): string {
return content.kind === "sampling"
? "Sampling Request"
: "Elicitation Request";
}
// Empty handler — the modal is intentionally non-dismissable; the request is
// only resolved via an explicit action button so the blocked call never hangs
// on an accidental dismissal.
function ignoreClose(): void {}
/**
* Returns a `responded` flag and a `once()` wrapper. The first invocation of
* any wrapped callback flips `responded` (to lock the panel's actions) and
* runs it; later invocations no-op. The ref makes the guard synchronous so a
* fast double-click can't resolve the request twice before the modal unmounts
* — respond()/reject() throw if called again. Reset per request via the body's
* `key`.
*/
function useRespondOnce(): {
responded: boolean;
once: (fn: () => void) => () => void;
} {
const [responded, setResponded] = useState(false);
const respondedRef = useRef(false);
const once = (fn: () => void) => () => {
if (respondedRef.current) return;
respondedRef.current = true;
setResponded(true);
fn();
};
return { responded, once };
}
function SamplingModalBody({
request,
onRespond,
onReject,
}: {
request: CreateMessageRequestParams;
onRespond: (result: CreateMessageResult) => void;
onReject: () => void;
}) {
const [draftResult, setDraftResult] = useState<CreateMessageResult>(
createDefaultSamplingResult,
);
const { responded, once } = useRespondOnce();
return (
<SamplingRequestPanel
request={request}
draftResult={draftResult}
onResultChange={setDraftResult}
onAutoRespond={once(() => onRespond(createDefaultSamplingResult()))}
onSend={once(() => onRespond(draftResult))}
onReject={once(onReject)}
busy={responded}
/>
);
}
function ElicitationFormModalBody({
request,
serverName,
onRespond,
}: {
request: ElicitRequestFormParams;
serverName: string;
onRespond: (result: ElicitResult) => void;
}) {
// Seed with the schema's defaults so default-only fields the user never
// touches are still included on submit (the form shows them via
// resolveValue, but onChange only writes edited fields). The modal body is
// keyed by request id, so this lazy initializer re-runs per request.
const [values, setValues] = useState<Record<string, unknown>>(() =>
collectSchemaDefaults(request.requestedSchema as JsonSchemaType),
);
const { responded, once } = useRespondOnce();
return (
<ElicitationFormPanel
request={request}
serverName={serverName}
values={values}
onChange={setValues}
onSubmit={once(() =>
onRespond({
action: "accept",
content: values as ElicitResult["content"],
}),
)}
onDecline={once(() => onRespond({ action: "decline" }))}
onCancel={once(() => onRespond({ action: "cancel" }))}
busy={responded}
/>
);
}
function ElicitationUrlModalBody({
id,
message,
url,
onRespond,
}: {
id: string;
message: string;
url: string;
onRespond: (result: ElicitResult) => void;
}) {
const { responded, once } = useRespondOnce();
return (
<ElicitationUrlPanel
message={message}
url={url}
requestId={id}
isWaiting={false}
onCopyUrl={() => {
void navigator.clipboard?.writeText(url);
}}
onOpenInBrowser={once(() => {
window.open(url, "_blank", "noopener,noreferrer");
// Accept-on-open: the inspector can't observe completion of an external
// flow, so opening the URL is treated as acceptance. This is optimistic
// — the user could close the tab without finishing. A proper two-step
// "open, then confirm completion" flow (using ElicitationUrlPanel's
// isWaiting state) is tracked as a follow-up; see #1415.
onRespond({ action: "accept" });
})}
onCancel={once(() => onRespond({ action: "cancel" }))}
busy={responded}
/>
);
}
/**
* App-level modal that surfaces a server-initiated sampling/elicitation request
* while a call (e.g. a tool execution) is in flight. Responding resolves the
* client's handler Promise, which unblocks the originating call.
*/
export function PendingClientRequestModal({
request,
serverName,
queuePosition,
onSamplingRespond,
onSamplingReject,
onElicitationRespond,
}: PendingClientRequestModalProps) {
return (
<Modal
opened={request !== null}
onClose={ignoreClose}
withCloseButton={false}
closeOnClickOutside={false}
closeOnEscape={false}
size="lg"
title={
request && (
<TitleRow>
<TitleText>{titleFor(request)}</TitleText>
{queuePosition && <QueueLabel>{queuePosition}</QueueLabel>}
</TitleRow>
)
}
>
{request?.kind === "sampling" && (
<SamplingModalBody
key={request.id}
request={request.request}
onRespond={onSamplingRespond}
onReject={onSamplingReject}
/>
)}
{request?.kind === "elicitation-form" && (
<ElicitationFormModalBody
key={request.id}
request={request.request}
serverName={serverName}
onRespond={onElicitationRespond}
/>
)}
{request?.kind === "elicitation-url" && (
<ElicitationUrlModalBody
key={request.id}
id={request.id}
message={request.message}
url={request.url}
onRespond={onElicitationRespond}
/>
)}
</Modal>
);
}