From 404d8334aaedc6b078ab910d6c64f7bfe40dde96 Mon Sep 17 00:00:00 2001 From: iris Date: Sun, 25 Oct 2026 01:57:29 +0200 Subject: [PATCH] swarm-ui: preview a draft flow's hit counts in the editor with the dry-run route The editor's preview button posts the draft to POST /api/notify/dry-run and puts each box's hit count on the canvas; any edit hides the numbers. Boxes of kind request_reviewer label themselves "reviewer", so the long kind name no longer runs into the hit count shown beside it. --- docs/swarm/ui.md | 6 ++ .../src/pages/notifications/FlowBox.tsx | 5 +- .../src/pages/notifications/FlowCanvas.tsx | 6 +- .../src/pages/notifications/FlowDraft.ts | 13 +++++ .../pages/notifications/FlowEditorPage.tsx | 55 +++++++++++++++++++ .../src/pages/notifications/RouterLive.ts | 8 +-- .../swarm-ui/test/flow-draft.test.mjs | 10 ++++ 7 files changed, 97 insertions(+), 6 deletions(-) diff --git a/docs/swarm/ui.md b/docs/swarm/ui.md index c4a413a1..9fa5515d 100644 --- a/docs/swarm/ui.md +++ b/docs/swarm/ui.md @@ -296,6 +296,12 @@ editor offers to reload and drop your edits, or to keep them and replace this flow's latest version with them. For any other refusal the editor shows the controller's own message. The router runs a saved flow from its next event. +**preview** runs the draft, saved or not, over the newest forge events and +shows on each box how many of them reached it, as a dry run does for the saved +flows in the list. It needs no save, so you can try a half-built flow. The +numbers go as soon as you edit the flow. If the draft would not save, or the +router can't run a dry run yet, the controller's own message shows instead. + Leaving the page with unsaved edits asks first, whether you close or reload the tab, follow a link in the app, or press the browser's back button. Choose **leave and drop edits** to go on, or cancel to stay and save. diff --git a/frontend/packages/swarm-ui/src/pages/notifications/FlowBox.tsx b/frontend/packages/swarm-ui/src/pages/notifications/FlowBox.tsx index 9630fc51..d76e5672 100644 --- a/frontend/packages/swarm-ui/src/pages/notifications/FlowBox.tsx +++ b/frontend/packages/swarm-ui/src/pages/notifications/FlowBox.tsx @@ -10,6 +10,9 @@ import { NODE_HEIGHT, NODE_WIDTH } from "./FlowLayout.js"; const PAD = 10; +// The long kind name would run into the aside beside it. +const SHORT_KIND: Record = { request_reviewer: "reviewer" }; + export function BoxFace({ kind, text, @@ -33,7 +36,7 @@ export function BoxFace({ <> - {kind} + {SHORT_KIND[kind] ?? kind} {aside ? ( ; } & CanvasEdits) { const layout = layoutFlow(flow); const byId = new Map(flow.nodes.map((n) => [n.id, n])); @@ -338,7 +341,7 @@ export function FlowCanvas({ tabIndex={p.id === tabStop ? 0 : -1} role="button" aria-pressed={p.id === selected} - aria-label={`${node.kind} ${p.id}: ${text}${order}`} + aria-label={`${node.kind} ${p.id}: ${text}${order}${hits ? `, ${hits.get(p.id) ?? 0} hits` : ""}`} onPointerDown={(e) => { if (node.kind === "source") { // The source cannot move; a press just picks it. @@ -359,6 +362,7 @@ export function FlowCanvas({ order={ pos.count > 1 ? `runs ${ordinal(pos.index + 1)}` : undefined } + hits={hits ? (hits.get(p.id) ?? 0) : undefined} /> ); diff --git a/frontend/packages/swarm-ui/src/pages/notifications/FlowDraft.ts b/frontend/packages/swarm-ui/src/pages/notifications/FlowDraft.ts index 3d7a0f3f..642adf43 100644 --- a/frontend/packages/swarm-ui/src/pages/notifications/FlowDraft.ts +++ b/frontend/packages/swarm-ui/src/pages/notifications/FlowDraft.ts @@ -429,6 +429,19 @@ export function saveRequest( }; } +// The `POST /api/notify/dry-run` request for the draft: the same document a +// save sends, without a revision since nothing is stored. +export function previewRequest( + flows: Flow[], + disabledBuiltins: string[], +): { method: "POST"; headers: Record; body: string } { + return { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ disabled_builtins: disabledBuiltins, flows }), + }; +} + export type SaveOutcome = "saved" | "conflict" | "invalid" | "error"; // What a save's HTTP status means to the editor: 409 is a stale revision (read diff --git a/frontend/packages/swarm-ui/src/pages/notifications/FlowEditorPage.tsx b/frontend/packages/swarm-ui/src/pages/notifications/FlowEditorPage.tsx index 9c01d4fc..fefd37e9 100644 --- a/frontend/packages/swarm-ui/src/pages/notifications/FlowEditorPage.tsx +++ b/frontend/packages/swarm-ui/src/pages/notifications/FlowEditorPage.tsx @@ -38,6 +38,7 @@ import { reparent, savedAs, saveOutcome, + previewRequest, saveRequest, seedFlow, parentOf, @@ -48,6 +49,7 @@ import { import { useLeaveGuard } from "./useLeaveGuard.js"; import { NodeInspector } from "./NodeInspector.js"; import { FALLBACK_SCHEMA, type Flow, type FlowsView } from "./FlowTypes.js"; +import { DRY_RUN_URL, hitsOf, type DryRun } from "./RouterLive.js"; import "./NotificationsPage.css"; const FLOWS_URL = "/api/notify/flows"; @@ -75,6 +77,14 @@ export function FlowEditorPage({ const [saveError, setSaveError] = useState(null); const [stale, setStale] = useState(false); const [notice, setNotice] = useState(null); + // The last preview and the draft it ran, which stops counting once the + // draft is edited. + const [preview, setPreview] = useState<{ + draft: Flow; + flowId: string; + dry: DryRun; + } | null>(null); + const [previewError, setPreviewError] = useState(null); const [busy, setBusy] = useState(false); const [confirmDelete, setConfirmDelete] = useState(false); @@ -148,6 +158,32 @@ export function FlowEditorPage({ return false; } + // Run the draft, saved or not, over the newest forge events, and put the + // number each node would have caught on its box. + async function runPreview() { + if (!view || !draft) return; + const flow = isNew ? savedAs(view, draft) : draft; + setBusy(true); + setPreviewError(null); + try { + const r = await fetch( + DRY_RUN_URL, + previewRequest(withFlow(view.flows, flow), view.disabled_builtins), + ); + if (r.ok) { + setPreview({ draft, flowId: flow.id, dry: (await r.json()) as DryRun }); + } else { + setPreview(null); + setPreviewError(await readApiError(r)); + } + } catch (err) { + setPreview(null); + setPreviewError({ detail: String(err) }); + } finally { + setBusy(false); + } + } + function onEdit(result: Edit, select?: string) { if (!result.ok) { setNotice(result.reason); @@ -217,6 +253,8 @@ export function FlowEditorPage({ const schema = view.schema ?? FALLBACK_SCHEMA; const limits = limitsOf(view.schema); const node = draft.nodes.find((n) => n.id === selected) ?? null; + const shown = preview?.draft === draft ? preview : null; + const hits = shown ? hitsOf(shown.dry, shown.flowId) : undefined; return ( ) : null} + {previewError ? ( + + ) : null} {notice ? (

{notice} @@ -288,6 +332,9 @@ export function FlowEditorPage({ > discard edits + {isNew ? null : (