Files
ThothII/frontend/src/widgets/ArtifactGateWidget.tsx
T
e9b2934ef7 style(frontend): look&feel v2 refinements — 70% gate modal, structured cards, mono labels
- 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>
2026-07-06 23:29:49 +02:00

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>
);
}