- ArtifactGateWidget: gate modal 90%→70% of viewport, larger heading, more padding. - ArtifactView StructuredValue: depth-aware hierarchy (top-level hairline dividers, warm bg-muted card surfaces + shadow for object-array items). - index.css: .thot-label micro-labels switched to the mono register (SF Mono) with tightened tracking, distinguishing labels/meta from sans body prose. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
84 lines
3.4 KiB
TypeScript
84 lines
3.4 KiB
TypeScript
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 { GateArtifactBody } from "./GateArtifactBody";
|
|
import { ReservedControls } from "./ReservedControls";
|
|
import { LinkageHost } from "./LinkageHost";
|
|
|
|
/**
|
|
* Artifact review gate rendered as a centered (70%) 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, sessionId }: WidgetProps) {
|
|
const [pendingLinkage, setPendingLinkage] = useState<{
|
|
parentResponse: UiResponse;
|
|
childDescriptor: WidgetDescriptor;
|
|
} | null>(null);
|
|
|
|
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) setPendingLinkage({ parentResponse, childDescriptor: option.opens });
|
|
else onRespond(parentResponse);
|
|
}
|
|
|
|
return (
|
|
<Dialog open>
|
|
<DialogContent
|
|
showCloseButton={false}
|
|
className="grid h-[70vh] w-[70vw] max-w-[70vw] grid-rows-[auto_1fr_auto] gap-3.5 sm:max-w-[70vw]"
|
|
>
|
|
<DialogTitle className="text-lg leading-snug">
|
|
{descriptor.title ?? "Artifact review"}
|
|
</DialogTitle>
|
|
|
|
<div className="min-h-0 overflow-auto rounded-xl border border-border/70 bg-background p-5">
|
|
{descriptor.artifact ? (
|
|
<GateArtifactBody artifact={descriptor.artifact} sessionId={sessionId} />
|
|
) : (
|
|
<p className="text-sm text-muted-foreground">No artifact.</p>
|
|
)}
|
|
</div>
|
|
|
|
<div className="flex flex-col gap-2 border-t border-border/60 pt-3">
|
|
{pendingLinkage ? (
|
|
<LinkageHost
|
|
parentResponse={pendingLinkage.parentResponse}
|
|
childDescriptor={pendingLinkage.childDescriptor}
|
|
onRespond={onRespond}
|
|
/>
|
|
) : (
|
|
<>
|
|
<div className="flex flex-wrap gap-2">
|
|
{descriptor.options?.map((o, i) => (
|
|
<button
|
|
key={o.id}
|
|
className={
|
|
i === 0
|
|
? "rounded-lg bg-primary px-3.5 py-2 text-left font-semibold text-primary-foreground shadow-xs transition-colors hover:bg-[oklch(var(--primary-hover))]"
|
|
: "rounded-lg border border-border/70 px-3 py-2 text-left shadow-xs transition-colors 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>
|
|
);
|
|
}
|