@calliopelabs/ui 0.11.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
@@ -284,6 +284,19 @@ The package fetches nothing. `DataTable` reads a `TableController`: rows, state,
284
284
 
285
285
  ## Workflows
286
286
 
287
+ `WorkflowQuestionCard` shows a host-owned runtime question, including several
288
+ fields, optional choices and Unicode answers. Bind `question.id`, its opaque
289
+ `revision`, `answers` and delivery `state` to the same request. `onSubmit` reports
290
+ answer intent only; the host must authorize delivery and set `submitting` before
291
+ starting its request. Use `accepted` when the service accepted the answer and
292
+ `acknowledged` only with correlated worker evidence. For uncertain delivery,
293
+ `onRefresh` reads the original request without submitting it again. A changed
294
+ question identity or revision requires a fresh host-owned answer draft.
295
+
296
+ The card handles loading, empty, read-only, expired and unavailable states. Its
297
+ copy is overridable through the `workflowQuestion` message group. It never starts
298
+ work, grants permission, fetches data or infers acknowledgment from a click.
299
+
287
300
  The workflow surfaces are module-neutral: software, content, media, legal, document review, process, paperwork and automations are labels and versions the host supplies, and nothing in the model assumes tickets, repositories or code. They are host-driven: data in, callbacks out. The package never fetches, saves, authorizes or runs anything.
288
301
 
289
302
  - **Gallery.** `WorkflowGallery` is a full page of `WorkflowCard`s with search, a module filter, scope and status filters, grid and list views, and create and import when the host passes their handlers. Its filters are controlled (`filters`, `onFiltersChange`): a host that searches on its server passes them to its query; one that holds every definition uses `filterWorkflows(workflows, filters)`. Empty and no-matches are different states.
@@ -360,6 +373,24 @@ const registry: WorkflowRegistry = {
360
373
 
361
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.
362
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
+
363
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.
364
395
 
365
396
  `@xyflow/react` comes with the package; its structural CSS is in the preset.
package/dist/index.d.ts CHANGED
@@ -64,6 +64,8 @@ export { Tabs, TabsContent, TabsList, TabsTrigger } from "./primitives/Tabs.js";
64
64
  export { Skeleton } from "./primitives/Skeleton.js";
65
65
  export type { WorkflowAction, WorkflowAvailability, WorkflowModule, WorkflowPermissions, WorkflowScope, WorkflowStatus, WorkflowSummary, } from "./workflow/model.js";
66
66
  export { WorkflowCard, WorkflowCardSkeleton, type WorkflowCardActions, type WorkflowCardProps, } from "./workflow/WorkflowCard.js";
67
+ export { WorkflowQuestionCard, type WorkflowQuestionCardProps, } from "./workflow/WorkflowQuestionCard.js";
68
+ export type { WorkflowQuestion, WorkflowQuestionField, WorkflowQuestionState, WorkflowQuestionAnswers, WorkflowQuestionSubmission, } from "./workflow/question.js";
67
69
  export { ANY_FILTER, DEFAULT_WORKFLOW_FILTERS, filterWorkflows, isFiltered, type WorkflowGalleryFilters, } from "./workflow/gallery.js";
68
70
  export { WorkflowGallery, type WorkflowGalleryProps, type WorkflowGalleryView, } from "./workflow/WorkflowGallery.js";
69
71
  export { ARTIFACT_TONE, GATE_TONE, RUN_STAGE_TONE, VERDICT_TONE, type Artifact, type ArtifactStatus, type Gate, type GateStatus, type ReviewComment, type ReviewDecision, type ReviewVerdict, type RunStage, type RunStageStatus, type RunValue, } from "./workflow/run.js";
@@ -74,7 +76,7 @@ export { ReviewPanel, type ReviewPanelProps } from "./workflow/ReviewPanel.js";
74
76
  export { MediaReview, formatTimecode, type MediaAnchor, type MediaCaptionTrack, type MediaComment, type MediaRegion, type MediaReviewProps, } from "./workflow/MediaReview.js";
75
77
  export { DocumentReview, type DocumentAnnotation, type DocumentBlock, type DocumentPage, type DocumentReviewProps, type DocumentSegment, type DocumentSignOff, type SignOffStatus, } from "./workflow/DocumentReview.js";
76
78
  export { FormReview, type FormField, type FormFieldBox, type FormFieldStatus, type FormReviewProps, type FormSourcePage, } from "./workflow/FormReview.js";
77
- 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";
78
80
  export { WORKFLOW_PROBLEM_CODES, blockingProblems, checkDefinition, definitionAvailability, nodeAvailability, type WorkflowProblem, type WorkflowProblemCode, type WorkflowProblemLevel, } from "./workflow/problems.js";
79
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";
80
82
  export { NODE_HEADER, NODE_WIDTH, PORT_ROW, arrangeFlow, layoutFlow, nodeSize, portRows, ranksOf, regionsOf, type WorkflowRegion, } from "./workflow/layout.js";
@@ -82,7 +84,7 @@ export { insertPaletteItem, paletteOf, type WorkflowPaletteGroup, type WorkflowP
82
84
  export { READABLE_ZOOM, WorkflowCanvas, type WorkflowCanvasProps, } from "./workflow/canvas/WorkflowCanvas.js";
83
85
  export { WorkflowPalette, type WorkflowPaletteProps } from "./workflow/editor/WorkflowPalette.js";
84
86
  export { WorkflowProblems, problemTarget, type WorkflowProblemsProps, } from "./workflow/editor/WorkflowProblems.js";
85
- 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";
86
88
  export { WorkflowJsonView, type WorkflowJsonViewProps, } from "./workflow/editor/WorkflowJsonView.js";
87
89
  export { documentStatus, type WorkflowDocumentStatus, type WorkflowRevision, } from "./workflow/workspace.js";
88
90
  export { WorkflowDiff, type WorkflowDiffProps } from "./workflow/editor/WorkflowDiff.js";
package/dist/index.js CHANGED
@@ -74,6 +74,7 @@ export { Popover, PopoverAnchor, PopoverContent, PopoverHeader, PopoverTrigger,
74
74
  export { Tabs, TabsContent, TabsList, TabsTrigger } from "./primitives/Tabs.js";
75
75
  export { Skeleton } from "./primitives/Skeleton.js";
76
76
  export { WorkflowCard, WorkflowCardSkeleton, } from "./workflow/WorkflowCard.js";
77
+ export { WorkflowQuestionCard, } from "./workflow/WorkflowQuestionCard.js";
77
78
  export { ANY_FILTER, DEFAULT_WORKFLOW_FILTERS, filterWorkflows, isFiltered, } from "./workflow/gallery.js";
78
79
  export { WorkflowGallery, } from "./workflow/WorkflowGallery.js";
79
80
  export { ARTIFACT_TONE, GATE_TONE, RUN_STAGE_TONE, VERDICT_TONE, } from "./workflow/run.js";
@@ -84,7 +85,7 @@ export { ReviewPanel } from "./workflow/ReviewPanel.js";
84
85
  export { MediaReview, formatTimecode, } from "./workflow/MediaReview.js";
85
86
  export { DocumentReview, } from "./workflow/DocumentReview.js";
86
87
  export { FormReview, } from "./workflow/FormReview.js";
87
- 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";
88
89
  export { WORKFLOW_PROBLEM_CODES, blockingProblems, checkDefinition, definitionAvailability, nodeAvailability, } from "./workflow/problems.js";
89
90
  export { WORKFLOW_PACKAGE_FORMAT, WORKFLOW_PACKAGE_VERSION, diffDefinitions, exportWorkflowPackage, previewWorkflowImport, readWorkflowDefinition, readWorkflowPackage, requirementsOf, sameDefinition, } from "./workflow/portable.js";
90
91
  export { NODE_HEADER, NODE_WIDTH, PORT_ROW, arrangeFlow, layoutFlow, nodeSize, portRows, ranksOf, regionsOf, } from "./workflow/layout.js";
@@ -1,7 +1,8 @@
1
1
  import type { ProductTheme } from "../appearance/product.js";
2
+ import type { WorkflowQuestionState } from "../workflow/question.js";
2
3
  import type { WorkflowAction, WorkflowAvailability, WorkflowScope, WorkflowStatus } from "../workflow/model.js";
3
4
  import type { ArtifactStatus, GateStatus, ReviewDecision, ReviewVerdict, RunStageStatus } from "../workflow/run.js";
4
- import type { WorkflowConnectReason, WorkflowNode, WorkflowNodeKind, WorkflowSelector } from "../workflow/definition.js";
5
+ import type { WorkflowConnectReason, WorkflowFlowNameProblem, WorkflowNode, WorkflowNodeKind, WorkflowSelector } from "../workflow/definition.js";
5
6
  /** The names a node's summary line uses, looked up by the caller. */
6
7
  export interface WorkflowSummaryNames {
7
8
  /** A step's or variants' preset, and its module. */
@@ -21,6 +22,23 @@ import type { WorkflowReadError } from "../workflow/portable.js";
21
22
  * it (plurals, numbers, word order).
22
23
  */
23
24
  export interface Messages {
25
+ workflowQuestion: {
26
+ title: string;
27
+ worker: (worker: string) => string;
28
+ state: (state: WorkflowQuestionState) => string;
29
+ description: (state: WorkflowQuestionState) => string;
30
+ loading: string;
31
+ empty: string;
32
+ answer: string;
33
+ answers: string;
34
+ otherAnswers: string;
35
+ multipleHint: string;
36
+ invalidAnswer: string;
37
+ readOnly: string;
38
+ send: string;
39
+ sending: string;
40
+ refresh: string;
41
+ };
24
42
  shell: {
25
43
  skipToContent: string;
26
44
  /** The mobile button and drawer title for the sidebar. */
@@ -376,6 +394,22 @@ export interface Messages {
376
394
  importing: string;
377
395
  importedNotice: string;
378
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;
379
413
  modules: string;
380
414
  noModules: string;
381
415
  moduleStatus: (status: "available" | "missing" | "version" | "unavailable", installed?: string) => string;
@@ -144,6 +144,39 @@ function problemText({ code, values: v }) {
144
144
  }
145
145
  }
146
146
  export const defaultMessages = {
147
+ workflowQuestion: {
148
+ title: "Question for this run",
149
+ worker: (worker) => `Worker: ${worker}`,
150
+ state: (state) => ({
151
+ waiting: "Waiting for an answer",
152
+ submitting: "Sending answer",
153
+ accepted: "Answer sent",
154
+ acknowledged: "Worker acknowledged",
155
+ expired: "Answer window closed",
156
+ "delivery-unknown": "Delivery unconfirmed",
157
+ unavailable: "Question unavailable",
158
+ })[state],
159
+ description: (state) => ({
160
+ waiting: "Answer the questions below to continue this task.",
161
+ submitting: "Sending your answer to this task.",
162
+ accepted: "Waiting for the worker to acknowledge your answer.",
163
+ acknowledged: "The worker acknowledged this answer.",
164
+ expired: "This question can no longer accept an answer.",
165
+ "delivery-unknown": "Check delivery before sending another answer.",
166
+ unavailable: "The current question could not be read. Check again to continue.",
167
+ })[state],
168
+ loading: "Loading question…",
169
+ empty: "Nothing needs an answer right now.",
170
+ answer: "Answer",
171
+ answers: "Answers",
172
+ otherAnswers: "Additional answers",
173
+ multipleHint: "Choose options or enter additional answers on separate lines. Up to eight answers.",
174
+ invalidAnswer: "Answer each question with up to 4,000 characters per answer. Multiple answers must be distinct, with no more than eight.",
175
+ readOnly: "An operator can provide these answers.",
176
+ send: "Send answer",
177
+ sending: "Sending answer…",
178
+ refresh: "Check delivery",
179
+ },
147
180
  shell: {
148
181
  skipToContent: "Skip to content",
149
182
  navigation: "Navigation",
@@ -567,6 +600,26 @@ export const defaultMessages = {
567
600
  importAction: "Import",
568
601
  importing: "Importing",
569
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}.`,
570
623
  importAvailability: (status) => ({
571
624
  available: "Runs here",
572
625
  "missing-capabilities": "Missing capabilities here",
@@ -0,0 +1,18 @@
1
+ import * as React from "react";
2
+ import type { WorkflowQuestion, WorkflowQuestionAnswers, WorkflowQuestionState, WorkflowQuestionSubmission } from "./question.js";
3
+ export interface WorkflowQuestionCardProps {
4
+ question: WorkflowQuestion | null;
5
+ state: WorkflowQuestionState;
6
+ answers?: WorkflowQuestionAnswers;
7
+ onAnswersChange?: (answers: WorkflowQuestionAnswers) => void;
8
+ /** Synchronous intent notification. Set the controlled state before starting delivery. */
9
+ onSubmit?: (submission: WorkflowQuestionSubmission) => void;
10
+ /** Read current delivery state. This action never submits an answer. */
11
+ onRefresh?: () => void;
12
+ canAnswer?: boolean;
13
+ reason?: string;
14
+ loading?: boolean;
15
+ className?: string;
16
+ }
17
+ /** Controlled projection: a submit callback is never evidence of runtime acknowledgment. */
18
+ export declare function WorkflowQuestionCard(props: WorkflowQuestionCardProps): React.JSX.Element;
@@ -0,0 +1,78 @@
1
+ "use client";
2
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
+ import * as React from "react";
4
+ import { cn } from "../lib/cn.js";
5
+ import { Button } from "../primitives/Button.js";
6
+ import { Checkbox } from "../primitives/Checkbox.js";
7
+ import { Field } from "../primitives/Field.js";
8
+ import { Textarea } from "../primitives/Textarea.js";
9
+ import { Skeleton } from "../primitives/Skeleton.js";
10
+ import { useUI } from "../provider/UIProvider.js";
11
+ import { StatusBadge } from "../status/StatusBadge.js";
12
+ const TONE = {
13
+ waiting: "warn",
14
+ submitting: "info",
15
+ accepted: "info",
16
+ acknowledged: "ok",
17
+ expired: "muted",
18
+ "delivery-unknown": "warn",
19
+ unavailable: "error",
20
+ };
21
+ function textValid(value) {
22
+ return value.trim().length > 0 && Array.from(value).length <= 4000 && !value.includes("\0");
23
+ }
24
+ function answerValid(field, value) {
25
+ if (field.multiple)
26
+ return (value !== undefined &&
27
+ typeof value !== "string" &&
28
+ value.length > 0 &&
29
+ value.length <= 8 &&
30
+ new Set(value).size === value.length &&
31
+ value.every(textValid));
32
+ return typeof value === "string" && textValid(value);
33
+ }
34
+ /** Controlled projection: a submit callback is never evidence of runtime acknowledgment. */
35
+ export function WorkflowQuestionCard(props) {
36
+ return (_jsx(QuestionBody, { ...props }, JSON.stringify([props.question?.id, props.question?.revision])));
37
+ }
38
+ function QuestionBody({ question, state, answers = {}, onAnswersChange, onSubmit, onRefresh, canAnswer = true, reason, loading = false, className, }) {
39
+ const t = useUI().messages.workflowQuestion;
40
+ const id = React.useId();
41
+ const [attempted, setAttempted] = React.useState(false);
42
+ const editable = !loading && state === "waiting" && canAnswer && !!onSubmit && !!onAnswersChange;
43
+ const set = (field, value) => {
44
+ if (editable)
45
+ onAnswersChange?.({ ...answers, [field]: value });
46
+ };
47
+ const submit = (event) => {
48
+ event.preventDefault();
49
+ if (!editable || !question?.fields.length)
50
+ return;
51
+ setAttempted(true);
52
+ if (question.fields.some((field) => !answerValid(field, answers[field.id])))
53
+ return;
54
+ const selected = Object.fromEntries(question.fields.map((field) => {
55
+ const value = answers[field.id];
56
+ return [field.id, typeof value === "string" ? value : [...value]];
57
+ }));
58
+ onSubmit?.({ questionId: question.id, revision: question.revision, answers: selected });
59
+ };
60
+ return (_jsxs("section", { "data-slot": "workflow-question-card", "aria-labelledby": `${id}-title`, "aria-busy": loading || state === "submitting", className: cn("space-y-4 rounded-lg border bg-card p-4 text-card-foreground", className), children: [_jsxs("div", { className: "flex flex-wrap items-start justify-between gap-3", children: [_jsxs("div", { className: "min-w-0 space-y-1", children: [_jsx("h3", { id: `${id}-title`, className: "font-medium break-words", children: question?.title ?? t.title }), question?.worker && (_jsx("p", { className: "text-sm text-muted-foreground", children: t.worker(question.worker) }))] }), !loading && (_jsx(StatusBadge, { tone: TONE[state], pulse: state === "submitting" || state === "accepted", children: t.state(state) }))] }), _jsx("p", { role: "status", className: "text-sm text-muted-foreground", children: loading ? t.loading : t.description(state) }), reason && _jsx("p", { className: "text-sm text-muted-foreground", children: reason }), loading ? (_jsxs("div", { className: "space-y-3", children: [_jsx(Skeleton, { className: "h-5 w-1/2" }), _jsx(Skeleton, { className: "h-20 w-full" })] })) : !question?.fields.length ? (state === "waiting" ? (_jsx("p", { className: "text-sm text-muted-foreground", children: t.empty })) : null) : (_jsxs("form", { onSubmit: submit, noValidate: true, className: "space-y-5", children: [question.fields.map((field, index) => {
61
+ const value = answers[field.id];
62
+ const selected = value !== undefined && typeof value !== "string" ? value : [];
63
+ const labels = new Set(field.options?.map((option) => option.label));
64
+ const custom = selected.filter((answer) => !labels.has(answer));
65
+ const error = attempted && !answerValid(field, value) ? t.invalidAnswer : undefined;
66
+ return (_jsxs("fieldset", { disabled: !editable, className: "min-w-0 space-y-3", children: [_jsx("legend", { className: "mb-3 text-sm font-medium break-words whitespace-pre-wrap", children: field.text }), !!field.options?.length && (_jsx("div", { className: "flex flex-wrap gap-2", children: field.options.map((option, optionIndex) => field.multiple ? (_jsxs("label", { htmlFor: `${id}-${index}-${optionIndex}`, className: "flex max-w-full min-w-0 items-start gap-2 rounded-lg border p-2 text-sm", children: [_jsx(Checkbox, { id: `${id}-${index}-${optionIndex}`, disabled: !editable, checked: selected.includes(option.label), onCheckedChange: (checked) => set(field.id, checked === true
67
+ ? [
68
+ ...selected.filter((answer) => answer !== option.label),
69
+ option.label,
70
+ ]
71
+ : selected.filter((answer) => answer !== option.label)) }), _jsxs("span", { className: "min-w-0 break-words", children: [option.label, option.description && (_jsxs("span", { className: "mt-1 block text-xs text-muted-foreground", children: [" ", option.description] }))] })] }, option.label)) : (_jsx(Button, { type: "button", variant: "outline", disabled: !editable, className: "h-auto max-w-full min-w-0 py-2 text-left", "aria-pressed": value === option.label, onClick: () => set(field.id, option.label), children: _jsxs("span", { className: "min-w-0 break-words whitespace-normal", children: [option.label, option.description && (_jsxs("span", { className: "mt-1 block text-xs text-muted-foreground", children: [" ", option.description] }))] }) }, option.label))) })), _jsx(Field, { label: field.multiple ? (field.options?.length ? t.otherAnswers : t.answers) : t.answer, hint: field.multiple ? t.multipleHint : undefined, error: error, children: _jsx(Textarea, { readOnly: !editable, disabled: !editable, rows: 3, value: field.multiple ? custom.join("\n") : typeof value === "string" ? value : "", onChange: (event) => set(field.id, field.multiple
72
+ ? [
73
+ ...selected.filter((answer) => labels.has(answer)),
74
+ ...(event.target.value === "" ? [] : event.target.value.split("\n")),
75
+ ]
76
+ : event.target.value) }) })] }, field.id));
77
+ }), !canAnswer && state === "waiting" && !reason && (_jsx("p", { className: "text-sm text-muted-foreground", children: t.readOnly })), onSubmit && (state === "waiting" || state === "submitting") && (_jsx(Button, { type: "submit", disabled: !editable, children: state === "submitting" ? t.sending : t.send }))] })), onRefresh && (state === "delivery-unknown" || state === "unavailable") && (_jsx(Button, { type: "button", variant: "outline", onClick: onRefresh, children: t.refresh }))] }));
78
+ }
@@ -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
+ }
@@ -0,0 +1,26 @@
1
+ /** Runtime question state supplied by the host, independently of form submission. */
2
+ export type WorkflowQuestionState = "waiting" | "submitting" | "accepted" | "acknowledged" | "expired" | "delivery-unknown" | "unavailable";
3
+ export interface WorkflowQuestionField {
4
+ id: string;
5
+ text: string;
6
+ options?: readonly {
7
+ label: string;
8
+ description?: string;
9
+ }[];
10
+ multiple?: boolean;
11
+ }
12
+ export interface WorkflowQuestion {
13
+ id: string;
14
+ /** Opaque host revision, echoed unchanged with the answer intent. */
15
+ revision: string;
16
+ title?: string;
17
+ worker?: string;
18
+ fields: readonly WorkflowQuestionField[];
19
+ }
20
+ export type WorkflowQuestionAnswers = Readonly<Record<string, string | readonly string[]>>;
21
+ /** Intent only. The host owns authorization, delivery correlation and acknowledgment. */
22
+ export interface WorkflowQuestionSubmission {
23
+ questionId: string;
24
+ revision: string;
25
+ answers: WorkflowQuestionAnswers;
26
+ }
@@ -0,0 +1 @@
1
+ export {};
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@calliopelabs/ui",
3
- "version": "0.11.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",