inspector/clients/web/src/components/groups/SamplingRequestPanel/SamplingRequestPanel.tsx
Cliff Hall bb32d1db5f
fix(web): section-header hover outline + clear buttons out of tab order (#1487) (#1488)
* fix(web): section-header hover outline + clear buttons out of tab order (#1487)

Two button-behavior adjustments:

1. History section headers (Pinned / History) now use the filter-toggle
   style — a thin outline on hover instead of a background fill — by pointing
   the `sectionHeader` Group variant at the shared `.filter-toggle` class. The
   open section keeps its filled background (passed per-instance via `bg`).

2. Every input clear button added in #1448 is removed from the keyboard tab
   order via `tabIndex={-1}` (CloseButton clear buttons app-wide, plus the
   Autocomplete clear button through `theme/Autocomplete.ts` clearButtonProps),
   so tabbing through a form moves to the next field, not onto the clear button.
   The buttons stay mouse-clickable.

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

* fix(web): apply filter-toggle hover outline to Resources accordion headers (#1487)

The Resources screen `disclosure` accordion controls now share the same
outline-on-hover treatment as the filter toggle buttons and History section
headers: a thin border on hover instead of a background fill, plus a filled
background when the section is open (keyed off `aria-expanded`). The `.filter-
toggle` class is added to the disclosure control slot; the default-variant
accordion (Server Settings) keeps its background-fill hover via a `:not(.filter-
toggle)` guard.

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

* refactor(web): extract ClearButton element, replace 34 inline clear buttons

Addresses the code-review suggestion on PR #1488: the same five-line
`CloseButton` clear-affordance block (aria-label="Clear" + tabIndex={-1} +
onClick) was hand-copied across 17 files. Extract a single
`elements/ClearButton` (Mantine `CloseButton.withProps` with the aria-label and
tabIndex baked in) and replace all 34 call sites, so the clear-button contract
lives in one place. A global CloseButton theme default would be wrong here — it
would also catch tabbable modal-close X buttons and the Select clear buttons —
so a dedicated element is the right altitude.

Net -151 lines. Adds element unit tests + a Storybook play test covering the
aria-label / tabIndex / clickable contract for all sites at once.

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

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 22:27:46 -04:00

211 lines
5.6 KiB
TypeScript

import {
Badge,
Button,
Divider,
Group,
Paper,
Select,
Stack,
Text,
TextInput,
Textarea,
Title,
} from "@mantine/core";
import { ClearButton } from "../../elements/ClearButton/ClearButton";
import type {
CreateMessageRequestParams,
CreateMessageResult,
} from "@modelcontextprotocol/sdk/types.js";
import { MessageBubble } from "../../elements/MessageBubble/MessageBubble";
export interface SamplingRequestPanelProps {
request: CreateMessageRequestParams;
draftResult: CreateMessageResult;
onResultChange: (result: CreateMessageResult) => void;
onSend: () => void;
onReject: () => void;
/**
* A response has been dispatched; lock the actions so a second click can't
* resolve the request twice (the underlying handler throws if called again).
*/
busy?: boolean;
}
function formatPriority(value: number): string {
if (value < 0.4) return `low (${value})`;
if (value <= 0.7) return `medium (${value})`;
return `high (${value})`;
}
function formatOptional(value: unknown, fallback: string): string {
return value !== undefined ? String(value) : fallback;
}
function serializeJson(value: unknown): string {
return JSON.stringify(value);
}
const HintText = Text.withProps({
size: "sm",
c: "dimmed",
});
const PreferencesContainer = Paper.withProps({
p: "sm",
withBorder: true,
});
const RejectButton = Button.withProps({
variant: "light",
color: "red",
});
export function SamplingRequestPanel({
request,
draftResult,
onResultChange,
onSend,
onReject,
busy = false,
}: SamplingRequestPanelProps) {
const {
messages,
modelPreferences,
maxTokens,
stopSequences,
temperature,
includeContext,
} = request;
const hints =
(modelPreferences?.hints?.map((h) => h.name).filter(Boolean) as string[]) ??
[];
return (
<Stack gap="md">
<HintText>The server is requesting an LLM completion.</HintText>
<Title order={5}>Messages:</Title>
{messages.map((message, index) => (
<MessageBubble key={index} index={index} message={message} />
))}
{modelPreferences && (
<PreferencesContainer>
<Stack gap="xs">
<Title order={5}>Model Preferences:</Title>
{hints.length > 0 && (
<Group gap="xs">
<Text size="sm">Hints:</Text>
{hints.map((hint) => (
<Badge key={hint}>{hint}</Badge>
))}
</Group>
)}
{modelPreferences?.costPriority !== undefined && (
<Text size="sm">
Cost Priority: {formatPriority(modelPreferences.costPriority)}
</Text>
)}
{modelPreferences?.speedPriority !== undefined && (
<Text size="sm">
Speed Priority: {formatPriority(modelPreferences.speedPriority)}
</Text>
)}
{modelPreferences?.intelligencePriority !== undefined && (
<Text size="sm">
Intelligence Priority:{" "}
{formatPriority(modelPreferences.intelligencePriority)}
</Text>
)}
</Stack>
</PreferencesContainer>
)}
<Title order={5}>Parameters:</Title>
<Text size="sm">
Max Tokens: {formatOptional(maxTokens, "not specified")}
</Text>
<Text size="sm">
Stop Sequences:{" "}
{stopSequences ? serializeJson(stopSequences) : "not specified"}
</Text>
<Text size="sm">
Temperature: {formatOptional(temperature, "not specified")}
</Text>
{includeContext && (
<Group gap="xs">
<Text size="sm">Include Context:</Text>
<Badge>{includeContext}</Badge>
</Group>
)}
<Divider />
<Title order={5}>Response:</Title>
<Textarea
value={
draftResult.content.type === "text" ? draftResult.content.text : ""
}
onChange={(event) =>
onResultChange({
...draftResult,
content: { type: "text", text: event.currentTarget.value },
})
}
autosize
minRows={3}
rightSectionPointerEvents="auto"
rightSection={
draftResult.content.type === "text" && draftResult.content.text ? (
<ClearButton
onClick={() =>
onResultChange({
...draftResult,
content: { type: "text", text: "" },
})
}
/>
) : null
}
/>
<Group>
<TextInput
label="Model Used"
value={draftResult.model}
onChange={(event) =>
onResultChange({ ...draftResult, model: event.currentTarget.value })
}
rightSectionPointerEvents="auto"
rightSection={
draftResult.model ? (
<ClearButton
onClick={() => onResultChange({ ...draftResult, model: "" })}
/>
) : null
}
/>
<Select
label="Stop Reason"
data={["endTurn", "stopSequence", "maxTokens"]}
value={draftResult.stopReason ?? null}
onChange={(value) =>
onResultChange({
...draftResult,
stopReason: value ?? undefined,
})
}
/>
</Group>
<Group justify="flex-end">
<RejectButton onClick={onReject} disabled={busy}>
Reject
</RejectButton>
<Button onClick={onSend} disabled={busy}>
Send Response
</Button>
</Group>
</Stack>
);
}