Skip to content

Commit bd9dfc0

Browse files
a-effortvishu-bh
authored andcommitted
fix(ui-rewrite): reset prompt preview when Code-tab language changes (#5665)
Switching snippet language on the prompt details drawer left the previous run's response body visible and kept the button on "Re-run". The Preview state is prompt-scoped, but the visible affordances sit outside the language tabs — so state persisted across tab switches. - Add `reset()` to `usePromptPreview` that aborts any in-flight request, clears result/error, and explicitly clears `isLoading` (run's finally skips setLoading(false) when the signal is aborted). - Make `PromptSnippetTabs` fully controlled via `value` + `onValueChange`. - `PromptCodeTab` owns the active language and calls `preview.reset()` before switching, so the button returns to "Preview" and the response block unmounts. Signed-off-by: a-effort <anna.effort@ibm.com>
1 parent 0cd763d commit bd9dfc0

6 files changed

Lines changed: 180 additions & 11 deletions

File tree

client/src/components/prompts/PromptCodeTab.test.tsx

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,4 +99,26 @@ describe("PromptCodeTab", () => {
9999
expect(screen.queryByRole("heading", { name: /^arguments$/i })).not.toBeInTheDocument();
100100
expect(screen.getByRole("tab", { name: "curl" })).toBeInTheDocument();
101101
});
102+
103+
it("resets the Preview button and hides the response when the language tab changes", async () => {
104+
vi.mocked(promptsApi.render).mockResolvedValue({
105+
rendered: { messages: [{ role: "user", content: { type: "text", text: "hi" } }] },
106+
status: 200,
107+
});
108+
const user = userEvent.setup();
109+
render(<PromptCodeTab prompt={mockPrompt()} />);
110+
111+
await user.click(screen.getByRole("button", { name: /^preview$/i }));
112+
await waitFor(() =>
113+
expect(screen.getByRole("button", { name: /re-run/i })).toBeInTheDocument(),
114+
);
115+
// Status row is announced live once the run completes.
116+
expect(screen.getByRole("status")).toBeInTheDocument();
117+
118+
await user.click(screen.getByRole("tab", { name: "Python" }));
119+
120+
expect(screen.getByRole("button", { name: /^preview$/i })).toBeInTheDocument();
121+
expect(screen.queryByRole("button", { name: /re-run/i })).not.toBeInTheDocument();
122+
expect(screen.queryByRole("status")).not.toBeInTheDocument();
123+
});
102124
});

client/src/components/prompts/PromptCodeTab.tsx

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { useState } from "react";
1+
import { useCallback, useState } from "react";
22

33
import type { PromptRead } from "@/generated/types";
44
import { PromptArgsForm } from "./PromptArgsForm";
@@ -7,6 +7,8 @@ import { PromptPreviewResult } from "./PromptPreviewResult";
77
import { PromptSnippetTabs } from "./PromptSnippetTabs";
88
import { usePromptPreview } from "@/hooks/usePromptPreview";
99

10+
const DEFAULT_LANGUAGE = "curl";
11+
1012
export interface PromptCodeTabProps {
1113
prompt: NonNullable<PromptRead>;
1214
}
@@ -24,18 +26,33 @@ export interface PromptCodeTabProps {
2426
*/
2527
export function PromptCodeTab({ prompt }: PromptCodeTabProps) {
2628
const [args, setArgs] = useState<Record<string, string>>(() => seedArgs(prompt));
29+
const [language, setLanguage] = useState<string>(DEFAULT_LANGUAGE);
2730
// Address the prompt by name — same identifier the snippets show and what
2831
// MCP-spec clients use on the wire. See `promptsApi.render` for the full
2932
// rationale and the tracked "server-scoped MCP transport" follow-up.
3033
const preview = usePromptPreview(prompt.name, args);
3134

35+
// Switching languages clears the previous run so the Preview affordances
36+
// match the active snippet — the button returns to "Preview" and the
37+
// rendered response is unmounted. Same wire call regardless of language,
38+
// so nothing useful is discarded.
39+
const handleLanguageChange = useCallback(
40+
(next: string) => {
41+
preview.reset();
42+
setLanguage(next);
43+
},
44+
[preview],
45+
);
46+
3247
return (
3348
<div className="space-y-6">
3449
<PromptArgsForm args={args} schema={prompt.arguments} onChange={setArgs} />
3550
<div className="space-y-4">
3651
<PromptSnippetTabs
3752
promptName={prompt.name}
3853
args={args}
54+
value={language}
55+
onValueChange={handleLanguageChange}
3956
actions={<PromptPreviewButton preview={preview} />}
4057
/>
4158
<PromptPreviewResult preview={preview} />

client/src/components/prompts/PromptSnippetTabs.test.tsx

Lines changed: 44 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { useState, type ReactNode } from "react";
12
import { describe, it, expect, vi, beforeEach } from "vitest";
23
import { screen } from "@testing-library/react";
34
import userEvent from "@testing-library/user-event";
@@ -13,36 +14,69 @@ function activeCode(): string {
1314
return pre?.textContent ?? "";
1415
}
1516

17+
// Thin wrapper that supplies the controlled `value` + `onValueChange` — most
18+
// tests only care about the snippet behavior, not the wiring, so keep the
19+
// controller boilerplate out of each case.
20+
function ControlledSnippetTabs(props: {
21+
promptName: string;
22+
args: Record<string, string>;
23+
actions?: ReactNode;
24+
onChange?: (value: string) => void;
25+
initialValue?: string;
26+
}) {
27+
const [value, setValue] = useState(props.initialValue ?? "curl");
28+
return (
29+
<PromptSnippetTabs
30+
promptName={props.promptName}
31+
args={props.args}
32+
value={value}
33+
onValueChange={(next) => {
34+
props.onChange?.(next);
35+
setValue(next);
36+
}}
37+
actions={props.actions}
38+
/>
39+
);
40+
}
41+
1642
beforeEach(() => {
1743
vi.clearAllMocks();
1844
});
1945

2046
describe("PromptSnippetTabs", () => {
2147
it("renders all four language tabs", () => {
22-
render(<PromptSnippetTabs promptName="greet" args={{}} />);
48+
render(<ControlledSnippetTabs promptName="greet" args={{}} />);
2349
expect(screen.getByRole("tab", { name: "curl" })).toBeInTheDocument();
2450
expect(screen.getByRole("tab", { name: "JSON-RPC" })).toBeInTheDocument();
2551
expect(screen.getByRole("tab", { name: "Python" })).toBeInTheDocument();
2652
expect(screen.getByRole("tab", { name: "TypeScript" })).toBeInTheDocument();
2753
});
2854

29-
it("defaults to the curl snippet on first render", () => {
30-
render(<PromptSnippetTabs promptName="greet" args={{ user: "Alice" }} />);
55+
it("shows the curl snippet when the controlled value is 'curl'", () => {
56+
render(<ControlledSnippetTabs promptName="greet" args={{ user: "Alice" }} />);
3157
expect(activeCode()).toContain("curl -X POST");
3258
expect(activeCode()).toContain('"user":"Alice"');
3359
});
3460

3561
it("switches to the JSON-RPC snippet when its tab is activated", async () => {
3662
const user = userEvent.setup();
37-
render(<PromptSnippetTabs promptName="greet" args={{}} />);
63+
render(<ControlledSnippetTabs promptName="greet" args={{}} />);
3864
await user.click(screen.getByRole("tab", { name: "JSON-RPC" }));
3965
expect(activeCode()).toContain('"method": "prompts/get"');
4066
});
4167

68+
it("calls onValueChange with the newly-activated language", async () => {
69+
const user = userEvent.setup();
70+
const onChange = vi.fn();
71+
render(<ControlledSnippetTabs promptName="greet" args={{}} onChange={onChange} />);
72+
await user.click(screen.getByRole("tab", { name: "Python" }));
73+
expect(onChange).toHaveBeenCalledWith("python");
74+
});
75+
4276
it("copies the active snippet to the clipboard on click", async () => {
4377
const user = userEvent.setup();
4478
const writeText = vi.spyOn(navigator.clipboard, "writeText").mockResolvedValue();
45-
render(<PromptSnippetTabs promptName="greet" args={{ user: "Alice" }} />);
79+
render(<ControlledSnippetTabs promptName="greet" args={{ user: "Alice" }} />);
4680
await user.click(screen.getByRole("button", { name: /copy curl snippet/i }));
4781
expect(writeText).toHaveBeenCalledTimes(1);
4882
expect(writeText.mock.calls[0][0]).toContain("curl -X POST");
@@ -51,15 +85,17 @@ describe("PromptSnippetTabs", () => {
5185
});
5286

5387
it("rebuilds the snippet when args change", () => {
54-
const { rerender } = render(<PromptSnippetTabs promptName="greet" args={{ user: "Alice" }} />);
88+
const { rerender } = render(
89+
<ControlledSnippetTabs promptName="greet" args={{ user: "Alice" }} />,
90+
);
5591
expect(activeCode()).toContain('"user":"Alice"');
56-
rerender(<PromptSnippetTabs promptName="greet" args={{ user: "Bob" }} />);
92+
rerender(<ControlledSnippetTabs promptName="greet" args={{ user: "Bob" }} />);
5793
expect(activeCode()).toContain('"user":"Bob"');
5894
});
5995

6096
it("renders the trailing actions slot beside the tab list", () => {
6197
render(
62-
<PromptSnippetTabs
98+
<ControlledSnippetTabs
6399
promptName="greet"
64100
args={{}}
65101
actions={<button type="button">Preview</button>}

client/src/components/prompts/PromptSnippetTabs.tsx

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,10 @@ import { buildTypescript } from "./snippets/buildTypescript";
1111
export interface PromptSnippetTabsProps {
1212
promptName: string;
1313
args: Record<string, string>;
14+
/** Currently-active language tab. Controlled by the parent. */
15+
value: string;
16+
/** Fired when the user activates a different language tab. */
17+
onValueChange: (value: string) => void;
1418
/** Rendered to the right of the tab row — typically the Preview button. */
1519
actions?: ReactNode;
1620
}
@@ -54,7 +58,13 @@ const SNIPPETS: SnippetSpec[] = [
5458
},
5559
];
5660

57-
export function PromptSnippetTabs({ promptName, args, actions }: PromptSnippetTabsProps) {
61+
export function PromptSnippetTabs({
62+
promptName,
63+
args,
64+
value,
65+
onValueChange,
66+
actions,
67+
}: PromptSnippetTabsProps) {
5868
const intl = useIntl();
5969

6070
const rendered = useMemo(
@@ -65,7 +75,7 @@ export function PromptSnippetTabs({ promptName, args, actions }: PromptSnippetTa
6575
const copiedLabel = intl.formatMessage({ id: "prompts.details.code.copySuccess" });
6676

6777
return (
68-
<Tabs defaultValue="curl">
78+
<Tabs value={value} onValueChange={onValueChange}>
6979
<div className="mb-2 flex items-center justify-between gap-4">
7080
<TabsList>
7181
{SNIPPETS.map((spec) => (

client/src/hooks/usePromptPreview.test.tsx

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -177,4 +177,70 @@ describe("usePromptPreview", () => {
177177
});
178178
expect(result.current.isLoading).toBe(false);
179179
});
180+
181+
it("reset() clears a completed run so hasRun returns to false", async () => {
182+
vi.mocked(promptsApi.render).mockResolvedValue({ rendered: { messages: [] }, status: 200 });
183+
const { result } = setup();
184+
185+
await act(async () => {
186+
await result.current.run();
187+
});
188+
expect(result.current.hasRun).toBe(true);
189+
190+
act(() => {
191+
result.current.reset();
192+
});
193+
expect(result.current.result).toBeNull();
194+
expect(result.current.error).toBeNull();
195+
expect(result.current.hasRun).toBe(false);
196+
expect(result.current.isLoading).toBe(false);
197+
});
198+
199+
it("reset() clears a captured error", async () => {
200+
vi.mocked(promptsApi.render).mockRejectedValue(new Error("boom"));
201+
const { result } = setup();
202+
203+
await act(async () => {
204+
await result.current.run();
205+
});
206+
expect(result.current.error).not.toBeNull();
207+
208+
act(() => {
209+
result.current.reset();
210+
});
211+
expect(result.current.error).toBeNull();
212+
expect(result.current.hasRun).toBe(false);
213+
});
214+
215+
it("reset() aborts an in-flight request and clears isLoading", async () => {
216+
let capturedSignal: AbortSignal | undefined;
217+
let resolve: ((v: { rendered: { messages: never[] }; status: number }) => void) | undefined;
218+
vi.mocked(promptsApi.render).mockImplementation((_name, _args, opts) => {
219+
capturedSignal = opts?.signal;
220+
return new Promise((r) => {
221+
resolve = r;
222+
});
223+
});
224+
const { result } = setup();
225+
226+
act(() => {
227+
void result.current.run();
228+
});
229+
await waitFor(() => expect(result.current.isLoading).toBe(true));
230+
expect(capturedSignal?.aborted).toBe(false);
231+
232+
act(() => {
233+
result.current.reset();
234+
});
235+
expect(capturedSignal?.aborted).toBe(true);
236+
expect(result.current.isLoading).toBe(false);
237+
expect(result.current.result).toBeNull();
238+
expect(result.current.error).toBeNull();
239+
240+
// Late resolution after reset must not repopulate state.
241+
resolve?.({ rendered: { messages: [] }, status: 200 });
242+
await new Promise((r) => setTimeout(r, 0));
243+
expect(result.current.result).toBeNull();
244+
expect(result.current.hasRun).toBe(false);
245+
});
180246
});

client/src/hooks/usePromptPreview.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ export interface PreviewFailure {
2020

2121
export interface PromptPreviewState {
2222
run: () => Promise<void>;
23+
reset: () => void;
2324
isLoading: boolean;
2425
result: PreviewSuccess | null;
2526
error: PreviewFailure | null;
@@ -61,6 +62,22 @@ export function usePromptPreview(
6162
};
6263
}, []);
6364

65+
// Clears result/error and cancels any in-flight request. Callers use this
66+
// to return the Preview affordances to their pristine state — e.g. when the
67+
// user switches the Code-tab language, we don't want the previous run's
68+
// response body to appear under a different snippet.
69+
//
70+
// Explicitly clears isLoading: run()'s finally-clause skips setLoading(false)
71+
// when the signal is aborted (so a late resolution can't touch state after
72+
// unmount), which would otherwise leave the button stuck on "Rendering…".
73+
const reset = useCallback(() => {
74+
abortRef.current?.abort();
75+
abortRef.current = null;
76+
setResult(null);
77+
setError(null);
78+
setLoading(false);
79+
}, []);
80+
6481
const run = useCallback(async () => {
6582
abortRef.current?.abort();
6683
const controller = new AbortController();
@@ -93,6 +110,7 @@ export function usePromptPreview(
93110

94111
return {
95112
run,
113+
reset,
96114
isLoading,
97115
result,
98116
error,

0 commit comments

Comments
 (0)