Skip to content

Commit b1b541b

Browse files
cliffhallclaude
andcommitted
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>
1 parent c61e15f commit b1b541b

8 files changed

Lines changed: 230 additions & 4 deletions

File tree

clients/web/src/components/groups/PromptMessagesDisplay/PromptMessagesDisplay.test.tsx

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,4 +46,21 @@ describe("PromptMessagesDisplay", () => {
4646
await user.click(screen.getByRole("button", { name: "Copy All" }));
4747
expect(onCopyAll).toHaveBeenCalledTimes(1);
4848
});
49+
50+
it("renders a close button when onClose is provided and invokes it on click", async () => {
51+
const user = userEvent.setup();
52+
const onClose = vi.fn();
53+
renderWithMantine(
54+
<PromptMessagesDisplay messages={messages} onClose={onClose} />,
55+
);
56+
await user.click(screen.getByRole("button", { name: "Close messages" }));
57+
expect(onClose).toHaveBeenCalledTimes(1);
58+
});
59+
60+
it("does not render a close button when onClose is omitted", () => {
61+
renderWithMantine(<PromptMessagesDisplay messages={messages} />);
62+
expect(
63+
screen.queryByRole("button", { name: "Close messages" }),
64+
).not.toBeInTheDocument();
65+
});
4966
});

clients/web/src/components/groups/PromptMessagesDisplay/PromptMessagesDisplay.tsx

Lines changed: 27 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,24 @@
1-
import { Button, Group, ScrollArea, Stack, Text, Title } from "@mantine/core";
1+
import {
2+
Button,
3+
CloseButton,
4+
Group,
5+
ScrollArea,
6+
Stack,
7+
Text,
8+
Title,
9+
} from "@mantine/core";
210
import type { PromptMessage } from "@modelcontextprotocol/sdk/types.js";
311
import { MessageBubble } from "../../elements/MessageBubble/MessageBubble";
412

513
export interface PromptMessagesDisplayProps {
614
messages: PromptMessage[];
715
onCopyAll?: () => void;
16+
/**
17+
* When provided, a top-left X button dismisses the panel. The host
18+
* (`PromptsScreen`) decides what to show in its place — typically
19+
* the prompt's argument form (if it has arguments) or the empty state.
20+
*/
21+
onClose?: () => void;
822
}
923

1024
const CopyAllButton = Button.withProps({
@@ -26,6 +40,11 @@ const HeaderRow = Group.withProps({
2640
flex: "0 0 auto",
2741
});
2842

43+
const HeaderLeft = Group.withProps({
44+
gap: "xs",
45+
wrap: "nowrap",
46+
});
47+
2948
// `0 1 auto` lets the scroll region shrink (but not grow) when the card
3049
// hits its mah. `mih: 0` is required for flex children to shrink below
3150
// their content's intrinsic height.
@@ -45,11 +64,17 @@ const MessagesStack = Stack.withProps({
4564
export function PromptMessagesDisplay({
4665
messages,
4766
onCopyAll,
67+
onClose,
4868
}: PromptMessagesDisplayProps) {
4969
return (
5070
<PanelStack>
5171
<HeaderRow>
52-
<Title order={4}>Messages</Title>
72+
<HeaderLeft>
73+
{onClose && (
74+
<CloseButton aria-label="Close messages" onClick={onClose} />
75+
)}
76+
<Title order={4}>Messages</Title>
77+
</HeaderLeft>
5378
{onCopyAll && messages.length > 0 && (
5479
<CopyAllButton onClick={onCopyAll}>Copy All</CopyAllButton>
5580
)}

clients/web/src/components/groups/ResourcePreviewPanel/ResourcePreviewPanel.test.tsx

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -237,6 +237,23 @@ describe("ResourcePreviewPanel", () => {
237237
).toBeInTheDocument();
238238
});
239239

240+
it("renders a close button when onClose is provided and invokes it on click", async () => {
241+
const user = userEvent.setup();
242+
const onClose = vi.fn();
243+
renderWithMantine(
244+
<ResourcePreviewPanel {...baseProps} onClose={onClose} />,
245+
);
246+
await user.click(screen.getByRole("button", { name: "Close preview" }));
247+
expect(onClose).toHaveBeenCalledTimes(1);
248+
});
249+
250+
it("does not render a close button when onClose is omitted", () => {
251+
renderWithMantine(<ResourcePreviewPanel {...baseProps} />);
252+
expect(
253+
screen.queryByRole("button", { name: "Close preview" }),
254+
).not.toBeInTheDocument();
255+
});
256+
240257
it("does not render plain-text content as markdown even with markdown-looking text", () => {
241258
renderWithMantine(
242259
<ResourcePreviewPanel

clients/web/src/components/groups/ResourcePreviewPanel/ResourcePreviewPanel.tsx

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import {
22
Button,
3+
CloseButton,
34
Flex,
45
Group,
56
ScrollArea,
@@ -26,6 +27,12 @@ export interface ResourcePreviewPanelProps {
2627
onRefresh: () => void;
2728
onSubscribe: () => void;
2829
onUnsubscribe: () => void;
30+
/**
31+
* When provided, a top-left X button dismisses the panel. The host
32+
* (`ResourcesScreen`) decides what to show in its place — either the
33+
* originating template form or the empty state.
34+
*/
35+
onClose?: () => void;
2936
}
3037

3138
function toContentBlock(
@@ -57,6 +64,11 @@ const HeaderRow = Group.withProps({
5764
flex: "0 0 auto",
5865
});
5966

67+
const HeaderLeft = Group.withProps({
68+
gap: "xs",
69+
wrap: "nowrap",
70+
});
71+
6072
const UriGroup = Group.withProps({
6173
gap: "xs",
6274
wrap: "nowrap",
@@ -159,14 +171,20 @@ export function ResourcePreviewPanel({
159171
onRefresh,
160172
onSubscribe,
161173
onUnsubscribe,
174+
onClose,
162175
}: ResourcePreviewPanelProps) {
163176
const { uri, annotations } = resource;
164177
const mimeType = effectiveMime(contents[0]?.mimeType, resource);
165178

166179
return (
167180
<PanelStack>
168181
<HeaderRow>
169-
<Title order={4}>Resource</Title>
182+
<HeaderLeft>
183+
{onClose && (
184+
<CloseButton aria-label="Close preview" onClick={onClose} />
185+
)}
186+
<Title order={4}>Resource</Title>
187+
</HeaderLeft>
170188
<UriGroup>
171189
<UriText>{uri}</UriText>
172190
<CopyButton value={uri} />

clients/web/src/components/screens/PromptsScreen/PromptsScreen.test.tsx

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -201,6 +201,55 @@ describe("PromptsScreen", () => {
201201
expect(screen.getByPlaceholderText("Enter y...")).toHaveValue("");
202202
});
203203

204+
it("closing the preview for an arg-bearing prompt brings the form back", async () => {
205+
const user = userEvent.setup();
206+
renderWithMantine(
207+
<PromptsScreen
208+
{...baseProps}
209+
getPromptState={{
210+
status: "ok",
211+
promptName: "summarize",
212+
result: {
213+
messages: [{ role: "user", content: { type: "text", text: "hi" } }],
214+
},
215+
}}
216+
/>,
217+
);
218+
await user.click(screen.getByText("summarize"));
219+
await user.type(screen.getByPlaceholderText("Enter topic..."), "math");
220+
await user.click(screen.getByRole("button", { name: "Get Prompt" }));
221+
// Preview is showing now — close it.
222+
await user.click(screen.getByRole("button", { name: "Close messages" }));
223+
expect(
224+
screen.getByRole("button", { name: "Get Prompt" }),
225+
).toBeInTheDocument();
226+
// Argument value is preserved so the user can edit + re-submit.
227+
expect(screen.getByPlaceholderText("Enter topic...")).toHaveValue("math");
228+
});
229+
230+
it("closing the preview for a no-arg prompt drops the selection", async () => {
231+
const user = userEvent.setup();
232+
renderWithMantine(
233+
<PromptsScreen
234+
{...baseProps}
235+
prompts={noArgPrompts}
236+
getPromptState={{
237+
status: "ok",
238+
promptName: "ping",
239+
result: {
240+
messages: [{ role: "user", content: { type: "text", text: "hi" } }],
241+
},
242+
}}
243+
/>,
244+
);
245+
await user.click(screen.getByText("ping"));
246+
await user.click(screen.getByRole("button", { name: "Close messages" }));
247+
// No form to fall back to → empty state.
248+
expect(
249+
screen.getByText("Select a prompt to view details"),
250+
).toBeInTheDocument();
251+
});
252+
204253
it("threads onCompleteArgument with a ref/prompt envelope", async () => {
205254
const user = userEvent.setup();
206255
const onCompleteArgument = vi

clients/web/src/components/screens/PromptsScreen/PromptsScreen.tsx

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -138,6 +138,19 @@ export function PromptsScreen({
138138
onGetPrompt(selectedPrompt.name, argumentValues);
139139
}
140140

141+
function handleClosePreview() {
142+
// For prompts with arguments, flip back to the form so the user can
143+
// edit and re-submit (argumentValues are preserved). For no-arg
144+
// prompts there's no form to return to, so drop the selection and
145+
// fall back to the empty state.
146+
if (selectedPrompt && hasArguments(selectedPrompt)) {
147+
setSubmittedFor(undefined);
148+
} else {
149+
setSelectedPromptName(undefined);
150+
setSubmittedFor(undefined);
151+
}
152+
}
153+
141154
// The preview is "active" when we've submitted (or auto-fetched) the
142155
// currently-selected prompt and the parent's result/pending/error state
143156
// matches. Without the name check, a stale result from a previous
@@ -175,6 +188,7 @@ export function PromptsScreen({
175188
<PromptMessagesDisplay
176189
messages={getPromptState.result.messages}
177190
onCopyAll={onCopyMessages}
191+
onClose={handleClosePreview}
178192
/>
179193
</PreviewCard>
180194
);

clients/web/src/components/screens/ResourcesScreen/ResourcesScreen.test.tsx

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -165,6 +165,70 @@ describe("ResourcesScreen", () => {
165165
expect(onSubscribeResource).toHaveBeenCalledWith("file:///x");
166166
});
167167

168+
it("closing the preview returns to the originating template form", async () => {
169+
const user = userEvent.setup();
170+
const onReadResource = vi.fn();
171+
const templates: ResourceTemplate[] = [
172+
{ uriTemplate: "file:///{path}", name: "files" },
173+
];
174+
const { rerender } = renderWithMantine(
175+
<ResourcesScreen
176+
{...baseProps}
177+
templates={templates}
178+
onReadResource={onReadResource}
179+
/>,
180+
);
181+
// Open the template form.
182+
await user.click(screen.getByText("Templates (1)"));
183+
await user.click(screen.getByText("files"));
184+
// Submit it — the screen calls onReadResource and remembers the
185+
// template URI for the close handler.
186+
await user.type(screen.getByLabelText("path"), "alpha");
187+
await user.click(screen.getByRole("button", { name: "Read Resource" }));
188+
expect(onReadResource).toHaveBeenCalledWith("file:///alpha");
189+
190+
// Parent re-renders with the read result; the preview appears.
191+
rerender(
192+
<ResourcesScreen
193+
{...baseProps}
194+
templates={templates}
195+
onReadResource={onReadResource}
196+
readState={{
197+
status: "ok",
198+
uri: "file:///alpha",
199+
result: okResult,
200+
}}
201+
/>,
202+
);
203+
expect(
204+
screen.queryByRole("button", { name: "Read Resource" }),
205+
).not.toBeInTheDocument();
206+
await user.click(screen.getByRole("button", { name: "Close preview" }));
207+
// Closing brings the template form back.
208+
expect(
209+
screen.getByRole("button", { name: "Read Resource" }),
210+
).toBeInTheDocument();
211+
});
212+
213+
it("closing the preview for a plain resource returns to the empty state", async () => {
214+
const user = userEvent.setup();
215+
renderWithMantine(
216+
<ResourcesScreen
217+
{...baseProps}
218+
readState={{
219+
status: "ok",
220+
uri: "file:///x",
221+
result: okResult,
222+
}}
223+
/>,
224+
);
225+
await user.click(screen.getByText("x.txt"));
226+
await user.click(screen.getByRole("button", { name: "Close preview" }));
227+
expect(
228+
screen.getByText("Select a resource to preview"),
229+
).toBeInTheDocument();
230+
});
231+
168232
it("invokes onUnsubscribeResource when already subscribed", async () => {
169233
const user = userEvent.setup();
170234
const onUnsubscribeResource = vi.fn();

clients/web/src/components/screens/ResourcesScreen/ResourcesScreen.tsx

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,13 @@ export function ResourcesScreen({
110110
const [selectedTemplateUri, setSelectedTemplateUri] = useState<
111111
string | undefined
112112
>(undefined);
113+
// Tracks which template (if any) produced the current preview so that
114+
// closing the preview can restore the template form. Cleared when the
115+
// user navigates to a non-template resource or picks a different
116+
// template directly from the sidebar.
117+
const [originatingTemplateUri, setOriginatingTemplateUri] = useState<
118+
string | undefined
119+
>(undefined);
113120

114121
const selectedResource = selectedResourceUri
115122
? resources.find((r) => r.uri === selectedResourceUri)
@@ -129,24 +136,38 @@ export function ResourcesScreen({
129136
function handleSelectResource(uri: string) {
130137
setSelectedTemplateUri(undefined);
131138
setSelectedResourceUri(uri);
139+
setOriginatingTemplateUri(undefined);
132140
onReadResource(uri);
133141
}
134142

135143
function handleSelectTemplate(uriTemplate: string) {
136144
setSelectedResourceUri(undefined);
137145
setSelectedTemplateUri(uriTemplate);
146+
setOriginatingTemplateUri(undefined);
138147
}
139148

140149
function handleReadResource(uri: string) {
141150
// Once the user reads (either from the template form or a refresh
142151
// inside the preview panel), hand the screen over to the preview:
143152
// clearing the template selection hides the template form so only
144-
// the rendered resource is shown.
153+
// the rendered resource is shown. We remember the template URI so
154+
// closing the preview can restore the form.
155+
if (selectedTemplateUri) {
156+
setOriginatingTemplateUri(selectedTemplateUri);
157+
}
145158
setSelectedTemplateUri(undefined);
146159
setSelectedResourceUri(uri);
147160
onReadResource(uri);
148161
}
149162

163+
function handleClosePreview() {
164+
setSelectedResourceUri(undefined);
165+
if (originatingTemplateUri) {
166+
setSelectedTemplateUri(originatingTemplateUri);
167+
setOriginatingTemplateUri(undefined);
168+
}
169+
}
170+
150171
function renderReadState() {
151172
if (!readState) return null;
152173

@@ -182,6 +203,7 @@ export function ResourcesScreen({
182203
onRefresh={() => handleReadResource(readResource.uri)}
183204
onSubscribe={() => onSubscribeResource(readResource.uri)}
184205
onUnsubscribe={() => onUnsubscribeResource(readResource.uri)}
206+
onClose={handleClosePreview}
185207
/>
186208
</PreviewCard>
187209
);

0 commit comments

Comments
 (0)