feat(gate): render reviewer artifacts in a 90% modal (fix empty artifact.data gate)
This commit is contained in:
@@ -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();
|
||||
});
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user