import { SmithersGatewayClient } from "smithers-orchestrator/gateway-client";
const WORKFLOW_KEY = "demo-vanilla-ui";
const runId = new URLSearchParams(location.search).get("runId") ?? undefined;
const client = new SmithersGatewayClient(); // reads __SMITHERS_GATEWAY_UI__ + same-origin baseUrl
const root = document.getElementById("root")!;
function render(state: {
status?: string;
shipRow?: Record<string, unknown> | null;
eventCount: number;
approvals: Array<{ runId: string; nodeId: string; iteration?: number }>;
error?: string;
}) {
root.innerHTML = "";
const main = document.createElement("main");
main.dataset.testid = "demo-vanilla-ui";
main.style.cssText = "font: 13px/1.5 system-ui; padding: 24px; max-width: 720px; margin: 0 auto; color: #eaeaee; background: #0b0b0e; min-height: 100vh;";
main.innerHTML = `
<header style="display:flex;gap:12px;align-items:center;margin-bottom:16px">
<h1 style="flex:1;font-size:16px;margin:0">Demo Vanilla Workflow UI</h1>
<span style="font-size:11px;padding:4px 10px;border-radius:999px;background:#1a1a1f;border:1px solid #2a2a30;text-transform:uppercase">${state.status ?? "—"}</span>
<code data-testid="demo-run-id" style="font:12px ui-monospace;background:#1a1a1f;padding:2px 8px;border-radius:4px">${runId ?? "(none)"}</code>
</header>
<section style="background:#15151a;border:1px solid #26262d;border-radius:8px;padding:16px;margin-bottom:12px">
<h2 style="font-size:12px;color:#8a8a92;text-transform:uppercase;margin:0 0 8px">Ship output</h2>
<pre data-testid="ship-output" style="margin:0;font:12px/1.5 ui-monospace;white-space:pre-wrap;color:#c5c5cc">${state.shipRow ? escapeHtml(JSON.stringify(state.shipRow, null, 2)) : "—"}</pre>
</section>
<section style="background:#15151a;border:1px solid #26262d;border-radius:8px;padding:16px;margin-bottom:12px">
<h2 style="font-size:12px;color:#8a8a92;text-transform:uppercase;margin:0 0 8px">Live events <span data-testid="event-count">${state.eventCount}</span></h2>
</section>
<section style="background:#15151a;border:1px solid #26262d;border-radius:8px;padding:16px">
<h2 style="font-size:12px;color:#8a8a92;text-transform:uppercase;margin:0 0 8px">Pending approvals (${state.approvals.length})</h2>
${state.approvals
.map(
(a) => `
<div style="display:flex;gap:8px;align-items:center;margin-bottom:6px">
<code style="font:12px ui-monospace;background:#1a1a1f;padding:2px 8px;border-radius:4px">${escapeHtml(a.nodeId)}</code>
<button data-action="approve" data-node="${escapeHtml(a.nodeId)}" data-iter="${a.iteration ?? 0}">Approve</button>
<button data-action="deny" data-node="${escapeHtml(a.nodeId)}" data-iter="${a.iteration ?? 0}">Deny</button>
</div>`
)
.join("") || "<em>none waiting</em>"}
</section>
${state.error ? `<pre style="color:#f87171;margin-top:12px">${escapeHtml(state.error)}</pre>` : ""}
`;
main.querySelectorAll<HTMLButtonElement>("button[data-action]").forEach((btn) => {
btn.addEventListener("click", async () => {
if (!runId) return;
const approved = btn.dataset.action === "approve";
const nodeId = btn.dataset.node!;
const iteration = Number(btn.dataset.iter ?? 0);
try {
await client.submitApproval({ runId, nodeId, iteration, decision: { approved } });
void refreshApprovals();
} catch (err) {
state.error = err instanceof Error ? err.message : String(err);
render(state);
}
});
});
root.replaceChildren(main);
}
function escapeHtml(s: string): string {
return s.replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", "\"": """, "'": "'" })[c]!);
}
const state = {
status: undefined as string | undefined,
shipRow: null as Record<string, unknown> | null,
eventCount: 0,
approvals: [] as Array<{ runId: string; nodeId: string; iteration?: number }>,
error: undefined as string | undefined,
};
async function refreshRun(): Promise<void> {
if (!runId) return;
try {
// `getRun` and `getNodeOutput` return `Record<string, unknown>`; narrow at the
// call site to the structural shapes you actually consume.
const run = (await client.getRun({ runId })) as { status?: string };
state.status = run.status;
const output = (await client.getNodeOutput({ runId, nodeId: "ship" })) as
| { status: "produced"; row: Record<string, unknown> }
| { status: "pending" | "failed" };
state.shipRow = output.status === "produced" ? output.row : null;
render(state);
} catch (err) {
state.error = err instanceof Error ? err.message : String(err);
render(state);
}
}
async function refreshApprovals(): Promise<void> {
if (!runId) return;
try {
// `listApprovals` returns `GatewayApprovalSummary[]` directly — no wrapper.
const res = await client.listApprovals({ filter: { runId, limit: 20 } });
state.approvals = res.map((a) => ({ runId: a.runId, nodeId: a.nodeId, iteration: a.iteration }));
render(state);
} catch {
/* leave previous list — listApprovals is non-critical */
}
}
async function watchRun(): Promise<void> {
if (!runId) return;
const abort = new AbortController();
window.addEventListener("beforeunload", () => abort.abort());
try {
for await (const frame of client.streamRunEventsResilient({ runId }, { signal: abort.signal })) {
state.eventCount += 1;
// Refetch the heavy state only when something materially changed; the
// event stream itself is the lightweight tick for the counter.
if (
frame.event === "node.finished" ||
frame.event === "approval.requested" ||
frame.event === "approval.decided" ||
frame.event === "run.completed"
) {
void refreshRun();
void refreshApprovals();
} else {
render(state);
}
}
} catch (err) {
state.error = err instanceof Error ? err.message : String(err);
render(state);
}
}
render(state);
void refreshRun();
void refreshApprovals();
void watchRun();