* 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>
211 lines
5.6 KiB
TypeScript
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>
|
|
);
|
|
}
|