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 userEvent from "@testing-library/user-event";
|
||||||
import { ArtifactGateWidget } from "./ArtifactGateWidget";
|
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();
|
const onRespond = vi.fn();
|
||||||
render(
|
render(
|
||||||
<ArtifactGateWidget
|
<ArtifactGateWidget
|
||||||
descriptor={{
|
descriptor={{
|
||||||
id: "u1",
|
id: "u1",
|
||||||
widget: "artifact-gate",
|
widget: "artifact-gate",
|
||||||
artifact: { kind: "cte", content: "SELECT 42" },
|
title: "SQL finale",
|
||||||
|
artifact: { kind: "sql", data: "SELECT 42" },
|
||||||
options: [{ id: "approve", label: "Approva" }],
|
options: [{ id: "approve", label: "Approva" }],
|
||||||
}}
|
}}
|
||||||
onRespond={onRespond}
|
onRespond={onRespond}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
expect(screen.getByRole("dialog")).toBeInTheDocument();
|
||||||
expect(screen.getByText("SELECT 42")).toBeInTheDocument();
|
expect(screen.getByText("SELECT 42")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,18 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import type { WidgetProps } from "./types";
|
import type { WidgetProps } from "./types";
|
||||||
import type { UiResponse, WidgetDescriptor } from "../api/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 { ReservedControls } from "./ReservedControls";
|
||||||
import { LinkageHost } from "./LinkageHost";
|
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) {
|
export function ArtifactGateWidget({ descriptor, onRespond }: WidgetProps) {
|
||||||
const [pendingLinkage, setPendingLinkage] = useState<{
|
const [pendingLinkage, setPendingLinkage] = useState<{
|
||||||
parentResponse: UiResponse;
|
parentResponse: UiResponse;
|
||||||
@@ -13,48 +22,56 @@ export function ArtifactGateWidget({ descriptor, onRespond }: WidgetProps) {
|
|||||||
function handleOption(optionId: string) {
|
function handleOption(optionId: string) {
|
||||||
const option = descriptor.options?.find((o) => o.id === optionId);
|
const option = descriptor.options?.find((o) => o.id === optionId);
|
||||||
const parentResponse: UiResponse = { id: descriptor.id, kind: "artifact-gate", choices: [optionId] };
|
const parentResponse: UiResponse = { id: descriptor.id, kind: "artifact-gate", choices: [optionId] };
|
||||||
|
if (option?.opens) setPendingLinkage({ parentResponse, childDescriptor: option.opens });
|
||||||
if (option?.opens) {
|
else onRespond(parentResponse);
|
||||||
// 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}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-3">
|
<Dialog open>
|
||||||
{descriptor.title && <p className="font-medium">{descriptor.title}</p>}
|
<DialogContent
|
||||||
{descriptor.artifact?.content && (
|
showCloseButton={false}
|
||||||
<pre className="overflow-auto max-h-64 bg-muted p-3 text-sm rounded border">
|
className="grid h-[90vh] w-[90vw] max-w-[90vw] grid-rows-[auto_1fr_auto] gap-3 sm:max-w-[90vw]"
|
||||||
{descriptor.artifact.content}
|
>
|
||||||
</pre>
|
<DialogTitle>{descriptor.title ?? "Artifact review"}</DialogTitle>
|
||||||
)}
|
|
||||||
<div className="flex flex-col gap-2">
|
<div className="min-h-0 overflow-auto rounded border bg-background p-3">
|
||||||
{descriptor.options?.map((o) => (
|
{descriptor.artifact ? (
|
||||||
<button
|
<ArtifactView artifact={descriptor.artifact} />
|
||||||
key={o.id}
|
) : (
|
||||||
className="border rounded px-3 py-2 text-left hover:bg-accent"
|
<p className="text-sm text-muted-foreground">No artifact.</p>
|
||||||
onClick={() => handleOption(o.id)}
|
)}
|
||||||
>
|
</div>
|
||||||
{o.label}
|
|
||||||
</button>
|
<div className="flex flex-col gap-2 border-t pt-3">
|
||||||
))}
|
{pendingLinkage ? (
|
||||||
</div>
|
<LinkageHost
|
||||||
<ReservedControls
|
parentResponse={pendingLinkage.parentResponse}
|
||||||
reserved={descriptor.reserved}
|
childDescriptor={pendingLinkage.childDescriptor}
|
||||||
onControl={(c, t) => onRespond({ id: descriptor.id, control: c, ...(t !== undefined ? { text: t } : {}) })}
|
onRespond={onRespond}
|
||||||
/>
|
/>
|
||||||
</div>
|
) : (
|
||||||
|
<>
|
||||||
|
<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