/** @jsxImportSource react */
import { useMemo } from "react";
import {
createGatewayReactRoot,
useGatewayActions,
useGatewayApprovals,
useGatewayNodeOutput,
useGatewayRun,
useGatewayRunEvents,
useGatewayRuns,
} from "smithers-orchestrator/gateway-react";
function runIdFromUrl(): string | undefined {
if (typeof location === "undefined") return undefined;
return new URLSearchParams(location.search).get("runId") ?? undefined;
}
function statusClass(status: string | undefined): string {
if (status === "running" || status === "continued") return "running";
if (status === "finished" || status === "succeeded") return "finished";
if (status === "failed" || status === "cancelled") return "failed";
if (status === "waiting" || status === "paused" || status?.startsWith("waiting-")) return "waiting";
return "";
}
// The RPC payload types are intentionally loose `Record<string, unknown>` so
// the wire stays cheap to extend; narrow at the consumer with the structural
// shapes you actually rely on. `.smithers/ui/grill-me.tsx` uses the same pattern.
type RunSummary = { runId: string; status?: string; workflowKey?: string };
type RunStateView = { state?: string };
type RunRecord = { status?: string; workflowKey?: string; runState?: RunStateView };
type NodeOutputView =
| { status: "produced"; row: Record<string, unknown> }
| { status: "pending" | "failed" };
function App() {
const initialRunId = useMemo(runIdFromUrl, []);
// `useGatewayRuns` lets the picker work when no `?runId=` was passed.
// `ListRunsRequest.filter` accepts `status` and `limit` only; the bundle is
// already mounted for one workflow, so there is no workflow filter on the wire.
const runs = useGatewayRuns({ filter: { limit: 20 } });
const runSummaries = (runs.data ?? []) as RunSummary[];
const runId = initialRunId ?? runSummaries[0]?.runId;
// Live run + event stream. Both clear automatically when `runId` changes,
// so no previous run's data ever leaks into the new one.
const run = useGatewayRun(runId);
const runRecord = run.data as RunRecord | undefined;
const status = runRecord?.runState?.state ?? runRecord?.status;
const events = useGatewayRunEvents(runId, { maxEvents: 200 });
const approvals = useGatewayApprovals(runId ? { filter: { runId, limit: 20 } } : {});
// Read the final task's output row. Pending runs return `status: "pending"`.
const shipOutput = useGatewayNodeOutput({ runId, nodeId: "ship" });
const shipView = shipOutput.data as NodeOutputView | undefined;
const { submitApproval, cancelRun } = useGatewayActions();
return (
<main className="shell">
<header className="topbar">
<h1>Demo React Workflow UI</h1>
<span className={`status ${statusClass(status)}`}>
{status ?? "-"}
</span>
<code data-testid="demo-run-id">{runId ?? "(no run)"}</code>
<button onClick={() => runId && cancelRun({ runId })} disabled={!runId}>
Cancel
</button>
</header>
<section className="row">
<div className="card">
<h2>Ship output</h2>
{shipOutput.loading && <em>loading…</em>}
{shipOutput.error && <pre className="err">{shipOutput.error.message}</pre>}
{shipView?.status === "produced" && (
<pre>{JSON.stringify(shipView.row, null, 2)}</pre>
)}
{shipView?.status === "pending" && <em>not produced yet</em>}
</div>
<div className="card">
<h2>Pending approvals</h2>
{(approvals.data ?? []).map((a) => (
<div key={`${a.runId}:${a.nodeId}:${a.iteration ?? 0}`} className="row gap">
<code>{a.nodeId}</code>
<button
onClick={() =>
submitApproval({
runId: a.runId,
nodeId: a.nodeId,
iteration: a.iteration,
decision: { approved: true },
})
}
>
Approve
</button>
<button
onClick={() =>
submitApproval({
runId: a.runId,
nodeId: a.nodeId,
iteration: a.iteration,
decision: { approved: false },
})
}
>
Deny
</button>
</div>
))}
{!approvals.data?.length && <em>none waiting</em>}
</div>
</section>
<section className="card events">
<h2>Live events {events.streaming ? "•" : "○"}</h2>
<ul>
{events.events.slice(-20).map((frame) => (
<li key={frame.seq}>
<code>{String(frame.seq).padStart(4, "0")}</code> {frame.event}
</li>
))}
</ul>
{events.error && <pre className="err">{events.error.message}</pre>}
</section>
</main>
);
}
// Minimal styles so the demo reads as a polished surface, not an unstyled tree.
const styles = `
:root { color-scheme: dark; }
body { margin: 0; background: #0b0b0e; color: #eaeaee; font: 13px/1.5 -apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif; }
.shell { display: flex; flex-direction: column; gap: 16px; padding: 20px; max-width: 960px; margin: 0 auto; }
.topbar { display: flex; gap: 12px; align-items: center; }
h1 { font-size: 16px; font-weight: 600; margin: 0; flex: 1; }
.status { font-size: 11px; padding: 4px 10px; border-radius: 999px; background: #1a1a1f; border: 1px solid #2a2a30; text-transform: uppercase; letter-spacing: 0.04em; }
.status.running { color: #5e8df0; border-color: #2a3f6a; }
.status.finished { color: #4ade80; border-color: #2a5a3a; }
.status.failed { color: #f87171; border-color: #6a2a2a; }
.status.waiting { color: #fbbf24; border-color: #6a4a1a; }
code { font-family: ui-monospace, monospace; background: #1a1a1f; padding: 2px 8px; border-radius: 4px; font-size: 12px; }
button { background: #1a1a1f; color: #eaeaee; border: 1px solid #2a2a30; padding: 6px 12px; border-radius: 6px; font: inherit; cursor: pointer; }
button:hover:not(:disabled) { border-color: #5e6ad2; color: #fff; }
button:disabled { opacity: 0.45; cursor: not-allowed; }
.row { display: flex; gap: 12px; flex-wrap: wrap; }
.row.gap { gap: 8px; align-items: center; }
.card { background: #15151a; border: 1px solid #26262d; border-radius: 8px; padding: 16px; flex: 1 1 360px; }
.card h2 { font-size: 12px; font-weight: 500; color: #8a8a92; text-transform: uppercase; letter-spacing: 0.06em; margin: 0 0 12px; }
.card pre { margin: 0; font: 12px/1.5 ui-monospace, monospace; white-space: pre-wrap; word-break: break-word; color: #c5c5cc; }
.card .err { color: #f87171; }
.card.events ul { list-style: none; padding: 0; margin: 0; max-height: 200px; overflow-y: auto; font: 12px/1.6 ui-monospace, monospace; }
.card.events li { display: flex; gap: 8px; color: #c5c5cc; }
.card.events li code { background: transparent; padding: 0; color: #8a8a92; }
`;
if (typeof document !== "undefined") {
const sheet = document.createElement("style");
sheet.textContent = styles;
document.head.appendChild(sheet);
}
createGatewayReactRoot(<App />);