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 : (