@anchrd/intel-ui 0.35.0 → 0.37.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@anchrd/intel-ui",
3
- "version": "0.35.0",
3
+ "version": "0.37.0",
4
4
  "type": "module",
5
5
  "license": "UNLICENSED",
6
6
  "repository": {
@@ -1,10 +1,15 @@
1
1
  import { useNavigate, useRouterState } from "@tanstack/react-router";
2
- import { History, Network, PencilRuler } from "lucide-react";
2
+ import { ListChecks, Network, PencilRuler } from "lucide-react";
3
3
  import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
4
4
  import { useI18n } from "@/i18n/i18n-context.tsx";
5
5
  import { type IntelView, viewFrom } from "@/router/selection-search.ts";
6
6
 
7
- const icons = { editor: PencilRuler, graph: Network, runs: History } as const;
7
+ // ⚠️ `runs` is NOT `History`, and that is the point of this line rather than a taste in icons.
8
+ // `History` is what the resource menu draws beside "Version history" (`resource-menu.tsx`), and both
9
+ // are visible on the same flow header — one as this switch, one in the menu right below it. The same
10
+ // clock for two different things was read as one: the switch was taken for the version history and
11
+ // asked about as such (#635). Whatever stands here has to stay out of that menu.
12
+ const icons = { editor: PencilRuler, graph: Network, runs: ListChecks } as const;
8
13
  const labels = {
9
14
  editor: "view.showEditor",
10
15
  graph: "view.showGraph",
@@ -45,7 +45,7 @@ import { SaveButton, UnsavedChangesGuard } from "@/save-button/save-button.tsx";
45
45
  import { useResolvedTheme } from "@/theme/theme-context.tsx";
46
46
  import { TitleRow, TitleRowFrame, TitleRowScrollArea } from "@/title-row/title-row.tsx";
47
47
 
48
- type CanvasNode = ReactFlowNode<{ node: FlowNode }, "intel">;
48
+ type CanvasNode = ReactFlowNode<{ node: FlowNode; branches?: readonly string[] }, "intel">;
49
49
  type CanvasEdge = Edge;
50
50
 
51
51
  // One handle id at both ends of a context edge. React Flow reports the handles a connection was
@@ -53,6 +53,38 @@ type CanvasEdge = Edge;
53
53
  // stores the meaning itself, not which dot it was dragged from.
54
54
  const ContextHandle = "context";
55
55
 
56
+ // What a condition drawn on this canvas calls its branches. A DEFAULT for a new one, not a
57
+ // vocabulary: `sourceHandle` is any non-empty string in the contract, and `compileFlow` asks only
58
+ // that a condition's branches are unique and not null. A graph arrives over MCP as readily as from
59
+ // here, and one whose branches are called something else is as valid as this one.
60
+ const DefaultBranches = ["yes", "no"] as const;
61
+
62
+ /**
63
+ * The branch names of one condition, as the loaded graph spells them.
64
+ *
65
+ * ⚠️ Read from the edges, never imposed on them. Rendering a fixed `yes`/`no` pair left every edge
66
+ * saved with other names unattached to any handle — the author redrew it, the redrawn edge took
67
+ * this canvas's name, and the old edge stayed in the state until its target had two incoming edges
68
+ * and `compileFlow` refused the save (#627). The names are taken once, when the version is loaded,
69
+ * so deleting an edge does not take its branch off the card: the handle stays, and drawing the edge
70
+ * again lands on the same name it had.
71
+ *
72
+ * Filled up to two from the defaults only where the graph is short of them — a condition needs two
73
+ * branches to be worth drawing, and one with none at all would offer nowhere to start.
74
+ */
75
+ function branchesOf(graph: FlowGraph, nodeId: string): string[] {
76
+ const named = graph.edges
77
+ .filter((edge) => edge.kind === "flow" && edge.source === nodeId)
78
+ .map((edge) => edge.sourceHandle)
79
+ .filter((handle): handle is string => handle !== null);
80
+ const branches = [...new Set(named)];
81
+ for (const fallback of DefaultBranches) {
82
+ if (branches.length >= DefaultBranches.length) break;
83
+ if (!branches.includes(fallback)) branches.push(fallback);
84
+ }
85
+ return branches;
86
+ }
87
+
56
88
  // The order the bar offers them in: roughly the order a flow is built, from the start to the result.
57
89
  // ⚠️ The start is in the bar even though every graph opens with one, because it can be deleted —
58
90
  // without an entry there would be no way back to a flow that has a beginning.
@@ -114,6 +146,7 @@ function FlowCard({ data, selected }: NodeProps<CanvasNode>) {
114
146
  const broken = isLinkNode(contract) && invalidLinks.has(contract.configuration.resourceId);
115
147
  const Icon = nodeIcon[contract.kind];
116
148
  const branching = contract.kind === "condition";
149
+ const branches = data.branches ?? DefaultBranches;
117
150
  // ⚠️ The start is told apart by silhouette first (#39): a pill among rectangles, in the primary
118
151
  // token. Shape survives zooming out past the point where the label is legible, and it is the only
119
152
  // one of the three that also carries into the overview map, where nothing is written at all.
@@ -155,12 +188,18 @@ function FlowCard({ data, selected }: NodeProps<CanvasNode>) {
155
188
  {contract.kind !== "output" && !branching && (
156
189
  <Handle type="source" position={Position.Right} />
157
190
  )}
158
- {branching && (
159
- <>
160
- <Handle id="yes" type="source" position={Position.Right} style={{ top: "35%" }} />
161
- <Handle id="no" type="source" position={Position.Right} style={{ top: "70%" }} />
162
- </>
163
- )}
191
+ {branching &&
192
+ branches.map((branch, index) => (
193
+ <Handle
194
+ key={branch}
195
+ id={branch}
196
+ type="source"
197
+ position={Position.Right}
198
+ // Spread over the right edge rather than pinned: a condition may carry more than the two
199
+ // this canvas offers, and three branches stacked on two fixed points would hide one.
200
+ style={{ top: `${(100 * (index + 1)) / (branches.length + 1)}%` }}
201
+ />
202
+ ))}
164
203
  {/* The context point (#37). Sideways is the order of work, downwards is what a step works
165
204
  with — including at the output, where the same edge reads as where the result goes. Both
166
205
  ends sit on the vertical axis so an attachment hangs under its step and the two meanings
@@ -207,13 +246,34 @@ function defaultGraph(): FlowGraph {
207
246
  };
208
247
  }
209
248
 
249
+ /**
250
+ * The edges left over once `connection` is drawn.
251
+ *
252
+ * ⚠️ A point in the order of work leads to ONE place, and drawing from it again means "there
253
+ * instead". `compileFlow` says so twice — a step has exactly one outgoing edge, and a condition's
254
+ * branches are unique — so a second line from the same point can only ever be a graph that will not
255
+ * save. Dropping the old one here is what #627 cost when nothing did: the author redrew a branch,
256
+ * both lines stayed, and the refusal that followed named a node they had not touched.
257
+ *
258
+ * The context point is the exception it has always been: a step works with as much material as it
259
+ * needs, and those edges leave from the same handle by design.
260
+ */
261
+ export function clearedFor(edges: CanvasEdge[], connection: Connection): CanvasEdge[] {
262
+ if (connection.sourceHandle === ContextHandle) return edges;
263
+ return edges.filter(
264
+ (edge) =>
265
+ edge.source !== connection.source ||
266
+ (edge.sourceHandle ?? null) !== (connection.sourceHandle ?? null),
267
+ );
268
+ }
269
+
210
270
  function canvas(graph: FlowGraph) {
211
271
  return {
212
272
  nodes: graph.nodes.map((node) => ({
213
273
  id: node.id,
214
274
  type: "intel" as const,
215
275
  position: node.position,
216
- data: { node },
276
+ data: node.kind === "condition" ? { node, branches: branchesOf(graph, node.id) } : { node },
217
277
  })),
218
278
  edges: graph.edges.map((edge) => {
219
279
  const context = edge.kind === "context";
@@ -615,6 +675,7 @@ function FlowsEditor() {
615
675
  saving={save.isPending}
616
676
  canMutate={canMutate}
617
677
  incomplete={stepsMissingServer.map((node) => ({ label: node.data.node.label }))}
678
+ scrollingView={selection.view === "runs"}
618
679
  onSave={() => save.mutate()}
619
680
  onPublish={() => setPublishing(true)}
620
681
  />
@@ -734,7 +795,7 @@ function FlowsEditor() {
734
795
  id: crypto.randomUUID(),
735
796
  ...contextEdgeStyle(connection.sourceHandle === ContextHandle),
736
797
  },
737
- current,
798
+ clearedFor(current, connection),
738
799
  ),
739
800
  );
740
801
  }}
@@ -883,6 +944,7 @@ function FlowTitle({
883
944
  saving,
884
945
  canMutate,
885
946
  incomplete,
947
+ scrollingView,
886
948
  onSave,
887
949
  onPublish,
888
950
  }: {
@@ -890,6 +952,9 @@ function FlowTitle({
890
952
  dirty: boolean;
891
953
  saving: boolean;
892
954
  canMutate: boolean;
955
+ // Whether what stands below this line can scroll under it. The runs list can and earns its edge
956
+ // the usual way; the canvas and the graph pan instead, and would never get one (#634).
957
+ scrollingView: boolean;
893
958
  // Tool steps that name no server yet. They cannot be saved, so the refusal is shown here rather
894
959
  // than fetched from the server as a message that names no step.
895
960
  incomplete: Array<{ label: string }>;
@@ -914,7 +979,12 @@ function FlowTitle({
914
979
  // loudest thing in this line and used to hold the edge; the menu takes it, because a menu one can
915
980
  // hit without looking is worth more than the primary button being outermost. `TitleRow` is what
916
981
  // enforces that — nothing passed in here can get past the menu.
917
- <TitleRow title={flow.title} description={flow.description} target={{ type: "flow", flow }}>
982
+ <TitleRow
983
+ title={flow.title}
984
+ description={flow.description}
985
+ target={{ type: "flow", flow }}
986
+ separated={!scrollingView}
987
+ >
918
988
  {/* ⚠️ #454: the toggle used to stand in the global header, beside the search — where what
919
989
  applies EVERYWHERE stands. But it says how THIS flow is shown, and so belongs in the line
920
990
  that names this flow. A flow is the only level with runs, and therefore the only one with
package/src/i18n/de.json CHANGED
@@ -75,7 +75,6 @@
75
75
  "resource.archive": "Archivieren",
76
76
  "resource.columns": "Spalten",
77
77
  "resource.export": "Export",
78
- "resource.exportExcludes": "Nimmt den aktuellen Stand mit. Frühere Versionen, Freigaben und Flow-Läufe bleiben zurück. Von einem veröffentlichten Flow reist der veröffentlichte Graph — spätere Änderungen am Entwurf bleiben zurück.",
79
78
  "resource.import": "Import",
80
79
  "resource.validate": "Prüfen",
81
80
  "resource.links": "Verweise",
package/src/i18n/en.json CHANGED
@@ -75,7 +75,6 @@
75
75
  "resource.archive": "Archive",
76
76
  "resource.columns": "Columns",
77
77
  "resource.export": "Export",
78
- "resource.exportExcludes": "Carries the current state. Earlier versions, shares and flow runs stay behind. A published flow travels as its published graph — changes drafted since stay behind.",
79
78
  "resource.import": "Import",
80
79
  "resource.validate": "Check",
81
80
  "resource.links": "Links",
package/src/i18n/es.json CHANGED
@@ -75,7 +75,6 @@
75
75
  "resource.archive": "Archivar",
76
76
  "resource.columns": "Columnas",
77
77
  "resource.export": "Exportar",
78
- "resource.exportExcludes": "Se lleva el estado actual. Las versiones anteriores, los permisos y las ejecuciones de flujo se quedan. Un flujo publicado viaja con su grafo publicado; los cambios redactados después se quedan.",
79
78
  "resource.import": "Importar",
80
79
  "resource.validate": "Comprobar",
81
80
  "resource.links": "Enlaces",
@@ -18,7 +18,7 @@ import {
18
18
  ShieldCheck,
19
19
  } from "lucide-react";
20
20
  import type * as React from "react";
21
- import { useId, useState } from "react";
21
+ import { useState } from "react";
22
22
  import { AccessSummary } from "@/access-summary/access-summary.tsx";
23
23
  import { allTreeLevelsKey, moveErrorKey, useTreeMove } from "@/app/tree-move/tree-move.tsx";
24
24
  import {
@@ -154,11 +154,6 @@ export function ResourceMenu({
154
154
  // nowhere else: a person had strictly less reach on their own table than a model did.
155
155
  const isTable = node !== null && node.kind === "table";
156
156
  const bundleImport = useBundleImport({ where: title });
157
- // ⚠️ Per instance, not a constant: the folder table renders one menu PER ROW, so a fixed id would
158
- // put the same `id` in the document twenty times and every entry's name would resolve to the
159
- // first one's.
160
- const exportLabelId = useId();
161
- const exportHintId = useId();
162
157
 
163
158
  // Everything a change here can make stale. The row sits in one level of the tree, the open screen
164
159
  // reads the record, and the flat collections behind the search, the link picker and the relation
@@ -323,38 +318,17 @@ export function ResourceMenu({
323
318
  (#534). That is the export's gap on every kind it can hit, not something this entry
324
319
  was covering — which is why it is fixed there and not by keeping a second entry.
325
320
 
326
- ⚠️ The second line is not decoration and it belongs HERE rather than after the click
327
- (#433, ADR-0006). A bundle carries one version per node; whoever moves an installation
328
- this way loses every earlier one, and until now the only place that said so was
329
- `excluded` in a `manifest.json` inside the zipread, if ever, long after the source
330
- installation is gone. A loss that cannot be undone has to be readable BEFORE the
331
- gesture, and a menu item is where this gesture is chosen.
332
-
333
- ⚠️ Its third clause is the flow half (#585), and it is a DIFFERENT loss from the first
334
- two rather than an example of them: a published flow travels as its published graph,
335
- so edits drafted since do not travel at all. Those edits are not an earlier version —
336
- they are the newest state there is, and somebody reading only "earlier versions stay
337
- behind" would expect the opposite of what happens. `Excluded` in `bundle.ts` names it
338
- with a word of its own for the same reason.
339
-
340
- ⚠️ It is the entry's DESCRIPTION, not part of its name: `aria-labelledby` keeps the
341
- name at the one word every other entry here uses, and `aria-describedby` is what a
342
- screen reader reads after it. Folding a whole sentence into the name would make this
343
- the only entry in the menu somebody has to listen through to know what it does — and
344
- the sentence is a condition of the action, which is what a description is for. */}
345
- <DropdownMenuItem
346
- className="items-start"
347
- aria-labelledby={exportLabelId}
348
- aria-describedby={exportHintId}
349
- onSelect={() => exportBundle.mutate()}
350
- >
351
- <FolderDown aria-hidden="true" className="mt-0.5" />
352
- <span className="flex flex-col gap-0.5">
353
- <span id={exportLabelId}>{i18n.t("resource.export")}</span>
354
- <span id={exportHintId} className="text-xs text-muted-foreground">
355
- {i18n.t("resource.exportExcludes")}
356
- </span>
357
- </span>
321
+ ⚠️ The entry carried a three-clause description under its name until 2026-08-19, put
322
+ there by #433/ADR-0006 so that an irreversible loss was readable BEFORE the gesture: a
323
+ bundle takes one version per node, shares and runs stay behind, and a published flow
324
+ travels as its published graph (#585). Jack removed itit was the only entry running
325
+ over two lines and it pushed the menu apart. What the reader now gets instead is
326
+ `excluded` in the zip's `manifest.json` and `BundleImportResult.excluded` after an
327
+ import, both of which are read only once the zip exists. That is a deliberate trade,
328
+ recorded in ADR-0006 and in #636, and NOT a leftover to be restored. */}
329
+ <DropdownMenuItem onSelect={() => exportBundle.mutate()}>
330
+ <FolderDown aria-hidden="true" />
331
+ {i18n.t("resource.export")}
358
332
  </DropdownMenuItem>
359
333
  {/* The other half of the same round trip, next to it rather than in the tree's plus
360
334
  (#346): one word in the menu, both sources under it. */}
@@ -526,31 +500,23 @@ function VersionHistory({ target, close }: { target: ResourceTarget; close(): vo
526
500
  }));
527
501
  },
528
502
  });
503
+ // ⚠️ A Modal, like every other panel in this menu — NOT an `absolute` drawer. `absolute` measures
504
+ // against the nearest positioned ancestor, and this menu has no say in what that is: on the title
505
+ // line it is the title row (`title-row.tsx`, `relative z-10`, around 100px tall), which clipped
506
+ // the list to its first entry on every viewport; in the folder listing it is the table container
507
+ // (`table.tsx`, `relative … overflow-x-auto`), which clips it differently again (#628). The list
508
+ // scrolls INSIDE the dialog instead, the way `NodeLinksPanel` further down does it.
529
509
  return (
530
- <aside
531
- aria-label={i18n.t("node.versions")}
532
- className="absolute bottom-0 right-0 top-0 z-10 w-80 overflow-y-auto border-l bg-card p-5 shadow-xl"
533
- >
534
- <div className="flex items-start justify-between gap-4">
535
- <h3 className="font-semibold">{i18n.t("node.versions")}</h3>
536
- <button
537
- type="button"
538
- onClick={close}
539
- aria-label={i18n.t("common.close")}
540
- className="rounded-md px-2 py-1 text-muted-foreground outline-none hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring"
541
- >
542
- ×
543
- </button>
544
- </div>
510
+ <Modal title={i18n.t("node.versions")} close={close}>
545
511
  {versions.isPending ? (
546
- <p className="mt-4 text-sm text-muted-foreground">{i18n.t("common.loading")}</p>
512
+ <p className="text-sm text-muted-foreground">{i18n.t("common.loading")}</p>
547
513
  ) : null}
548
514
  {versions.isError ? (
549
- <p role="alert" className="mt-4 text-sm text-destructive">
515
+ <p role="alert" className="text-sm text-destructive">
550
516
  {i18n.t(target.type === "flow" ? "flows.operationFailed" : "node.operationFailed")}
551
517
  </p>
552
518
  ) : null}
553
- <ol className="mt-4 space-y-3">
519
+ <ol className="max-h-72 space-y-3 overflow-y-auto">
554
520
  {versions.data?.map((version) => (
555
521
  <li key={version.id} className="rounded-md border p-3 text-sm">
556
522
  <span className="font-medium">
@@ -563,7 +529,7 @@ function VersionHistory({ target, close }: { target: ResourceTarget; close(): vo
563
529
  </li>
564
530
  ))}
565
531
  </ol>
566
- </aside>
532
+ </Modal>
567
533
  );
568
534
  }
569
535
 
@@ -69,11 +69,22 @@ export function TitleRow({
69
69
  title,
70
70
  description,
71
71
  target,
72
+ separated,
72
73
  children,
73
74
  }: {
74
75
  title: string;
75
76
  description?: string | null;
76
77
  target: ResourceTarget;
78
+ /**
79
+ * A separator that does NOT wait for a scroll.
80
+ *
81
+ * ⚠️ For a surface where scrolling cannot happen, and that is the only case it is for (#634). The
82
+ * edge below this row is normally `scrolled`, set by `TitleRowScrollArea` — which is right where
83
+ * content moves under the row. The flow canvas does not scroll, it pans: nothing ever sets
84
+ * `scrolled`, so the row sat on the dark canvas with no edge at all. Do not pass this where the
85
+ * content does scroll; a permanent edge there would replace a signal with decoration.
86
+ */
87
+ separated?: boolean;
77
88
  children?: ReactNode;
78
89
  }) {
79
90
  const scroll = useContext(TitleRowScrollContext);
@@ -88,7 +99,7 @@ export function TitleRow({
88
99
  data-scrolled={scroll?.scrolled || undefined}
89
100
  className={cn(
90
101
  "relative z-10 flex shrink-0 items-start justify-between gap-5 px-6 py-4 transition-shadow",
91
- scroll?.scrolled && "shadow-[inset_0_-1px_0_var(--border)]",
102
+ (separated || scroll?.scrolled) && "shadow-[inset_0_-1px_0_var(--border)]",
92
103
  )}
93
104
  >
94
105
  <div className="flex min-w-0 items-center gap-3">