From 68b49aa7f98c2b0ee2fa6b30c55f0dbe6dc35f75 Mon Sep 17 00:00:00 2001 From: Serge Zenchenko Date: Wed, 26 Aug 2026 15:11:42 +0100 Subject: [PATCH 1/6] Add custom React views to workflow runs --- apps/ui/src/api/client.ts | 10 + apps/ui/src/api/types.ts | 25 +- apps/ui/src/app/app.test.tsx | 60 ++- .../molecules/WorkflowViewFrame.module.css | 65 +++ .../molecules/WorkflowViewFrame.tsx | 151 +++++++ apps/ui/src/components/organisms/GatePane.tsx | 56 ++- apps/ui/src/components/organisms/StepPane.tsx | 10 + apps/ui/src/domain/adapt.ts | 4 + apps/ui/src/domain/types.ts | 4 +- docs/custom-react-ui-in-workflows.md | 381 ++++++++++++++++++ packages/cli/src/commands/check.ts | 2 + packages/cli/src/commands/run.ts | 15 +- packages/core/src/ctx.ts | 27 ++ packages/core/src/engine.ts | 35 +- packages/core/src/events.ts | 27 ++ packages/core/src/index.ts | 3 + packages/core/src/projections.ts | 15 +- packages/core/src/replay.ts | 35 +- packages/core/src/runtime.ts | 130 +++++- packages/core/test/ui.test.ts | 172 ++++++++ packages/daemon/src/api/presentations.ts | 89 ++++ packages/daemon/src/api/starts.ts | 1 + packages/daemon/src/app.ts | 2 + packages/daemon/src/index.ts | 1 + packages/daemon/src/state.ts | 1 + packages/daemon/test/api.test.ts | 56 +++ packages/gate/package.json | 2 + packages/gate/src/bundle.ts | 20 +- packages/gate/src/load.ts | 8 +- packages/gate/src/registry.ts | 36 +- packages/gate/src/ui-bundle.ts | 312 ++++++++++++++ packages/gate/test/gate.test.ts | 80 +++- packages/host/src/index.ts | 4 +- packages/host/src/weft.ts | 72 +++- packages/mcp/src/runs.ts | 10 +- packages/mcp/src/tools.ts | 44 +- packages/mcp/src/typings.ts | 2 +- packages/sdk/package.json | 8 + packages/sdk/src/index.ts | 14 +- packages/sdk/src/types.ts | 15 +- packages/sdk/src/ui.ts | 97 +++++ pnpm-lock.yaml | 6 + 42 files changed, 2045 insertions(+), 62 deletions(-) create mode 100644 apps/ui/src/components/molecules/WorkflowViewFrame.module.css create mode 100644 apps/ui/src/components/molecules/WorkflowViewFrame.tsx create mode 100644 docs/custom-react-ui-in-workflows.md create mode 100644 packages/core/test/ui.test.ts create mode 100644 packages/daemon/src/api/presentations.ts create mode 100644 packages/gate/src/ui-bundle.ts create mode 100644 packages/sdk/src/ui.ts diff --git a/apps/ui/src/api/client.ts b/apps/ui/src/api/client.ts index 2d1d834d..e63226db 100644 --- a/apps/ui/src/api/client.ts +++ b/apps/ui/src/api/client.ts @@ -147,4 +147,14 @@ export const api = { if (!res.ok) throw new ApiError(res.status, `blob ${ref} is not readable`); return res.text(); }), + + blobJson: (ref: string) => + fetch(`/api/blobs/${encodeURIComponent(ref)}?as=json`).then(async (res) => { + if (GATEWAY.has(res.status)) throw new ApiError(res.status, UNREACHABLE); + if (!res.ok) throw new ApiError(res.status, `blob ${ref} is not readable`); + return (await res.json()) as unknown; + }), + + presentationFrameUrl: (runId: string, presentationId: string) => + `/api/runs/${encodeURIComponent(runId)}/presentations/${encodeURIComponent(presentationId)}/frame`, }; diff --git a/apps/ui/src/api/types.ts b/apps/ui/src/api/types.ts index a98c101c..0089887d 100644 --- a/apps/ui/src/api/types.ts +++ b/apps/ui/src/api/types.ts @@ -30,7 +30,25 @@ export type StepKind = | "fs" | "env" | "check" - | "sleep"; + | "sleep" + | "ui" + | "signal" + | "sideeffect"; + +export interface UiPresentation { + id: string; + asset: { + id: string; + revision: string; + bundleRef: { $blob: string; size: number; preview?: string }; + protocol: 1; + }; + props: + | { inline: unknown; hash: string } + | { ref: { $blob: string; size: number; preview?: string }; hash: string }; + mode: "display" | "input"; + slot?: string; +} export type Risk = "low" | "medium" | "high" | "irreversible"; export type ApprovalMode = "auto" | "ask"; @@ -80,6 +98,7 @@ export interface StepState { transcriptRef?: { $blob: string; size: number; preview?: string }; patchRef?: string; childRunId?: string; + presentation?: UiPresentation; } export interface HumanState { @@ -90,11 +109,12 @@ export interface HumanState { detail?: string; risk?: Risk; schema: unknown; - status: "pending" | "answered"; + status: "pending" | "answered" | "superseded"; answer?: unknown; answeredBy?: string; requestedAt: number; artifactRef?: { $blob: string; size: number; preview?: string }; + ui?: UiPresentation; } /** `GET /api/runs/:id`, with `?detail=1` adding `limits` and `inputs`. */ @@ -142,6 +162,7 @@ export interface PendingRequest { rootRunId: string; rootWorkflow: string; artifactRef?: { $blob: string; size: number; preview?: string }; + ui?: UiPresentation; } export interface PendingResponse { diff --git a/apps/ui/src/app/app.test.tsx b/apps/ui/src/app/app.test.tsx index f23246c9..de02a228 100644 --- a/apps/ui/src/app/app.test.tsx +++ b/apps/ui/src/app/app.test.tsx @@ -1,4 +1,4 @@ -import { screen, waitFor, within } from "@testing-library/react"; +import { fireEvent, screen, waitFor, within } from "@testing-library/react"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { type FakeDaemon, fakeDaemon } from "~/test/daemon"; import { renderApp } from "~/test/renderApp"; @@ -113,6 +113,64 @@ describe("a run", () => { ); }); + it("keeps host controls and the standard form around a workflow-provided input view", async () => { + const presentation = { + id: "h1", + asset: { + id: "release-review", + revision: "2", + bundleRef: { $blob: "e".repeat(64), size: 128 }, + protocol: 1 as const, + }, + props: { inline: { tag: "v0.9.0" }, hash: "f".repeat(64) }, + mode: "input" as const, + }; + daemon.state.detail["r-waiting"]!.humans[0]!.ui = presentation; + daemon.state.pending.pending[0]!.ui = presentation; + + const { user } = renderApp("/runs/r-waiting?from=queue&tab=steps&step=gate:h1"); + const view = await screen.findByRole("region", { name: "Workflow-provided view: release-review" }); + expect(within(view).getByText(/revision 2/)).toBeInTheDocument(); + expect(within(view).getByTitle("Workflow view release-review")).toHaveAttribute( + "src", + "/api/runs/r-waiting/presentations/h1/frame", + ); + expect(screen.getByLabelText("note")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: /Approve/ })).toBeInTheDocument(); + + await user.click(within(view).getByRole("button", { name: "Disable" })); + expect(screen.getByText(/Custom view disabled/)).toBeInTheDocument(); + await user.click(screen.getByRole("button", { name: "Enable custom view" })); + const frame = await screen.findByTitle("Workflow view release-review"); + const contentWindow = frame.contentWindow; + if (!contentWindow) throw new Error("test iframe has no contentWindow"); + + let componentPort: MessagePort | undefined; + let init: Record | undefined; + contentWindow.postMessage = ((message: unknown, _origin: string, transfer?: Transferable[]) => { + init = message as Record; + componentPort = transfer?.[0] as MessagePort | undefined; + }) as typeof contentWindow.postMessage; + fireEvent.load(frame); + await waitFor(() => expect(componentPort).toBeDefined()); + componentPort!.postMessage({ + type: "candidate", + presentationId: init!.presentationId, + generation: init!.generation, + answer: { approved: false, note: "not yet" }, + }); + expect(await screen.findByRole("button", { name: "Submit and resume" })).toBeInTheDocument(); + await user.click(screen.getByRole("button", { name: "Submit and resume" })); + const answered = await waitFor(() => { + const call = daemon.calls.find( + (candidate) => candidate.method === "POST" && candidate.path.endsWith("/answer"), + ); + expect(call).toBeDefined(); + return call!; + }); + expect(answered.body).toMatchObject({ answer: { approved: false, note: "not yet" } }); + }); + it("keeps the attached report when the gate falls back to run detail", async () => { daemon.state.pending.pending = []; renderApp("/runs/r-waiting?from=runs&tab=steps&step=gate:h1"); diff --git a/apps/ui/src/components/molecules/WorkflowViewFrame.module.css b/apps/ui/src/components/molecules/WorkflowViewFrame.module.css new file mode 100644 index 00000000..1053f855 --- /dev/null +++ b/apps/ui/src/components/molecules/WorkflowViewFrame.module.css @@ -0,0 +1,65 @@ +.shell { + position: relative; + overflow: hidden; + border: 1px solid var(--color-line, #d8d6d0); + border-radius: 8px; + background: var(--color-surface, #fff); +} + +.header { + display: flex; + align-items: center; + gap: 12px; + min-height: 34px; + padding: 0 10px; + border-bottom: 1px solid var(--color-line, #d8d6d0); + color: var(--color-text-muted, #68645c); + font: + 11px / 1.2 ui-monospace, + SFMono-Regular, + Menlo, + monospace; +} + +.header span:nth-child(2) { + margin-left: auto; +} + +.header button, +.fallback button { + border: 0; + color: inherit; + background: transparent; + cursor: pointer; + text-decoration: underline; +} + +.frame { + display: block; + width: 100%; + min-height: 80px; + border: 0; + background: transparent; +} + +.loading, +.error, +.fallback { + display: block; + padding: 12px; + color: var(--color-text-muted, #68645c); + font-size: 12px; +} + +.error { + color: #9b2f24; + background: #fff2ef; +} + +.fallback { + display: flex; + justify-content: space-between; + gap: 16px; + border: 1px dashed var(--color-line, #d8d6d0); + border-radius: 8px; +} diff --git a/apps/ui/src/components/molecules/WorkflowViewFrame.tsx b/apps/ui/src/components/molecules/WorkflowViewFrame.tsx new file mode 100644 index 00000000..397b04b4 --- /dev/null +++ b/apps/ui/src/components/molecules/WorkflowViewFrame.tsx @@ -0,0 +1,151 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import { api } from "~/api/client"; +import type { UiPresentation } from "~/api/types"; +import styles from "./WorkflowViewFrame.module.css"; + +type Props = { + runId: string; + presentation: UiPresentation; + onCandidate?: (answer: unknown) => void; +}; + +type FrameStatus = "loading" | "ready" | "error" | "disabled"; + +const MAX_MESSAGE_BYTES = 64 * 1024; +const MIN_HEIGHT = 80; +const MAX_HEIGHT = 720; +const READY_TIMEOUT_MS = 5_000; + +function jsonSize(value: unknown): number | undefined { + try { + const encoded = JSON.stringify(value); + return encoded === undefined ? undefined : encoded.length; + } catch { + return undefined; + } +} + +/** Capability-minimal host for one journaled workflow presentation. */ +export function WorkflowViewFrame({ runId, presentation, onCandidate }: Props) { + const frame = useRef(null); + const channel = useRef(null); + const generation = useRef(0); + const readyTimer = useRef | null>(null); + const lastResize = useRef(0); + const [status, setStatus] = useState("loading"); + const [height, setHeight] = useState(180); + const [message, setMessage] = useState(""); + + useEffect(() => { + return () => { + if (readyTimer.current) clearTimeout(readyTimer.current); + channel.current?.port1.close(); + channel.current?.port2.close(); + }; + }, []); + + const initialize = useCallback(async () => { + const target = frame.current?.contentWindow; + if (!target || status === "disabled") return; + try { + const props = + "inline" in presentation.props + ? presentation.props.inline + : await api.blobJson(presentation.props.ref.$blob); + const bytes = jsonSize(props); + if (bytes === undefined) throw new Error("presentation props must be JSON serializable"); + if (bytes > MAX_MESSAGE_BYTES * 8) throw new Error("presentation props are too large to render"); + const next = new MessageChannel(); + channel.current?.port1.close(); + channel.current?.port2.close(); + channel.current = next; + const mounted = String(++generation.current); + if (readyTimer.current) clearTimeout(readyTimer.current); + readyTimer.current = setTimeout(() => { + setStatus("error"); + setMessage("custom view did not become ready in time"); + next.port1.close(); + }, READY_TIMEOUT_MS); + next.port1.onmessage = (event: MessageEvent) => { + const value = event.data; + if (typeof value !== "object" || value === null) return; + const data = value as Record; + if (data.presentationId !== presentation.id || data.generation !== mounted) return; + const size = jsonSize(data); + if (size === undefined || size > MAX_MESSAGE_BYTES) return; + if (data.type === "ready") { + if (readyTimer.current) clearTimeout(readyTimer.current); + readyTimer.current = null; + setStatus("ready"); + } else if (data.type === "resize" && typeof data.height === "number") { + const now = performance.now(); + if (now - lastResize.current < 50) return; + lastResize.current = now; + setHeight(Math.max(MIN_HEIGHT, Math.min(MAX_HEIGHT, Math.ceil(data.height)))); + } else if (data.type === "candidate" && presentation.mode === "input") { + if (jsonSize(data.answer) === undefined) return; + onCandidate?.(data.answer); + } else if (data.type === "error" && typeof data.message === "string") { + if (readyTimer.current) clearTimeout(readyTimer.current); + readyTimer.current = null; + setStatus("error"); + setMessage(data.message.slice(0, 300)); + } + }; + next.port1.start(); + target.postMessage( + { + type: "weft.ui.init", + protocol: 1, + presentationId: presentation.id, + generation: mounted, + props, + }, + "*", + [next.port2], + ); + } catch (error) { + if (readyTimer.current) clearTimeout(readyTimer.current); + readyTimer.current = null; + setStatus("error"); + setMessage(error instanceof Error ? error.message : String(error)); + } + }, [onCandidate, presentation, status]); + + if (status === "disabled") { + return ( +
+ Custom view disabled. The standard data view remains available. + +
+ ); + } + + return ( +
+
+ Workflow-provided view + + {presentation.asset.id} · revision {presentation.asset.revision} + + +
+ {status === "error" ?
Custom view unavailable: {message}
: null} +