@anchrd/intel-ui 0.6.0 → 0.7.2

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.
@@ -1,4 +1,5 @@
1
1
  import type { Flow, FlowGraph, FlowNode, KnowledgeNode } from "@anchrd/intel-contract";
2
+ import { flowNodeLayer } from "@anchrd/intel-contract";
2
3
  import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
3
4
  import { useNavigate, useRouterState } from "@tanstack/react-router";
4
5
  import {
@@ -17,12 +18,16 @@ import {
17
18
  type NodeProps,
18
19
  Position,
19
20
  ReactFlow,
21
+ ReactFlowProvider,
22
+ useReactFlow,
20
23
  } from "@xyflow/react";
21
24
  import { FileSearch, Info, Send, Wrench } from "lucide-react";
22
25
  import { useEffect, useId, useMemo, useState } from "react";
23
26
  import { ActionSlot } from "@/app/action-slot/action-slot.tsx";
27
+ import { TreeEntryMediaType } from "@/app/app-tree/app-tree.tsx";
24
28
  import { ViewToggle } from "@/app/view-toggle/view-toggle.tsx";
25
29
  import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
30
+ import { EntryPicker } from "@/entry-picker/entry-picker.tsx";
26
31
  import { FlowRuns } from "@/flow-runs/flow-runs.tsx";
27
32
  import { nodeIcon } from "@/flows/node-icon/node-icon.ts";
28
33
  import { NodePalette, usePaletteOpen } from "@/flows/node-palette/node-palette.tsx";
@@ -48,19 +53,46 @@ const ContextHandle = "context";
48
53
  const paletteKinds = [
49
54
  "trigger",
50
55
  "instruction",
51
- "knowledge",
52
- "tool",
53
56
  "condition",
54
- "approval",
55
57
  "subflow",
58
+ "folder",
59
+ "document",
60
+ "upload",
61
+ "table",
62
+ "tool",
56
63
  "output",
57
64
  ] as const;
58
65
 
66
+ // What the tree hands over, read back defensively: the payload crosses a browser API, so it is
67
+ // parsed rather than trusted. A drop that does not carry what we put there is simply ignored.
68
+ function droppedEntry(payload: string): { id: string; kind: string; title: string } | null {
69
+ try {
70
+ const parsed: unknown = JSON.parse(payload);
71
+ if (typeof parsed !== "object" || parsed === null) return null;
72
+ const { id, kind, title } = parsed as Record<string, unknown>;
73
+ if (typeof id !== "string" || typeof kind !== "string" || typeof title !== "string")
74
+ return null;
75
+ return { id, kind, title };
76
+ } catch {
77
+ return null;
78
+ }
79
+ }
80
+
81
+ // A row's kind decides what dropping it makes, so nothing has to be asked. A folder becomes a
82
+ // Folder link, a flow becomes the Flow step that calls it (D25).
83
+ const flowKindOfEntry: Record<string, FlowNode["kind"] | undefined> = {
84
+ folder: "folder",
85
+ document: "document",
86
+ attachment: "upload",
87
+ table: "table",
88
+ flow: "subflow",
89
+ };
90
+
59
91
  function FlowCard({ data, selected }: NodeProps<CanvasNode>) {
60
92
  const { i18n } = useIntelRouterContext();
61
93
  const contract = data.node;
62
94
  const Icon = nodeIcon[contract.kind];
63
- const branching = contract.kind === "condition" || contract.kind === "approval";
95
+ const branching = contract.kind === "condition";
64
96
  // ⚠️ The start is told apart by silhouette first (#39): a pill among rectangles, in the primary
65
97
  // token. Shape survives zooming out past the point where the label is legible, and it is the only
66
98
  // one of the three that also carries into the overview map, where nothing is written at all.
@@ -99,18 +131,8 @@ function FlowCard({ data, selected }: NodeProps<CanvasNode>) {
99
131
  )}
100
132
  {branching && (
101
133
  <>
102
- <Handle
103
- id={contract.kind === "approval" ? "approved" : "yes"}
104
- type="source"
105
- position={Position.Right}
106
- style={{ top: "35%" }}
107
- />
108
- <Handle
109
- id={contract.kind === "approval" ? "rejected" : "no"}
110
- type="source"
111
- position={Position.Right}
112
- style={{ top: "70%" }}
113
- />
134
+ <Handle id="yes" type="source" position={Position.Right} style={{ top: "35%" }} />
135
+ <Handle id="no" type="source" position={Position.Right} style={{ top: "70%" }} />
114
136
  </>
115
137
  )}
116
138
  {/* The context point (#37). Sideways is the order of work, downwards is what a step works
@@ -143,7 +165,7 @@ function defaultGraph(): FlowGraph {
143
165
  kind: "output",
144
166
  label: "Result",
145
167
  position: { x: 520, y: 180 },
146
- configuration: { template: "" },
168
+ configuration: {},
147
169
  },
148
170
  ],
149
171
  edges: [
@@ -236,6 +258,27 @@ function editsEdges(changes: EdgeChange<CanvasEdge>[]): boolean {
236
258
  );
237
259
  }
238
260
 
261
+ // ⚠️ `upload` is what the flow calls an attachment. The two names are deliberately not unified:
262
+ // in the tree it is a file somebody uploaded, in a flow it is material a step reads, and renaming
263
+ // either one to match the other would make the other read wrong.
264
+ //
265
+ // The reverse map is DERIVED rather than written a second time — two hand-kept tables of the same
266
+ // correspondence are two tables that eventually disagree, and the disagreement would show up as a
267
+ // picker offering the wrong kind.
268
+ const nodeKindOfLink = Object.fromEntries(
269
+ Object.entries(flowKindOfEntry)
270
+ .filter(([, kind]) => kind !== undefined && kind !== "subflow")
271
+ .map(([nodeKind, kind]) => [kind, nodeKind]),
272
+ ) as Partial<Record<FlowNode["kind"], KnowledgeNode["kind"]>>;
273
+
274
+ type LinkNode = Extract<FlowNode, { configuration: { resourceId: string } }>;
275
+
276
+ // A predicate rather than a boolean, so the update below can build the one configuration a link
277
+ // has without widening every other node's to match.
278
+ function isLinkNode(node: FlowNode): node is LinkNode {
279
+ return flowNodeLayer[node.kind] === "link" && "resourceId" in node.configuration;
280
+ }
281
+
239
282
  function newNode(
240
283
  kind: FlowNode["kind"],
241
284
  index: number,
@@ -259,16 +302,14 @@ function newNode(
259
302
  kind,
260
303
  configuration: { prompt: "Describe what the AI should do." },
261
304
  };
262
- case "knowledge":
263
- return {
264
- ...common,
265
- kind,
266
- configuration: {
267
- resourceIds: firstKnowledgeId ? [firstKnowledgeId] : [],
268
- mode: "relevant",
269
- query: null,
270
- },
271
- };
305
+ // The four link kinds differ only in what the picker offers, so they are one branch. A link
306
+ // starts out naming nothing: it is filled in from the inspector, and `compileFlow` refuses to
307
+ // publish a graph whose reference is empty.
308
+ case "folder":
309
+ case "document":
310
+ case "upload":
311
+ case "table":
312
+ return { ...common, kind, configuration: { resourceId: firstKnowledgeId ?? "" } };
272
313
  case "tool":
273
314
  return {
274
315
  ...common,
@@ -288,15 +329,6 @@ function newNode(
288
329
  instruction: "Describe how to choose yes or no.",
289
330
  },
290
331
  };
291
- case "approval":
292
- return {
293
- ...common,
294
- kind,
295
- configuration: {
296
- prompt: "Describe what must be approved.",
297
- timeout: "7 days",
298
- },
299
- };
300
332
  case "subflow":
301
333
  // `latest` while drafting: nobody should have to version things while building, and
302
334
  // publishing turns it into the concrete version (ADR-0004 §5).
@@ -306,12 +338,24 @@ function newNode(
306
338
  configuration: { flowId: firstFlowId ?? "", version: { mode: "latest" } },
307
339
  };
308
340
  case "output":
309
- return { ...common, kind, configuration: { template: "" } };
341
+ return { ...common, kind, configuration: {} };
310
342
  }
311
343
  }
312
344
 
345
+ // ⚠️ The provider wraps the editor rather than sitting inside it: `useReactFlow` — which is how the
346
+ // drop turns a screen point into a graph point — has to be called from a component UNDER it, and
347
+ // the hook lives in the same component that renders the canvas.
313
348
  export function Flows() {
349
+ return (
350
+ <ReactFlowProvider>
351
+ <FlowsEditor />
352
+ </ReactFlowProvider>
353
+ );
354
+ }
355
+
356
+ function FlowsEditor() {
314
357
  const { data, i18n } = useIntelRouterContext();
358
+ const flow = useReactFlow();
315
359
  const theme = useSystemTheme();
316
360
  const queryClient = useQueryClient();
317
361
  const navigate = useNavigate();
@@ -398,15 +442,6 @@ export function Flows() {
398
442
  ]);
399
443
  },
400
444
  });
401
- const run = useMutation({
402
- mutationFn: () =>
403
- data.startFlow({
404
- flowId: selectedFlowId ?? "",
405
- input: {},
406
- parent: null,
407
- idempotencyKey: crypto.randomUUID(),
408
- }),
409
- });
410
445
 
411
446
  function updateNode(update: (node: FlowNode) => FlowNode) {
412
447
  setDirty(true);
@@ -435,10 +470,8 @@ export function Flows() {
435
470
  dirty={dirty}
436
471
  saving={save.isPending}
437
472
  canMutate={canMutate}
438
- running={run.isPending}
439
473
  onSave={() => save.mutate()}
440
474
  onPublish={() => setPublishing(true)}
441
- onRun={() => run.mutate()}
442
475
  />
443
476
  ) : null}
444
477
  <UnsavedChangesGuard dirty={dirty} />
@@ -551,6 +584,50 @@ export function Flows() {
551
584
  setSelectedNodeId(null);
552
585
  setPaletteOpen(false);
553
586
  }}
587
+ // ⚠️ `copy`, not `move` (#75). The tree's own drop targets say `move`, and the two
588
+ // have to feel different while the pointer is still travelling: dropping here makes
589
+ // a node that POINTS at the row, it does not take the row out of its folder.
590
+ onDragOver={(event) => {
591
+ if (!event.dataTransfer.types.includes(TreeEntryMediaType)) return;
592
+ event.preventDefault();
593
+ event.dataTransfer.dropEffect = "copy";
594
+ }}
595
+ onDrop={(event) => {
596
+ const payload = event.dataTransfer.getData(TreeEntryMediaType);
597
+ if (!payload) return;
598
+ event.preventDefault();
599
+ const dropped = droppedEntry(payload);
600
+ if (!dropped) return;
601
+ const kind = flowKindOfEntry[dropped.kind];
602
+ if (!kind) return;
603
+ // Where the pointer let go, in the graph's own coordinates — not the screen's, or
604
+ // the node would land somewhere else at every zoom level.
605
+ const position = flow.screenToFlowPosition({
606
+ x: event.clientX,
607
+ y: event.clientY,
608
+ });
609
+ setDirty(true);
610
+ setNodes((current) => [
611
+ ...current,
612
+ {
613
+ id: `${kind}-${crypto.randomUUID()}`,
614
+ type: "intel" as const,
615
+ position,
616
+ data: {
617
+ node: {
618
+ id: `${kind}-${crypto.randomUUID()}`,
619
+ kind,
620
+ label: dropped.title,
621
+ position,
622
+ configuration:
623
+ kind === "subflow"
624
+ ? { flowId: dropped.id, version: { mode: "latest" as const } }
625
+ : { resourceId: dropped.id },
626
+ } as FlowNode,
627
+ },
628
+ },
629
+ ]);
630
+ }}
554
631
  >
555
632
  <Background />
556
633
  {/* The overview map has no room for a label, so the start is marked there the only
@@ -570,7 +647,6 @@ export function Flows() {
570
647
  node={selectedNode}
571
648
  update={updateNode}
572
649
  tools={tools.data?.items ?? []}
573
- knowledge={knowledge.data?.nodes ?? []}
574
650
  flows={(callable.data?.items ?? []).filter((entry) => entry.id !== selectedFlowId)}
575
651
  />
576
652
  <FlowNeeds flowId={selectedFlowId} />
@@ -578,15 +654,7 @@ export function Flows() {
578
654
  </>
579
655
  )}
580
656
  </div>
581
- {run.data && (
582
- <div className="fixed bottom-5 right-5 z-20 max-w-sm rounded-xl border bg-card p-4 text-sm shadow-xl">
583
- <p className="font-medium">{i18n.t("flows.runStarted")}</p>
584
- <p className="mt-1 text-xs text-muted-foreground">
585
- {run.data.node?.label ?? run.data.run.status} · {run.data.run.id}
586
- </p>
587
- </div>
588
- )}
589
- {(save.isError || run.isError) && (
657
+ {save.isError && (
590
658
  <p
591
659
  role="alert"
592
660
  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"
@@ -636,19 +704,15 @@ function FlowTitle({
636
704
  dirty,
637
705
  saving,
638
706
  canMutate,
639
- running,
640
707
  onSave,
641
708
  onPublish,
642
- onRun,
643
709
  }: {
644
710
  flow: Flow;
645
711
  dirty: boolean;
646
712
  saving: boolean;
647
713
  canMutate: boolean;
648
- running: boolean;
649
714
  onSave(): void;
650
715
  onPublish(): void;
651
- onRun(): void;
652
716
  }) {
653
717
  const { i18n } = useIntelRouterContext();
654
718
  // A draft that is already the published one freezes nothing, so publishing it would be a no-op
@@ -693,14 +757,6 @@ function FlowTitle({
693
757
  </Tooltip>
694
758
  </TooltipProvider>
695
759
  <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
760
  </TitleRow>
705
761
  );
706
762
  }
@@ -795,13 +851,11 @@ function NodeInspector({
795
851
  node,
796
852
  update,
797
853
  tools,
798
- knowledge,
799
854
  flows,
800
855
  }: {
801
856
  node: CanvasNode | null;
802
857
  update(fn: (node: FlowNode) => FlowNode): void;
803
858
  tools: Array<{ name: string; title: string | null; fingerprint: string }>;
804
- knowledge: KnowledgeNode[];
805
859
  flows: Flow[];
806
860
  }) {
807
861
  const { i18n } = useIntelRouterContext();
@@ -810,23 +864,24 @@ function NodeInspector({
810
864
  return (
811
865
  <div className="p-5">
812
866
  <h2 className="font-semibold">{i18n.t(`flows.node.${contract.kind}`)}</h2>
813
- <Field
814
- label={i18n.t("common.title")}
815
- value={contract.label}
816
- setValue={(label) => update((value) => ({ ...value, label }))}
817
- />
818
- {(contract.kind === "instruction" ||
819
- contract.kind === "condition" ||
820
- contract.kind === "approval") && (
867
+ {/* ⚠️ A marker has no name to give (D25). Start and End are the same two points in every
868
+ flow, and a start renamed "Rechnung holen" reads on the canvas as a step that does
869
+ something — which is exactly the confusion the layer split is meant to end. */}
870
+ {flowNodeLayer[contract.kind] !== "marker" && (
871
+ <Field
872
+ label={i18n.t("common.title")}
873
+ value={contract.label}
874
+ setValue={(label) => update((value) => ({ ...value, label }))}
875
+ />
876
+ )}
877
+ {(contract.kind === "instruction" || contract.kind === "condition") && (
821
878
  <TextArea
822
879
  label={i18n.t("flows.instruction")}
823
880
  hint={i18n.t("flows.instructionHint")}
824
881
  value={
825
882
  contract.kind === "instruction"
826
883
  ? contract.configuration.prompt
827
- : contract.kind === "condition"
828
- ? contract.configuration.instruction
829
- : contract.configuration.prompt
884
+ : contract.configuration.instruction
830
885
  }
831
886
  setValue={(text) =>
832
887
  update((value) =>
@@ -837,76 +892,26 @@ function NodeInspector({
837
892
  ...value,
838
893
  configuration: { mode: "semantic", instruction: text },
839
894
  }
840
- : value.kind === "approval"
841
- ? {
842
- ...value,
843
- configuration: { ...value.configuration, prompt: text },
844
- }
845
- : value,
895
+ : value,
846
896
  )
847
897
  }
848
898
  />
849
899
  )}
850
- {contract.kind === "knowledge" && (
851
- <>
852
- <fieldset className="mt-4 rounded-lg border p-3">
853
- <legend className="px-1 text-sm font-medium">{i18n.t("flows.knowledgeIds")}</legend>
854
- {knowledge.map((item) => (
855
- <label
856
- key={item.id}
857
- className="flex cursor-pointer items-start gap-2 rounded-md px-2 py-1.5 text-sm hover:bg-muted"
858
- >
859
- <input
860
- type="checkbox"
861
- checked={contract.configuration.resourceIds.includes(item.id)}
862
- disabled={
863
- contract.configuration.resourceIds.length === 1 &&
864
- contract.configuration.resourceIds[0] === item.id
865
- }
866
- onChange={(event) =>
867
- update((value) => {
868
- if (value.kind !== "knowledge") return value;
869
- const selected = new Set(value.configuration.resourceIds);
870
- if (event.target.checked) selected.add(item.id);
871
- else selected.delete(item.id);
872
- return {
873
- ...value,
874
- configuration: {
875
- ...value.configuration,
876
- resourceIds: [...selected],
877
- },
878
- };
879
- })
880
- }
881
- className="mt-0.5 size-4 accent-primary disabled:cursor-not-allowed disabled:opacity-50"
882
- />
883
- <span className="min-w-0 truncate">{item.title}</span>
884
- </label>
885
- ))}
886
- {knowledge.length === 0 && (
887
- <p className="px-2 py-1 text-sm text-muted-foreground">
888
- {i18n.t("flows.knowledgeEmpty")}
889
- </p>
890
- )}
891
- </fieldset>
892
- <TextArea
893
- label={i18n.t("flows.knowledgeQuery")}
894
- value={contract.configuration.query ?? ""}
895
- setValue={(query) =>
896
- update((value) =>
897
- value.kind === "knowledge"
898
- ? {
899
- ...value,
900
- configuration: {
901
- ...value.configuration,
902
- query: query || null,
903
- },
904
- }
905
- : value,
906
- )
907
- }
908
- />
909
- </>
900
+ {/* ⚠️ One reference, chosen — not a list, ticked. What replaces this properly is the picker
901
+ in #75 (search, the tree, dropping from the sidebar); until then a select does the one
902
+ thing the layer rule needs it to do, which is make "exactly one" the only sayable state.
903
+ The list is filtered to the kind of the node, so a Table node cannot name a folder. */}
904
+ {isLinkNode(contract) && (
905
+ <EntryPicker
906
+ kind={nodeKindOfLink[contract.kind] ?? null}
907
+ value={contract.configuration.resourceId}
908
+ label={i18n.t(`flows.node.${contract.kind}`)}
909
+ onSelect={(resourceId) =>
910
+ update((value) =>
911
+ isLinkNode(value) ? { ...value, configuration: { resourceId } } : value,
912
+ )
913
+ }
914
+ />
910
915
  )}
911
916
  {contract.kind === "subflow" && (
912
917
  <div className="mt-4">
@@ -1,11 +1,13 @@
1
1
  import type { FlowNode } from "@anchrd/intel-contract";
2
2
  import {
3
- CheckCircle2,
3
+ CircleDot,
4
4
  CirclePlay,
5
- FileSearch,
5
+ FileText,
6
+ Folder,
6
7
  GitBranch,
7
- ShieldCheck,
8
+ Paperclip,
8
9
  Sparkles,
10
+ Table,
9
11
  Workflow,
10
12
  Wrench,
11
13
  } from "lucide-react";
@@ -19,10 +21,14 @@ export type NodeIcon = ComponentType<SVGProps<SVGSVGElement>>;
19
21
  export const nodeIcon: Record<FlowNode["kind"], NodeIcon> = {
20
22
  trigger: CirclePlay,
21
23
  instruction: Sparkles,
22
- knowledge: FileSearch,
23
- tool: Wrench,
24
24
  condition: GitBranch,
25
- approval: ShieldCheck,
26
25
  subflow: Workflow,
27
- output: CheckCircle2,
26
+ // The four links wear the symbol their kind wears in the tree, so a document is the same shape
27
+ // wherever it is seen — the sidebar, the picker and the canvas do not each teach their own.
28
+ folder: Folder,
29
+ document: FileText,
30
+ upload: Paperclip,
31
+ table: Table,
32
+ tool: Wrench,
33
+ output: CircleDot,
28
34
  };
@@ -1,5 +1,6 @@
1
+ import { flowNodeLayer } from "@anchrd/intel-contract";
1
2
  import { Plus, X } from "lucide-react";
2
- import { useId, useRef, useState } from "react";
3
+ import { Fragment, useId, useRef, useState } from "react";
3
4
  import { type NodeIcon, nodeIcon } from "@/flows/node-icon/node-icon.ts";
4
5
  import { cn } from "@/lib/utils.ts";
5
6
  import { useIntelRouterContext } from "@/router/router-context.ts";
@@ -172,25 +173,38 @@ export function NodePalette<K extends PaletteKind>({
172
173
  {kinds.map((kind, index) => {
173
174
  const Icon: NodeIcon = nodeIcon[kind];
174
175
  const reason = disabledReason?.(kind) ?? null;
176
+ // ⚠️ A separator before the first entry of each layer after the first (D25). It is
177
+ // decoration, not structure: the toolbar's keyboard walks `[data-palette-item]`, so a
178
+ // divider in between must not be one — otherwise the arrows would stop on a line.
179
+ const opensLayer =
180
+ index > 0 && flowNodeLayer[kind] !== flowNodeLayer[kinds[index - 1] ?? kind];
175
181
  return (
176
- <button
177
- key={kind}
178
- type="button"
179
- data-palette-item=""
180
- disabled={reason !== null}
181
- title={reason ?? undefined}
182
- tabIndex={index === active ? 0 : -1}
183
- onClick={() => {
184
- setActive(index);
185
- add(kind);
186
- }}
187
- // `h-9` is the trigger's height: sharing one row only reads as one row if the entries
188
- // start on the trigger's top edge instead of floating in the middle of it.
189
- className="inline-flex h-9 items-center gap-1.5 rounded-md px-2 text-xs outline-none hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 disabled:hover:bg-transparent"
190
- >
191
- <Icon aria-hidden="true" className="size-3.5" />
192
- {i18n.t(`flows.node.${kind}`)}
193
- </button>
182
+ <Fragment key={kind}>
183
+ {opensLayer ? (
184
+ <span
185
+ aria-hidden="true"
186
+ className="mx-1 my-1.5 w-px self-stretch bg-border"
187
+ title={i18n.t(`flows.layer.${flowNodeLayer[kind]}`)}
188
+ />
189
+ ) : null}
190
+ <button
191
+ type="button"
192
+ data-palette-item=""
193
+ disabled={reason !== null}
194
+ title={reason ?? undefined}
195
+ tabIndex={index === active ? 0 : -1}
196
+ onClick={() => {
197
+ setActive(index);
198
+ add(kind);
199
+ }}
200
+ // `h-9` is the trigger's height: sharing one row only reads as one row if the entries
201
+ // start on the trigger's top edge instead of floating in the middle of it.
202
+ className="inline-flex h-9 items-center gap-1.5 rounded-md px-2 text-xs outline-none hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 disabled:hover:bg-transparent"
203
+ >
204
+ <Icon aria-hidden="true" className="size-3.5" />
205
+ {i18n.t(`flows.node.${kind}`)}
206
+ </button>
207
+ </Fragment>
194
208
  );
195
209
  })}
196
210
  </div>