@calliopelabs/ui 0.12.0 → 0.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -373,6 +373,24 @@ const registry: WorkflowRegistry = {
373
373
 
374
374
  It marks unsaved edits and a draft that differs from live (`documentStatus`). A newer saved revision is taken when there is nothing to lose; when it moves under unsaved edits, it stops and asks: compare, use theirs, or keep mine. Publishing shows the draft against live (`WorkflowDiff`) and is held while anything blocks. A rejected promise's message is shown and the edits stay. Persistence and authority stay with the host: it never saves, publishes or runs anything itself. Below a 64rem container the palette and the side panel open as drawers.
375
375
 
376
+ A host can reach further into the editor. Each is opt-in; without them the editor is as above.
377
+
378
+ - **Deep links.** `defaultSelection` opens the editor on a flow and a node (`{ type: "node", flow, id }`); `selection` with `onSelectionChange` holds it in the host. A selection in another flow opens that flow, and the canvas brings the node into view.
379
+ - **Flows.** `onFlowCreate` offers New flow and `onFlowRename` offers Rename flow for the open one. The dialog checks the name as it is typed against `WORKFLOW_FLOW_NAME` (a lowercase slug of up to 32 characters), against the other flows and against `WORKFLOW_MAX_FLOWS`, then hands the host the next definition to apply. `renameFlow` renames the key, `main` and every sub-flow node that runs it; anything else the host keeps that names flows is the host's to follow. `flowNameProblem`, `addFlow` and `renameFlow` are exported for hosts that edit flows themselves.
380
+ - **Host fields.** `renderNodeFields` adds the host's own fields to the inspector after a node's kind fields, with the node, its flow and an `onChange` that patches it. Switch on `node.kind` and return null for none.
381
+
382
+ ```tsx
383
+ <WorkflowWorkspace
384
+ {...props}
385
+ defaultSelection={at ? { type: "node", flow: at.flow, id: at.node } : null}
386
+ onFlowCreate={(next) => setDraft(next)}
387
+ onFlowRename={(next, from, to) => setDraft(next) /* and follow `from` to `to` in what you keep */}
388
+ renderNodeFields={({ node, readOnly }) =>
389
+ node.kind === "step" ? <RunnerPicker step={node.id} readOnly={readOnly} /> : null
390
+ }
391
+ />
392
+ ```
393
+
376
394
  The canvas pans, zooms and fits without going below `READABLE_ZOOM`; loop bodies and fan-out branches fold, and a sub-flow shows in place. Every drag has a keyboard equivalent: Tab to a node or edge, Enter to select, arrows to move, Delete to remove, and Add and Connect in its toolbar.
377
395
 
378
396
  `@xyflow/react` comes with the package; its structural CSS is in the preset.
package/dist/index.d.ts CHANGED
@@ -76,7 +76,7 @@ export { ReviewPanel, type ReviewPanelProps } from "./workflow/ReviewPanel.js";
76
76
  export { MediaReview, formatTimecode, type MediaAnchor, type MediaCaptionTrack, type MediaComment, type MediaRegion, type MediaReviewProps, } from "./workflow/MediaReview.js";
77
77
  export { DocumentReview, type DocumentAnnotation, type DocumentBlock, type DocumentPage, type DocumentReviewProps, type DocumentSegment, type DocumentSignOff, type SignOffStatus, } from "./workflow/DocumentReview.js";
78
78
  export { FormReview, type FormField, type FormFieldBox, type FormFieldStatus, type FormReviewProps, type FormSourcePage, } from "./workflow/FormReview.js";
79
- export { IN, LOOP_IN, LOOP_OUT, OUT, WORKFLOW_DEFINITION_VERSION, WORKFLOW_NODE_KINDS, WORKFLOW_SELECTORS, addNode, canConnect, createNode, dataTypes, downstream, edgeId, exitLabel, exitsOf, findFact, findGateKind, findPreset, freshId, inputsOf, isJudge, moveNode, operatorsFor, outputsOf, portOf, removeEdge, removeNode, startOf, typeAccepts, updateEdge, updateNode, upstream, withFlow, type WorkflowCase, type WorkflowCheckNode, type WorkflowClassifierNode, type WorkflowClassifierOption, type WorkflowCondition, type WorkflowConditionNode, type WorkflowConnectReason, type WorkflowConnectResult, type WorkflowConnection, type WorkflowDataType, type WorkflowDefinition, type WorkflowEdge, type WorkflowEvaluatorNode, type WorkflowFact, type WorkflowFactType, type WorkflowFanoutNode, type WorkflowFlow, type WorkflowGateField, type WorkflowGateKind, type WorkflowGateNode, type WorkflowGatePolicy, type WorkflowGotoNode, type WorkflowJoinNode, type WorkflowJudgeNode, type WorkflowModuleSpec, type WorkflowNode, type WorkflowNodeKind, type WorkflowNodeOf, type WorkflowNodeTemplate, type WorkflowOperator, type WorkflowOption, type WorkflowPort, type WorkflowPosition, type WorkflowRegistry, type WorkflowSelection, type WorkflowSelector, type WorkflowStepNode, type WorkflowStepPreset, type WorkflowSubflowNode, type WorkflowVariant, type WorkflowVariantsNode, } from "./workflow/definition.js";
79
+ export { IN, LOOP_IN, LOOP_OUT, OUT, WORKFLOW_DEFINITION_VERSION, WORKFLOW_FLOW_NAME, WORKFLOW_MAX_FLOWS, WORKFLOW_NODE_KINDS, WORKFLOW_SELECTORS, addFlow, addNode, canConnect, createNode, dataTypes, downstream, edgeId, exitLabel, exitsOf, findFact, findGateKind, findPreset, flowNameProblem, freshId, inputsOf, isJudge, moveNode, operatorsFor, outputsOf, portOf, removeEdge, removeNode, renameFlow, startOf, typeAccepts, updateEdge, updateNode, upstream, withFlow, type WorkflowCase, type WorkflowCheckNode, type WorkflowClassifierNode, type WorkflowClassifierOption, type WorkflowCondition, type WorkflowConditionNode, type WorkflowConnectReason, type WorkflowConnectResult, type WorkflowConnection, type WorkflowDataType, type WorkflowDefinition, type WorkflowEdge, type WorkflowEvaluatorNode, type WorkflowFact, type WorkflowFactType, type WorkflowFanoutNode, type WorkflowFlow, type WorkflowFlowNameProblem, type WorkflowGateField, type WorkflowGateKind, type WorkflowGateNode, type WorkflowGatePolicy, type WorkflowGotoNode, type WorkflowJoinNode, type WorkflowJudgeNode, type WorkflowModuleSpec, type WorkflowNode, type WorkflowNodeKind, type WorkflowNodeOf, type WorkflowNodeTemplate, type WorkflowOperator, type WorkflowOption, type WorkflowPort, type WorkflowPosition, type WorkflowRegistry, type WorkflowSelection, type WorkflowSelector, type WorkflowStepNode, type WorkflowStepPreset, type WorkflowSubflowNode, type WorkflowVariant, type WorkflowVariantsNode, } from "./workflow/definition.js";
80
80
  export { WORKFLOW_PROBLEM_CODES, blockingProblems, checkDefinition, definitionAvailability, nodeAvailability, type WorkflowProblem, type WorkflowProblemCode, type WorkflowProblemLevel, } from "./workflow/problems.js";
81
81
  export { WORKFLOW_PACKAGE_FORMAT, WORKFLOW_PACKAGE_VERSION, diffDefinitions, exportWorkflowPackage, previewWorkflowImport, readWorkflowDefinition, readWorkflowPackage, requirementsOf, sameDefinition, type WorkflowDefinitionDiff, type WorkflowDiffEdge, type WorkflowDiffItem, type WorkflowDisabledNode, type WorkflowFlowDiff, type WorkflowImportSummary, type WorkflowModuleGap, type WorkflowPackage, type WorkflowReadError, type WorkflowReadResult, type WorkflowRequirements, } from "./workflow/portable.js";
82
82
  export { NODE_HEADER, NODE_WIDTH, PORT_ROW, arrangeFlow, layoutFlow, nodeSize, portRows, ranksOf, regionsOf, type WorkflowRegion, } from "./workflow/layout.js";
@@ -84,7 +84,7 @@ export { insertPaletteItem, paletteOf, type WorkflowPaletteGroup, type WorkflowP
84
84
  export { READABLE_ZOOM, WorkflowCanvas, type WorkflowCanvasProps, } from "./workflow/canvas/WorkflowCanvas.js";
85
85
  export { WorkflowPalette, type WorkflowPaletteProps } from "./workflow/editor/WorkflowPalette.js";
86
86
  export { WorkflowProblems, problemTarget, type WorkflowProblemsProps, } from "./workflow/editor/WorkflowProblems.js";
87
- export { ConditionEditor, GatePolicyEditor, WorkflowInspector, type WorkflowInspectorProps, } from "./workflow/editor/WorkflowInspector.js";
87
+ export { ConditionEditor, GatePolicyEditor, WorkflowInspector, type WorkflowInspectorProps, type WorkflowNodeFieldsContext, } from "./workflow/editor/WorkflowInspector.js";
88
88
  export { WorkflowJsonView, type WorkflowJsonViewProps, } from "./workflow/editor/WorkflowJsonView.js";
89
89
  export { documentStatus, type WorkflowDocumentStatus, type WorkflowRevision, } from "./workflow/workspace.js";
90
90
  export { WorkflowDiff, type WorkflowDiffProps } from "./workflow/editor/WorkflowDiff.js";
package/dist/index.js CHANGED
@@ -85,7 +85,7 @@ export { ReviewPanel } from "./workflow/ReviewPanel.js";
85
85
  export { MediaReview, formatTimecode, } from "./workflow/MediaReview.js";
86
86
  export { DocumentReview, } from "./workflow/DocumentReview.js";
87
87
  export { FormReview, } from "./workflow/FormReview.js";
88
- export { IN, LOOP_IN, LOOP_OUT, OUT, WORKFLOW_DEFINITION_VERSION, WORKFLOW_NODE_KINDS, WORKFLOW_SELECTORS, addNode, canConnect, createNode, dataTypes, downstream, edgeId, exitLabel, exitsOf, findFact, findGateKind, findPreset, freshId, inputsOf, isJudge, moveNode, operatorsFor, outputsOf, portOf, removeEdge, removeNode, startOf, typeAccepts, updateEdge, updateNode, upstream, withFlow, } from "./workflow/definition.js";
88
+ export { IN, LOOP_IN, LOOP_OUT, OUT, WORKFLOW_DEFINITION_VERSION, WORKFLOW_FLOW_NAME, WORKFLOW_MAX_FLOWS, WORKFLOW_NODE_KINDS, WORKFLOW_SELECTORS, addFlow, addNode, canConnect, createNode, dataTypes, downstream, edgeId, exitLabel, exitsOf, findFact, findGateKind, findPreset, flowNameProblem, freshId, inputsOf, isJudge, moveNode, operatorsFor, outputsOf, portOf, removeEdge, removeNode, renameFlow, startOf, typeAccepts, updateEdge, updateNode, upstream, withFlow, } from "./workflow/definition.js";
89
89
  export { WORKFLOW_PROBLEM_CODES, blockingProblems, checkDefinition, definitionAvailability, nodeAvailability, } from "./workflow/problems.js";
90
90
  export { WORKFLOW_PACKAGE_FORMAT, WORKFLOW_PACKAGE_VERSION, diffDefinitions, exportWorkflowPackage, previewWorkflowImport, readWorkflowDefinition, readWorkflowPackage, requirementsOf, sameDefinition, } from "./workflow/portable.js";
91
91
  export { NODE_HEADER, NODE_WIDTH, PORT_ROW, arrangeFlow, layoutFlow, nodeSize, portRows, ranksOf, regionsOf, } from "./workflow/layout.js";
@@ -2,7 +2,7 @@ import type { ProductTheme } from "../appearance/product.js";
2
2
  import type { WorkflowQuestionState } from "../workflow/question.js";
3
3
  import type { WorkflowAction, WorkflowAvailability, WorkflowScope, WorkflowStatus } from "../workflow/model.js";
4
4
  import type { ArtifactStatus, GateStatus, ReviewDecision, ReviewVerdict, RunStageStatus } from "../workflow/run.js";
5
- import type { WorkflowConnectReason, WorkflowNode, WorkflowNodeKind, WorkflowSelector } from "../workflow/definition.js";
5
+ import type { WorkflowConnectReason, WorkflowFlowNameProblem, WorkflowNode, WorkflowNodeKind, WorkflowSelector } from "../workflow/definition.js";
6
6
  /** The names a node's summary line uses, looked up by the caller. */
7
7
  export interface WorkflowSummaryNames {
8
8
  /** A step's or variants' preset, and its module. */
@@ -394,6 +394,22 @@ export interface Messages {
394
394
  importing: string;
395
395
  importedNotice: string;
396
396
  importAvailability: (status: WorkflowAvailability["status"]) => string;
397
+ newFlow: string;
398
+ newFlowTitle: string;
399
+ newFlowDescription: string;
400
+ createFlow: string;
401
+ renameFlow: string;
402
+ renameFlowTitle: (name: string) => string;
403
+ renameFlowDescription: string;
404
+ rename: string;
405
+ flowName: string;
406
+ flowNameHint: string;
407
+ flowLabel: string;
408
+ flowLabelHint: string;
409
+ flowNameProblem: (problem: WorkflowFlowNameProblem) => string;
410
+ flowsFull: (max: number) => string;
411
+ flowCreatedNotice: (name: string) => string;
412
+ flowRenamedNotice: (name: string) => string;
397
413
  modules: string;
398
414
  noModules: string;
399
415
  moduleStatus: (status: "available" | "missing" | "version" | "unavailable", installed?: string) => string;
@@ -600,6 +600,26 @@ export const defaultMessages = {
600
600
  importAction: "Import",
601
601
  importing: "Importing",
602
602
  importedNotice: "Imported.",
603
+ newFlow: "New flow",
604
+ newFlowTitle: "New flow",
605
+ newFlowDescription: "An empty flow beside the others. A sub-flow node runs it.",
606
+ createFlow: "Create flow",
607
+ renameFlow: "Rename flow",
608
+ renameFlowTitle: (name) => `Rename ${name}`,
609
+ renameFlowDescription: "Sub-flow nodes that run it, and the main flow, follow the new name.",
610
+ rename: "Rename",
611
+ flowName: "Name",
612
+ flowNameHint: "Lowercase letters, digits and dashes, starting with a letter. Up to 32.",
613
+ flowLabel: "Label",
614
+ flowLabelHint: "How the flow is shown. Its name when empty.",
615
+ flowNameProblem: (problem) => ({
616
+ empty: "Give the flow a name.",
617
+ format: "Use lowercase letters, digits and dashes, starting with a letter, up to 32.",
618
+ taken: "Another flow has this name.",
619
+ })[problem],
620
+ flowsFull: (max) => `A workflow holds at most ${count(max)} flows.`,
621
+ flowCreatedNotice: (name) => `Flow ${name} created.`,
622
+ flowRenamedNotice: (name) => `Flow renamed to ${name}.`,
603
623
  importAvailability: (status) => ({
604
624
  available: "Runs here",
605
625
  "missing-capabilities": "Missing capabilities here",
@@ -191,6 +191,35 @@ function CanvasInner({ definition, flow: flowProp, registry, onChange, readOnly:
191
191
  const y = b.height * zoom <= h - pad * 2 ? (h - b.height * zoom) / 2 - b.y * zoom : pad - b.y * zoom;
192
192
  void view.setViewport({ x, y, zoom });
193
193
  }, [view]);
194
+ /**
195
+ * Centers a node that is out of view at the current zoom. `whole`: unless
196
+ * all of it shows (a deep link); otherwise unless its middle shows, so
197
+ * choosing a node at the edge does not move the canvas.
198
+ */
199
+ const reveal = React.useCallback((id, whole = false) => {
200
+ const el = pane.current;
201
+ const n = view.getInternalNode(id);
202
+ if (!el || !n || !el.clientWidth || !el.clientHeight)
203
+ return;
204
+ const { x, y, zoom } = view.getViewport();
205
+ const w = n.measured.width ?? 0;
206
+ const h = n.measured.height ?? 0;
207
+ const left = n.internals.positionAbsolute.x * zoom + x;
208
+ const top = n.internals.positionAbsolute.y * zoom + y;
209
+ const [x0, y0, x1, y1] = whole
210
+ ? [left, top, left + w * zoom, top + h * zoom]
211
+ : [
212
+ left + (w * zoom) / 2,
213
+ top + (h * zoom) / 2,
214
+ left + (w * zoom) / 2,
215
+ top + (h * zoom) / 2,
216
+ ];
217
+ if (x0 >= 0 && y0 >= 0 && x1 <= el.clientWidth && y1 <= el.clientHeight)
218
+ return;
219
+ const cx = n.internals.positionAbsolute.x + w / 2;
220
+ const cy = n.internals.positionAbsolute.y + h / 2;
221
+ void view.setCenter(cx, cy, { zoom });
222
+ }, [view]);
194
223
  const flowName = flowProp && definition.flows[flowProp] ? flowProp : definition.main;
195
224
  const flow = definition.flows[flowName] ?? EMPTY_FLOW;
196
225
  const readOnly = readOnlyProp || !onChange;
@@ -218,6 +247,17 @@ function CanvasInner({ definition, flow: flowProp, registry, onChange, readOnly:
218
247
  const selectedNode = selection?.type === "node" && selection.flow === flowName
219
248
  ? flow.nodes.find((n) => n.id === selection.id)
220
249
  : undefined;
250
+ // A node selected from outside the canvas (a deep link, a problem) comes into view.
251
+ const selectedId = selectedNode?.id;
252
+ React.useEffect(() => {
253
+ if (selectedId)
254
+ reveal(selectedId);
255
+ }, [selectedId, reveal]);
256
+ const init = React.useCallback(() => {
257
+ place();
258
+ if (selectedId)
259
+ reveal(selectedId, true);
260
+ }, [place, reveal, selectedId]);
221
261
  const selectedEdge = selection?.type === "edge" && selection.flow === flowName
222
262
  ? flow.edges.find((e) => edgeId(e) === selection.id)
223
263
  : undefined;
@@ -624,7 +664,7 @@ function CanvasInner({ definition, flow: flowProp, registry, onChange, readOnly:
624
664
  : m.kind(item.template.kind)), item.available ? null : (_jsx("span", { className: "ml-auto text-2xs text-muted-foreground", children: m.notRun }))] }, item.key)))] }, g.key))) })] }), _jsxs(Button, { size: "sm", variant: "outline", disabled: !selectedNode, onClick: () => setConnecting(true), children: [_jsx(CableIcon, { "aria-hidden": true }), m.connectAction] }), _jsxs(Button, { size: "sm", variant: "outline", disabled: !selectedNode && !selectedEdge, onClick: () => remove(selectedNode ? [selectedNode.id] : [], selectedEdge ? [edgeId(selectedEdge)] : []), children: [_jsx(Trash2Icon, { "aria-hidden": true }), m.deleteSelection] }), _jsxs(Button, { size: "sm", variant: "outline", disabled: !flow.nodes.length, onClick: () => {
625
665
  change(arrangeFlow(flow, registry, definition));
626
666
  window.requestAnimationFrame(place);
627
- }, children: [_jsx(LayoutGridIcon, { "aria-hidden": true }), m.arrange] })] })), _jsxs("span", { className: "ml-auto flex items-center gap-1", children: [_jsx(Button, { size: "icon-sm", variant: "ghost", "aria-label": m.zoomOut, title: m.zoomOut, onClick: () => void view.zoomOut(), children: _jsx(ZoomOutIcon, { "aria-hidden": true }) }), _jsx(Button, { size: "icon-sm", variant: "ghost", "aria-label": m.zoomIn, title: m.zoomIn, onClick: () => void view.zoomIn(), children: _jsx(ZoomInIcon, { "aria-hidden": true }) }), _jsx(Button, { size: "icon-sm", variant: "ghost", "aria-label": m.fit, title: m.fit, onClick: place, children: _jsx(MaximizeIcon, { "aria-hidden": true }) })] })] }), _jsx("div", { ref: pane, className: "relative min-h-0 flex-1", children: _jsx(ReactFlow, { nodes: nodes, edges: built.edges, nodeTypes: NODE_TYPES, onInit: place, minZoom: READABLE_ZOOM, maxZoom: 1.5, panOnScroll: true, snapToGrid: true, snapGrid: [GRID, GRID], nodesDraggable: !readOnly, nodesConnectable: !readOnly, nodesFocusable: true, edgesFocusable: true, elementsSelectable: true, multiSelectionKeyCode: null, selectionKeyCode: null, deleteKeyCode: readOnly ? null : ["Backspace", "Delete"], ariaLabelConfig: {
667
+ }, children: [_jsx(LayoutGridIcon, { "aria-hidden": true }), m.arrange] })] })), _jsxs("span", { className: "ml-auto flex items-center gap-1", children: [_jsx(Button, { size: "icon-sm", variant: "ghost", "aria-label": m.zoomOut, title: m.zoomOut, onClick: () => void view.zoomOut(), children: _jsx(ZoomOutIcon, { "aria-hidden": true }) }), _jsx(Button, { size: "icon-sm", variant: "ghost", "aria-label": m.zoomIn, title: m.zoomIn, onClick: () => void view.zoomIn(), children: _jsx(ZoomInIcon, { "aria-hidden": true }) }), _jsx(Button, { size: "icon-sm", variant: "ghost", "aria-label": m.fit, title: m.fit, onClick: place, children: _jsx(MaximizeIcon, { "aria-hidden": true }) })] })] }), _jsx("div", { ref: pane, className: "relative min-h-0 flex-1", children: _jsx(ReactFlow, { nodes: nodes, edges: built.edges, nodeTypes: NODE_TYPES, onInit: init, minZoom: READABLE_ZOOM, maxZoom: 1.5, panOnScroll: true, snapToGrid: true, snapGrid: [GRID, GRID], nodesDraggable: !readOnly, nodesConnectable: !readOnly, nodesFocusable: true, edgesFocusable: true, elementsSelectable: true, multiSelectionKeyCode: null, selectionKeyCode: null, deleteKeyCode: readOnly ? null : ["Backspace", "Delete"], ariaLabelConfig: {
628
668
  "node.a11yDescription.default": m.nodeHelp,
629
669
  "node.a11yDescription.keyboardDisabled": m.nodeHelp,
630
670
  "node.a11yDescription.ariaLiveMessage": ({ direction, x, y }) => m.movedLive(direction, x, y),
@@ -405,4 +405,24 @@ export interface WorkflowSelection {
405
405
  flow: string;
406
406
  id: string;
407
407
  }
408
+ /** A flow's name, its key in `flows`: a lowercase slug of up to 32 characters ("build", "escalation"). */
409
+ export declare const WORKFLOW_FLOW_NAME: RegExp;
410
+ /** The most flows one definition holds. */
411
+ export declare const WORKFLOW_MAX_FLOWS = 20;
412
+ /** Why a flow name is refused. The editor's copy names each one. */
413
+ export type WorkflowFlowNameProblem = "empty" | "format" | "taken";
414
+ /**
415
+ * Whether `name` can name a flow of the definition: a slug by
416
+ * `WORKFLOW_FLOW_NAME` that no other flow has. `current` is the flow being
417
+ * renamed, which may keep its own name. Null when it can.
418
+ */
419
+ export declare function flowNameProblem(definition: WorkflowDefinition, name: string, current?: string): WorkflowFlowNameProblem | null;
420
+ /** The definition with a new, empty flow. A label that is empty is left out. */
421
+ export declare function addFlow(definition: WorkflowDefinition, name: string, label?: string): WorkflowDefinition;
422
+ /**
423
+ * Renames a flow everywhere the definition names it: its key, `main`, and
424
+ * the sub-flow nodes that run it. `label` replaces the flow's label; an empty
425
+ * one removes it; omitted, the label stays. Flows keep their order.
426
+ */
427
+ export declare function renameFlow(definition: WorkflowDefinition, from: string, to: string, label?: string): WorkflowDefinition;
408
428
  export {};
@@ -409,3 +409,47 @@ export function updateEdge(flow, id, patch) {
409
409
  export function withFlow(definition, name, flow) {
410
410
  return { ...definition, flows: { ...definition.flows, [name]: flow } };
411
411
  }
412
+ // ---------------------------------------------------------------- flows
413
+ /** A flow's name, its key in `flows`: a lowercase slug of up to 32 characters ("build", "escalation"). */
414
+ export const WORKFLOW_FLOW_NAME = /^[a-z][a-z0-9-]{0,31}$/;
415
+ /** The most flows one definition holds. */
416
+ export const WORKFLOW_MAX_FLOWS = 20;
417
+ /**
418
+ * Whether `name` can name a flow of the definition: a slug by
419
+ * `WORKFLOW_FLOW_NAME` that no other flow has. `current` is the flow being
420
+ * renamed, which may keep its own name. Null when it can.
421
+ */
422
+ export function flowNameProblem(definition, name, current) {
423
+ if (!name)
424
+ return "empty";
425
+ if (!WORKFLOW_FLOW_NAME.test(name))
426
+ return "format";
427
+ if (name !== current && Object.hasOwn(definition.flows, name))
428
+ return "taken";
429
+ return null;
430
+ }
431
+ /** The definition with a new, empty flow. A label that is empty is left out. */
432
+ export function addFlow(definition, name, label) {
433
+ const flow = { ...(label ? { label } : {}), nodes: [], edges: [] };
434
+ return withFlow(definition, name, flow);
435
+ }
436
+ /**
437
+ * Renames a flow everywhere the definition names it: its key, `main`, and
438
+ * the sub-flow nodes that run it. `label` replaces the flow's label; an empty
439
+ * one removes it; omitted, the label stays. Flows keep their order.
440
+ */
441
+ export function renameFlow(definition, from, to, label) {
442
+ if (!definition.flows[from])
443
+ return definition;
444
+ const flows = {};
445
+ for (const [name, f] of Object.entries(definition.flows)) {
446
+ const nodes = f.nodes.map((n) => n.kind === "subflow" && n.flow === from ? { ...n, flow: to } : n);
447
+ let flow = { ...f, nodes };
448
+ if (name === from && label !== undefined) {
449
+ const { label: _old, ...rest } = flow;
450
+ flow = label ? { ...rest, label } : rest;
451
+ }
452
+ flows[name === from ? to : name] = flow;
453
+ }
454
+ return { ...definition, main: definition.main === from ? to : definition.main, flows };
455
+ }
@@ -1,5 +1,5 @@
1
1
  import * as React from "react";
2
- import { type WorkflowCondition, type WorkflowDefinition, type WorkflowFlow, type WorkflowGatePolicy, type WorkflowRegistry, type WorkflowSelection } from "../definition.js";
2
+ import { type WorkflowCondition, type WorkflowDefinition, type WorkflowFlow, type WorkflowGatePolicy, type WorkflowNode, type WorkflowRegistry, type WorkflowSelection } from "../definition.js";
3
3
  export interface WorkflowInspectorProps {
4
4
  definition: WorkflowDefinition;
5
5
  registry: WorkflowRegistry;
@@ -9,8 +9,25 @@ export interface WorkflowInspectorProps {
9
9
  /** After a delete from the inspector, the selection is cleared through this. */
10
10
  onSelectionChange?: (selection: WorkflowSelection | null) => void;
11
11
  readOnly?: boolean;
12
+ /**
13
+ * The host's own fields for the selected node, after the fields of its
14
+ * kind: a runner picker, what a step inherits. Switch on `node.kind`;
15
+ * return null to add nothing. It is called while the inspector renders, so
16
+ * fields that need hooks go in a component it returns.
17
+ */
18
+ renderNodeFields?: (context: WorkflowNodeFieldsContext) => React.ReactNode;
12
19
  className?: string;
13
20
  }
21
+ /** What the inspector hands `renderNodeFields`. */
22
+ export interface WorkflowNodeFieldsContext {
23
+ node: WorkflowNode;
24
+ /** The flow the node is in. */
25
+ flow: string;
26
+ definition: WorkflowDefinition;
27
+ /** Patches the node, as the inspector's own fields do. Does nothing when read only. */
28
+ onChange: (patch: Partial<WorkflowNode>) => void;
29
+ readOnly: boolean;
30
+ }
14
31
  /** One comparison on a run fact: the fact, its node when it is a node's, the operator and the value. */
15
32
  export declare function ConditionEditor({ condition, onChange, flow, registry, label, disabled, }: {
16
33
  condition: WorkflowCondition;
@@ -34,7 +51,8 @@ export declare function GatePolicyEditor({ policy, onChange, registry, approvers
34
51
  * The selected node's or connection's fields, edited in place: per-kind
35
52
  * fields, a condition editor over the host's facts and operators, a gate
36
53
  * policy editor (roles, members, quorum, timeout), and the host's model,
37
- * harness and effort pickers. With nothing selected, the workflow's name and
54
+ * harness and effort pickers, then any fields the host adds for the node
55
+ * (`renderNodeFields`). With nothing selected, the workflow's name and
38
56
  * description. Every edit goes out as the whole next definition.
39
57
  */
40
- export declare function WorkflowInspector({ definition, registry, selection, onChange, onSelectionChange, readOnly, className, }: WorkflowInspectorProps): React.JSX.Element;
58
+ export declare function WorkflowInspector({ definition, registry, selection, onChange, onSelectionChange, readOnly, renderNodeFields, className, }: WorkflowInspectorProps): React.JSX.Element;
@@ -149,10 +149,11 @@ function PortsEditor({ legend, ports, onChange, registry, disabled, }) {
149
149
  * The selected node's or connection's fields, edited in place: per-kind
150
150
  * fields, a condition editor over the host's facts and operators, a gate
151
151
  * policy editor (roles, members, quorum, timeout), and the host's model,
152
- * harness and effort pickers. With nothing selected, the workflow's name and
152
+ * harness and effort pickers, then any fields the host adds for the node
153
+ * (`renderNodeFields`). With nothing selected, the workflow's name and
153
154
  * description. Every edit goes out as the whole next definition.
154
155
  */
155
- export function WorkflowInspector({ definition, registry, selection = null, onChange, onSelectionChange, readOnly = false, className, }) {
156
+ export function WorkflowInspector({ definition, registry, selection = null, onChange, onSelectionChange, readOnly = false, renderNodeFields, className, }) {
156
157
  const { messages } = useUI();
157
158
  const m = messages.editor;
158
159
  const disabled = readOnly || !onChange;
@@ -165,7 +166,14 @@ export function WorkflowInspector({ definition, registry, selection = null, onCh
165
166
  let title;
166
167
  if (node) {
167
168
  title = m.inspectorFor(m.kind(node.kind), node.label);
168
- body = (_jsx(NodeFields, { node: node, flow: flow, definition: definition, registry: registry, disabled: disabled, onPatch: (patch) => setFlow(updateNode(flow, node.id, patch)), onDelete: () => {
169
+ const patch = (p) => setFlow(updateNode(flow, node.id, p));
170
+ body = (_jsx(NodeFields, { node: node, flow: flow, definition: definition, registry: registry, disabled: disabled, host: renderNodeFields?.({
171
+ node,
172
+ flow: flowName,
173
+ definition,
174
+ onChange: disabled ? () => { } : patch,
175
+ readOnly: disabled,
176
+ }), onPatch: patch, onDelete: () => {
169
177
  setFlow(removeNode(flow, node.id));
170
178
  onSelectionChange?.(null);
171
179
  } }));
@@ -187,7 +195,7 @@ export function WorkflowInspector({ definition, registry, selection = null, onCh
187
195
  }
188
196
  return (_jsxs("section", { "aria-label": m.inspector, "data-slot": "workflow-inspector", className: cn("flex min-h-0 flex-col gap-3", className), children: [_jsx("h2", { className: "truncate text-sm font-medium", children: title }), _jsx("div", { className: "flex min-h-0 flex-col gap-4 overflow-y-auto pb-2", children: body })] }));
189
197
  }
190
- function NodeFields({ node, flow, definition, registry, disabled, onPatch, onDelete, }) {
198
+ function NodeFields({ node, flow, definition, registry, disabled, host, onPatch, onDelete, }) {
191
199
  const { messages } = useUI();
192
200
  const m = messages.editor;
193
201
  const presetOptions = registry.modules.flatMap((mod) => mod.presets.map((p) => ({ value: `${mod.id}/${p.id}`, label: `${mod.label} · ${p.label}` })));
@@ -327,7 +335,7 @@ function NodeFields({ node, flow, definition, registry, disabled, onPatch, onDel
327
335
  fields = (_jsxs(_Fragment, { children: [_jsx(Field, { label: m.question, children: _jsx(Textarea, { value: node.question, disabled: disabled, onChange: (e) => onPatch({ question: e.target.value }) }) }), _jsx(Field, { label: m.yesAt, children: _jsx(PercentInput, { value: node.threshold, disabled: disabled, onValue: (threshold) => onPatch({ threshold: threshold ?? 0.5 }) }) })] }));
328
336
  break;
329
337
  }
330
- return (_jsxs("div", { className: "flex flex-col gap-4", children: [common, fields, _jsx(Field, { label: m.note, children: _jsx(Textarea, { value: node.note ?? "", disabled: disabled, onChange: (e) => onPatch({ note: e.target.value || undefined }) }) }), disabled ? null : (_jsxs(Button, { type: "button", variant: "destructive", size: "sm", className: "self-start", onClick: onDelete, children: [_jsx(Trash2Icon, { "aria-hidden": true }), m.deleteNode(node.label)] }))] }));
338
+ return (_jsxs("div", { className: "flex flex-col gap-4", children: [common, fields, host == null || host === false ? null : (_jsx("div", { "data-slot": "workflow-inspector-host-fields", className: "flex flex-col gap-4", children: host })), _jsx(Field, { label: m.note, children: _jsx(Textarea, { value: node.note ?? "", disabled: disabled, onChange: (e) => onPatch({ note: e.target.value || undefined }) }) }), disabled ? null : (_jsxs(Button, { type: "button", variant: "destructive", size: "sm", className: "self-start", onClick: onDelete, children: [_jsx(Trash2Icon, { "aria-hidden": true }), m.deleteNode(node.label)] }))] }));
331
339
  }
332
340
  /** A preset's contract, read from the registry. */
333
341
  function PortSummary({ node, definition, registry, }) {
@@ -1,7 +1,8 @@
1
1
  import * as React from "react";
2
- import type { WorkflowDefinition, WorkflowRegistry } from "../definition.js";
2
+ import { type WorkflowDefinition, type WorkflowRegistry, type WorkflowSelection } from "../definition.js";
3
3
  import { type WorkflowImportSummary as ImportPreview } from "../portable.js";
4
4
  import { type WorkflowRevision } from "../workspace.js";
5
+ import { type WorkflowInspectorProps } from "./WorkflowInspector.js";
5
6
  /** A host action: may return a promise; a rejection's message is shown. */
6
7
  type HostAction<A extends unknown[]> = (...args: A) => Promise<unknown> | unknown;
7
8
  export interface WorkflowWorkspaceProps {
@@ -26,6 +27,28 @@ export interface WorkflowWorkspaceProps {
26
27
  importPreview?: ImportPreview | null;
27
28
  onImportConfirm?: HostAction<[]>;
28
29
  onImportCancel?: () => void;
30
+ /**
31
+ * What is selected, held by the host: a deep link to a node opens its flow
32
+ * with the node selected and in view. Uncontrolled when omitted.
33
+ */
34
+ selection?: WorkflowSelection | null;
35
+ /** Where the editor opens when the host does not hold the selection. */
36
+ defaultSelection?: WorkflowSelection | null;
37
+ onSelectionChange?: (selection: WorkflowSelection | null) => void;
38
+ /**
39
+ * Offers New flow. The workspace asks for a name (`WORKFLOW_FLOW_NAME`, not
40
+ * taken) and an optional label, and hands the host the definition with the
41
+ * flow added. The host applies it, with anything it keeps beside it.
42
+ */
43
+ onFlowCreate?: HostAction<[definition: WorkflowDefinition, name: string]>;
44
+ /**
45
+ * Offers Rename flow, for the open flow. The definition handed over has the
46
+ * flow renamed everywhere it names it (`renameFlow`): its key, `main` and
47
+ * the sub-flow nodes that run it. Anything else that names flows is the host's.
48
+ */
49
+ onFlowRename?: HostAction<[definition: WorkflowDefinition, from: string, to: string]>;
50
+ /** The host's own fields for the selected node in the inspector. */
51
+ renderNodeFields?: WorkflowInspectorProps["renderNodeFields"];
29
52
  readOnly?: boolean;
30
53
  loading?: boolean;
31
54
  error?: string | {
@@ -42,8 +65,10 @@ export interface WorkflowWorkspaceProps {
42
65
  * there are no edits, and when the saved revision moves under unsaved edits
43
66
  * it stops and asks (use theirs, keep mine, compare). Publishing shows the
44
67
  * draft against live first and is held while anything blocks. Save, publish,
45
- * save as, import and export are the host's; it never persists anything. On
46
- * narrow containers the palette and the side panel open as drawers.
68
+ * save as, import and export are the host's; it never persists anything. A
69
+ * host may hold the selection (deep links), let people add and rename flows,
70
+ * and add its own fields to the inspector. On narrow containers the palette
71
+ * and the side panel open as drawers.
47
72
  */
48
73
  export declare function WorkflowWorkspace(props: WorkflowWorkspaceProps): React.JSX.Element;
49
74
  export {};
@@ -1,6 +1,6 @@
1
1
  "use client";
2
2
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
- import { CopyPlusIcon, DownloadIcon, ListTreeIcon, PanelRightIcon, RocketIcon, SaveIcon, UndoIcon, UploadIcon, } from "lucide-react";
3
+ import { CopyPlusIcon, DownloadIcon, ListTreeIcon, PanelRightIcon, PencilIcon, PlusIcon, RocketIcon, SaveIcon, UndoIcon, UploadIcon, } from "lucide-react";
4
4
  import * as React from "react";
5
5
  import { cn } from "../../lib/cn.js";
6
6
  import { ConfirmDialog } from "../../overlays/ConfirmDialog.js";
@@ -16,6 +16,7 @@ import { ErrorState } from "../../states/ErrorState.js";
16
16
  import { LoadingState } from "../../states/LoadingState.js";
17
17
  import { StatusBadge } from "../../status/StatusBadge.js";
18
18
  import { WorkflowCanvas } from "../canvas/WorkflowCanvas.js";
19
+ import { WORKFLOW_MAX_FLOWS, addFlow, flowNameProblem, renameFlow, } from "../definition.js";
19
20
  import { insertPaletteItem } from "../palette.js";
20
21
  import { diffDefinitions, sameDefinition, } from "../portable.js";
21
22
  import { blockingProblems, checkDefinition } from "../problems.js";
@@ -26,6 +27,7 @@ import { WorkflowInspector } from "./WorkflowInspector.js";
26
27
  import { WorkflowJsonView } from "./WorkflowJsonView.js";
27
28
  import { WorkflowPalette } from "./WorkflowPalette.js";
28
29
  import { WorkflowProblems } from "./WorkflowProblems.js";
30
+ const sameSelection = (a, b) => a === b || (!!a && !!b && a.type === b.type && a.flow === b.flow && a.id === b.id);
29
31
  const messageOf = (e) => (e instanceof Error ? e.message : typeof e === "string" ? e : "");
30
32
  /**
31
33
  * The whole editor on one page: the palette, the canvas and a side panel
@@ -35,8 +37,10 @@ const messageOf = (e) => (e instanceof Error ? e.message : typeof e === "string"
35
37
  * there are no edits, and when the saved revision moves under unsaved edits
36
38
  * it stops and asks (use theirs, keep mine, compare). Publishing shows the
37
39
  * draft against live first and is held while anything blocks. Save, publish,
38
- * save as, import and export are the host's; it never persists anything. On
39
- * narrow containers the palette and the side panel open as drawers.
40
+ * save as, import and export are the host's; it never persists anything. A
41
+ * host may hold the selection (deep links), let people add and rename flows,
42
+ * and add its own fields to the inspector. On narrow containers the palette
43
+ * and the side panel open as drawers.
40
44
  */
41
45
  export function WorkflowWorkspace(props) {
42
46
  const { messages } = useUI();
@@ -46,14 +50,22 @@ export function WorkflowWorkspace(props) {
46
50
  return (_jsx(ErrorState, { variant: "page", title: messages.workspace.loadFailed, error: props.error ?? null, onRetry: props.onRetry, className: props.className }));
47
51
  return _jsx(Workspace, { ...props, definition: props.definition });
48
52
  }
49
- function Workspace({ definition, onChange, registry, saved = null, published = null, onSave, onPublish, onSaveAs, onExport, onImport, importPreview = null, onImportConfirm, onImportCancel, readOnly: readOnlyProp = false, className, }) {
53
+ function Workspace({ definition, onChange, registry, saved = null, published = null, onSave, onPublish, onSaveAs, onExport, onImport, importPreview = null, onImportConfirm, onImportCancel, selection: selectionProp, defaultSelection = null, onSelectionChange, onFlowCreate, onFlowRename, renderNodeFields, readOnly: readOnlyProp = false, className, }) {
50
54
  const { messages } = useUI();
51
55
  const m = messages.workspace;
52
56
  const readOnly = readOnlyProp || !onChange;
53
57
  const [base, setBase] = React.useState(saved ?? published);
54
- const [flowName, setFlowName] = React.useState(definition.main);
58
+ const [ownSelection, setOwnSelection] = React.useState(defaultSelection);
59
+ const selection = selectionProp === undefined ? ownSelection : selectionProp;
60
+ const [flowName, setFlowName] = React.useState(selection?.flow ?? definition.main);
55
61
  const flow = definition.flows[flowName] ? flowName : definition.main;
56
- const [selection, setSelection] = React.useState(null);
62
+ // A selection in another flow (the host's, or a problem's) opens that flow.
63
+ const [shown, setShown] = React.useState(selection);
64
+ if (!sameSelection(shown, selection)) {
65
+ setShown(selection);
66
+ if (selection && selection.flow !== flowName)
67
+ setFlowName(selection.flow);
68
+ }
57
69
  const [panel, setPanel] = React.useState("inspector");
58
70
  const [drawer, setDrawer] = React.useState(null);
59
71
  const [dialog, setDialog] = React.useState(null);
@@ -103,9 +115,15 @@ function Workspace({ definition, onChange, registry, saved = null, published = n
103
115
  }
104
116
  };
105
117
  const select = (s) => {
106
- setSelection(s);
107
- if (s && s.flow !== flow)
108
- setFlowName(s.flow);
118
+ if (sameSelection(s, selection))
119
+ return;
120
+ if (selectionProp === undefined)
121
+ setOwnSelection(s);
122
+ onSelectionChange?.(s);
123
+ };
124
+ const openFlow = (next) => {
125
+ setFlowName(next);
126
+ select(null);
109
127
  };
110
128
  const add = (item) => {
111
129
  const label = item.label ||
@@ -120,7 +138,7 @@ function Workspace({ definition, onChange, registry, saved = null, published = n
120
138
  const flows = Object.entries(definition.flows);
121
139
  const edit = readOnly ? undefined : onChange;
122
140
  const palette = (_jsx(WorkflowPalette, { registry: registry, onAdd: readOnly ? undefined : add, className: "min-h-0 flex-1" }));
123
- const side = (_jsxs(Tabs, { value: panel, onValueChange: (v) => setPanel(v), className: "flex min-h-0 flex-1 flex-col", children: [_jsxs(TabsList, { "aria-label": m.panels, children: [_jsx(TabsTrigger, { value: "inspector", children: messages.editor.inspector }), _jsx(TabsTrigger, { value: "problems", children: m.problemsTab(blocking + warnings) }), _jsx(TabsTrigger, { value: "json", children: m.jsonTab })] }), _jsx(TabsContent, { value: "inspector", className: "min-h-0 flex-1 overflow-y-auto", children: _jsx(WorkflowInspector, { definition: definition, registry: registry, selection: selection, onSelectionChange: select, onChange: edit, readOnly: readOnly }) }), _jsx(TabsContent, { value: "problems", className: "min-h-0 flex-1 overflow-y-auto", children: _jsx(WorkflowProblems, { problems: problems, definition: definition, onSelect: (s) => {
141
+ const side = (_jsxs(Tabs, { value: panel, onValueChange: (v) => setPanel(v), className: "flex min-h-0 flex-1 flex-col", children: [_jsxs(TabsList, { "aria-label": m.panels, children: [_jsx(TabsTrigger, { value: "inspector", children: messages.editor.inspector }), _jsx(TabsTrigger, { value: "problems", children: m.problemsTab(blocking + warnings) }), _jsx(TabsTrigger, { value: "json", children: m.jsonTab })] }), _jsx(TabsContent, { value: "inspector", className: "min-h-0 flex-1 overflow-y-auto", children: _jsx(WorkflowInspector, { definition: definition, registry: registry, selection: selection, onSelectionChange: select, onChange: edit, readOnly: readOnly, renderNodeFields: renderNodeFields }) }), _jsx(TabsContent, { value: "problems", className: "min-h-0 flex-1 overflow-y-auto", children: _jsx(WorkflowProblems, { problems: problems, definition: definition, onSelect: (s) => {
124
142
  select(s);
125
143
  setPanel("inspector");
126
144
  setDrawer(null);
@@ -131,10 +149,13 @@ function Workspace({ definition, onChange, registry, saved = null, published = n
131
149
  }, children: [_jsx(CopyPlusIcon, { "aria-hidden": true }), m.saveAs] })) : null, onSave && !readOnly ? (_jsxs(Button, { size: "sm", variant: "outline", disabled: !status.dirty || status.conflict || busy !== null, onClick: () => void run("save", () => onSave(definition, base), m.savedNotice), children: [_jsx(SaveIcon, { "aria-hidden": true }), busy === "save" ? m.saving : m.save] })) : null, onPublish && !readOnly ? (_jsxs(Button, { size: "sm", disabled: status.conflict || busy !== null || (!status.unpublished && !!published), onClick: () => setDialog("publish"), children: [_jsx(RocketIcon, { "aria-hidden": true }), m.publish] })) : null] })] }), failure ? (_jsx("p", { role: "alert", className: "rounded-lg border border-danger-border bg-danger-bg px-3 py-2 text-sm text-danger-fg", children: failure })) : null, notice ? (_jsx("p", { className: "sr-only", role: "status", children: notice })) : null, status.conflict && saved ? (_jsxs("div", { role: "alert", className: "flex flex-wrap items-center gap-2 rounded-lg border border-warning-border bg-warning-bg px-3 py-2 text-sm text-warning-fg", children: [_jsx("span", { className: "min-w-0 flex-1", children: m.conflictDescription(saved.by, saved.label) }), _jsx(Button, { size: "sm", variant: "outline", onClick: () => setDialog("compare"), children: m.compare }), _jsx(Button, { size: "sm", variant: "outline", onClick: () => {
132
150
  setBase(saved);
133
151
  onChange?.(saved.definition);
134
- }, children: m.useTheirs }), _jsx(Button, { size: "sm", variant: "outline", onClick: () => setBase(saved), children: m.keepMine })] })) : null, _jsxs("div", { className: "flex min-h-0 flex-1 gap-3", children: [_jsx("aside", { "aria-label": m.paletteRegion, className: "hidden w-60 shrink-0 flex-col @5xl:flex", children: palette }), _jsxs("div", { className: "flex min-h-0 min-w-0 flex-1 flex-col gap-2", children: [_jsxs("div", { className: "flex flex-wrap items-center gap-2", children: [!readOnly ? (_jsxs(Button, { size: "sm", variant: "outline", className: "@5xl:hidden", onClick: () => setDrawer("palette"), children: [_jsx(ListTreeIcon, { "aria-hidden": true }), messages.editor.palette] })) : null, _jsxs(Button, { size: "sm", variant: "outline", className: "@5xl:hidden", onClick: () => setDrawer("panel"), children: [_jsx(PanelRightIcon, { "aria-hidden": true }), m.panels] }), flows.length > 1 ? (_jsx(Segmented, { label: m.flow, size: "sm", value: flow, onValueChange: (v) => {
135
- setFlowName(v);
136
- setSelection(null);
137
- }, options: flows.map(([key, f]) => ({ value: key, label: f.label ?? key })) })) : null] }), _jsx(WorkflowCanvas, { definition: definition, flow: flow, registry: registry, onChange: edit, readOnly: readOnly, selection: selection, onSelectionChange: select, problems: problems, className: "min-h-96 flex-1" }, flow)] }), _jsx("aside", { "aria-label": m.panels, className: "hidden w-88 shrink-0 flex-col @5xl:flex", children: side })] }), _jsx(Drawer, { open: drawer !== null, onOpenChange: (open) => !open && setDrawer(null), children: _jsxs(DrawerContent, { side: drawer === "palette" ? "left" : "right", className: "p-4", children: [_jsx(DrawerTitle, { children: drawer === "palette" ? messages.editor.palette : m.panels }), _jsx("div", { className: "flex min-h-0 flex-1 flex-col", children: drawer === "palette" ? palette : side })] }) }), _jsx(Dialog, { open: dialog === "publish", onOpenChange: (open) => !open && setDialog(null), children: _jsxs(DialogContent, { className: "max-w-xl", children: [_jsxs(DialogHeader, { children: [_jsx(DialogTitle, { children: m.publishTitle }), _jsx(DialogDescription, { children: published ? m.publishDescription(published.label) : m.firstPublish })] }), published ? (_jsx(WorkflowDiff, { diff: diffDefinitions(published.definition, definition), flows: definition.flows, label: m.draftAgainstLive, className: "max-h-80 overflow-y-auto" })) : null, blocking ? (_jsx("p", { role: "alert", className: "text-sm text-danger-fg", children: m.publishBlocked(blocking) })) : null, _jsxs(DialogFooter, { children: [_jsx(Button, { type: "button", variant: "outline", onClick: () => setDialog(null), children: messages.editor.cancel }), _jsx(Button, { type: "button", disabled: blocking > 0 || busy !== null, onClick: async () => {
152
+ }, children: m.useTheirs }), _jsx(Button, { size: "sm", variant: "outline", onClick: () => setBase(saved), children: m.keepMine })] })) : null, _jsxs("div", { className: "flex min-h-0 flex-1 gap-3", children: [_jsx("aside", { "aria-label": m.paletteRegion, className: "hidden w-60 shrink-0 flex-col @5xl:flex", children: palette }), _jsxs("div", { className: "flex min-h-0 min-w-0 flex-1 flex-col gap-2", children: [_jsxs("div", { className: "flex flex-wrap items-center gap-2", children: [!readOnly ? (_jsxs(Button, { size: "sm", variant: "outline", className: "@5xl:hidden", onClick: () => setDrawer("palette"), children: [_jsx(ListTreeIcon, { "aria-hidden": true }), messages.editor.palette] })) : null, _jsxs(Button, { size: "sm", variant: "outline", className: "@5xl:hidden", onClick: () => setDrawer("panel"), children: [_jsx(PanelRightIcon, { "aria-hidden": true }), m.panels] }), flows.length > 1 ? (_jsx(Segmented, { label: m.flow, size: "sm", value: flow, onValueChange: openFlow, options: flows.map(([key, f]) => ({ value: key, label: f.label ?? key })) })) : null, onFlowRename && !readOnly ? (_jsxs(Button, { size: "sm", variant: "ghost", disabled: busy !== null, onClick: () => {
153
+ setFailure(null);
154
+ setDialog("rename-flow");
155
+ }, children: [_jsx(PencilIcon, { "aria-hidden": true }), m.renameFlow] })) : null, onFlowCreate && !readOnly ? (_jsxs(Button, { size: "sm", variant: "ghost", disabled: busy !== null, onClick: () => {
156
+ setFailure(null);
157
+ setDialog("new-flow");
158
+ }, children: [_jsx(PlusIcon, { "aria-hidden": true }), m.newFlow] })) : null] }), _jsx(WorkflowCanvas, { definition: definition, flow: flow, registry: registry, onChange: edit, readOnly: readOnly, selection: selection, onSelectionChange: select, problems: problems, className: "min-h-96 flex-1" }, flow)] }), _jsx("aside", { "aria-label": m.panels, className: "hidden w-88 shrink-0 flex-col @5xl:flex", children: side })] }), _jsx(Drawer, { open: drawer !== null, onOpenChange: (open) => !open && setDrawer(null), children: _jsxs(DrawerContent, { side: drawer === "palette" ? "left" : "right", className: "p-4", children: [_jsx(DrawerTitle, { children: drawer === "palette" ? messages.editor.palette : m.panels }), _jsx("div", { className: "flex min-h-0 flex-1 flex-col", children: drawer === "palette" ? palette : side })] }) }), _jsx(Dialog, { open: dialog === "publish", onOpenChange: (open) => !open && setDialog(null), children: _jsxs(DialogContent, { className: "max-w-xl", children: [_jsxs(DialogHeader, { children: [_jsx(DialogTitle, { children: m.publishTitle }), _jsx(DialogDescription, { children: published ? m.publishDescription(published.label) : m.firstPublish })] }), published ? (_jsx(WorkflowDiff, { diff: diffDefinitions(published.definition, definition), flows: definition.flows, label: m.draftAgainstLive, className: "max-h-80 overflow-y-auto" })) : null, blocking ? (_jsx("p", { role: "alert", className: "text-sm text-danger-fg", children: m.publishBlocked(blocking) })) : null, _jsxs(DialogFooter, { children: [_jsx(Button, { type: "button", variant: "outline", onClick: () => setDialog(null), children: messages.editor.cancel }), _jsx(Button, { type: "button", disabled: blocking > 0 || busy !== null, onClick: async () => {
138
159
  if (await run("publish", () => onPublish?.(definition), m.publishedNotice))
139
160
  setDialog(null);
140
161
  }, children: busy === "publish" ? m.publishing : m.publish })] })] }) }), _jsx(Dialog, { open: dialog === "save-as", onOpenChange: (open) => !open && setDialog(null), children: _jsxs(DialogContent, { children: [_jsxs(DialogHeader, { children: [_jsx(DialogTitle, { children: m.saveAsTitle }), _jsx(DialogDescription, { children: m.saveAsDescription })] }), _jsxs("form", { className: "flex flex-col gap-4", onSubmit: async (e) => {
@@ -147,8 +168,46 @@ function Workspace({ definition, onChange, registry, saved = null, published = n
147
168
  }, children: [_jsx(Field, { label: messages.editor.name, required: true, children: _jsx(Input, { value: name, onChange: (e) => setName(e.target.value) }) }), _jsxs(DialogFooter, { children: [_jsx(Button, { type: "button", variant: "outline", onClick: () => setDialog(null), children: messages.editor.cancel }), _jsx(Button, { type: "submit", disabled: !name.trim() || busy !== null, children: busy === "save-as" ? m.saving : m.saveAs })] })] })] }) }), _jsx(Dialog, { open: dialog === "compare", onOpenChange: (open) => !open && setDialog(null), children: _jsxs(DialogContent, { className: "max-w-xl", children: [_jsxs(DialogHeader, { children: [_jsx(DialogTitle, { children: m.compareTitle }), _jsx(DialogDescription, { children: m.compareDescription })] }), saved ? (_jsx(WorkflowDiff, { diff: diffDefinitions(saved.definition, definition), flows: definition.flows, label: m.compareTitle, className: "max-h-80 overflow-y-auto" })) : null] }) }), _jsx(ConfirmDialog, { open: dialog === "discard", onOpenChange: (open) => !open && setDialog(null), title: m.discardTitle, description: m.discardDescription, confirmLabel: m.discard, destructive: true, onConfirm: () => {
148
169
  if (base)
149
170
  onChange?.(base.definition);
150
- setSelection(null);
151
- } }), _jsx(Dialog, { open: !!importPreview, onOpenChange: (open) => !open && onImportCancel?.(), children: _jsxs(DialogContent, { className: "max-w-xl", children: [_jsxs(DialogHeader, { children: [_jsx(DialogTitle, { children: m.importTitle }), _jsx(DialogDescription, { children: m.importDescription })] }), importPreview ? (_jsx(WorkflowImportPreview, { preview: importPreview, busy: busy === "import", onCancel: onImportCancel, onConfirm: onImportConfirm
171
+ select(null);
172
+ } }), _jsx(Dialog, { open: dialog === "new-flow" || dialog === "rename-flow", onOpenChange: (open) => !open && setDialog(null), children: _jsxs(DialogContent, { children: [_jsxs(DialogHeader, { children: [_jsx(DialogTitle, { children: dialog === "rename-flow"
173
+ ? m.renameFlowTitle(definition.flows[flow]?.label ?? flow)
174
+ : m.newFlowTitle }), _jsx(DialogDescription, { children: dialog === "rename-flow" ? m.renameFlowDescription : m.newFlowDescription })] }), dialog === "new-flow" || dialog === "rename-flow" ? (_jsx(FlowForm, { definition: definition, current: dialog === "rename-flow" ? flow : undefined, busy: busy !== null, failure: failure, onCancel: () => setDialog(null), onSubmit: async (name, label) => {
175
+ const renaming = dialog === "rename-flow";
176
+ const done = renaming
177
+ ? await run("flow", () => onFlowRename?.(renameFlow(definition, flow, name, label), flow, name), m.flowRenamedNotice(name))
178
+ : await run("flow", () => onFlowCreate?.(addFlow(definition, name, label), name), m.flowCreatedNotice(name));
179
+ if (!done)
180
+ return;
181
+ setDialog(null);
182
+ if (renaming && selection?.flow === flow)
183
+ select({ ...selection, flow: name });
184
+ else if (!renaming)
185
+ select(null);
186
+ setFlowName(name);
187
+ } })) : null] }) }), _jsx(Dialog, { open: !!importPreview, onOpenChange: (open) => !open && onImportCancel?.(), children: _jsxs(DialogContent, { className: "max-w-xl", children: [_jsxs(DialogHeader, { children: [_jsx(DialogTitle, { children: m.importTitle }), _jsx(DialogDescription, { children: m.importDescription })] }), importPreview ? (_jsx(WorkflowImportPreview, { preview: importPreview, busy: busy === "import", onCancel: onImportCancel, onConfirm: onImportConfirm
152
188
  ? () => void run("import", () => onImportConfirm(), m.importedNotice)
153
189
  : undefined, className: "max-h-[70vh] overflow-y-auto" })) : null] }) })] }));
154
190
  }
191
+ /**
192
+ * A flow's name and label, checked as they are typed: a new flow, or the
193
+ * open one (`current`) being renamed. Holds its own state so typing does not
194
+ * redraw the canvas.
195
+ */
196
+ function FlowForm({ definition, current, busy, failure, onCancel, onSubmit, }) {
197
+ const { messages } = useUI();
198
+ const m = messages.workspace;
199
+ const currentLabel = current ? (definition.flows[current]?.label ?? "") : "";
200
+ const [name, setName] = React.useState(current ?? "");
201
+ const [label, setLabel] = React.useState(currentLabel);
202
+ const [touched, setTouched] = React.useState(false);
203
+ const trimmed = name.trim();
204
+ const problem = flowNameProblem(definition, trimmed, current);
205
+ const full = !current && Object.keys(definition.flows).length >= WORKFLOW_MAX_FLOWS;
206
+ const unchanged = !!current && trimmed === current && label.trim() === currentLabel;
207
+ return (_jsxs("form", { className: "flex flex-col gap-4", noValidate: true, onSubmit: (e) => {
208
+ e.preventDefault();
209
+ setTouched(true);
210
+ if (!problem && !full && !unchanged)
211
+ onSubmit(trimmed, label.trim());
212
+ }, children: [_jsx(Field, { label: m.flowName, hint: m.flowNameHint, error: touched && problem ? m.flowNameProblem(problem) : undefined, required: true, children: _jsx(Input, { value: name, autoComplete: "off", spellCheck: false, onChange: (e) => setName(e.target.value), onBlur: () => setTouched(true) }) }), _jsx(Field, { label: m.flowLabel, hint: m.flowLabelHint, children: _jsx(Input, { value: label, onChange: (e) => setLabel(e.target.value) }) }), full ? (_jsx("p", { role: "alert", className: "text-sm text-danger-fg", children: m.flowsFull(WORKFLOW_MAX_FLOWS) })) : null, failure ? (_jsx("p", { role: "alert", className: "text-sm text-danger-fg", children: failure })) : null, _jsxs(DialogFooter, { children: [_jsx(Button, { type: "button", variant: "outline", onClick: onCancel, children: messages.editor.cancel }), _jsx(Button, { type: "submit", disabled: full || unchanged || busy, children: busy ? m.saving : current ? m.rename : m.createFlow })] })] }));
213
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@calliopelabs/ui",
3
- "version": "0.12.0",
3
+ "version": "0.13.0",
4
4
  "description": "Calliope AI shared operator UI: app shell, navigation, page frame, data table and list patterns, status colours, design tokens and appearance for Astrolift AI and Zentinelle AI.",
5
5
  "keywords": [
6
6
  "calliope-ai",