feat(gate): render reviewer artifacts in a 90% modal (fix empty artifact.data gate)

This commit is contained in:
2026-07-03 13:17:54 +02:00
parent 0975a8a72b
commit 37b3eb45e9
2 changed files with 65 additions and 42 deletions
@@ -2,19 +2,25 @@ import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { ArtifactGateWidget } from "./ArtifactGateWidget";
test("renders artifact content in a pre block", () => {
vi.mock("../viewers/mermaid", () => ({
renderMermaid: vi.fn().mockResolvedValue('<svg data-testid="mm"></svg>'),
}));
test("renders inside a dialog and shows the artifact via ArtifactView (data path)", () => {
const onRespond = vi.fn();
render(
<ArtifactGateWidget
descriptor={{
id: "u1",
widget: "artifact-gate",
artifact: { kind: "cte", content: "SELECT 42" },
title: "SQL finale",
artifact: { kind: "sql", data: "SELECT 42" },
options: [{ id: "approve", label: "Approva" }],
}}
onRespond={onRespond}
/>
);
expect(screen.getByRole("dialog")).toBeInTheDocument();
expect(screen.getByText("SELECT 42")).toBeInTheDocument();
});
+57 -40
View File
@@ -1,9 +1,18 @@
import { useState } from "react";
import type { WidgetProps } from "./types";
import type { UiResponse, WidgetDescriptor } from "../api/types";
import { Dialog, DialogContent, DialogTitle } from "../components/ui/dialog";
import { ArtifactView } from "../viewers/ArtifactView";
import { ReservedControls } from "./ReservedControls";
import { LinkageHost } from "./LinkageHost";
/**
* Artifact review gate rendered as a full-screen (90%) modal: the artifact fills
* the top (scrollable) area via ArtifactView; the action bar (options + reserved
* controls) sits at the bottom. The gate contract forbids silent dismissal, so the
* dialog has no close button and is not closeable by Esc/backdrop — the only way
* out is an action or a reserved control, both of which call onRespond.
*/
export function ArtifactGateWidget({ descriptor, onRespond }: WidgetProps) {
const [pendingLinkage, setPendingLinkage] = useState<{
parentResponse: UiResponse;
@@ -13,48 +22,56 @@ export function ArtifactGateWidget({ descriptor, onRespond }: WidgetProps) {
function handleOption(optionId: string) {
const option = descriptor.options?.find((o) => o.id === optionId);
const parentResponse: UiResponse = { id: descriptor.id, kind: "artifact-gate", choices: [optionId] };
if (option?.opens) {
// Route through LinkageHost to collect child text first
setPendingLinkage({ parentResponse, childDescriptor: option.opens });
} else {
onRespond(parentResponse);
}
}
if (pendingLinkage) {
return (
<LinkageHost
parentResponse={pendingLinkage.parentResponse}
childDescriptor={pendingLinkage.childDescriptor}
onRespond={onRespond}
/>
);
if (option?.opens) setPendingLinkage({ parentResponse, childDescriptor: option.opens });
else onRespond(parentResponse);
}
return (
<div className="space-y-3">
{descriptor.title && <p className="font-medium">{descriptor.title}</p>}
{descriptor.artifact?.content && (
<pre className="overflow-auto max-h-64 bg-muted p-3 text-sm rounded border">
{descriptor.artifact.content}
</pre>
)}
<div className="flex flex-col gap-2">
{descriptor.options?.map((o) => (
<button
key={o.id}
className="border rounded px-3 py-2 text-left hover:bg-accent"
onClick={() => handleOption(o.id)}
>
{o.label}
</button>
))}
</div>
<ReservedControls
reserved={descriptor.reserved}
onControl={(c, t) => onRespond({ id: descriptor.id, control: c, ...(t !== undefined ? { text: t } : {}) })}
/>
</div>
<Dialog open>
<DialogContent
showCloseButton={false}
className="grid h-[90vh] w-[90vw] max-w-[90vw] grid-rows-[auto_1fr_auto] gap-3 sm:max-w-[90vw]"
>
<DialogTitle>{descriptor.title ?? "Artifact review"}</DialogTitle>
<div className="min-h-0 overflow-auto rounded border bg-background p-3">
{descriptor.artifact ? (
<ArtifactView artifact={descriptor.artifact} />
) : (
<p className="text-sm text-muted-foreground">No artifact.</p>
)}
</div>
<div className="flex flex-col gap-2 border-t pt-3">
{pendingLinkage ? (
<LinkageHost
parentResponse={pendingLinkage.parentResponse}
childDescriptor={pendingLinkage.childDescriptor}
onRespond={onRespond}
/>
) : (
<>
<div className="flex flex-wrap gap-2">
{descriptor.options?.map((o) => (
<button
key={o.id}
className="rounded border px-3 py-2 text-left hover:bg-accent"
onClick={() => handleOption(o.id)}
>
{o.label}
</button>
))}
</div>
<ReservedControls
reserved={descriptor.reserved}
onControl={(c, t) =>
onRespond({ id: descriptor.id, control: c, ...(t !== undefined ? { text: t } : {}) })
}
/>
</>
)}
</div>
</DialogContent>
</Dialog>
);
}