From 68e289fff5ad310ab8784cbd0f72dc6fb6bae070 Mon Sep 17 00:00:00 2001 From: iris Date: Sat, 24 Oct 2026 14:49:48 +0200 Subject: [PATCH] swarm-ui: rewire a notification flow by drag or keyboard The flow editor can now change a flow's shape on the canvas, not only through the inspector's fields: - drag a box onto another to move it and its branch there; a drop the save would refuse shows the target dashed red and gives the reason; - a palette of node kinds adds a node below the picked box on click, or below any box it is dragged onto; it offers what the controller lists; - arrow keys walk the tree, M picks a box up and Enter drops it under the focused one, Alt+Up/Down reorder a node among its siblings, Delete removes a branch; focus follows the edit; - each box with siblings says when it runs, and the inspector can move it earlier or later, since the router runs children in edge order; - request_reviewer is a second leaf kind beside todo, with a reviewer field; - leaving with unsaved edits asks first, also for in-app links and the back button (a router hook runs navigation past a page's guard). --- docs/swarm/ui.md | 40 +- frontend/packages/swarm-ui/src/App.tsx | 53 ++- .../src/pages/notifications/FlowCanvas.tsx | 440 +++++++++++++++--- .../src/pages/notifications/FlowDraft.ts | 99 +++- .../pages/notifications/FlowEditorPage.tsx | 53 ++- .../src/pages/notifications/FlowLayout.ts | 32 ++ .../src/pages/notifications/FlowTypes.ts | 15 +- .../src/pages/notifications/NodeInspector.tsx | 73 ++- .../pages/notifications/NotificationsPage.css | 80 +++- .../src/pages/notifications/useLeaveGuard.ts | 88 ++++ .../packages/swarm-ui/src/shell/navGuard.ts | 20 + .../swarm-ui/src/shell/useGuardedLocation.ts | 16 + .../swarm-ui/test/flow-draft.test.mjs | 97 ++++ .../swarm-ui/test/flow-layout.test.mjs | 40 ++ .../swarm-ui/test/flow-types.test.mjs | 29 ++ .../packages/swarm-ui/test/nav-guard.test.mjs | 28 ++ 16 files changed, 1071 insertions(+), 132 deletions(-) create mode 100644 frontend/packages/swarm-ui/src/pages/notifications/useLeaveGuard.ts create mode 100644 frontend/packages/swarm-ui/src/shell/navGuard.ts create mode 100644 frontend/packages/swarm-ui/src/shell/useGuardedLocation.ts create mode 100644 frontend/packages/swarm-ui/test/nav-guard.test.mjs diff --git a/docs/swarm/ui.md b/docs/swarm/ui.md index 50067068..a1427049 100644 --- a/docs/swarm/ui.md +++ b/docs/swarm/ui.md @@ -238,21 +238,38 @@ the page shows why. Reload to switch against the current document. #### Editing a flow `/notifications/` edits one custom flow. The canvas draws the flow left -to right: the source on the left, each match to its right, and the todos at the -ends. Pick a node to edit it in the panel beside the canvas: +to right: the source on the left, each match to its right, and the leaf nodes at +the ends. Pick a node to edit it in the panel beside the canvas: - a **source** picks the forge events the flow starts from; - a **match** lists conditions, each an event field and the value it must equal; - a **todo** says who gets it (the agents an event field names, or one agent), whether it sets or clears a todo, the todo's key, and its text. The key and the - text can use `{field}` placeholders. + text can use `{field}` placeholders; +- a **request_reviewer** names the forge login the router asks to review the + pull request. It ends a branch, like a todo, and the row of kinds above the + canvas only offers it when the controller does. -Beside the fields, **add a match below** and **add a todo below** grow the -branch, **move under** moves a node and what's below it to another match, and -**remove** takes a node and its branch out. The editor turns away any edit the -controller would refuse to save, and says why: a todo with children, a second -parent, a node under its own branch, or more nodes or levels than the -controller allows. +Rewire the flow on the canvas: + +- **Add** a node from the row of kinds above the canvas. Choose a kind to add it + below the picked box, or drag it onto any box. +- **Move** a node and its branch by dragging its box onto another box. With the + keyboard, the arrow keys walk the tree, **M** picks up the focused box, the + arrows go to the box you want it under, and **Enter** drops it there. + **Escape** puts it back. The panel's **move under** list does the same. +- **Reorder** the branches of a node. The router runs a node's branches in the + order they sit, so when a clear and an upsert share a key prefix, the order + decides what's left. A box with siblings says when it runs, such as "runs + 2nd". **run earlier** and **run later** in the panel, or **Alt+Up** and + **Alt+Down** on the canvas, swap it with its neighbour. +- **Remove** a node and its branch with **remove** in the panel or the + **Delete** key. + +The editor turns away any edit the controller would refuse to save, and says +why: a leaf node with children, a second parent, a node under its own branch, or +more nodes or levels than the controller allows. A drop it will refuse shows the +target box dashed and red while you hold the box over it. Edits stay in a draft until **save**, which sends the whole flows document with the revision the editor read. **discard edits** goes back to the saved flow and @@ -262,8 +279,9 @@ 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. -Closing or reloading the tab with unsaved edits asks first. Leaving through a -link in the app, or the browser's back button, drops the draft without asking. +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. ### Sending a message to an agent diff --git a/frontend/packages/swarm-ui/src/App.tsx b/frontend/packages/swarm-ui/src/App.tsx index 80786818..2081e70f 100644 --- a/frontend/packages/swarm-ui/src/App.tsx +++ b/frontend/packages/swarm-ui/src/App.tsx @@ -1,7 +1,8 @@ // Root shell component — routing only. Each route's content is its own // page component under `./pages/`; this file just maps paths to them. -import { Route, Switch } from "wouter-preact"; +import { Route, Router, Switch } from "wouter-preact"; import { Shell } from "./shell/Shell.js"; +import { useGuardedLocation } from "./shell/useGuardedLocation.js"; import { AgentsPage } from "./pages/agents/AgentsPage.js"; import { AgentTerminalPage } from "./pages/agents/AgentTerminalPage.js"; import { AlertsPage } from "./pages/AlertsPage.js"; @@ -29,29 +30,31 @@ function NotFound() { export function App() { return ( - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + ); } diff --git a/frontend/packages/swarm-ui/src/pages/notifications/FlowCanvas.tsx b/frontend/packages/swarm-ui/src/pages/notifications/FlowCanvas.tsx index 0905a107..d1988e02 100644 --- a/frontend/packages/swarm-ui/src/pages/notifications/FlowCanvas.tsx +++ b/frontend/packages/swarm-ui/src/pages/notifications/FlowCanvas.tsx @@ -1,74 +1,400 @@ // — a flow drawn as boxes and lines: the source on the left, its -// branches to the right. Pick a node to edit it; the inspector beside the -// canvas does the editing. -import { layoutFlow, NODE_HEIGHT, NODE_WIDTH } from "./FlowLayout.js"; -import { describeNode, ellipsize, type Flow } from "./FlowTypes.js"; +// branches to the right, with a palette of node kinds above. +// +// Rewiring is by pointer or by keyboard, and both end in the same edits the +// inspector makes: +// - drag a box onto another to move it (and its branch) under that one, or +// drag a palette chip onto a box to add a new node under it; +// - arrow keys walk the tree, M picks the focused box up and Enter drops it +// under the box now focused, Alt+Up/Down changes when a branch runs among +// its siblings, Delete removes the box and its branch. +// A drop the save would refuse is turned away with the reason, so a bad flow +// is never drawn. +import { useEffect, useRef, useState } from "preact/hooks"; +import { + addChild, + blankNode, + isLeafKind, + reparent, + siblingPosition, + type AddableKind, + type Edit, + type Limits, +} from "./FlowDraft.js"; +import { + layoutFlow, + neighbour, + nodeAt, + NODE_HEIGHT, + NODE_WIDTH, +} from "./FlowLayout.js"; +import { describeNode, ellipsize, ordinal, type Flow } from "./FlowTypes.js"; const TEXT_CHARS = 30; +// How far the pointer must travel before a press counts as a drag. +const DRAG_PX = 4; + +export interface PaletteKind { + kind: AddableKind; + description: string; +} + +type Source = { kind: "move"; id: string } | { kind: "add"; node: AddableKind }; + +interface Drag { + source: Source; + startX: number; + startY: number; + x: number; + y: number; + moved: boolean; + // The box under the pointer, if any. + over: string | undefined; +} + +export interface CanvasEdits { + onSelect: (id: string) => void; + onAdd: (kind: AddableKind, parentId: string) => void; + onMove: (id: string, parentId: string) => void; + onReorder: (id: string, delta: -1 | 1) => void; + onRemove: (id: string) => void; + // A drop or key press the draft turned away, and why. + onRefused: (reason: string) => void; +} + +const HELP = + "Drag a box onto another to move it there, or drag a kind from above onto a box to add it. " + + "Keys: arrows walk the tree, M picks a box up and Enter drops it under the focused box, " + + "Alt+Up and Alt+Down change when a branch runs, Delete removes a box and its branch."; export function FlowCanvas({ flow, selected, - onSelect, + limits, + palette, + ...edits }: { flow: Flow; selected: string | null; - onSelect: (id: string) => void; -}) { + limits: Limits; + palette: PaletteKind[]; +} & CanvasEdits) { const layout = layoutFlow(flow); const byId = new Map(flow.nodes.map((n) => [n.id, n])); + const svgRef = useRef(null); + const dragRef = useRef(null); + const [, redraw] = useState(0); + // The box picked up with the keyboard, and the one the cursor is on. + const [carrying, setCarrying] = useState(null); + const [cursor, setCursor] = useState(null); + const [said, setSaid] = useState(""); + // When a key press last asked for focus to follow the selection, so a stale + // request cannot pull focus out of the inspector later. + const refocusAt = useRef(0); + const wantRefocus = () => { + refocusAt.current = Date.now(); + }; + + // What an edit at `over` would do, without doing it. + const dryRun = (source: Source, over: string): Edit => + source.kind === "move" + ? reparent(flow, source.id, over, limits) + : addChild(flow, over, blankNode(flow, source.node), limits); + + // The pointer handlers outlive a render, so they read the latest of these. + const latest = useRef({ layout, flow, dryRun, edits }); + latest.current = { layout, flow, dryRun, edits }; + + const focusNode = (id: string) => + svgRef.current?.querySelector(`[data-node="${id}"]`)?.focus(); + + // After an edit that moved or removed the focused box, put focus on the + // selected one so the keyboard carries on from where it was. + useEffect(() => { + if (selected && Date.now() - refocusAt.current < 300) { + refocusAt.current = 0; + focusNode(selected); + } + }); + + const dragging = dragRef.current !== null; + useEffect(() => { + if (!dragging) return; + const overAt = (e: PointerEvent): string | undefined => { + const rect = svgRef.current?.getBoundingClientRect(); + if (!rect) return undefined; + return nodeAt( + latest.current.layout, + e.clientX - rect.left, + e.clientY - rect.top, + ); + }; + const onMove = (e: PointerEvent) => { + const d = dragRef.current; + if (!d) return; + const over = overAt(e); + dragRef.current = { + ...d, + x: e.clientX, + y: e.clientY, + moved: + d.moved || + Math.hypot(e.clientX - d.startX, e.clientY - d.startY) > DRAG_PX, + over: + d.source.kind === "move" && over === d.source.id ? undefined : over, + }; + redraw((n) => n + 1); + }; + const end = (drop: boolean) => { + const d = dragRef.current; + dragRef.current = null; + redraw((n) => n + 1); + if (!d) return; + const { edits: e, dryRun: dry } = latest.current; + if (!d.moved) { + // A press without travel: a click on a box picks it. A palette chip + // is a button, and its own click adds. + if (drop && d.source.kind === "move") e.onSelect(d.source.id); + return; + } + if (!drop || !d.over) return; + const result = dry(d.source, d.over); + if (!result.ok) { + e.onRefused(result.reason); + return; + } + if (d.source.kind === "move") e.onMove(d.source.id, d.over); + else e.onAdd(d.source.node, d.over); + }; + const up = () => end(true); + const cancel = () => end(false); + const key = (e: KeyboardEvent) => { + if (e.key === "Escape") cancel(); + }; + window.addEventListener("pointermove", onMove); + window.addEventListener("pointerup", up); + window.addEventListener("pointercancel", cancel); + window.addEventListener("keydown", key); + return () => { + window.removeEventListener("pointermove", onMove); + window.removeEventListener("pointerup", up); + window.removeEventListener("pointercancel", cancel); + window.removeEventListener("keydown", key); + }; + }, [dragging]); + + const startDrag = (e: PointerEvent, source: Source) => { + if (e.button !== 0) return; + dragRef.current = { + source, + startX: e.clientX, + startY: e.clientY, + x: e.clientX, + y: e.clientY, + moved: false, + over: undefined, + }; + redraw((n) => n + 1); + }; + + const drag = dragRef.current; + const dragSource = drag?.source; + // How a box reads while something is held over it. + const holdClass = (id: string): string => { + if (drag?.moved && dragSource && drag.over === id) { + return dryRun(dragSource, id).ok + ? " notify-box-target" + : " notify-box-refused"; + } + if (carrying && cursor === id && carrying !== id) { + return dryRun({ kind: "move", id: carrying }, id).ok + ? " notify-box-target" + : " notify-box-refused"; + } + return ""; + }; + + const drop = (id: string) => { + if (!carrying) return; + const moving = carrying; + const result = reparent(flow, moving, id, limits); + setCarrying(null); + if (!result.ok) { + edits.onRefused(result.reason); + setSaid(`Not moved: ${result.reason}.`); + return; + } + wantRefocus(); + edits.onMove(moving, id); + setSaid(`Moved ${moving} under ${id}.`); + }; + + const onKey = (e: KeyboardEvent, id: string) => { + const dirs: Record = { + ArrowUp: "up", + ArrowDown: "down", + ArrowLeft: "left", + ArrowRight: "right", + }; + const dir = dirs[e.key]; + if (dir) { + if (e.altKey && (dir === "up" || dir === "down")) { + e.preventDefault(); + wantRefocus(); + edits.onReorder(id, dir === "up" ? -1 : 1); + return; + } + const next = neighbour(layout, flow, id, dir); + if (!next) return; + e.preventDefault(); + setCursor(next); + if (!carrying) edits.onSelect(next); + focusNode(next); + return; + } + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + if (carrying) drop(id); + else edits.onSelect(id); + } else if (e.key === "m" || e.key === "M") { + const node = byId.get(id); + if (!node || node.kind === "source") return; + e.preventDefault(); + setCarrying(id); + setCursor(id); + setSaid( + `Picked up ${id}. Arrow to the box to move it under, Enter to drop, Escape to cancel.`, + ); + } else if (e.key === "Escape" && carrying) { + e.preventDefault(); + setCarrying(null); + setSaid(`Put ${carrying} back.`); + } else if (e.key === "Delete" || e.key === "Backspace") { + const node = byId.get(id); + if (!node || node.kind === "source") return; + e.preventDefault(); + wantRefocus(); + edits.onRemove(id); + } + }; + + const tabStop = selected ?? flow.nodes.find((n) => n.kind === "source")?.id; + const selectedNode = selected ? byId.get(selected) : undefined; + const canAddBelow = !!selectedNode && !isLeafKind(selectedNode.kind); + return ( -
- - {layout.edges.map((e) => { - const mid = (e.x1 + e.x2) / 2; - return ( - ${e.to}`} - class="notify-edge" - d={`M${e.x1} ${e.y1} C${mid} ${e.y1} ${mid} ${e.y2} ${e.x2} ${e.y2}`} - /> - ); - })} - {layout.nodes.map((p) => { - const node = byId.get(p.id); - if (!node) return null; - const text = describeNode(node); - return ( - onSelect(p.id)} - onKeyDown={(e) => { - if (e.key === "Enter" || e.key === " ") { +
+
+ add + {palette.map((p) => ( + + ))} +
+
+ + {layout.edges.map((e) => { + const mid = (e.x1 + e.x2) / 2; + return ( + ${e.to}`} + class="notify-edge" + d={`M${e.x1} ${e.y1} C${mid} ${e.y1} ${mid} ${e.y2} ${e.x2} ${e.y2}`} + /> + ); + })} + {layout.nodes.map((p) => { + const node = byId.get(p.id); + if (!node) return null; + const text = describeNode(node); + const pos = siblingPosition(flow, p.id); + const order = + pos.count > 1 + ? `, runs ${ordinal(pos.index + 1)} of ${pos.count}` + : ""; + const held = + drag?.moved && + dragSource?.kind === "move" && + dragSource.id === p.id; + return ( + { + if (node.kind === "source") { + // The source cannot move; a press just picks it. + if (e.button === 0) edits.onSelect(p.id); + return; + } e.preventDefault(); - onSelect(p.id); - } - }} - > - {text} - - - {node.kind} - - - {ellipsize(text, TEXT_CHARS)} - - - ); - })} - + (e.currentTarget as SVGGElement).focus(); + startDrag(e, { kind: "move", id: p.id }); + }} + onFocus={() => setCursor(p.id)} + onKeyDown={(e) => onKey(e, p.id)} + > + {text} + + + {node.kind} + + {pos.count > 1 ? ( + + {`runs ${ordinal(pos.index + 1)}`} + + ) : null} + + {ellipsize(text, TEXT_CHARS)} + + + ); + })} + +
+

+ {HELP} +

+

+ {said} +

+ {drag?.moved ? ( +
+ {dragSource?.kind === "move" ? dragSource.id : dragSource?.node} +
+ ) : null}
); } diff --git a/frontend/packages/swarm-ui/src/pages/notifications/FlowDraft.ts b/frontend/packages/swarm-ui/src/pages/notifications/FlowDraft.ts index 1af89858..47c95f51 100644 --- a/frontend/packages/swarm-ui/src/pages/notifications/FlowDraft.ts +++ b/frontend/packages/swarm-ui/src/pages/notifications/FlowDraft.ts @@ -7,6 +7,11 @@ // would reject. The tree rules mirror `Flow::validate` in swarm-wire. import type { Flow, FlowNode, FlowSchema, FlowsView } from "./FlowTypes.js"; +// Kinds that end a branch: nothing hangs below them. +export function isLeafKind(kind: string): boolean { + return kind === "todo" || kind === "request_reviewer"; +} + export interface Limits { maxNodes: number; maxDepth: number; @@ -105,15 +110,47 @@ export function newNodeId(flow: Flow, prefix: string): string { } } +// The kinds a user can add; the source is only ever the root. +export type AddableKind = "match" | "todo" | "request_reviewer"; + +export const ADDABLE_KINDS: AddableKind[] = [ + "match", + "todo", + "request_reviewer", +]; + +// The kinds the palette offers, each with the controller's description of it. +// A controller that sends no kinds predates `request_reviewer`, so only the +// two it always had are offered. +export function paletteKinds( + schema: FlowSchema, +): { kind: AddableKind; description: string }[] { + const listed = new Map(schema.node_kinds.map((k) => [k.kind, k.description])); + return ADDABLE_KINDS.filter((kind) => + listed.size === 0 ? kind !== "request_reviewer" : listed.has(kind), + ).map((kind) => ({ kind, description: listed.get(kind) ?? "" })); +} + +// The word a new node's id starts with, such as `reviewer` for `reviewer1`. +const ID_PREFIX: Record = { + match: "match", + todo: "todo", + request_reviewer: "reviewer", +}; + // A node of `kind` with the fields a user is asked to fill in left empty. export function blankNode( flow: Flow, - kind: "match" | "todo", -): Extract { - const id = newNodeId(flow, kind); - return kind === "match" - ? { id, kind, when: [] } - : { + kind: AddableKind, +): Extract { + const id = newNodeId(flow, ID_PREFIX[kind]); + switch (kind) { + case "match": + return { id, kind, when: [] }; + case "request_reviewer": + return { id, kind, reviewer: "" }; + case "todo": + return { id, kind, to: { field: "assignees" }, @@ -122,6 +159,7 @@ export function blankNode( prefix: false, text: "", }; + } } // `added` under `parentId`. @@ -133,7 +171,9 @@ export function addChild( ): Edit { const parent = node(flow, parentId); if (!parent) return refuse("that parent is not in the flow"); - if (parent.kind === "todo") return refuse("a todo ends a branch"); + if (isLeafKind(parent.kind)) { + return refuse(`a ${parent.kind} ends a branch`); + } if (added.kind === "source") return refuse("a flow has one source"); if (!isId(added.id) || node(flow, added.id)) { return refuse(`${added.id} is not a free node id`); @@ -181,10 +221,15 @@ export function reparent( const parent = node(flow, parentId); if (!target || !parent) return refuse("that node is not in the flow"); if (target.kind === "source") return refuse("the source stays the root"); - if (parent.kind === "todo") return refuse("a todo ends a branch"); + if (isLeafKind(parent.kind)) { + return refuse(`a ${parent.kind} ends a branch`); + } if (subtreeIds(flow, id).includes(parentId)) { return refuse("a node cannot go under itself or its own branch"); } + if (parentOf(flow, id) === parentId) { + return refuse(`${id} is already under ${parentId}`); + } if (depthOf(flow, parentId) + 1 + heightBelow(flow, id) > limits.maxDepth) { return refuse(`a flow is at most ${limits.maxDepth} levels deep`); } @@ -200,6 +245,40 @@ export function reparent( }; } +// Where `id` runs among the nodes sharing its parent: `index` from 0 of +// `count`. A node with no parent (the source) is the only one of its kind. +export function siblingPosition( + flow: Flow, + id: string, +): { index: number; count: number } { + const parent = parentOf(flow, id); + if (parent === undefined) return { index: 0, count: 1 }; + const siblings = childrenOf(flow, parent); + return { index: Math.max(0, siblings.indexOf(id)), count: siblings.length }; +} + +// `id` swapped with its neighbour that runs `delta` places earlier (-1) or +// later (+1). The router runs a node's children in edge order, so this changes +// which branch acts first; other edges keep their places. +export function moveAmongSiblings(flow: Flow, id: string, delta: -1 | 1): Edit { + const parent = parentOf(flow, id); + if (parent === undefined) return refuse("only a branch can be reordered"); + const at: number[] = []; + flow.edges.forEach((e, i) => { + if (e.from === parent) at.push(i); + }); + const here = at.findIndex((i) => flow.edges[i].to === id); + const there = here + delta; + if (there < 0 || there >= at.length) { + return refuse( + delta < 0 ? `${id} already runs first` : `${id} already runs last`, + ); + } + const edges = [...flow.edges]; + [edges[at[here]], edges[at[there]]] = [edges[at[there]], edges[at[here]]]; + return { ok: true, flow: { ...flow, edges } }; +} + // A todo with its op switched. A clear carries no text, and only a clear may // be a prefix, so each is reset where the new op has no place for it. export function withOp( @@ -225,8 +304,8 @@ export function replaceNode(flow: Flow, next: FlowNode): Edit { "the source cannot change kind, and no other node can become one", ); } - if (next.kind === "todo" && childrenOf(flow, next.id).length > 0) { - return refuse("a node with children cannot become a todo"); + if (isLeafKind(next.kind) && childrenOf(flow, next.id).length > 0) { + return refuse(`a node with children cannot become a ${next.kind}`); } return { ok: true, diff --git a/frontend/packages/swarm-ui/src/pages/notifications/FlowEditorPage.tsx b/frontend/packages/swarm-ui/src/pages/notifications/FlowEditorPage.tsx index e398f3c4..41fc991f 100644 --- a/frontend/packages/swarm-ui/src/pages/notifications/FlowEditorPage.tsx +++ b/frontend/packages/swarm-ui/src/pages/notifications/FlowEditorPage.tsx @@ -19,14 +19,22 @@ import { TextField } from "../../ui/text-field/TextField.js"; import { fetchJson, ProblemError } from "../../ui/polled/usePolled.js"; import { FlowCanvas } from "./FlowCanvas.js"; import { + addChild, + blankNode, isDirty, limitsOf, + moveAmongSiblings, + paletteKinds, + removeNode, + reparent, saveOutcome, saveRequest, + parentOf, withFlow, withoutFlow, type Edit, } from "./FlowDraft.js"; +import { useLeaveGuard } from "./useLeaveGuard.js"; import { NodeInspector } from "./NodeInspector.js"; import { FALLBACK_SCHEMA, type Flow, type FlowsView } from "./FlowTypes.js"; import "./NotificationsPage.css"; @@ -87,13 +95,9 @@ export function FlowEditorPage({ draft !== null && isDirty(view, withFlow(view.flows, draft), view.disabled_builtins); - // Leaving the page with edits not saved asks first. - useEffect(() => { - if (!dirty) return; - const warn = (e: BeforeUnloadEvent) => e.preventDefault(); - window.addEventListener("beforeunload", warn); - return () => window.removeEventListener("beforeunload", warn); - }, [dirty]); + // Leaving the page with edits not saved asks first, whether by closing the + // tab, following a link or pressing back. + const leaving = useLeaveGuard(dirty); // Send `flows` as the document; true when the controller took it. async function put(flows: Flow[]): Promise { @@ -137,7 +141,11 @@ export function FlowEditorPage({ async function onDelete() { if (!view) return; setConfirmDelete(false); - if (await put(withoutFlow(view.flows, flowId))) navigate("/notifications"); + if (await put(withoutFlow(view.flows, flowId))) { + // The flow is gone; there is nothing left to lose by leaving. + leaving.release(); + navigate("/notifications"); + } } const back = all flows; @@ -237,7 +245,25 @@ export function FlowEditorPage({ {dirty ?

Not saved yet.

: null}
- + { + const added = blankNode(draft, kind); + onEdit(addChild(draft, parentId, added, limits), added.id); + }} + onMove={(id, parentId) => + onEdit(reparent(draft, id, parentId, limits), id) + } + onReorder={(id, delta) => + onEdit(moveAmongSiblings(draft, id, delta), id) + } + onRemove={(id) => onEdit(removeNode(draft, id), parentOf(draft, id))} + onRefused={setNotice} + /> {node ? (

Delete the flow {draft.name}? The router stops running it.

+ +

This flow has edits that are not saved. Leaving drops them.

+
); } diff --git a/frontend/packages/swarm-ui/src/pages/notifications/FlowLayout.ts b/frontend/packages/swarm-ui/src/pages/notifications/FlowLayout.ts index 523057c6..f23a84d4 100644 --- a/frontend/packages/swarm-ui/src/pages/notifications/FlowLayout.ts +++ b/frontend/packages/swarm-ui/src/pages/notifications/FlowLayout.ts @@ -35,6 +35,38 @@ export interface Layout { height: number; } +// The node whose box holds the point (`x`, `y`) in canvas coordinates, if any. +export function nodeAt( + layout: Layout, + x: number, + y: number, +): string | undefined { + return layout.nodes.find( + (n) => + x >= n.x && x <= n.x + NODE_WIDTH && y >= n.y && y <= n.y + NODE_HEIGHT, + )?.id; +} + +// The node next to `id` in direction `dir` on the canvas: left is its parent, +// right its first child, up and down the nearest box above or below it in the +// same column. Undefined at an edge, so a key press there does nothing. +export function neighbour( + layout: Layout, + flow: Flow, + id: string, + dir: "up" | "down" | "left" | "right", +): string | undefined { + const me = layout.nodes.find((n) => n.id === id); + if (!me) return undefined; + if (dir === "left") return flow.edges.find((e) => e.to === id)?.from; + if (dir === "right") return flow.edges.find((e) => e.from === id)?.to; + const column = layout.nodes + .filter((n) => n.depth === me.depth && n.id !== id) + .filter((n) => (dir === "up" ? n.y < me.y : n.y > me.y)) + .sort((a, b) => (dir === "up" ? b.y - a.y : a.y - b.y)); + return column[0]?.id; +} + export function layoutFlow(flow: Flow): Layout { const children = new Map(); for (const e of flow.edges) { diff --git a/frontend/packages/swarm-ui/src/pages/notifications/FlowTypes.ts b/frontend/packages/swarm-ui/src/pages/notifications/FlowTypes.ts index cbe8cfa4..a84634cb 100644 --- a/frontend/packages/swarm-ui/src/pages/notifications/FlowTypes.ts +++ b/frontend/packages/swarm-ui/src/pages/notifications/FlowTypes.ts @@ -20,7 +20,8 @@ export type FlowNode = key: string; prefix: boolean; text: string; - }; + } + | { id: string; kind: "request_reviewer"; reviewer: string }; export interface Flow { id: string; @@ -82,6 +83,11 @@ export function describeNode(node: FlowNode): string { return node.op === "clear" ? `clear todos ${node.key}${node.prefix ? "*" : ""} of ${describeRecipients(node.to)}` : `todo to ${describeRecipients(node.to)}, key ${node.key}: ${node.text}`; + case "request_reviewer": + return `request a review from ${node.reviewer || "(nobody yet)"}`; + default: + // A kind this editor predates: show its name rather than nothing. + return `${(node as { kind: string }).kind} (not editable here)`; } } @@ -148,6 +154,13 @@ export const FALLBACK_SCHEMA: FlowSchema = { limits: { max_nodes: 64, max_depth: 16, max_flows: 100 }, }; +// 1 as "1st", 2 as "2nd", 11 as "11th". +export function ordinal(n: number): string { + const tens = n % 100; + if (tens >= 11 && tens <= 13) return `${n}th`; + return `${n}${["th", "st", "nd", "rd"][n % 10] ?? "th"}`; +} + // `text` cut to `max` characters, with an ellipsis if it was longer. export function ellipsize(text: string, max: number): string { return text.length <= max ? text : `${text.slice(0, max - 1)}…`; diff --git a/frontend/packages/swarm-ui/src/pages/notifications/NodeInspector.tsx b/frontend/packages/swarm-ui/src/pages/notifications/NodeInspector.tsx index 3eec4668..2a205568 100644 --- a/frontend/packages/swarm-ui/src/pages/notifications/NodeInspector.tsx +++ b/frontend/packages/swarm-ui/src/pages/notifications/NodeInspector.tsx @@ -1,25 +1,26 @@ // — the fields of the picked node, and what can be done to -// it: add a child, move it under another node, remove it. Every change goes -// out as an `Edit` of the flow; the editor shows the reason when one is -// refused. +// it: move it under another node, change when it runs among its siblings, +// remove it. Every change goes out as an `Edit` of the flow; the editor shows +// the reason when one is refused. New nodes come from the canvas palette. import { Button } from "../../ui/button/Button.js"; import { CheckboxField } from "../../ui/checkbox-field/CheckboxField.js"; import { SelectField } from "../../ui/select-field/SelectField.js"; import { TextAreaField } from "../../ui/text-area-field/TextAreaField.js"; import { TextField } from "../../ui/text-field/TextField.js"; import { - addChild, - blankNode, + moveAmongSiblings, parentOf, removeNode, reparent, replaceNode, + siblingPosition, type Edit, type Limits, withOp, } from "./FlowDraft.js"; import { kindValues, + ordinal, type Condition, type Described, type Flow, @@ -31,6 +32,9 @@ type TodoNode = Extract; const OPS_FALLBACK = ["upsert", "clear"]; +// The kinds this editor has fields for. +const KNOWN_KINDS: string[] = ["source", "match", "todo", "request_reviewer"]; + function options(list: Described[]) { return list.map((d) => ({ value: d.name, @@ -57,15 +61,17 @@ export function NodeInspector({ const firstField = schema.fields[0]?.name ?? ""; const key = `ne-${node.id}`; - const add = (kind: "match" | "todo") => { - const added = blankNode(flow, kind); - onEdit(addChild(flow, node.id, added, limits), added.id); - }; - const parent = parentOf(flow, node.id); + // Where it can go: under its current parent, or under any node it could be + // moved to. const parents = flow.nodes - .filter((n) => n.id !== node.id && reparent(flow, node.id, n.id, limits).ok) + .filter( + (n) => + n.id === parent || + (n.id !== node.id && reparent(flow, node.id, n.id, limits).ok), + ) .map((n) => ({ value: n.id, label: `${n.kind} ${n.id}` })); + const order = siblingPosition(flow, node.id); return (
@@ -121,13 +127,46 @@ export function NodeInspector({ /> ) : null} + {node.kind === "request_reviewer" ? ( +
+ asks for a review + set({ ...node, reviewer })} + /> +
+ ) : null} + + {!KNOWN_KINDS.includes(node.kind) ? ( +

+ This editor does not know how to edit a {node.kind}. It is kept as it + is when you save. +

+ ) : null} + + {order.count > 1 ? ( +
+ + runs {ordinal(order.index + 1)} of {order.count} below its parent + + + +
+ ) : null} +
- {node.kind !== "todo" ? ( - <> - - - - ) : null} {node.kind !== "source" ? (