@anchrd/intel-ui 0.4.0 → 0.6.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.
Files changed (37) hide show
  1. package/package.json +1 -1
  2. package/src/app/action-slot/action-slot.tsx +27 -0
  3. package/src/app/app-sidebar/app-sidebar.tsx +39 -24
  4. package/src/app/app-tree/app-tree.tsx +332 -60
  5. package/src/app/app.tsx +31 -5
  6. package/src/app/sidebar-resize-handle/sidebar-resize-handle.tsx +2 -1
  7. package/src/app/tree-move/tree-move.tsx +331 -0
  8. package/src/app/user-footer/user-footer.tsx +73 -46
  9. package/src/app/view-toggle/view-toggle.tsx +77 -0
  10. package/src/blocknote-view/blocknote-view.tsx +19 -2
  11. package/src/branding/favicon.default.svg +2 -2
  12. package/src/branding/favicon.svg +2 -2
  13. package/src/components/ui/dropdown-menu.tsx +78 -0
  14. package/src/data/intel-data-provider/intel-data-provider.ts +135 -57
  15. package/src/data/intel-data-provider/intel-data-provider.types.ts +51 -13
  16. package/src/document-link/document-link.tsx +132 -0
  17. package/src/flow-runs/flow-runs.tsx +225 -0
  18. package/src/flows/flows.tsx +665 -271
  19. package/src/flows/node-icon/node-icon.ts +28 -0
  20. package/src/flows/node-palette/node-palette.tsx +200 -0
  21. package/src/flows/node-palette/node-palette.types.ts +15 -0
  22. package/src/graph-pane/graph-pane.tsx +44 -0
  23. package/src/i18n/en.json +144 -29
  24. package/src/knowledge/knowledge.tsx +91 -367
  25. package/src/knowledge-editor/knowledge-editor.tsx +169 -21
  26. package/src/knowledge-graph/knowledge-graph.ts +26 -24
  27. package/src/knowledge-graph/knowledge-graph.tsx +33 -24
  28. package/src/knowledge-table/knowledge-table.tsx +141 -0
  29. package/src/main.tsx +2 -2
  30. package/src/resource-menu/resource-menu.tsx +615 -0
  31. package/src/router/selection-search.ts +27 -3
  32. package/src/save-button/save-button.tsx +103 -0
  33. package/src/styles.css +37 -0
  34. package/src/theme/theme.ts +24 -0
  35. package/src/title-row/title-row.tsx +49 -0
  36. package/src/tools/tools.tsx +57 -38
  37. package/src/app/header-actions/header-actions.tsx +0 -15
@@ -1,6 +1,6 @@
1
- import type { FlowGraph, FlowNode, KnowledgeNode, ResourceRole } from "@anchrd/intel-contract";
1
+ import type { Flow, FlowGraph, FlowNode, KnowledgeNode } from "@anchrd/intel-contract";
2
2
  import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
3
- import { useRouterState } from "@tanstack/react-router";
3
+ import { useNavigate, useRouterState } from "@tanstack/react-router";
4
4
  import {
5
5
  addEdge,
6
6
  applyEdgeChanges,
@@ -18,60 +18,82 @@ import {
18
18
  Position,
19
19
  ReactFlow,
20
20
  } from "@xyflow/react";
21
- import {
22
- Bot,
23
- CheckCircle2,
24
- CirclePlay,
25
- FileSearch,
26
- GitBranch,
27
- Save,
28
- Send,
29
- Share2,
30
- ShieldCheck,
31
- Sparkles,
32
- Trash2,
33
- Wrench,
34
- } from "lucide-react";
35
- import { useEffect, useMemo, useState } from "react";
36
- import { HeaderActions } from "@/app/header-actions/header-actions.tsx";
21
+ import { FileSearch, Info, Send, Wrench } from "lucide-react";
22
+ import { useEffect, useId, useMemo, useState } from "react";
23
+ import { ActionSlot } from "@/app/action-slot/action-slot.tsx";
24
+ import { ViewToggle } from "@/app/view-toggle/view-toggle.tsx";
25
+ import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
26
+ import { FlowRuns } from "@/flow-runs/flow-runs.tsx";
27
+ import { nodeIcon } from "@/flows/node-icon/node-icon.ts";
28
+ import { NodePalette, usePaletteOpen } from "@/flows/node-palette/node-palette.tsx";
29
+ import { GraphPane } from "@/graph-pane/graph-pane.tsx";
37
30
  import { Modal } from "@/modal/modal.tsx";
38
31
  import { useIntelRouterContext } from "@/router/router-context.ts";
39
- import { selectedFrom } from "@/router/selection-search.ts";
32
+ import { selectedFrom, viewFrom } from "@/router/selection-search.ts";
33
+ import { SaveButton, UnsavedChangesGuard } from "@/save-button/save-button.tsx";
34
+ import { useSystemTheme } from "@/theme/theme.ts";
35
+ import { TitleRow } from "@/title-row/title-row.tsx";
40
36
 
41
37
  type CanvasNode = Node<{ node: FlowNode }, "intel">;
42
38
  type CanvasEdge = Edge;
43
39
 
44
- const nodeIcon = {
45
- trigger: CirclePlay,
46
- instruction: Sparkles,
47
- knowledge: FileSearch,
48
- tool: Wrench,
49
- condition: GitBranch,
50
- approval: ShieldCheck,
51
- output: CheckCircle2,
52
- } as const;
40
+ // One handle id at both ends of a context edge. React Flow reports the handles a connection was
41
+ // drawn between, and that is the only place the two meanings can still be told apart — the contract
42
+ // stores the meaning itself, not which dot it was dragged from.
43
+ const ContextHandle = "context";
44
+
45
+ // The order the bar offers them in: roughly the order a flow is built, from the start to the result.
46
+ // ⚠️ The start is in the bar even though every graph opens with one, because it can be deleted —
47
+ // without an entry there would be no way back to a flow that has a beginning.
48
+ const paletteKinds = [
49
+ "trigger",
50
+ "instruction",
51
+ "knowledge",
52
+ "tool",
53
+ "condition",
54
+ "approval",
55
+ "subflow",
56
+ "output",
57
+ ] as const;
53
58
 
54
59
  function FlowCard({ data, selected }: NodeProps<CanvasNode>) {
60
+ const { i18n } = useIntelRouterContext();
55
61
  const contract = data.node;
56
62
  const Icon = nodeIcon[contract.kind];
57
63
  const branching = contract.kind === "condition" || contract.kind === "approval";
64
+ // ⚠️ The start is told apart by silhouette first (#39): a pill among rectangles, in the primary
65
+ // token. Shape survives zooming out past the point where the label is legible, and it is the only
66
+ // one of the three that also carries into the overview map, where nothing is written at all.
67
+ const start = contract.kind === "trigger";
68
+ // ⚠️ A call that rides along says so on the call itself (ADR-0004 §5). A version choice that only
69
+ // showed up in the inspector would be invisible on exactly the screen people read a flow from,
70
+ // and "why did this flow change" would have no answer on the canvas.
71
+ const riding = contract.kind === "subflow" ? contract.configuration.version.mode : null;
58
72
  return (
59
73
  <div
60
- className={`min-w-48 rounded-xl border bg-card p-3 text-card-foreground shadow-sm ${selected ? "ring-2 ring-ring" : ""}`}
74
+ className={`min-w-48 border bg-card p-3 text-card-foreground shadow-sm ${start ? "rounded-full border-primary bg-primary/5 px-4" : "rounded-xl"} ${selected ? "ring-2 ring-ring" : ""}`}
61
75
  >
62
76
  {contract.kind !== "trigger" && <Handle type="target" position={Position.Left} />}
63
77
  <div className="flex items-center gap-2">
64
- <span className="grid size-8 place-items-center rounded-lg bg-primary/10 text-primary">
78
+ <span
79
+ className={`grid size-8 shrink-0 place-items-center bg-primary/10 text-primary ${start ? "rounded-full bg-primary text-primary-foreground" : "rounded-lg"}`}
80
+ >
65
81
  <Icon aria-hidden="true" className="size-4" />
66
82
  </span>
67
83
  <span className="min-w-0">
68
84
  <span className="block truncate text-sm font-medium">{contract.label}</span>
69
- <span className="block text-xs capitalize text-muted-foreground">{contract.kind}</span>
85
+ {/* The kind through the catalog, not the raw key: the start node is called Start on the
86
+ canvas as well, and `trigger` would name an event that no longer exists (#39). */}
87
+ <span className="block text-xs text-muted-foreground">
88
+ {i18n.t(`flows.node.${contract.kind}`)}
89
+ </span>
70
90
  </span>
91
+ {riding === "follows" || riding === "pinned" ? (
92
+ <span className="ml-auto shrink-0 rounded-full border px-2 py-0.5 text-[0.625rem] uppercase tracking-wide text-muted-foreground">
93
+ {i18n.t(`flows.subflowBadge.${riding}`)}
94
+ </span>
95
+ ) : null}
71
96
  </div>
72
- {contract.description && (
73
- <p className="mt-2 line-clamp-2 text-xs text-muted-foreground">{contract.description}</p>
74
- )}
75
97
  {contract.kind !== "output" && !branching && (
76
98
  <Handle type="source" position={Position.Right} />
77
99
  )}
@@ -91,6 +113,15 @@ function FlowCard({ data, selected }: NodeProps<CanvasNode>) {
91
113
  />
92
114
  </>
93
115
  )}
116
+ {/* The context point (#37). Sideways is the order of work, downwards is what a step works
117
+ with — including at the output, where the same edge reads as where the result goes. Both
118
+ ends sit on the vertical axis so an attachment hangs under its step and the two meanings
119
+ are told apart by direction as well as by the dashed line. The start has no top point:
120
+ the beginning of a flow is not material another step works with. */}
121
+ <Handle id={ContextHandle} type="source" position={Position.Bottom} />
122
+ {contract.kind !== "trigger" && (
123
+ <Handle id={ContextHandle} type="target" position={Position.Top} />
124
+ )}
94
125
  </div>
95
126
  );
96
127
  }
@@ -104,7 +135,6 @@ function defaultGraph(): FlowGraph {
104
135
  id: "trigger",
105
136
  kind: "trigger",
106
137
  label: "Manual start",
107
- description: null,
108
138
  position: { x: 80, y: 180 },
109
139
  configuration: { mode: "manual" },
110
140
  },
@@ -112,7 +142,6 @@ function defaultGraph(): FlowGraph {
112
142
  id: "output",
113
143
  kind: "output",
114
144
  label: "Result",
115
- description: null,
116
145
  position: { x: 520, y: 180 },
117
146
  configuration: { template: "" },
118
147
  },
@@ -122,6 +151,7 @@ function defaultGraph(): FlowGraph {
122
151
  id: "trigger-output",
123
152
  source: "trigger",
124
153
  target: "output",
154
+ kind: "flow",
125
155
  label: null,
126
156
  sourceHandle: null,
127
157
  },
@@ -137,13 +167,31 @@ function canvas(graph: FlowGraph) {
137
167
  position: node.position,
138
168
  data: { node },
139
169
  })),
140
- edges: graph.edges.map((edge) => ({
141
- id: edge.id,
142
- source: edge.source,
143
- target: edge.target,
144
- sourceHandle: edge.sourceHandle,
145
- label: edge.label ?? undefined,
146
- })),
170
+ edges: graph.edges.map((edge) => {
171
+ const context = edge.kind === "context";
172
+ return {
173
+ id: edge.id,
174
+ source: edge.source,
175
+ target: edge.target,
176
+ // The handle ids are put back from the stored meaning, so a context edge lands on the
177
+ // vertical pair again and a flow edge keeps the branch handle it was saved with.
178
+ sourceHandle: context ? ContextHandle : edge.sourceHandle,
179
+ targetHandle: context ? ContextHandle : null,
180
+ label: edge.label ?? undefined,
181
+ ...contextEdgeStyle(context),
182
+ };
183
+ }),
184
+ };
185
+ }
186
+
187
+ // Dashed and unarrowed, in the muted token rather than the colour of a flow edge: the reader has to
188
+ // be able to separate the procedure from the material without being told which is which.
189
+ function contextEdgeStyle(context: boolean): Partial<CanvasEdge> {
190
+ if (!context) return {};
191
+ return {
192
+ className: "intel-context-edge",
193
+ style: { strokeDasharray: "6 4" },
194
+ animated: false,
147
195
  };
148
196
  }
149
197
 
@@ -153,31 +201,58 @@ function graph(nodes: CanvasNode[], edges: CanvasEdge[]): FlowGraph {
153
201
  ...node.data.node,
154
202
  position: node.position,
155
203
  })),
156
- edges: edges.map((edge) => ({
157
- id: edge.id,
158
- source: edge.source,
159
- target: edge.target,
160
- label: typeof edge.label === "string" && edge.label ? edge.label : null,
161
- sourceHandle: edge.sourceHandle ?? null,
162
- })),
204
+ edges: edges.map((edge) => {
205
+ const context = edge.sourceHandle === ContextHandle;
206
+ return {
207
+ id: edge.id,
208
+ source: edge.source,
209
+ target: edge.target,
210
+ kind: context ? ("context" as const) : ("flow" as const),
211
+ label: typeof edge.label === "string" && edge.label ? edge.label : null,
212
+ // A context edge carries no branch: the handle it was drawn from is a fact about the canvas,
213
+ // and storing it would make `compileFlow` read a branch where there is none.
214
+ sourceHandle: context ? null : (edge.sourceHandle ?? null),
215
+ };
216
+ }),
163
217
  };
164
218
  }
165
219
 
220
+ // ⚠️ Not every change React Flow reports is an edit. It also announces selection and the sizes it
221
+ // has just measured, and both arrive unasked on the first paint — a flow would be "unsaved" the
222
+ // moment it was opened, and the one signal that means something would mean nothing.
223
+ function editsNodes(changes: NodeChange<CanvasNode>[]): boolean {
224
+ return changes.some(
225
+ (change) =>
226
+ change.type === "add" ||
227
+ change.type === "remove" ||
228
+ change.type === "replace" ||
229
+ (change.type === "position" && change.dragging === true),
230
+ );
231
+ }
232
+
233
+ function editsEdges(changes: EdgeChange<CanvasEdge>[]): boolean {
234
+ return changes.some(
235
+ (change) => change.type === "add" || change.type === "remove" || change.type === "replace",
236
+ );
237
+ }
238
+
166
239
  function newNode(
167
- kind: Exclude<FlowNode["kind"], "trigger">,
240
+ kind: FlowNode["kind"],
168
241
  index: number,
169
242
  firstKnowledgeId?: string,
243
+ firstFlowId?: string,
170
244
  ): FlowNode {
171
245
  const common = {
172
246
  id: `${kind}-${crypto.randomUUID()}`,
173
247
  label: kind[0]?.toUpperCase() + kind.slice(1),
174
- description: null,
175
248
  position: {
176
249
  x: 260 + (index % 3) * 240,
177
250
  y: 80 + Math.floor(index / 3) * 180,
178
251
  },
179
252
  };
180
253
  switch (kind) {
254
+ case "trigger":
255
+ return { ...common, kind, label: "Manual start", configuration: { mode: "manual" } };
181
256
  case "instruction":
182
257
  return {
183
258
  ...common,
@@ -222,6 +297,14 @@ function newNode(
222
297
  timeout: "7 days",
223
298
  },
224
299
  };
300
+ case "subflow":
301
+ // `latest` while drafting: nobody should have to version things while building, and
302
+ // publishing turns it into the concrete version (ADR-0004 §5).
303
+ return {
304
+ ...common,
305
+ kind,
306
+ configuration: { flowId: firstFlowId ?? "", version: { mode: "latest" } },
307
+ };
225
308
  case "output":
226
309
  return { ...common, kind, configuration: { template: "" } };
227
310
  }
@@ -229,7 +312,9 @@ function newNode(
229
312
 
230
313
  export function Flows() {
231
314
  const { data, i18n } = useIntelRouterContext();
315
+ const theme = useSystemTheme();
232
316
  const queryClient = useQueryClient();
317
+ const navigate = useNavigate();
233
318
  const tools = useQuery({
234
319
  queryKey: ["tools"],
235
320
  queryFn: () => data.listTools(),
@@ -240,12 +325,36 @@ export function Flows() {
240
325
  queryKey: ["knowledge-graph"],
241
326
  queryFn: () => data.getKnowledgeGraph(),
242
327
  });
243
- // The tree in the sidebar and the header search both name the flow to open through `?select=`.
244
- const selectedFlowId = useRouterState({
245
- select: (state) => selectedFrom(state.location.search),
328
+ // The candidates for a subflow step. Which of them a flow may actually call is answered when it is
329
+ // published (ADR-0004 §3) — the picker offers what the user may see, and the refusal names the
330
+ // reason rather than hiding the option.
331
+ const callable = useQuery({
332
+ queryKey: ["flows"],
333
+ queryFn: () => data.listFlows(),
334
+ });
335
+ // The tree in the sidebar and the header search both name the flow to open through `?select=`;
336
+ // `?view=` says how it is shown — the editor, the relation graph (#19) or the runs (#35).
337
+ const selection = useRouterState({
338
+ select: (state) => ({
339
+ id: selectedFrom(state.location.search),
340
+ view: viewFrom(state.location.search),
341
+ }),
342
+ });
343
+ const selectedFlowId = selection.id;
344
+ // A single flow answers the relation question for itself: what it reads and what it calls.
345
+ const relations = useQuery({
346
+ queryKey: ["relation-graph", "flow", selectedFlowId],
347
+ queryFn: () => data.getRelationGraph({ of: "flow", flowId: selectedFlowId ?? "" }),
348
+ enabled: Boolean(selectedFlowId) && selection.view === "graph",
246
349
  });
247
350
  const [selectedNodeId, setSelectedNodeId] = useState<string | null>(null);
248
- const [sharing, setSharing] = useState(false);
351
+ const [publishing, setPublishing] = useState(false);
352
+ // Lifted out of the bar because the canvas dismisses it too — a click on the pane is the third way
353
+ // out, next to the trigger and Escape.
354
+ const [paletteOpen, setPaletteOpen] = usePaletteOpen();
355
+ // A flow is shared through the folder it is filed in, on the Knowledge screen (ADR-0004 §2). It
356
+ // has no share action of its own — a narrower grant beside the folder's would break the guarantee
357
+ // that a flow only reaches flows in its own subtree.
249
358
  const document = useQuery({
250
359
  queryKey: ["flow", selectedFlowId],
251
360
  queryFn: () => data.getFlow(selectedFlowId ?? ""),
@@ -254,6 +363,7 @@ export function Flows() {
254
363
  const initial = useMemo(() => canvas(defaultGraph()), []);
255
364
  const [nodes, setNodes] = useState<CanvasNode[]>(initial.nodes);
256
365
  const [edges, setEdges] = useState<CanvasEdge[]>(initial.edges);
366
+ const [dirty, setDirty] = useState(false);
257
367
  // The canvas stays visible during a background refetch; only a mutation needs the stricter
258
368
  // guarantee that the loaded document is both the selected flow and settled.
259
369
  const documentReady = selectedFlowId !== null && document.data?.flow.id === selectedFlowId;
@@ -264,6 +374,7 @@ export function Flows() {
264
374
  setNodes(next.nodes);
265
375
  setEdges(next.edges);
266
376
  setSelectedNodeId(null);
377
+ setDirty(false);
267
378
  }, [document.data, selectedFlowId]);
268
379
  const selectedNode = nodes.find((node) => node.id === selectedNodeId) ?? null;
269
380
 
@@ -275,19 +386,16 @@ export function Flows() {
275
386
  graph: graph(nodes, edges),
276
387
  idempotencyKey: crypto.randomUUID(),
277
388
  }),
278
- onSuccess: (saved) => {
389
+ onSuccess: async (saved) => {
390
+ setDirty(false);
279
391
  queryClient.setQueryData(["flow", saved.flow.id], saved);
280
- },
281
- });
282
- const publish = useMutation({
283
- mutationFn: () =>
284
- data.publishFlow({
285
- flowId: selectedFlowId ?? "",
286
- versionId: document.data?.flow.currentVersionId ?? "",
287
- idempotencyKey: crypto.randomUUID(),
288
- }),
289
- onSuccess: async () => {
290
- await queryClient.invalidateQueries({ queryKey: ["flow", selectedFlowId] });
392
+ // Saving is the whole of what changes here: the graph is both what this flow reads and calls
393
+ // — so the relation view of it and of its folder move with it — and the whole of what it
394
+ // needs.
395
+ await Promise.all([
396
+ queryClient.invalidateQueries({ queryKey: ["relation-graph"] }),
397
+ queryClient.invalidateQueries({ queryKey: ["flow-requirements", saved.flow.id] }),
398
+ ]);
291
399
  },
292
400
  });
293
401
  const run = useMutation({
@@ -295,11 +403,13 @@ export function Flows() {
295
403
  data.startFlow({
296
404
  flowId: selectedFlowId ?? "",
297
405
  input: {},
406
+ parent: null,
298
407
  idempotencyKey: crypto.randomUUID(),
299
408
  }),
300
409
  });
301
410
 
302
411
  function updateNode(update: (node: FlowNode) => FlowNode) {
412
+ setDirty(true);
303
413
  setNodes((current) =>
304
414
  current.map((node) =>
305
415
  node.id === selectedNodeId ? { ...node, data: { node: update(node.data.node) } } : node,
@@ -309,45 +419,43 @@ export function Flows() {
309
419
 
310
420
  return (
311
421
  <div className="flex h-full min-h-0 flex-col">
312
- {/* A flow's own actions, in the shell's action bar. Creating a flow is not among them: that
313
- belongs to the tree, at the folder the flow is meant to live in. */}
422
+ {/* Only the view switch belongs to the area: it says how the thing the breadcrumb names is
423
+ being shown, and it is mounted only where there is a flow to draw a switch with nothing
424
+ to point at is what #19 rules out for the header. A flow is the one level that has runs,
425
+ so it is the one that offers the third view (#35). Everything that acts on the flow itself
426
+ left this bar for the flow's own title line below (#24). */}
314
427
  {selectedFlowId ? (
315
- <HeaderActions>
316
- <button
317
- type="button"
318
- onClick={() => setSharing(true)}
319
- className="inline-flex h-8 items-center gap-2 rounded-md border bg-background px-2.5 text-sm outline-none hover:bg-accent focus-visible:ring-2 focus-visible:ring-ring"
320
- >
321
- <Share2 aria-hidden="true" className="size-4" /> {i18n.t("flows.share")}
322
- </button>
323
- <button
324
- type="button"
325
- onClick={() => save.mutate()}
326
- disabled={!canMutate || save.isPending}
327
- className="inline-flex h-8 items-center gap-2 rounded-md border bg-background px-2.5 text-sm outline-none hover:bg-accent focus-visible:ring-2 focus-visible:ring-ring disabled:opacity-50"
328
- >
329
- <Save aria-hidden="true" className="size-4" /> {i18n.t("common.save")}
330
- </button>
331
- <button
332
- type="button"
333
- onClick={() => publish.mutate()}
334
- disabled={!canMutate || !document.data?.flow.currentVersionId || publish.isPending}
335
- className="inline-flex h-8 items-center gap-2 rounded-md border bg-background px-2.5 text-sm outline-none hover:bg-accent focus-visible:ring-2 focus-visible:ring-ring disabled:opacity-50"
336
- >
337
- <Send aria-hidden="true" className="size-4" /> {i18n.t("flows.publish")}
338
- </button>
339
- <button
340
- type="button"
341
- onClick={() => run.mutate()}
342
- disabled={!canMutate || !document.data?.flow.publishedVersionId || run.isPending}
343
- className="inline-flex h-8 items-center gap-2 rounded-md bg-primary px-2.5 text-sm font-medium text-primary-foreground outline-none hover:bg-primary/90 focus-visible:ring-2 focus-visible:ring-ring disabled:opacity-50"
344
- >
345
- <Bot aria-hidden="true" className="size-4" /> {i18n.t("flows.run")}
346
- </button>
347
- </HeaderActions>
428
+ <ActionSlot>
429
+ <ViewToggle views={["editor", "graph", "runs"]} />
430
+ </ActionSlot>
348
431
  ) : null}
432
+ {selectedFlowId && document.data ? (
433
+ <FlowTitle
434
+ flow={document.data.flow}
435
+ dirty={dirty}
436
+ saving={save.isPending}
437
+ canMutate={canMutate}
438
+ running={run.isPending}
439
+ onSave={() => save.mutate()}
440
+ onPublish={() => setPublishing(true)}
441
+ onRun={() => run.mutate()}
442
+ />
443
+ ) : null}
444
+ <UnsavedChangesGuard dirty={dirty} />
349
445
  <div className="flex min-h-0 flex-1">
350
- {!selectedFlowId ? (
446
+ {selectedFlowId && selection.view === "runs" ? (
447
+ <FlowRuns flowId={selectedFlowId} />
448
+ ) : selectedFlowId && selection.view === "graph" ? (
449
+ <GraphPane
450
+ query={relations}
451
+ select={(node) =>
452
+ void navigate({
453
+ to: node.kind === "flow" ? "/flows" : "/knowledge",
454
+ search: { select: node.id },
455
+ })
456
+ }
457
+ />
458
+ ) : !selectedFlowId ? (
351
459
  <div className="grid flex-1 place-items-center p-8 text-sm text-muted-foreground">
352
460
  {i18n.t("flows.select")}
353
461
  </div>
@@ -365,65 +473,108 @@ export function Flows() {
365
473
  ) : (
366
474
  <>
367
475
  <section className="relative min-w-0 flex-1">
368
- <div className="absolute left-4 top-4 z-10 flex max-w-[calc(100%-2rem)] flex-wrap gap-1 rounded-lg border bg-card/95 p-2 shadow-sm backdrop-blur">
369
- {(
370
- ["instruction", "knowledge", "tool", "condition", "approval", "output"] as const
371
- ).map((kind) => {
372
- const Icon = nodeIcon[kind];
373
- return (
374
- <button
375
- key={kind}
376
- type="button"
377
- onClick={() => {
378
- const item = newNode(kind, nodes.length, knowledge.data?.nodes[0]?.id);
379
- setNodes((current) => [
380
- ...current,
381
- {
382
- id: item.id,
383
- type: "intel",
384
- position: item.position,
385
- data: { node: item },
386
- },
387
- ]);
388
- }}
389
- className="inline-flex items-center gap-1.5 rounded-md px-2 py-1.5 text-xs outline-none hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring"
390
- >
391
- <Icon aria-hidden="true" className="size-3.5" />
392
- {i18n.t(`flows.node.${kind}`)}
393
- </button>
476
+ {/* Adding is not what one does most of the time reading the graph is. The bar stays
477
+ open once opened, so three steps in a row cost one click, not three. */}
478
+ <NodePalette
479
+ kinds={paletteKinds}
480
+ open={paletteOpen}
481
+ setOpen={setPaletteOpen}
482
+ // ⚠️ Only the bar is closed off here. `compileFlow` keeps insisting on exactly one
483
+ // start, because a graph can also arrive over MCP or an import, where no bar was
484
+ // involved — this is the earlier answer, not the only one.
485
+ disabledReason={(kind) =>
486
+ kind === "trigger" && nodes.some((node) => node.data.node.kind === "trigger")
487
+ ? i18n.t("flows.startExists")
488
+ : null
489
+ }
490
+ add={(kind) => {
491
+ setDirty(true);
492
+ const item = newNode(
493
+ kind,
494
+ nodes.length,
495
+ knowledge.data?.nodes[0]?.id,
496
+ callable.data?.items.find((entry) => entry.id !== selectedFlowId)?.id,
394
497
  );
395
- })}
396
- </div>
498
+ setNodes((current) => [
499
+ ...current,
500
+ {
501
+ id: item.id,
502
+ type: "intel",
503
+ position: item.position,
504
+ data: { node: item },
505
+ },
506
+ ]);
507
+ }}
508
+ />
509
+ {/* ⚠️ React Flow knows nothing about the `.dark` class our tokens hang off; without
510
+ `colorMode` it paints its controls and its minimap from its own light palette and
511
+ leaves two glaring white surfaces on a black canvas. The value comes from the same
512
+ media query that sets the class, so both turn together and mid-session. What the
513
+ surfaces are actually coloured with is in `styles.css`, through the library's own
514
+ theming variables — for the same reason `.react-sigma` is there. */}
397
515
  <ReactFlow<CanvasNode, CanvasEdge>
516
+ colorMode={theme}
398
517
  nodes={nodes}
399
518
  edges={edges}
400
519
  nodeTypes={nodeTypes}
401
520
  fitView
402
- onNodesChange={(changes: NodeChange<CanvasNode>[]) =>
403
- setNodes((current) => applyNodeChanges(changes, current))
404
- }
405
- onEdgesChange={(changes: EdgeChange<CanvasEdge>[]) =>
406
- setEdges((current) => applyEdgeChanges(changes, current))
521
+ onNodesChange={(changes: NodeChange<CanvasNode>[]) => {
522
+ if (editsNodes(changes)) setDirty(true);
523
+ setNodes((current) => applyNodeChanges(changes, current));
524
+ }}
525
+ onEdgesChange={(changes: EdgeChange<CanvasEdge>[]) => {
526
+ if (editsEdges(changes)) setDirty(true);
527
+ setEdges((current) => applyEdgeChanges(changes, current));
528
+ }}
529
+ // ⚠️ The two meanings cannot be mixed on one edge: a line from a context point into
530
+ // a flow input would be stored as context and drawn as an attachment, while the
531
+ // author meant "and then". Refused at the point where it is still visible.
532
+ isValidConnection={(connection) =>
533
+ (connection.sourceHandle === ContextHandle) ===
534
+ (connection.targetHandle === ContextHandle)
407
535
  }
408
- onConnect={(connection: Connection) =>
536
+ onConnect={(connection: Connection) => {
537
+ setDirty(true);
409
538
  setEdges((current) =>
410
- addEdge({ ...connection, id: crypto.randomUUID() }, current),
411
- )
412
- }
539
+ addEdge(
540
+ {
541
+ ...connection,
542
+ id: crypto.randomUUID(),
543
+ ...contextEdgeStyle(connection.sourceHandle === ContextHandle),
544
+ },
545
+ current,
546
+ ),
547
+ );
548
+ }}
413
549
  onNodeClick={(_event, node) => setSelectedNodeId(node.id)}
414
- onPaneClick={() => setSelectedNodeId(null)}
550
+ onPaneClick={() => {
551
+ setSelectedNodeId(null);
552
+ setPaletteOpen(false);
553
+ }}
415
554
  >
416
555
  <Background />
417
- <MiniMap pannable zoomable />
556
+ {/* The overview map has no room for a label, so the start is marked there the only
557
+ way that is left: its own colour, from the tokens rather than a fixed value. */}
558
+ <MiniMap
559
+ pannable
560
+ zoomable
561
+ nodeClassName={(node) =>
562
+ (node as CanvasNode).data.node.kind === "trigger" ? "intel-minimap-start" : ""
563
+ }
564
+ />
418
565
  <Controls />
419
566
  </ReactFlow>
420
567
  </section>
421
- <NodeInspector
422
- node={selectedNode}
423
- update={updateNode}
424
- tools={tools.data?.items ?? []}
425
- knowledge={knowledge.data?.nodes ?? []}
426
- />
568
+ <aside className="flex w-80 shrink-0 flex-col overflow-y-auto border-l bg-card">
569
+ <NodeInspector
570
+ node={selectedNode}
571
+ update={updateNode}
572
+ tools={tools.data?.items ?? []}
573
+ knowledge={knowledge.data?.nodes ?? []}
574
+ flows={(callable.data?.items ?? []).filter((entry) => entry.id !== selectedFlowId)}
575
+ />
576
+ <FlowNeeds flowId={selectedFlowId} />
577
+ </aside>
427
578
  </>
428
579
  )}
429
580
  </div>
@@ -435,7 +586,7 @@ export function Flows() {
435
586
  </p>
436
587
  </div>
437
588
  )}
438
- {(save.isError || publish.isError || run.isError) && (
589
+ {(save.isError || run.isError) && (
439
590
  <p
440
591
  role="alert"
441
592
  className="fixed bottom-5 left-[18rem] z-20 rounded-lg border border-destructive/30 bg-card px-4 py-3 text-sm text-destructive shadow-xl"
@@ -443,121 +594,199 @@ export function Flows() {
443
594
  {i18n.t("flows.operationFailed")}
444
595
  </p>
445
596
  )}
446
- {sharing && selectedFlowId && (
447
- <ShareFlow flowId={selectedFlowId} close={() => setSharing(false)} />
448
- )}
597
+ {publishing && selectedFlowId && document.data?.flow.currentVersionId ? (
598
+ <PublishPreview
599
+ flowId={selectedFlowId}
600
+ versionId={document.data.flow.currentVersionId}
601
+ close={() => setPublishing(false)}
602
+ onPublished={async () => {
603
+ setPublishing(false);
604
+ // Publishing appends the frozen version, so more than the flow row moves: the canvas
605
+ // reloads from the new current version, the preview cached under the old version id
606
+ // would still claim there is something left to freeze, and the relation view reads the
607
+ // graph that just changed.
608
+ await Promise.all([
609
+ queryClient.invalidateQueries({ queryKey: ["flow", selectedFlowId] }),
610
+ queryClient.invalidateQueries({ queryKey: ["flow-publish-preview", selectedFlowId] }),
611
+ queryClient.invalidateQueries({ queryKey: ["relation-graph"] }),
612
+ ]);
613
+ }}
614
+ />
615
+ ) : null}
449
616
  </div>
450
617
  );
451
618
  }
452
619
 
453
- function ShareFlow({ flowId, close }: { flowId: string; close(): void }) {
620
+ /**
621
+ * The flow's title line — the same one a document has (#24).
622
+ *
623
+ * Before this, a flow wore four spelled-out buttons in the shell's header while a document wore
624
+ * icons in its own title line: two screens of one application, built two ways, and a reader who has
625
+ * to learn where actions live twice.
626
+ *
627
+ * ⚠️ `Publish` and `Run` stay visible buttons rather than menu entries, and they are deliberately
628
+ * *not* the same shape as each other. Both reach outside the editor — publishing freezes the version
629
+ * every later run hangs on, and a run keeps going after the tab is closed (Cloudflare Workflows,
630
+ * binding `FLOWS`) — so neither belongs behind three dots. But `Run` is the only one that starts
631
+ * work, so it is the only button in the line that carries a word instead of a picture: the
632
+ * difference in form *is* the warning against hitting it by accident.
633
+ */
634
+ function FlowTitle({
635
+ flow,
636
+ dirty,
637
+ saving,
638
+ canMutate,
639
+ running,
640
+ onSave,
641
+ onPublish,
642
+ onRun,
643
+ }: {
644
+ flow: Flow;
645
+ dirty: boolean;
646
+ saving: boolean;
647
+ canMutate: boolean;
648
+ running: boolean;
649
+ onSave(): void;
650
+ onPublish(): void;
651
+ onRun(): void;
652
+ }) {
653
+ const { i18n } = useIntelRouterContext();
654
+ // A draft that is already the published one freezes nothing, so publishing it would be a no-op
655
+ // with a dialog in front of it.
656
+ const publishable =
657
+ canMutate &&
658
+ Boolean(flow.currentVersionId) &&
659
+ flow.currentVersionId !== flow.publishedVersionId;
660
+ // ⚠️ Nothing published means `Start run` answers 409 `flow_not_published` (`flows.ts:541`), and
661
+ // until now that was only readable by clicking it. The button that fixes it says so instead.
662
+ const unpublished = flow.publishedVersionId === null;
663
+ const publishLabel = i18n.t(
664
+ !publishable ? "flows.publishNothing" : unpublished ? "flows.publishNeeded" : "flows.publish",
665
+ );
666
+ return (
667
+ // ⚠️ The menu stands to the RIGHT of the run button, not before it (#53, deliberate). Run is the
668
+ // loudest thing in this line and used to hold the edge; the menu takes it, because a menu one can
669
+ // hit without looking is worth more than the primary button being outermost. `TitleRow` is what
670
+ // enforces that — nothing passed in here can get past the menu.
671
+ <TitleRow title={flow.title} description={flow.description} target={{ type: "flow", flow }}>
672
+ <TooltipProvider delayDuration={300}>
673
+ <Tooltip>
674
+ <TooltipTrigger asChild>
675
+ <span className="inline-flex">
676
+ {/* Publishing goes through the preview, always. ADR-0004 §5 asks the author to see
677
+ the freeze before it happens, and a second, quieter path around the dialog would
678
+ be the one everybody ends up using. */}
679
+ <button
680
+ type="button"
681
+ onClick={onPublish}
682
+ disabled={!publishable}
683
+ aria-label={publishLabel}
684
+ className={`inline-flex size-8 items-center justify-center rounded-md border bg-background outline-none hover:bg-accent focus-visible:ring-2 focus-visible:ring-ring disabled:opacity-50 ${
685
+ publishable && unpublished ? "border-primary text-primary" : ""
686
+ }`}
687
+ >
688
+ <Send aria-hidden="true" className="size-4" />
689
+ </button>
690
+ </span>
691
+ </TooltipTrigger>
692
+ <TooltipContent>{publishLabel}</TooltipContent>
693
+ </Tooltip>
694
+ </TooltipProvider>
695
+ <SaveButton dirty={dirty && canMutate} saving={saving} onSave={onSave} />
696
+ <button
697
+ type="button"
698
+ onClick={onRun}
699
+ disabled={!canMutate || unpublished || running}
700
+ className="inline-flex h-8 items-center rounded-md bg-primary px-3 text-sm font-medium text-primary-foreground outline-none hover:bg-primary/90 focus-visible:ring-2 focus-visible:ring-ring disabled:opacity-50"
701
+ >
702
+ {i18n.t("flows.run")}
703
+ </button>
704
+ </TitleRow>
705
+ );
706
+ }
707
+
708
+ // ⚠️ The list is the server's, not this component's. Publishing computes the same freeze from the
709
+ // same code, so what the author agrees to here is what happens — a version resolved in the browser
710
+ // would be a second opinion, and the two would part ways the moment someone else publishes.
711
+ function PublishPreview({
712
+ flowId,
713
+ versionId,
714
+ close,
715
+ onPublished,
716
+ }: {
717
+ flowId: string;
718
+ versionId: string;
719
+ close(): void;
720
+ onPublished(): Promise<void>;
721
+ }) {
454
722
  const { data, i18n } = useIntelRouterContext();
455
- const queryClient = useQueryClient();
456
- const [email, setEmail] = useState("");
457
- const [role, setRole] = useState<ResourceRole>("viewer");
458
- const grants = useQuery({
459
- queryKey: ["flow-grants", flowId],
460
- queryFn: () => data.listFlowGrants(flowId),
723
+ const preview = useQuery({
724
+ queryKey: ["flow-publish-preview", flowId, versionId],
725
+ queryFn: () => data.previewFlowPublish({ flowId, versionId }),
461
726
  });
462
- const share = useMutation({
463
- mutationFn: () =>
464
- data.shareFlow({
465
- resourceId: flowId,
466
- principal: { type: "email", email },
467
- role,
468
- expiresAt: null,
469
- idempotencyKey: crypto.randomUUID(),
470
- }),
471
- onSuccess: async () => {
472
- setEmail("");
473
- await queryClient.invalidateQueries({
474
- queryKey: ["flow-grants", flowId],
475
- });
476
- },
477
- });
478
- const revoke = useMutation({
479
- mutationFn: (grantId: string) =>
480
- data.revokeFlowGrant({
481
- resourceId: flowId,
482
- grantId,
483
- idempotencyKey: crypto.randomUUID(),
484
- }),
485
- onSuccess: async () => {
486
- await queryClient.invalidateQueries({
487
- queryKey: ["flow-grants", flowId],
488
- });
489
- },
727
+ const publish = useMutation({
728
+ mutationFn: () => data.publishFlow({ flowId, versionId, idempotencyKey: crypto.randomUUID() }),
729
+ onSuccess: onPublished,
490
730
  });
491
-
492
731
  return (
493
- <Modal title={i18n.t("flows.share")} close={close}>
494
- {(share.isError || revoke.isError) && (
495
- <p role="alert" className="mb-4 text-sm text-destructive">
496
- {i18n.t("flows.shareFailed")}
732
+ <Modal title={i18n.t("flows.publishPreview")} close={close}>
733
+ {preview.isPending ? (
734
+ <p className="text-sm text-muted-foreground">{i18n.t("common.loading")}</p>
735
+ ) : preview.isError ? (
736
+ <p role="alert" className="text-sm text-destructive">
737
+ {i18n.t("flows.operationFailed")}
497
738
  </p>
498
- )}
499
- <ul className="mb-5 max-h-44 space-y-2 overflow-y-auto">
500
- {grants.data?.items.map((grant) => (
501
- <li
502
- key={grant.id}
503
- className="flex items-center justify-between gap-3 rounded-md border p-3 text-sm"
504
- >
505
- <span className="min-w-0 truncate">
506
- {grant.principal.type === "email"
507
- ? grant.principal.email
508
- : grant.principal.type === "user"
509
- ? grant.principal.id
510
- : i18n.t("flows.organization")}
511
- <span className="ml-2 text-xs text-muted-foreground">{grant.role}</span>
512
- </span>
513
- <button
514
- type="button"
515
- onClick={() => revoke.mutate(grant.id)}
516
- aria-label={i18n.t("flows.revokeShare")}
517
- className="rounded-md p-2 text-destructive outline-none hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring"
518
- >
519
- <Trash2 aria-hidden="true" className="size-4" />
520
- </button>
521
- </li>
522
- ))}
523
- </ul>
524
- <form
525
- className="space-y-4 border-t pt-5"
526
- onSubmit={(event) => {
527
- event.preventDefault();
528
- share.mutate();
529
- }}
530
- >
531
- <label className="block text-sm font-medium">
532
- {i18n.t("knowledge.email")}
533
- <input
534
- type="email"
535
- required
536
- value={email}
537
- onChange={(event) => setEmail(event.target.value)}
538
- className="mt-2 w-full rounded-md border bg-background px-3 py-2 outline-none focus-visible:ring-2 focus-visible:ring-ring"
539
- />
540
- </label>
541
- <label className="block text-sm font-medium">
542
- {i18n.t("knowledge.role")}
543
- <select
544
- value={role}
545
- onChange={(event) => setRole(event.target.value as ResourceRole)}
546
- className="mt-2 w-full rounded-md border bg-background px-3 py-2 outline-none focus-visible:ring-2 focus-visible:ring-ring"
739
+ ) : (
740
+ <>
741
+ <p className="text-sm text-muted-foreground">
742
+ {preview.data.calls.length === 0
743
+ ? i18n.t("flows.publishPreviewEmpty")
744
+ : i18n.t("flows.publishPreviewIntro")}
745
+ </p>
746
+ <ul className="mt-4 space-y-2">
747
+ {preview.data.calls.map((call) => {
748
+ // ⚠️ One condition for the sentence and its colour. They were two, so a call that
749
+ // rides along into a callee with nothing published read "runs along" in the colour of
750
+ // a failure — the reader would have gone looking for a problem that is not there.
751
+ const blocked = !call.available && call.mode !== "follows";
752
+ return (
753
+ <li key={call.nodeId} className="rounded-md border p-3 text-sm">
754
+ <span className="block font-medium">{call.nodeLabel}</span>
755
+ <span className="block text-xs text-muted-foreground">{call.calleeTitle}</span>
756
+ <span
757
+ className={`mt-1 block text-xs ${blocked ? "text-destructive" : "text-muted-foreground"}`}
758
+ >
759
+ {blocked
760
+ ? i18n.t("flows.publishPreviewUnavailable")
761
+ : call.freezes
762
+ ? i18n.t("flows.publishPreviewFreeze", {
763
+ sequence: call.versionSequence ?? 0,
764
+ })
765
+ : call.mode === "pinned"
766
+ ? i18n.t("flows.publishPreviewPinned", {
767
+ sequence: call.versionSequence ?? 0,
768
+ })
769
+ : i18n.t("flows.publishPreviewFollows")}
770
+ </span>
771
+ </li>
772
+ );
773
+ })}
774
+ </ul>
775
+ {publish.isError && (
776
+ <p role="alert" className="mt-4 text-sm text-destructive">
777
+ {i18n.t("flows.publishFailed")}
778
+ </p>
779
+ )}
780
+ <button
781
+ type="button"
782
+ onClick={() => publish.mutate()}
783
+ disabled={publish.isPending}
784
+ className="mt-5 w-full rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground outline-none hover:bg-primary/90 focus-visible:ring-2 focus-visible:ring-ring disabled:opacity-50"
547
785
  >
548
- <option value="viewer">{i18n.t("knowledge.viewer")}</option>
549
- <option value="editor">{i18n.t("knowledge.editor")}</option>
550
- <option value="manager">{i18n.t("knowledge.manager")}</option>
551
- </select>
552
- </label>
553
- <button
554
- type="submit"
555
- disabled={share.isPending}
556
- className="w-full rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:opacity-50"
557
- >
558
- {i18n.t("knowledge.shareAction")}
559
- </button>
560
- </form>
786
+ {publish.isPending ? i18n.t("common.saving") : i18n.t("flows.publishConfirm")}
787
+ </button>
788
+ </>
789
+ )}
561
790
  </Modal>
562
791
  );
563
792
  }
@@ -567,40 +796,31 @@ function NodeInspector({
567
796
  update,
568
797
  tools,
569
798
  knowledge,
799
+ flows,
570
800
  }: {
571
801
  node: CanvasNode | null;
572
802
  update(fn: (node: FlowNode) => FlowNode): void;
573
803
  tools: Array<{ name: string; title: string | null; fingerprint: string }>;
574
804
  knowledge: KnowledgeNode[];
805
+ flows: Flow[];
575
806
  }) {
576
807
  const { i18n } = useIntelRouterContext();
577
- if (!node)
578
- return (
579
- <aside className="w-80 shrink-0 border-l bg-card p-5 text-sm text-muted-foreground">
580
- {i18n.t("flows.inspect")}
581
- </aside>
582
- );
808
+ if (!node) return <p className="p-5 text-sm text-muted-foreground">{i18n.t("flows.inspect")}</p>;
583
809
  const contract = node.data.node;
584
810
  return (
585
- <aside className="w-80 shrink-0 overflow-y-auto border-l bg-card p-5">
811
+ <div className="p-5">
586
812
  <h2 className="font-semibold">{i18n.t(`flows.node.${contract.kind}`)}</h2>
587
813
  <Field
588
814
  label={i18n.t("common.title")}
589
815
  value={contract.label}
590
816
  setValue={(label) => update((value) => ({ ...value, label }))}
591
817
  />
592
- <Field
593
- label={i18n.t("flows.nodeDescription")}
594
- value={contract.description ?? ""}
595
- setValue={(description) =>
596
- update((value) => ({ ...value, description: description || null }))
597
- }
598
- />
599
818
  {(contract.kind === "instruction" ||
600
819
  contract.kind === "condition" ||
601
820
  contract.kind === "approval") && (
602
821
  <TextArea
603
822
  label={i18n.t("flows.instruction")}
823
+ hint={i18n.t("flows.instructionHint")}
604
824
  value={
605
825
  contract.kind === "instruction"
606
826
  ? contract.configuration.prompt
@@ -688,6 +908,36 @@ function NodeInspector({
688
908
  />
689
909
  </>
690
910
  )}
911
+ {contract.kind === "subflow" && (
912
+ <div className="mt-4">
913
+ <label className="block text-sm font-medium">
914
+ {i18n.t("flows.subflowTarget")}
915
+ <select
916
+ value={contract.configuration.flowId}
917
+ onChange={(event) => {
918
+ const flowId = event.target.value;
919
+ update((value) =>
920
+ value.kind === "subflow"
921
+ ? { ...value, configuration: { ...value.configuration, flowId } }
922
+ : value,
923
+ );
924
+ }}
925
+ className="mt-2 w-full rounded-md border bg-background px-3 py-2 outline-none focus-visible:ring-2 focus-visible:ring-ring"
926
+ >
927
+ <option value="">{i18n.t("flows.selectSubflow")}</option>
928
+ {flows.map((entry) => (
929
+ <option key={entry.id} value={entry.id}>
930
+ {entry.title}
931
+ </option>
932
+ ))}
933
+ </select>
934
+ </label>
935
+ {/* The call rule is a folder question, so it cannot be answered while typing. Publishing
936
+ answers it and names the reason. */}
937
+ <p className="mt-2 text-xs text-muted-foreground">{i18n.t("flows.subflowRule")}</p>
938
+ <SubflowVersionField configuration={contract.configuration} update={update} />
939
+ </div>
940
+ )}
691
941
  {contract.kind === "tool" && (
692
942
  <div className="mt-4">
693
943
  <label className="block text-sm font-medium">
@@ -738,7 +988,125 @@ function NodeInspector({
738
988
  />
739
989
  </div>
740
990
  )}
741
- </aside>
991
+ </div>
992
+ );
993
+ }
994
+
995
+ // "What this flow needs": the documents and tools the graph names, and nothing about who may reach
996
+ // them. A standing "this flow has conflicts" badge is not offered on purpose — for tools it could
997
+ // never be true, because the catalog is a live query with each user's own portal token (ADR-0003).
998
+ function FlowNeeds({ flowId }: { flowId: string }) {
999
+ const { data, i18n } = useIntelRouterContext();
1000
+ const needs = useQuery({
1001
+ queryKey: ["flow-requirements", flowId],
1002
+ queryFn: () => data.getFlowRequirements(flowId),
1003
+ });
1004
+ // ⚠️ A flow that touches nothing and a flow whose every reference is hidden are two different
1005
+ // answers, so the counted ones keep the panel from claiming the first when it means the second.
1006
+ const empty =
1007
+ needs.data !== undefined &&
1008
+ needs.data.knowledge.length === 0 &&
1009
+ needs.data.hiddenKnowledge === 0 &&
1010
+ needs.data.tools.length === 0;
1011
+ return (
1012
+ <section aria-label={i18n.t("flows.needs")} className="mt-auto border-t p-5">
1013
+ <h2 className="font-semibold">{i18n.t("flows.needs")}</h2>
1014
+ {needs.isPending && (
1015
+ <p className="mt-2 text-sm text-muted-foreground">{i18n.t("common.loading")}</p>
1016
+ )}
1017
+ {needs.isError && (
1018
+ <p role="alert" className="mt-2 text-sm text-destructive">
1019
+ {i18n.t("flows.needsFailed")}
1020
+ </p>
1021
+ )}
1022
+ {empty && <p className="mt-2 text-sm text-muted-foreground">{i18n.t("flows.needsEmpty")}</p>}
1023
+ {needs.data && !empty && (
1024
+ <>
1025
+ {(needs.data.knowledge.length > 0 || needs.data.hiddenKnowledge > 0) && (
1026
+ <>
1027
+ <h3 className="mt-4 text-xs font-medium uppercase tracking-wide text-muted-foreground">
1028
+ {i18n.t("flows.needsKnowledge")}
1029
+ </h3>
1030
+ <ul className="mt-2 space-y-1 text-sm">
1031
+ {needs.data.knowledge.map((reference) => (
1032
+ <li key={reference.id} className="flex items-center gap-2">
1033
+ <FileSearch aria-hidden="true" className="size-3.5 shrink-0" />
1034
+ <span className="min-w-0 truncate">{reference.title}</span>
1035
+ </li>
1036
+ ))}
1037
+ {/* ⚠️ Counted, never named. A title is exactly what someone without access to the
1038
+ document may not learn from a list about it. */}
1039
+ {needs.data.hiddenKnowledge > 0 && (
1040
+ <li className="text-muted-foreground">
1041
+ {i18n.t("flows.needsHidden", { count: needs.data.hiddenKnowledge })}
1042
+ </li>
1043
+ )}
1044
+ </ul>
1045
+ </>
1046
+ )}
1047
+ {needs.data.tools.length > 0 && (
1048
+ <>
1049
+ <h3 className="mt-4 text-xs font-medium uppercase tracking-wide text-muted-foreground">
1050
+ {i18n.t("flows.needsTools")}
1051
+ </h3>
1052
+ <ul className="mt-2 space-y-1 text-sm">
1053
+ {needs.data.tools.map((name) => (
1054
+ <li key={name} className="flex items-center gap-2">
1055
+ <Wrench aria-hidden="true" className="size-3.5 shrink-0" />
1056
+ <span className="min-w-0 truncate">{name}</span>
1057
+ </li>
1058
+ ))}
1059
+ </ul>
1060
+ </>
1061
+ )}
1062
+ <p className="mt-4 text-xs text-muted-foreground">{i18n.t("flows.needsHint")}</p>
1063
+ </>
1064
+ )}
1065
+ </section>
1066
+ );
1067
+ }
1068
+
1069
+ // The version choice of one call (ADR-0004 §5). Two of the three modes are chosen here; the third,
1070
+ // `pinned`, is what publishing writes, so it is offered as the current value and can be given up
1071
+ // again — but no arbitrary version is picked by hand. Which version a pin points at is a fact of the
1072
+ // published graph, not something to browse for while drafting.
1073
+ function SubflowVersionField({
1074
+ configuration,
1075
+ update,
1076
+ }: {
1077
+ configuration: Extract<FlowNode, { kind: "subflow" }>["configuration"];
1078
+ update(fn: (node: FlowNode) => FlowNode): void;
1079
+ }) {
1080
+ const { i18n } = useIntelRouterContext();
1081
+ const mode = configuration.version.mode;
1082
+ return (
1083
+ <div className="mt-4">
1084
+ <label className="block text-sm font-medium">
1085
+ {i18n.t("flows.subflowVersion")}
1086
+ <select
1087
+ value={mode}
1088
+ onChange={(event) => {
1089
+ const next = event.target.value;
1090
+ if (next !== "latest" && next !== "follows") return;
1091
+ update((value) =>
1092
+ value.kind === "subflow"
1093
+ ? { ...value, configuration: { ...value.configuration, version: { mode: next } } }
1094
+ : value,
1095
+ );
1096
+ }}
1097
+ className="mt-2 w-full rounded-md border bg-background px-3 py-2 outline-none focus-visible:ring-2 focus-visible:ring-ring"
1098
+ >
1099
+ <option value="latest">{i18n.t("flows.subflowVersion.latest")}</option>
1100
+ <option value="follows">{i18n.t("flows.subflowVersion.follows")}</option>
1101
+ {mode === "pinned" && (
1102
+ <option value="pinned">{i18n.t("flows.subflowVersion.pinned")}</option>
1103
+ )}
1104
+ </select>
1105
+ </label>
1106
+ <p className="mt-2 text-xs text-muted-foreground">
1107
+ {i18n.t(`flows.subflowVersionHint.${mode}`)}
1108
+ </p>
1109
+ </div>
742
1110
  );
743
1111
  }
744
1112
 
@@ -807,20 +1175,46 @@ function TextArea({
807
1175
  label,
808
1176
  value,
809
1177
  setValue,
1178
+ hint,
810
1179
  }: {
811
1180
  label: string;
812
1181
  value: string;
813
1182
  setValue(value: string): void;
1183
+ hint?: string;
814
1184
  }) {
1185
+ const hintId = useId();
815
1186
  return (
816
1187
  <label className="mt-4 block text-sm font-medium">
817
- {label}
1188
+ <span className="flex items-center gap-1.5">
1189
+ {label}
1190
+ {/* The note that keeps the box of parts small (#39): a condition, a check or a branch can be
1191
+ said in words inside the instruction, so `condition` can stay a node without everyone
1192
+ having to drag one for every small decision. A button, not a bare icon — a hint reachable
1193
+ only by hovering is not reachable at all. */}
1194
+ {hint && (
1195
+ <button
1196
+ type="button"
1197
+ aria-label={hint}
1198
+ aria-describedby={hintId}
1199
+ title={hint}
1200
+ className="inline-flex size-4 shrink-0 items-center justify-center rounded-full text-muted-foreground outline-none hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring"
1201
+ >
1202
+ <Info aria-hidden="true" className="size-3.5" />
1203
+ </button>
1204
+ )}
1205
+ </span>
818
1206
  <textarea
819
1207
  rows={6}
820
1208
  value={value}
1209
+ aria-describedby={hint ? hintId : undefined}
821
1210
  onChange={(event) => setValue(event.target.value)}
822
1211
  className="mt-2 w-full resize-y rounded-md border bg-background px-3 py-2 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring"
823
1212
  />
1213
+ {hint && (
1214
+ <span id={hintId} className="mt-1 block text-xs font-normal text-muted-foreground">
1215
+ {hint}
1216
+ </span>
1217
+ )}
824
1218
  </label>
825
1219
  );
826
1220
  }