* 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:
parent
6678313373
commit
8d6dea0af2
2 changed files with 1428 additions and 224 deletions
|
|
@ -7,17 +7,29 @@ import { renderWithMantine, screen } from "../../../test/renderWithMantine";
|
|||
import { MessageBubble } from "./MessageBubble";
|
||||
|
||||
describe("MessageBubble", () => {
|
||||
it("renders a text sampling message", () => {
|
||||
it("renders a text sampling message as markdown", () => {
|
||||
const message: SamplingMessage = {
|
||||
role: "user",
|
||||
content: { type: "text", text: "hello" },
|
||||
};
|
||||
renderWithMantine(<MessageBubble index={0} message={message} />);
|
||||
expect(screen.getByText("[0] role: user")).toBeInTheDocument();
|
||||
expect(screen.getByText('"hello"')).toBeInTheDocument();
|
||||
expect(screen.getByText("hello")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders a copy button when there is text", () => {
|
||||
it("renders markdown formatting in prompt text", () => {
|
||||
const message: PromptMessage = {
|
||||
role: "assistant",
|
||||
content: { type: "text", text: "# Heading\n\nSome **bold** text" },
|
||||
};
|
||||
renderWithMantine(<MessageBubble index={0} message={message} />);
|
||||
expect(
|
||||
screen.getByRole("heading", { level: 1, name: "Heading" }),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByText("bold")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders a copy button for text content via ContentViewer copyable", () => {
|
||||
const message: SamplingMessage = {
|
||||
role: "user",
|
||||
content: { type: "text", text: "hello" },
|
||||
|
|
@ -52,7 +64,7 @@ describe("MessageBubble", () => {
|
|||
);
|
||||
});
|
||||
|
||||
it("renders embedded resource text from a prompt message array", () => {
|
||||
it("renders embedded resource text from a prompt message", () => {
|
||||
const message: PromptMessage = {
|
||||
role: "user",
|
||||
content: {
|
||||
|
|
@ -61,7 +73,7 @@ describe("MessageBubble", () => {
|
|||
},
|
||||
};
|
||||
renderWithMantine(<MessageBubble index={3} message={message} />);
|
||||
expect(screen.getByText('"embedded"')).toBeInTheDocument();
|
||||
expect(screen.getByText("embedded")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders blob resource placeholder", () => {
|
||||
|
|
@ -77,24 +89,39 @@ describe("MessageBubble", () => {
|
|||
},
|
||||
};
|
||||
renderWithMantine(<MessageBubble index={4} message={message} />);
|
||||
expect(screen.getByText('"[resource: file:///b]"')).toBeInTheDocument();
|
||||
expect(screen.getByText("[blob: file:///b]")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders resource_link content", () => {
|
||||
const message = {
|
||||
role: "user",
|
||||
content: { type: "resource_link", uri: "ui://app" },
|
||||
content: { type: "resource_link", uri: "ui://app", name: "Cool App" },
|
||||
} as unknown as PromptMessage;
|
||||
renderWithMantine(<MessageBubble index={5} message={message} />);
|
||||
expect(screen.getByText('"[resource: ui://app]"')).toBeInTheDocument();
|
||||
expect(screen.getByText("Cool App")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders fallback for unknown content types", () => {
|
||||
it("still renders the role label for unknown content types", () => {
|
||||
const message = {
|
||||
role: "user",
|
||||
content: { type: "weird" },
|
||||
} as unknown as SamplingMessage;
|
||||
renderWithMantine(<MessageBubble index={6} message={message} />);
|
||||
expect(screen.getByText('"[weird]"')).toBeInTheDocument();
|
||||
// ContentViewer returns null for unknown block types; the bubble's
|
||||
// role-label header still renders so the message isn't invisible.
|
||||
expect(screen.getByText("[6] role: user")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders multiple content blocks from an array", () => {
|
||||
const message: PromptMessage = {
|
||||
role: "user",
|
||||
content: [
|
||||
{ type: "text", text: "first" },
|
||||
{ type: "text", text: "second" },
|
||||
] as unknown as PromptMessage["content"],
|
||||
};
|
||||
renderWithMantine(<MessageBubble index={7} message={message} />);
|
||||
expect(screen.getByText("first")).toBeInTheDocument();
|
||||
expect(screen.getByText("second")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,72 +1,56 @@
|
|||
import { Group, Image, Paper, Stack, Text } from "@mantine/core";
|
||||
import { Group, Paper, Stack, Text } from "@mantine/core";
|
||||
import type {
|
||||
ContentBlock,
|
||||
PromptMessage,
|
||||
SamplingMessage,
|
||||
} from "@modelcontextprotocol/sdk/types.js";
|
||||
import { CopyButton } from "../CopyButton/CopyButton";
|
||||
import { ContentViewer } from "../ContentViewer/ContentViewer";
|
||||
|
||||
export interface MessageBubbleProps {
|
||||
index: number;
|
||||
message: SamplingMessage | PromptMessage;
|
||||
}
|
||||
|
||||
function buildDataUri(mimeType: string, data: string): string {
|
||||
return `data:${mimeType};base64,${data}`;
|
||||
}
|
||||
|
||||
function formatRoleLabel(index: number, role: string): string {
|
||||
return `[${index}] role: ${role}`;
|
||||
}
|
||||
|
||||
function formatQuotedContent(content: string): string {
|
||||
return `"${content}"`;
|
||||
// PromptMessage/SamplingMessage content unions in the SDK are wider than
|
||||
// ContentBlock (they admit tool_use, tool_result, etc. for the agent
|
||||
// messages flowing into prompts). ContentViewer renders only the visual
|
||||
// subset; everything else is silently dropped here. The bubble's role
|
||||
// header keeps an empty message from being invisible.
|
||||
const RENDERABLE_TYPES = new Set([
|
||||
"text",
|
||||
"image",
|
||||
"audio",
|
||||
"resource",
|
||||
"resource_link",
|
||||
]);
|
||||
|
||||
function isRenderableBlock(block: unknown): block is ContentBlock {
|
||||
if (typeof block !== "object" || block === null) return false;
|
||||
const t = (block as { type?: string }).type;
|
||||
return typeof t === "string" && RENDERABLE_TYPES.has(t);
|
||||
}
|
||||
|
||||
interface ContentBlockRendered {
|
||||
text: string;
|
||||
imageUri?: string;
|
||||
audioUri?: string;
|
||||
audioMime?: string;
|
||||
}
|
||||
|
||||
function extractContent(
|
||||
message: SamplingMessage | PromptMessage,
|
||||
): ContentBlockRendered {
|
||||
const content = message.content;
|
||||
const blocks = Array.isArray(content) ? content : [content];
|
||||
let text = "";
|
||||
let imageUri: string | undefined;
|
||||
let audioUri: string | undefined;
|
||||
let audioMime: string | undefined;
|
||||
|
||||
for (const block of blocks) {
|
||||
switch (block.type) {
|
||||
case "text":
|
||||
text += block.text;
|
||||
break;
|
||||
case "image":
|
||||
imageUri = buildDataUri(block.mimeType, block.data);
|
||||
break;
|
||||
case "audio":
|
||||
audioUri = buildDataUri(block.mimeType, block.data);
|
||||
audioMime = block.mimeType;
|
||||
break;
|
||||
case "resource":
|
||||
text +=
|
||||
"text" in block.resource
|
||||
? block.resource.text
|
||||
: `[resource: ${block.resource.uri}]`;
|
||||
break;
|
||||
case "resource_link":
|
||||
text += `[resource: ${block.uri}]`;
|
||||
break;
|
||||
default:
|
||||
text += `[${block.type}]`;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return { text, imageUri, audioUri, audioMime };
|
||||
// Prompt content blocks don't carry a mimeType on the text variant
|
||||
// (SDK `TextContent` is just `{ type: "text", text }`). Render text as
|
||||
// markdown by default so prompt prose with code fences, lists, and links
|
||||
// looks like prose rather than a preformatted dump. Image / audio blocks
|
||||
// already carry mimeType; ContentViewer routes them itself.
|
||||
//
|
||||
// Caveat: this is unconditional — a server that emits a raw shell
|
||||
// snippet, log line, or string containing `#` / `_` / backticks will
|
||||
// have it transformed. Most prompts are prose so the trade-off is
|
||||
// worth it, but this differs from the resource side (where
|
||||
// ResourcePreviewPanel only promotes to markdown when the server
|
||||
// supplies `text/markdown` or the URI suffix matches). If the MCP
|
||||
// spec ever adds a per-block mimeType for prompt messages, switch
|
||||
// back to opt-in rendering here.
|
||||
function effectiveMimeForBlock(block: ContentBlock): string | undefined {
|
||||
if (block.type === "text") return "text/markdown";
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const BubbleContainer = Paper.withProps({
|
||||
|
|
@ -81,29 +65,29 @@ const RoleLabel = Text.withProps({
|
|||
ff: "monospace",
|
||||
});
|
||||
|
||||
const PreviewImage = Image.withProps({
|
||||
maw: 300,
|
||||
radius: "sm",
|
||||
mt: "xs",
|
||||
const HeaderRow = Group.withProps({
|
||||
justify: "space-between",
|
||||
});
|
||||
|
||||
export function MessageBubble({ index, message }: MessageBubbleProps) {
|
||||
const { text, imageUri, audioUri, audioMime } = extractContent(message);
|
||||
const content = message.content;
|
||||
const rawBlocks = Array.isArray(content) ? content : [content];
|
||||
const blocks = rawBlocks.filter(isRenderableBlock);
|
||||
|
||||
return (
|
||||
<BubbleContainer>
|
||||
<Stack gap="xs">
|
||||
<Group justify="space-between">
|
||||
<HeaderRow>
|
||||
<RoleLabel>{formatRoleLabel(index, message.role)}</RoleLabel>
|
||||
{text && <CopyButton value={text} />}
|
||||
</Group>
|
||||
{text && <Text size="sm">{formatQuotedContent(text)}</Text>}
|
||||
{imageUri && <PreviewImage src={imageUri} />}
|
||||
{audioUri && (
|
||||
<audio controls>
|
||||
<source src={audioUri} type={audioMime} />
|
||||
</audio>
|
||||
)}
|
||||
</HeaderRow>
|
||||
{blocks.map((block, blockIndex) => (
|
||||
<ContentViewer
|
||||
key={blockIndex}
|
||||
block={block}
|
||||
mimeType={effectiveMimeForBlock(block)}
|
||||
copyable
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
</BubbleContainer>
|
||||
);
|
||||
|
|
|
|||
Loading…
Reference in a new issue