Embedded in the Omics Portal the SPA sits to the right of the portal sidebar,
but dialogs used `fixed left-1/2` — centered on the whole browser window, so
gates rendered visibly off-center (overlapping the sidebar).
- AppShell publishes the app area's horizontal geometry (--app-area-left/width)
as CSS vars on <html> via ResizeObserver + window resize.
- DialogContent centers on those vars (falls back to the viewport when the app
is standalone, where the two coincide).
- ArtifactGateWidget: narrower — cap at 52rem instead of 70vw.
- tools/replay: resume now returns {id, alreadyActive} JSON — the post-f979ada
frontend reads result.alreadyActive and a bare 204 broke replay resume.
Verified on the replay harness: with a simulated 300px portal sidebar the gate
centers on the app area (center 790 = 300 + 980/2); standalone unchanged.
Co-Authored-By: Claude Fable 5 <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-[min(52rem,calc(var(--app-area-width,100vw)-3rem))] max-w-none grid-rows-[auto_1fr_auto] gap-3.5 sm:max-w-none"
|
|
>
|
|
<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>
|
|
);
|
|
}
|