feat(frontend): multiselect/artifact-gate/artifact widgets + linkage + no-limbo

This commit is contained in:
2026-06-28 12:36:13 +02:00
parent 2b55e96608
commit be9e761e0a
8 changed files with 389 additions and 1 deletions
@@ -0,0 +1,60 @@
import { useState } from "react";
import type { WidgetProps } from "./types";
import type { UiResponse, WidgetDescriptor } from "../api/types";
import { ReservedControls } from "./ReservedControls";
import { LinkageHost } from "./LinkageHost";
export function ArtifactGateWidget({ descriptor, onRespond }: 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) {
// 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 (
<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) => onRespond({ id: descriptor.id, control: c })}
/>
</div>
);
}