Skip to content

Commit 8b33cf8

Browse files
Marek DanoMarek Dano
authored andcommitted
fix(ui-rewrite): bound dialog height and reconcile failed prompt deletes
Signed-off-by: Marek Dano <Marek.Dano@ibm.com>
1 parent 28bce9d commit 8b33cf8

4 files changed

Lines changed: 113 additions & 10 deletions

File tree

client/src/components/ui/dialog.test.tsx

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -211,6 +211,26 @@ describe("Dialog Components", () => {
211211
expect(content).toHaveClass("z-50");
212212
});
213213

214+
it("bounds its height to the viewport and scrolls overflowing content", () => {
215+
// Tall dialogs must stay within the viewport so the header, top content,
216+
// and close button remain reachable instead of extending off-screen.
217+
render(
218+
<Dialog open={true}>
219+
<DialogContent data-testid="content">
220+
<DialogTitle>Tall dialog</DialogTitle>
221+
<DialogDescription>Lots of content</DialogDescription>
222+
{Array.from({ length: 100 }, (_, i) => (
223+
<p key={i}>Row {i}</p>
224+
))}
225+
</DialogContent>
226+
</Dialog>,
227+
);
228+
229+
const content = screen.getByTestId("content");
230+
expect(content).toHaveClass("max-h-[calc(100vh-2rem)]");
231+
expect(content).toHaveClass("overflow-y-auto");
232+
});
233+
214234
it("should include close button", () => {
215235
render(
216236
<Dialog open={true}>

client/src/components/ui/dialog.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ const DialogContent = React.forwardRef<
3636
<DialogPrimitive.Content
3737
ref={ref}
3838
className={cn(
39-
"fixed inset-x-0 top-1/2 z-50 mx-auto grid w-full max-w-lg -translate-y-1/2 gap-5 border bg-popover p-7 shadow-lg duration-150 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 sm:rounded-lg",
39+
"fixed inset-x-0 top-1/2 z-50 mx-auto grid max-h-[calc(100vh-2rem)] w-full max-w-lg -translate-y-1/2 gap-5 overflow-y-auto border bg-popover p-7 shadow-lg duration-150 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 sm:rounded-lg",
4040
className,
4141
)}
4242
{...props}

client/src/pages/Prompts.test.tsx

Lines changed: 66 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -405,7 +405,7 @@ describe("Prompts", () => {
405405
expect(screen.getByRole("button", { name: /more options for translate/i })).toBeInTheDocument();
406406
});
407407

408-
it("restores the deleted prompt row when the DELETE request fails", async () => {
408+
it("reconciles with the server and restores the row when the DELETE fails and the prompt survives", async () => {
409409
const user = userEvent.setup();
410410
const prompts: Prompt[] = [
411411
createMockPrompt({
@@ -424,6 +424,9 @@ describe("Prompts", () => {
424424
}),
425425
];
426426

427+
// The DELETE fails and the prompt is NOT removed server-side (a pre-commit
428+
// failure), so the post-failure refetch still returns it and the row
429+
// reappears.
427430
server.use(
428431
http.get("/prompts", () => HttpResponse.json(prompts)),
429432
http.delete("/prompts/prompt-1", () =>
@@ -447,14 +450,75 @@ describe("Prompts", () => {
447450
const dialog = await screen.findByRole("dialog");
448451
await user.click(within(dialog).getByRole("button", { name: /^delete$/i }));
449452

450-
// The optimistic removal is rolled back, so the row reappears.
453+
// The optimistic removal is reconciled against server truth, so the row
454+
// reappears because the prompt still exists.
451455
await waitFor(() => {
452456
expect(
453457
screen.getByRole("button", { name: /more options for summarize/i }),
454458
).toBeInTheDocument();
455459
});
456460
});
457461

462+
it("does not resurrect a prompt when the DELETE reports an error but the row is already gone", async () => {
463+
const user = userEvent.setup();
464+
// Simulates a post-commit failure: PromptService.delete_prompt() commits
465+
// the removal, then a later step (notification/cache invalidation) fails
466+
// and the endpoint returns an error. The server no longer has the prompt,
467+
// so reconciling must NOT restore it.
468+
let remaining: Prompt[] = [
469+
createMockPrompt({
470+
id: "prompt-1",
471+
name: "summarize",
472+
displayName: "Summarize document",
473+
gatewayId: "gw-github",
474+
gatewaySlug: "gh-repo-tasks",
475+
}),
476+
createMockPrompt({
477+
id: "prompt-2",
478+
name: "translate",
479+
displayName: "Translate document",
480+
gatewayId: "gw-github",
481+
gatewaySlug: "gh-repo-tasks",
482+
}),
483+
];
484+
485+
server.use(
486+
http.get("/prompts", () => HttpResponse.json(remaining)),
487+
http.delete("/prompts/prompt-1", () => {
488+
// Row is committed as deleted, but the request still errors out.
489+
remaining = remaining.filter((p) => p.id !== "prompt-1");
490+
return HttpResponse.json({ detail: "Post-commit hook failed" }, { status: 500 });
491+
}),
492+
);
493+
494+
renderWithProviders(<Prompts />);
495+
496+
await waitFor(() => {
497+
expect(screen.getByText("gh-repo-tasks")).toBeInTheDocument();
498+
});
499+
500+
await user.click(screen.getByRole("button", { name: "More options for gh-repo-tasks" }));
501+
await user.click(await screen.findByRole("menuitem", { name: "View details" }));
502+
await user.click(await screen.findByRole("tab", { name: /definition/i }));
503+
504+
await user.click(await screen.findByRole("button", { name: /more options for summarize/i }));
505+
await user.click(await screen.findByRole("menuitem", { name: "Delete" }));
506+
507+
const dialog = await screen.findByRole("dialog");
508+
await user.click(within(dialog).getByRole("button", { name: /^delete$/i }));
509+
510+
// The drawer stays open with the surviving sibling, and the deleted prompt
511+
// is not brought back by the failure path.
512+
await waitFor(() => {
513+
expect(
514+
screen.getByRole("button", { name: /more options for translate/i }),
515+
).toBeInTheDocument();
516+
});
517+
expect(
518+
screen.queryByRole("button", { name: /more options for summarize/i }),
519+
).not.toBeInTheDocument();
520+
});
521+
458522
it("collapses gateway-less prompts into a single REST prompts card", async () => {
459523
const prompts: Prompt[] = [
460524
createMockPrompt({

client/src/pages/Prompts.tsx

Lines changed: 26 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -293,9 +293,9 @@ export function Prompts() {
293293
const prompt = promptToDelete;
294294
const name = getPromptLabel(prompt) || prompt.id;
295295

296-
// Snapshot the list and the open group so we can roll back the optimistic
297-
// removal if the DELETE fails, mirroring the Tools page delete flow.
298-
const previousData = promptsData;
296+
// Remember the open group so a failed delete can re-resolve it against
297+
// server truth (below). We deliberately do NOT snapshot the whole list:
298+
// reconciling with the server on failure is safer than blindly restoring.
299299
const previousActiveGroup = activeGroup;
300300

301301
const removeFromList = (list: (PromptRead | null)[]) =>
@@ -329,14 +329,33 @@ export function Prompts() {
329329
console.error("Failed to refresh prompts after deletion:", sanitizeError(refreshErr));
330330
}
331331
} catch (err) {
332-
// Restore the pre-delete list and reopen the group on failure.
333-
setPromptsData(() => previousData);
334-
setActiveGroup(previousActiveGroup);
332+
// A DELETE error does not guarantee the prompt still exists:
333+
// PromptService.delete_prompt() commits before running notification and
334+
// cache invalidation, so a post-commit failure returns an error even
335+
// though the row is already gone. Reconcile with the server instead of
336+
// restoring a pre-delete snapshot — that would resurrect an
337+
// already-deleted prompt, and unconditionally restoring the whole list
338+
// could also clobber newer state from an overlapping delete.
339+
try {
340+
const fresh = await refetch();
341+
// Re-resolve the open drawer's group from server truth so the drawer
342+
// reopens/repopulates on a genuine failure (or stays closed if the
343+
// group is now empty).
344+
if (previousActiveGroup) {
345+
const freshGroups = buildPromptGroups(getPromptItems(fresh), restPromptsLabel);
346+
setActiveGroup(freshGroups.find((g) => g.key === previousActiveGroup.key) ?? null);
347+
}
348+
} catch (refreshErr) {
349+
console.error(
350+
"Failed to reconcile prompts after failed deletion:",
351+
sanitizeError(refreshErr),
352+
);
353+
}
335354

336355
// Surface the backend detail when present, otherwise the localized fallback.
337356
toast.error(parseApiError(err, intl.formatMessage({ id: "prompts.delete.error" })));
338357
}
339-
}, [promptToDelete, promptsData, activeGroup, setPromptsData, refetch, intl]);
358+
}, [promptToDelete, activeGroup, setPromptsData, refetch, intl, restPromptsLabel]);
340359

341360
return (
342361
<div className="p-6">

0 commit comments

Comments
 (0)