@anchrd/intel-ui 0.34.0 → 0.36.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.34.0",
3
+ "version": "0.36.0",
4
4
  "type": "module",
5
5
  "license": "UNLICENSED",
6
6
  "repository": {
@@ -33,7 +33,7 @@
33
33
  "typecheck": "tsc --noEmit"
34
34
  },
35
35
  "dependencies": {
36
- "@anchrd/intel-contract": "^0.21.0",
36
+ "@anchrd/intel-contract": "^0.22.0",
37
37
  "@blocknote/core": "^0.52.1",
38
38
  "@blocknote/react": "^0.52.1",
39
39
  "@blocknote/shadcn": "^0.52.1",
@@ -10,7 +10,7 @@ import {
10
10
  Plus,
11
11
  Upload,
12
12
  } from "lucide-react";
13
- import { useRef, useState } from "react";
13
+ import { useId, useRef, useState } from "react";
14
14
  import {
15
15
  allTreeLevelsKey,
16
16
  type MoveDestination,
@@ -112,6 +112,10 @@ export function AppTree() {
112
112
  const uploadInput = useRef<HTMLInputElement>(null);
113
113
  const [dragged, setDragged] = useState<Carried | null>(null);
114
114
  const draggedRef = useRef<Carried | null>(null);
115
+ // The id of the one sentence every movable row is described by (#590). Minted rather than written
116
+ // down: two trees on one page would otherwise share a literal id, and the second would describe
117
+ // its rows with the first one's element.
118
+ const movableId = useId();
115
119
  // Which target the pointer is over: `undefined` for none, `null` for the root strip, an id for a
116
120
  // folder row. Three answers, because "the root" and "nothing" are not the same drop.
117
121
  const [over, setOver] = useState<string | null | undefined>(undefined);
@@ -626,6 +630,21 @@ export function AppTree() {
626
630
  putting `draggable` on this form control is precisely the Safari failure from #483. */}
627
631
  <SidebarMenuButton
628
632
  isActive={isActive}
633
+ /* ⚠️ What says the row is movable BEFORE anything touches it (#590). Until now only
634
+ `cursor-grab` said it, and that is a statement addressed to a pointer that is already
635
+ on the row — nobody navigating by keyboard, by touch or by ear ever received it, and
636
+ a way nobody sees is a way nobody uses. That is how the dead gesture of #483 stayed
637
+ dead for months without a single report.
638
+
639
+ ⚠️ A DESCRIPTION and not part of the name: the name is the title, and twenty rows
640
+ each announcing an instruction would bury it. It is also one shared element rather
641
+ than one per row — the sentence is the same for every movable row, and a copy per row
642
+ is a copy per row to keep in step.
643
+
644
+ ⚠️ Absent on a derived row, and that is the whole discrimination: what a flow calls
645
+ has no `parent_id` to rewrite and is not draggable (`draggable={!derived}`), so
646
+ claiming it were movable would send somebody after a gesture that is refused. */
647
+ aria-describedby={derived ? undefined : movableId}
629
648
  data-drop={
630
649
  dragged === null ? undefined : isDragged ? "dragged" : (drop?.verdict ?? "none")
631
650
  }
@@ -673,8 +692,30 @@ export function AppTree() {
673
692
 
674
693
  return (
675
694
  <SidebarGroup className="min-h-0 flex-1 overflow-y-auto">
695
+ {/* The tree is the navigation and carries no heading of its own (ADR-0004); the list is
696
+ named for assistive technology instead. */}
697
+ {renderLevel({ id: null, type: "folder" }, new Set(), i18n.t("tree.label"))}
698
+ {/* ⚠️ The sentence every movable row points at (#590). It names BOTH ways on purpose: the
699
+ drag for whoever has a pointer, and the entry in the title line for whoever has not —
700
+ `resource.move` is the same word it carries there. Naming only the drag would describe a
701
+ gesture that keyboard and touch do not have, which is the state this ticket found.
702
+
703
+ ⚠️ It is rendered AFTER the rows and it is `sr-only`, so it takes no space and moves
704
+ nothing. Anything a row could be pushed down by is the abort of #483 all over again — and
705
+ an element that exists from the first render pushes nothing in any case. */}
706
+ <span id={movableId} className="sr-only">
707
+ {i18n.t("tree.move.rowHint")}
708
+ </span>
676
709
  {/* The root has no row to drop on, so while something is being carried it gets one. It is the
677
- only way back out of a folder, and it appears exactly when it can be used. */}
710
+ only way back out of a folder, and it appears exactly when it can be used.
711
+ ⚠️ BELOW the tree, and that is not a matter of taste (#483). It used to stand above it, so
712
+ the moment a drag began every row moved down by its height — including the row being
713
+ dragged. Chromium ABORTS a drag whose source shifts under it: `dragstart` fired, `dragend`
714
+ followed six milliseconds later, and no `drag`, `dragover` or `drop` ever came. The hand
715
+ appeared and nothing moved, which is exactly what the ticket describes. Below the tree the
716
+ strip pushes nothing that sits above it, so the source stays where the gesture started.
717
+ Measured in a real Chromium: with the strip above, the gesture dies; with it here, the same
718
+ drag reaches the drop and opens the move dialog. */}
678
719
  {rootDrop.verdict === undefined || dragged === null ? null : (
679
720
  <button
680
721
  type="button"
@@ -691,7 +732,7 @@ export function AppTree() {
691
732
  });
692
733
  }}
693
734
  data-drop={rootDrop.verdict}
694
- className={`mb-1 flex w-full items-center gap-1 rounded-md border border-dashed px-2 py-1.5 text-left text-sm outline-none focus-visible:ring-2 focus-visible:ring-sidebar-ring ${
735
+ className={`mt-1 flex w-full items-center gap-1 rounded-md border border-dashed px-2 py-1.5 text-left text-sm outline-none focus-visible:ring-2 focus-visible:ring-sidebar-ring ${
695
736
  rootDrop.verdict === "ok"
696
737
  ? over === null
697
738
  ? "bg-sidebar-accent ring-2 ring-sidebar-ring"
@@ -703,9 +744,6 @@ export function AppTree() {
703
744
  {i18n.t("tree.move.dropRoot")}
704
745
  </button>
705
746
  )}
706
- {/* The tree is the navigation and carries no heading of its own (ADR-0004); the list is
707
- named for assistive technology instead. */}
708
- {renderLevel({ id: null, type: "folder" }, new Set(), i18n.t("tree.label"))}
709
747
  {create.isError || upload.isError || bundleImport.isError ? (
710
748
  <p role="alert" className="px-2 py-1.5 text-sm text-destructive">
711
749
  {bundleImport.isError
@@ -5,8 +5,10 @@ import { ArchiveRestore, LoaderCircle, Trash2 } from "lucide-react";
5
5
  import { useState } from "react";
6
6
  import { allTreeLevelsKey } from "@/app/tree-move/tree-move.tsx";
7
7
  import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
8
+ import { IntelRequestError } from "@/data/intel-data-provider/intel-data-provider.ts";
8
9
  import { useI18n } from "@/i18n/i18n-context.tsx";
9
10
  import { kindIcons } from "@/kind-icon.ts";
11
+ import { cn } from "@/lib/utils.ts";
10
12
  import { Modal } from "@/modal/modal.tsx";
11
13
  import { useIntelRouterContext } from "@/router/router-context.ts";
12
14
  import { RelativeTime } from "@/time/relative-time.tsx";
@@ -27,6 +29,70 @@ interface ArchivedEntry {
27
29
  previewPurge?(): Promise<{ inboundLinks: number; totalItems: number }>;
28
30
  }
29
31
 
32
+ /**
33
+ * The refusal of a permanent deletion — and the one place the reader can actually reach it.
34
+ *
35
+ * ⚠️ #601. It used to stand on the page only, and the confirmation dialog that produces it **stays
36
+ * open** on a refusal while marking everything outside itself `aria-hidden` (`Modal` → react-aria's
37
+ * `ModalOverlay`). Behind that the sentence was not merely awkward to get at: for a screen reader it
38
+ * was not there at all, and it announced nothing. Since #593 this is the sentence carrying the NAMES
39
+ * of the flows that still call the flow — the one thing that makes the refusal actionable, and the
40
+ * reason the ticket was built.
41
+ *
42
+ * ⚠️ It is rendered in exactly ONE of two places, never both: inside the dialog while that is open,
43
+ * on the page once it is closed. The second half is not a leftover — the reader closes the dialog to
44
+ * go and change the callers, and the sentence has to still be there when they come back. Two copies
45
+ * would be two announcements of one answer, which is the `#445` mistake in a new costume.
46
+ *
47
+ * ⚠️ This is NOT the `#430` class and does not go through `RefusalNotice`. That one answers a
48
+ * refused QUERY — a `403`/`404` that leaves a whole pane with nothing to show, told as a centered
49
+ * sentence without a retry. This is a refused mutation that carries data (`409` with `callers`),
50
+ * belongs beside the button that caused it, and its problem was never the wording.
51
+ */
52
+ function PurgeRefusal({ error, className }: { error: unknown; className?: string }) {
53
+ const i18n = useI18n();
54
+ /**
55
+ * ⚠️ Deleting a flow for good is refused with `409 flow_in_use_by_flow` while published flows
56
+ * still call it (`flows.ts`, `purge`), and the CALLERS are what makes that refusal actionable —
57
+ * "reload and try again" is advice for a flow nobody calls, and this one is called.
58
+ *
59
+ * ⚠️ Since #593 they arrive as data — the flows this reader may see by name, the rest as a count
60
+ * — so the sentence is written here, in the reader's language, exactly as the share dialog writes
61
+ * the folder's half of the same rule (#448). The server still decides WHICH may be named; that is
62
+ * an authorization answer and nothing on this side recomputes it.
63
+ */
64
+ const requestError = error instanceof IntelRequestError ? error : null;
65
+ const inUse = requestError?.code === "flow_in_use_by_flow" ? requestError : null;
66
+ return (
67
+ <div role="alert" className={cn("space-y-1 text-sm text-destructive", className)}>
68
+ {inUse ? (
69
+ <>
70
+ <p>{i18n.t("archive.purgeInUse")}</p>
71
+ {/* An API older than #593 sends no `callers`, and then its own sentence is the only place
72
+ the titles exist at all. Showing it beats showing nothing. */}
73
+ {inUse.callers === null ? (
74
+ <p className="text-xs">{inUse.message}</p>
75
+ ) : (
76
+ <p className="text-xs">
77
+ {inUse.callers.titles.length > 0
78
+ ? i18n.t("archive.purgeInUseCallers", {
79
+ titles: inUse.callers.titles.join(", "),
80
+ })
81
+ : i18n.t("archive.purgeInUseCallersHidden", { count: inUse.callers.hidden })}
82
+ {inUse.callers.titles.length > 0 && inUse.callers.hidden > 0
83
+ ? ` ${i18n.t("archive.purgeInUseCallersMore", { count: inUse.callers.hidden })}`
84
+ : ""}
85
+ {` ${i18n.t("archive.purgeInUseHint")}`}
86
+ </p>
87
+ )}
88
+ </>
89
+ ) : (
90
+ <p>{i18n.t("archive.purgeFailed")}</p>
91
+ )}
92
+ </div>
93
+ );
94
+ }
95
+
30
96
  export function Archive() {
31
97
  const { data } = useIntelRouterContext();
32
98
  const i18n = useI18n();
@@ -153,12 +219,11 @@ export function Archive() {
153
219
  {i18n.t("archive.restoreFailed")}
154
220
  </p>
155
221
  ) : null}
156
- {/* ⚠️ The refusal stands HERE and not in the dialog: it carries the reason — folder not
157
- empty, a flow still uses it — and it has to stay readable after the dialog is closed. */}
158
- {purge.isError ? (
159
- <p role="alert" className="mb-4 text-sm text-destructive">
160
- {i18n.t("archive.purgeFailed")}
161
- </p>
222
+ {/* ⚠️ On the page only while no dialog is open (#601). The refusal carries the reason — a
223
+ flow still calls this one — and it has to survive the dialog it came from; but as long as
224
+ that dialog stands, the page is `aria-hidden` and this is the copy nobody can reach. */}
225
+ {purge.isError && !confirming ? (
226
+ <PurgeRefusal error={purge.error} className="mb-4" />
162
227
  ) : null}
163
228
  {archived.data?.length === 0 ? (
164
229
  <p className="text-sm text-muted-foreground">{i18n.t("archive.empty")}</p>
@@ -225,7 +290,14 @@ export function Archive() {
225
290
  <button
226
291
  type="button"
227
292
  disabled={purge.isPending}
228
- onClick={() => setConfirming(entry)}
293
+ onClick={() => {
294
+ // ⚠️ The last refusal is cleared as the NEXT dialog opens, not when one closes:
295
+ // the sentence has to outlive the dialog it came from (#601), and it must not be
296
+ // standing in a dialog about a different entry — that would answer a question
297
+ // nobody asked, about the wrong thing.
298
+ purge.reset();
299
+ setConfirming(entry);
300
+ }}
229
301
  aria-label={i18n.t("archive.purge", { title: entry.title })}
230
302
  className="inline-flex size-8 shrink-0 items-center justify-center rounded-md border text-destructive outline-none hover:bg-destructive/10 focus-visible:ring-2 focus-visible:ring-ring disabled:opacity-60"
231
303
  >
@@ -263,6 +335,10 @@ export function Archive() {
263
335
  </p>
264
336
  ) : null}
265
337
  <p className="mt-2 text-sm text-muted-foreground">{i18n.t("archive.purge.grants")}</p>
338
+ {/* ⚠️ Inside the dialog, because the dialog stays open on a refusal and hides the page
339
+ behind it from the accessibility tree entirely (#601). Above the buttons rather than
340
+ below them: the answer belongs between the reason and the button that asks again. */}
341
+ {purge.isError ? <PurgeRefusal error={purge.error} className="mt-4" /> : null}
266
342
  <div className="mt-6 flex justify-end gap-2">
267
343
  <button
268
344
  type="button"
@@ -1,4 +1,4 @@
1
- import { ProblemDetails, SessionUser } from "@anchrd/intel-contract";
1
+ import { type NamedOrCounted, ProblemDetails, SessionUser } from "@anchrd/intel-contract";
2
2
  import { BundleImportResult } from "@anchrd/intel-contract/bundle";
3
3
  import {
4
4
  ArchiveFlowInput,
@@ -78,10 +78,20 @@ import type { IntelDataProvider, TreeEntry } from "./intel-data-provider.types.t
78
78
  // else changed it" — cannot do so from `detail`, which is a sentence the server is free to
79
79
  // reword. The code is the contract (`ProblemDetails.code`); the message stays what it was.
80
80
  export class IntelRequestError extends Error {
81
+ /**
82
+ * ⚠️ `callers` is the structured half of the two refusals that name flows — `folder_execute_in_use`
83
+ * (#448) and `flow_in_use_by_flow` (#593): the flows this reader may see, by name, and a count of
84
+ * the ones they may not. It is `null` for every other failure, and a screen that reads it has to
85
+ * say which code it read it for — the field is only meaningful beside its own.
86
+ *
87
+ * `message` still holds the server's finished English sentence. It is no longer what the dialog
88
+ * shows; it is what a log or a report has when nobody translated anything.
89
+ */
81
90
  constructor(
82
91
  public readonly status: number,
83
92
  public readonly code: string | null,
84
93
  message: string,
94
+ public readonly callers: NamedOrCounted | null = null,
85
95
  ) {
86
96
  super(message);
87
97
  this.name = "IntelRequestError";
@@ -162,6 +172,7 @@ export function createIntelDataProvider(
162
172
  parsed.success
163
173
  ? (parsed.data.detail ?? parsed.data.title)
164
174
  : `Intel responded with ${result.status}`,
175
+ parsed.success ? (parsed.data.callers ?? null) : null,
165
176
  );
166
177
  }
167
178
  return result;
@@ -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";
@@ -397,6 +457,50 @@ function saveRefusalText(error: unknown, i18n: I18n): string {
397
457
  return i18n.t("flows.saveFailed");
398
458
  }
399
459
 
460
+ /**
461
+ * Which sentence a refused PUBLISH is told with — #508 one surface further (#551).
462
+ *
463
+ * Publishing answers with at least eight different refusals: the compile rules of the save path,
464
+ * a link whose document is out of reach, a tool server that is gone, a frozen schema that moved,
465
+ * three rules about calling another flow — and all of them read
466
+ * "Publishing failed. Reload the latest version and try again." Reloading is the way out of exactly
467
+ * one of them, and for the rest it is the advice that costs the canvas and changes nothing.
468
+ *
469
+ * The split is therefore by what the answer IS, not by which code it carries:
470
+ *
471
+ * - **A refusal** (`4xx`) always shows the server's own sentence. It is the only party that knows
472
+ * which step broke which rule, and since #508 it names that step by its label rather than by an
473
+ * id — which is why #551 rewrote `flow_node_unavailable`, the one that still named a resource id.
474
+ * - **Two of them add the way out**, because the server's sentence identifies the step and stops
475
+ * there: a document out of reach is answered by asking for access, and a tool server that is
476
+ * gone by looking in the portal. Neither is something the API should prescribe per surface.
477
+ * - **A conflict** keeps the reload sentence, because there reloading IS the way out.
478
+ * - **Everything else** — a timeout, a `502`, a network that came back — says nothing about the
479
+ * loaded version and is worth repeating, so it gets neither.
480
+ */
481
+ function publishRefusalText(error: unknown, i18n: I18n): string {
482
+ const refusal = error instanceof IntelRequestError ? error : null;
483
+ // ⚠️ A `5xx` carries a sentence too, and showing it would dress a broken deployment up as a
484
+ // decision about this flow. Only the deliberate range speaks for itself.
485
+ if (refusal === null || refusal.status < 400 || refusal.status >= 500) {
486
+ return i18n.t("flows.publishFailed");
487
+ }
488
+ const detail = { detail: refusal.message };
489
+ switch (refusal.code) {
490
+ case "flow_node_unavailable":
491
+ return i18n.t("flows.publishNodeUnavailable", detail);
492
+ case "flow_tool_unavailable":
493
+ return i18n.t("flows.publishToolUnavailable", detail);
494
+ // The two ways the version being published stopped being the one to publish. Both are races
495
+ // against another editor, and the loaded version is genuinely stale afterwards.
496
+ case "flow_version_conflict":
497
+ case "flow_publish_conflict":
498
+ return i18n.t("flows.publishConflict");
499
+ default:
500
+ return i18n.t("flows.publishRefused", detail);
501
+ }
502
+ }
503
+
400
504
  // ⚠️ The provider wraps the editor rather than sitting inside it: `useReactFlow` — which is how the
401
505
  // drop turns a screen point into a graph point — has to be called from a component UNDER it, and
402
506
  // the hook lives in the same component that renders the canvas.
@@ -690,7 +794,7 @@ function FlowsEditor() {
690
794
  id: crypto.randomUUID(),
691
795
  ...contextEdgeStyle(connection.sourceHandle === ContextHandle),
692
796
  },
693
- current,
797
+ clearedFor(current, connection),
694
798
  ),
695
799
  );
696
800
  }}
@@ -1007,7 +1111,7 @@ function PublishPreview({
1007
1111
  </ul>
1008
1112
  {publish.isError && (
1009
1113
  <p role="alert" className="mt-4 text-sm text-destructive">
1010
- {i18n.t("flows.publishFailed")}
1114
+ {publishRefusalText(publish.error, i18n)}
1011
1115
  </p>
1012
1116
  )}
1013
1117
  <button
package/src/i18n/de.json CHANGED
@@ -48,8 +48,7 @@
48
48
  "tree.kind.attachment": "Datei",
49
49
  "tree.kind.table": "Tabelle",
50
50
  "tree.kind.flow": "Flow",
51
- "tree.move.action": "Verschieben nach …",
52
- "tree.move.hint": "oder ziehen",
51
+ "tree.move.rowHint": "Beweglich: diese Zeile auf einen Ordner ziehen, oder sie öffnen und Verschieben wählen.",
53
52
  "tree.move.title": "{title} verschieben",
54
53
  "tree.move.root": "Oberste Ebene",
55
54
  "tree.move.dropRoot": "Hier ablegen, um auf die oberste Ebene zu verschieben",
@@ -76,6 +75,7 @@
76
75
  "resource.archive": "Archivieren",
77
76
  "resource.columns": "Spalten",
78
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
79
  "resource.import": "Import",
80
80
  "resource.validate": "Prüfen",
81
81
  "resource.links": "Verweise",
@@ -102,12 +102,17 @@
102
102
  "archive.purge.links.one": "Ein anderes Dokument verweist darauf; dieser Verweis wird brechen.",
103
103
  "archive.purge.links.many": "{count} andere Dokumente verweisen darauf; diese Verweise werden brechen.",
104
104
  "archive.purge.grants": "Die Freigaben werden ebenfalls gelöscht.",
105
- "archive.purge.items": "Damit werden insgesamt {count} Elemente endgültig gelöscht.",
105
+ "archive.purge.items": "Insgesamt endgültig gelöscht: {count}.",
106
106
  "archive.purge.previewLoading": "Verweise werden geprüft …",
107
107
  "archive.purge.previewFailed": "Die Verweise konnten nicht geprüft werden. Lade neu und versuche es vor dem Löschen erneut.",
108
108
  "archive.purge.confirm": "Endgültig löschen",
109
109
  "archive.purge.cancel": "Abbrechen",
110
110
  "archive.purgeFailed": "Es konnte nicht vollständig gelöscht werden. Lade neu und versuche es erneut.",
111
+ "archive.purgeInUse": "Es lässt sich noch nicht löschen, weil veröffentlichte Flows es weiterhin aufrufen.",
112
+ "archive.purgeInUseCallers": "Aufgerufen von: {titles}.",
113
+ "archive.purgeInUseCallersMore": "Und {count} weitere, die du nicht sehen kannst.",
114
+ "archive.purgeInUseCallersHidden": "Es wird noch von {count} aufgerufen, die du nicht sehen kannst.",
115
+ "archive.purgeInUseHint": "Ändere oder depubliziere sie zuerst.",
111
116
  "archive.archived": "Archiviert",
112
117
  "resource.conflict": "Nicht geändert: Jemand anderes hat diesen Eintrag zuerst geändert. Lade ihn neu und versuche es erneut.",
113
118
  "resource.forbidden": "Nicht geändert: Du darfst diesen Eintrag nicht ändern.",
@@ -154,6 +159,10 @@
154
159
  "node.shareOrganizationHint": "Jedes angemeldete Mitglied der Organisation bekommt diesen Zugriff, auch wer später dazukommt.",
155
160
  "node.shareLibraryWarning": "Ausführen für die ganze Organisation macht diesen Ordner zu einer Bibliothek: Flows von überall im Baum dürfen dann hineinrufen. Das Zurücknehmen wird abgelehnt, solange einer davon es noch tut.",
156
161
  "node.revokeInUse": "Dieser Zugriff lässt sich noch nicht zurücknehmen, weil Flows außerhalb dieses Ordners noch hineinrufen.",
162
+ "node.revokeInUseCallers": "Aufgerufen von: {titles}.",
163
+ "node.revokeInUseCallersMore": "Und {count} weitere, die du nicht sehen kannst.",
164
+ "node.revokeInUseCallersHidden": "Aufgerufen von {count}, die du nicht sehen kannst.",
165
+ "node.revokeInUseHint": "Ändere oder depubliziere sie, bevor du den Ordner enger machst.",
157
166
  "node.someUser": "Ein Benutzerkonto",
158
167
  "node.verbs": "Was diese Freigabe erlaubt",
159
168
  "node.verb.read": "Lesen",
@@ -173,9 +182,9 @@
173
182
  "node.revokeVerbFor": "{verb} für {recipient} entziehen",
174
183
  "node.revokeShare": "Zugriff entziehen",
175
184
  "node.shareFailed": "Der Zugriff wurde nicht geändert. Prüfe deine Berechtigung und versuche es erneut.",
176
- "node.shareUnreadable": "Flows in diesem Ordner lesen Dokumente, die diese Freigabe nicht abdeckt: {titles}.",
177
- "node.shareUnreadableMore": "{count} weitere liegen ebenfalls außer Reichweite, und du kannst sie nicht sehen.",
178
- "node.shareUnreadableHidden": "{count} Dokumente, die Flows in diesem Ordner lesen, liegen mit dieser Freigabe außer Reichweite. Du kannst sie nicht sehen.",
185
+ "node.shareUnreadable": "Diese Freigabe deckt nicht ab, was Flows in diesem Ordner lesen: {titles}.",
186
+ "node.shareUnreadableMore": "Und {count} weitere, die du nicht sehen kannst.",
187
+ "node.shareUnreadableHidden": "Diese Freigabe deckt nicht alles ab, was Flows in diesem Ordner lesen: {count} davon kannst du nicht sehen.",
179
188
  "node.shareUnreadableHint": "Nichts ist blockiert. Ein Lauf dieser Flows hält für sie schlicht an dieser Stelle an.",
180
189
  "node.versions": "Versionsverlauf",
181
190
  "node.version": "Version {sequence}",
@@ -227,8 +236,7 @@
227
236
  "tools.outputSchema": "Ergebnisschema",
228
237
  "tools.destructiveShort": "Verändernd",
229
238
  "tools.destructiveCount": "{count} verändernd",
230
- "tools.toolCount": "{count} Werkzeuge",
231
- "tools.toolCountOne": "1 Werkzeug",
239
+ "tools.toolCount": "Werkzeuge: {count}",
232
240
  "tools.liveNote": "Diese Liste ist eine Live-Abfrage mit deinem eigenen Portal-Zugriff. Intel speichert weder die Werkzeuge noch, wer sie nutzen darf; beides entscheidet das Portal.",
233
241
  "flows.select": "Wähle einen Flow oder lege einen an, um den visuellen Editor zu öffnen.",
234
242
  "flows.inspect": "Wähle einen Schritt, um seine Konfiguration zu bearbeiten.",
@@ -290,7 +298,11 @@
290
298
  "flows.publishPreviewFollows": "läuft mit dem aufgerufenen Flow mit",
291
299
  "flows.publishPreviewUnavailable": "Der aufgerufene Flow hat noch nichts veröffentlicht, das lässt sich nicht einfrieren.",
292
300
  "flows.publishConfirm": "Veröffentlichen",
293
- "flows.publishFailed": "Das Veröffentlichen ist fehlgeschlagen. Lade die neueste Fassung und versuche es erneut.",
301
+ "flows.publishFailed": "Das Veröffentlichen ist fehlgeschlagen. Versuche es erneut.",
302
+ "flows.publishRefused": "Nicht veröffentlicht: {detail}",
303
+ "flows.publishConflict": "Nicht veröffentlicht: Jemand anderes hat diesen Flow zuerst geändert. Lade die neueste Fassung und versuche es erneut.",
304
+ "flows.publishNodeUnavailable": "Nicht veröffentlicht: {detail}. Frag den Zugriff an oder lass den Schritt auf etwas anderes zeigen.",
305
+ "flows.publishToolUnavailable": "Nicht veröffentlicht: {detail}. Prüf im Portal, ob du diesen Server noch erreichst.",
294
306
  "flows.tool": "MCP-Server",
295
307
  "flows.selectTool": "Server auswählen",
296
308
  "flows.operationFailed": "Der Flow-Vorgang ist fehlgeschlagen. Lade die neueste Fassung und versuche es erneut.",
@@ -300,7 +312,7 @@
300
312
  "flows.needs": "Was dieser Flow braucht",
301
313
  "flows.needsNodes": "Dokumente",
302
314
  "flows.needsTools": "Werkzeuge",
303
- "flows.needsInvalid": "{count} weitere, die es nicht mehr gibt — ersetze oder entferne diese Schritte",
315
+ "flows.needsInvalid": "Nicht mehr vorhanden: {count} — ersetze oder entferne jeden betroffenen Schritt",
304
316
  "flows.needsHidden": "{count} weitere, die du nicht sehen kannst",
305
317
  "flows.needsEmpty": "Dieser Flow liest keine Dokumente und ruft keine Werkzeuge auf.",
306
318
  "flows.needsFailed": "Was dieser Flow braucht, konnte nicht geladen werden.",
@@ -344,7 +356,7 @@
344
356
  "reindex.running": "Wird gesendet …",
345
357
  "reindex.cancel": "Abbrechen",
346
358
  "reindex.close": "Schließen",
347
- "reindex.queued": "{count} Knoten sind eingereiht. Der Index zieht im Hintergrund nach; die Suche funktioniert währenddessen weiter.",
359
+ "reindex.queued": "Eingereiht: {count}. Der Index zieht im Hintergrund nach; die Suche funktioniert währenddessen weiter.",
348
360
  "reindex.failed": "Der Neuaufbau wurde nicht gestartet. Prüfe deine Berechtigung und versuche es noch einmal.",
349
361
  "auth.signOut": "Abmelden",
350
362
  "auth.signOutFailed": "Das Abmelden ist fehlgeschlagen. Prüfe deine Verbindung und versuche es erneut.",
@@ -381,18 +393,18 @@
381
393
  "flows.toolFunctions": "Funktionen",
382
394
  "flows.toolServersDisconnected": "Du bist nicht mit dem Portal verbunden, daher lässt sich kein Server wählen. Verbinde es zuerst in den Werkzeug-Einstellungen.",
383
395
  "flows.toolAllowNone": "Es ist noch keine Funktion ausgewählt, daher lässt sich dieser Schritt nicht veröffentlichen.",
384
- "flows.toolAllowUnknown": "{count} ausgewählte Funktion(en) bietet dieser Server gerade nicht an.",
396
+ "flows.toolAllowUnknown": "Die Auswahl enthält, was dieser Server gerade nicht anbietet: {count}.",
385
397
  "flows.toolStepNeedsServer": "Dem Schritt „{label}“ fehlt noch ein Server.",
386
398
  "flows.toolFunctionsFailed": "Die Funktionsliste ließ sich nicht laden, daher ist die Auswahl möglicherweise unvollständig.",
387
399
  "flows.publishPreviewToolUnavailable": "Diesen Server erreichst du nicht, daher wird das Veröffentlichen abgelehnt.",
388
400
  "flows.publishPreviewToolAllow": "Eingefroren auf: {functions}",
389
- "flow.shareUnreadable": "Dieser Flow liest Dokumente, die diese Freigabe nicht abdeckt: {titles}.",
390
- "flow.shareUnreadableMore": "{count} weitere liegen ebenfalls außer Reichweite, und du kannst sie nicht sehen.",
391
- "flow.shareUnreadableHidden": "{count} Dokumente, die dieser Flow liest, liegen mit dieser Freigabe außer Reichweite. Du kannst sie nicht sehen.",
401
+ "flow.shareUnreadable": "Diese Freigabe deckt nicht ab, was dieser Flow liest: {titles}.",
402
+ "flow.shareUnreadableMore": "Und {count} weitere, die du nicht sehen kannst.",
403
+ "flow.shareUnreadableHidden": "Diese Freigabe deckt nicht alles ab, was dieser Flow liest: {count} davon kannst du nicht sehen.",
392
404
  "flow.shareUnreadableHint": "Nichts ist blockiert. Ein Lauf hält für sie schlicht an dieser Stelle an.",
393
- "flow.shareUnrunnable": "Dieser Flow ruft Flows auf, die diese Freigabe nicht abdeckt: {titles}.",
394
- "flow.shareUnrunnableMore": "{count} weitere liegen ebenfalls außer Reichweite, und du kannst sie nicht sehen.",
395
- "flow.shareUnrunnableHidden": "{count} Flows, die dieser aufruft, liegen mit dieser Freigabe außer Reichweite. Du kannst sie nicht sehen.",
405
+ "flow.shareUnrunnable": "Diese Freigabe deckt nicht ab, was dieser Flow aufruft: {titles}.",
406
+ "flow.shareUnrunnableMore": "Und {count} weitere, die du nicht sehen kannst.",
407
+ "flow.shareUnrunnableHidden": "Diese Freigabe deckt nicht alles ab, was dieser Flow aufruft: {count} davon kannst du nicht sehen.",
396
408
  "flow.shareUnrunnableHint": "Eine Freigabe auf einem Flow reicht nur bis zu diesem Flow. Gib die anderen ebenfalls frei oder den Ordner, in dem sie liegen. Mit „Prüfen“ sehen die Beschenkten es vor dem Start selbst.",
397
409
  "flow.verbHint.read": "Diesen Flow öffnen und lesen",
398
410
  "flow.verbHint.write": "Diesen Flow ändern",
package/src/i18n/en.json CHANGED
@@ -48,8 +48,7 @@
48
48
  "tree.kind.attachment": "File",
49
49
  "tree.kind.table": "Table",
50
50
  "tree.kind.flow": "Flow",
51
- "tree.move.action": "Move to…",
52
- "tree.move.hint": "or drag",
51
+ "tree.move.rowHint": "Movable: drag this row onto a folder, or open it and choose Move.",
53
52
  "tree.move.title": "Move {title}",
54
53
  "tree.move.root": "Top level",
55
54
  "tree.move.dropRoot": "Drop here to move to the top level",
@@ -76,6 +75,7 @@
76
75
  "resource.archive": "Archive",
77
76
  "resource.columns": "Columns",
78
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
79
  "resource.import": "Import",
80
80
  "resource.validate": "Check",
81
81
  "resource.links": "Links",
@@ -102,12 +102,17 @@
102
102
  "archive.purge.links.one": "One other document links to it and that link will break.",
103
103
  "archive.purge.links.many": "{count} other documents link to it and those links will break.",
104
104
  "archive.purge.grants": "Its shares will also be deleted.",
105
- "archive.purge.items": "This permanently deletes {count} items in total.",
105
+ "archive.purge.items": "Deleted for good in total: {count}.",
106
106
  "archive.purge.previewLoading": "Checking links…",
107
107
  "archive.purge.previewFailed": "The links could not be checked. Reload and try again before deleting.",
108
108
  "archive.purge.confirm": "Delete for good",
109
109
  "archive.purge.cancel": "Cancel",
110
110
  "archive.purgeFailed": "It could not be deleted completely. Reload and try again.",
111
+ "archive.purgeInUse": "It cannot be deleted yet, because published flows still call it.",
112
+ "archive.purgeInUseCallers": "Still calling it: {titles}.",
113
+ "archive.purgeInUseCallersMore": "And {count} more you cannot see.",
114
+ "archive.purgeInUseCallersHidden": "It is still called by {count} you cannot see.",
115
+ "archive.purgeInUseHint": "Change or unpublish them first.",
111
116
  "archive.archived": "Archived",
112
117
  "resource.conflict": "It was not changed: somebody else changed this entry first. Reload it and try again.",
113
118
  "resource.forbidden": "It was not changed: you may not change this entry.",
@@ -154,6 +159,10 @@
154
159
  "node.shareOrganizationHint": "Every signed-in member of the organization gets this access, including people who join later.",
155
160
  "node.shareLibraryWarning": "Run for the whole organization makes this folder a library: flows anywhere in the tree may then call into it. Taking it back is refused while any of them still does.",
156
161
  "node.revokeInUse": "This access cannot be withdrawn yet, because flows outside this folder still call into it.",
162
+ "node.revokeInUseCallers": "Still calling into it: {titles}.",
163
+ "node.revokeInUseCallersMore": "And {count} more you cannot see.",
164
+ "node.revokeInUseCallersHidden": "It is still called by {count} you cannot see.",
165
+ "node.revokeInUseHint": "Change or unpublish them before narrowing the folder.",
157
166
  "node.someUser": "A user account",
158
167
  "node.verbs": "What this grant allows",
159
168
  "node.verb.read": "Read",
@@ -173,9 +182,9 @@
173
182
  "node.revokeVerbFor": "Revoke {verb} for {recipient}",
174
183
  "node.revokeShare": "Revoke access",
175
184
  "node.shareFailed": "Access was not changed. Check your permission and try again.",
176
- "node.shareUnreadable": "Flows in this folder read documents this grant does not cover: {titles}.",
177
- "node.shareUnreadableMore": "{count} more are out of reach too, and you cannot see them.",
178
- "node.shareUnreadableHidden": "{count} documents that flows in this folder read are out of reach with this grant. You cannot see them.",
185
+ "node.shareUnreadable": "This grant does not cover what flows in this folder read: {titles}.",
186
+ "node.shareUnreadableMore": "And {count} more you cannot see.",
187
+ "node.shareUnreadableHidden": "This grant does not cover everything flows in this folder read: {count} you cannot see.",
179
188
  "node.shareUnreadableHint": "Nothing is blocked. A run of those flows will simply stop at that step for them.",
180
189
  "node.versions": "Version history",
181
190
  "node.version": "Version {sequence}",
@@ -227,8 +236,7 @@
227
236
  "tools.outputSchema": "Result schema",
228
237
  "tools.destructiveShort": "Destructive",
229
238
  "tools.destructiveCount": "{count} destructive",
230
- "tools.toolCount": "{count} tools",
231
- "tools.toolCountOne": "1 tool",
239
+ "tools.toolCount": "Tools: {count}",
232
240
  "tools.liveNote": "This list is a live query with your own portal access. Intel stores neither the tools nor who may use them; the portal decides both.",
233
241
  "flows.select": "Select a flow or create one to open the visual editor.",
234
242
  "flows.inspect": "Select a node to edit its configuration.",
@@ -290,7 +298,11 @@
290
298
  "flows.publishPreviewFollows": "runs along with the called flow",
291
299
  "flows.publishPreviewUnavailable": "The called flow has published nothing yet, so this cannot be frozen.",
292
300
  "flows.publishConfirm": "Publish",
293
- "flows.publishFailed": "Publishing failed. Reload the latest version and try again.",
301
+ "flows.publishFailed": "Publishing failed. Try again.",
302
+ "flows.publishRefused": "Not published: {detail}",
303
+ "flows.publishConflict": "Not published: somebody else changed this flow first. Reload the latest version and try again.",
304
+ "flows.publishNodeUnavailable": "Not published: {detail}. Ask for access to it, or point the step at something else.",
305
+ "flows.publishToolUnavailable": "Not published: {detail}. Check in the portal whether you still reach that server.",
294
306
  "flows.tool": "MCP server",
295
307
  "flows.selectTool": "Select a server",
296
308
  "flows.operationFailed": "The flow operation failed. Reload the latest version and try again.",
@@ -300,7 +312,7 @@
300
312
  "flows.needs": "What this flow needs",
301
313
  "flows.needsNodes": "Documents",
302
314
  "flows.needsTools": "Tools",
303
- "flows.needsInvalid": "{count} more that no longer exist — replace or remove those steps",
315
+ "flows.needsInvalid": "No longer there: {count} — replace or remove every affected step",
304
316
  "flows.needsHidden": "{count} more you cannot see",
305
317
  "flows.needsEmpty": "This flow reads no documents and calls no tools.",
306
318
  "flows.needsFailed": "What this flow needs could not be loaded.",
@@ -344,7 +356,7 @@
344
356
  "reindex.running": "Sending…",
345
357
  "reindex.cancel": "Cancel",
346
358
  "reindex.close": "Close",
347
- "reindex.queued": "{count} nodes are queued. The index catches up in the background; searching keeps working meanwhile.",
359
+ "reindex.queued": "Queued: {count}. The index catches up in the background; searching keeps working meanwhile.",
348
360
  "reindex.failed": "The rebuild was not started. Check your access and try again.",
349
361
  "auth.signOut": "Sign out",
350
362
  "auth.signOutFailed": "Signing out failed. Check your connection and try again.",
@@ -381,18 +393,18 @@
381
393
  "flows.toolFunctions": "Functions",
382
394
  "flows.toolServersDisconnected": "You are not connected to the portal, so no server can be chosen. Connect it in the tool settings first.",
383
395
  "flows.toolAllowNone": "No function is selected yet, so this step cannot be published.",
384
- "flows.toolAllowUnknown": "{count} selected function(s) are not offered by this server right now.",
396
+ "flows.toolAllowUnknown": "The selection includes what this server does not offer right now: {count}.",
385
397
  "flows.toolStepNeedsServer": "The step “{label}” still needs a server.",
386
398
  "flows.toolFunctionsFailed": "The list of functions could not be loaded, so the selection may be incomplete.",
387
399
  "flows.publishPreviewToolUnavailable": "You do not reach this server, so publishing will be refused.",
388
400
  "flows.publishPreviewToolAllow": "Frozen to: {functions}",
389
- "flow.shareUnreadable": "This flow reads documents this grant does not cover: {titles}.",
390
- "flow.shareUnreadableMore": "{count} more are out of reach too, and you cannot see them.",
391
- "flow.shareUnreadableHidden": "{count} documents this flow reads are out of reach with this grant. You cannot see them.",
401
+ "flow.shareUnreadable": "This grant does not cover what this flow reads: {titles}.",
402
+ "flow.shareUnreadableMore": "And {count} more you cannot see.",
403
+ "flow.shareUnreadableHidden": "This grant does not cover everything this flow reads: {count} you cannot see.",
392
404
  "flow.shareUnreadableHint": "Nothing is blocked. A run will simply stop at that step for them.",
393
- "flow.shareUnrunnable": "This flow calls flows this grant does not cover: {titles}.",
394
- "flow.shareUnrunnableMore": "{count} more are out of reach too, and you cannot see them.",
395
- "flow.shareUnrunnableHidden": "{count} flows this one calls are out of reach with this grant. You cannot see them.",
405
+ "flow.shareUnrunnable": "This grant does not cover what this flow calls: {titles}.",
406
+ "flow.shareUnrunnableMore": "And {count} more you cannot see.",
407
+ "flow.shareUnrunnableHidden": "This grant does not cover everything this flow calls: {count} you cannot see.",
396
408
  "flow.shareUnrunnableHint": "A grant on one flow reaches that flow alone. Share those flows too, or share the folder they are filed in. Let them check with Validate before they run it.",
397
409
  "flow.verbHint.read": "Open this flow and read it",
398
410
  "flow.verbHint.write": "Change this flow",
package/src/i18n/es.json CHANGED
@@ -48,8 +48,7 @@
48
48
  "tree.kind.attachment": "Archivo",
49
49
  "tree.kind.table": "Tabla",
50
50
  "tree.kind.flow": "Flujo",
51
- "tree.move.action": "Mover a",
52
- "tree.move.hint": "o arrastra",
51
+ "tree.move.rowHint": "Movible: arrastra esta fila a una carpeta, o ábrela y elige Mover.",
53
52
  "tree.move.title": "Mover {title}",
54
53
  "tree.move.root": "Nivel superior",
55
54
  "tree.move.dropRoot": "Suelta aquí para mover al nivel superior",
@@ -76,6 +75,7 @@
76
75
  "resource.archive": "Archivar",
77
76
  "resource.columns": "Columnas",
78
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
79
  "resource.import": "Importar",
80
80
  "resource.validate": "Comprobar",
81
81
  "resource.links": "Enlaces",
@@ -102,12 +102,17 @@
102
102
  "archive.purge.links.one": "Otro documento contiene un enlace a este elemento y ese enlace dejará de funcionar.",
103
103
  "archive.purge.links.many": "Otros {count} documentos contienen enlaces a este elemento y dejarán de funcionar.",
104
104
  "archive.purge.grants": "Sus permisos compartidos también se eliminarán.",
105
- "archive.purge.items": "Esto elimina definitivamente {count} elementos en total.",
105
+ "archive.purge.items": "Eliminado definitivamente en total: {count}.",
106
106
  "archive.purge.previewLoading": "Comprobando enlaces…",
107
107
  "archive.purge.previewFailed": "No se pudieron comprobar los enlaces. Recarga e inténtalo de nuevo antes de eliminar.",
108
108
  "archive.purge.confirm": "Eliminar definitivamente",
109
109
  "archive.purge.cancel": "Cancelar",
110
110
  "archive.purgeFailed": "No se pudo eliminar por completo. Recarga e inténtalo de nuevo.",
111
+ "archive.purgeInUse": "Todavía no se puede eliminar, porque hay flujos publicados que siguen invocándolo.",
112
+ "archive.purgeInUseCallers": "Invocado por: {titles}.",
113
+ "archive.purgeInUseCallersMore": "Y {count} más que no puedes ver.",
114
+ "archive.purgeInUseCallersHidden": "Todavía lo invocan {count} que no puedes ver.",
115
+ "archive.purgeInUseHint": "Cámbialos o retira su publicación primero.",
111
116
  "archive.archived": "Archivado",
112
117
  "resource.conflict": "No se ha cambiado: otra persona ha cambiado esta entrada antes. Recárgala e inténtalo de nuevo.",
113
118
  "resource.forbidden": "No se ha cambiado: no puedes cambiar esta entrada.",
@@ -154,6 +159,10 @@
154
159
  "node.shareOrganizationHint": "Todos los miembros de la organización que hayan iniciado sesión obtienen este acceso, incluidos quienes se incorporen más tarde.",
155
160
  "node.shareLibraryWarning": "Ejecutar para toda la organización convierte esta carpeta en una biblioteca: los flujos de cualquier parte del árbol podrán invocarla. Retirarlo se rechaza mientras alguno siga haciéndolo.",
156
161
  "node.revokeInUse": "Este acceso todavía no se puede retirar, porque hay flujos fuera de esta carpeta que siguen invocándola.",
162
+ "node.revokeInUseCallers": "Invocada por: {titles}.",
163
+ "node.revokeInUseCallersMore": "Y {count} más que no puedes ver.",
164
+ "node.revokeInUseCallersHidden": "Invocada por {count} que no puedes ver.",
165
+ "node.revokeInUseHint": "Cámbialos o retira su publicación antes de restringir la carpeta.",
157
166
  "node.someUser": "Una cuenta de usuario",
158
167
  "node.verbs": "Qué permite este acceso",
159
168
  "node.verb.read": "Leer",
@@ -173,9 +182,9 @@
173
182
  "node.revokeVerbFor": "Retirar {verb} a {recipient}",
174
183
  "node.revokeShare": "Retirar el acceso",
175
184
  "node.shareFailed": "El acceso no se ha cambiado. Comprueba tu permiso e inténtalo de nuevo.",
176
- "node.shareUnreadable": "Los flujos de esta carpeta leen documentos que este acceso no cubre: {titles}.",
177
- "node.shareUnreadableMore": "{count} más también quedan fuera de alcance, y no puedes verlos.",
178
- "node.shareUnreadableHidden": "{count} documentos que leen los flujos de esta carpeta quedan fuera de alcance con este acceso. No puedes verlos.",
185
+ "node.shareUnreadable": "Este acceso no cubre lo que leen los flujos de esta carpeta: {titles}.",
186
+ "node.shareUnreadableMore": "Y {count} más que no puedes ver.",
187
+ "node.shareUnreadableHidden": "Este acceso no cubre todo lo que leen los flujos de esta carpeta: {count} que no puedes ver.",
179
188
  "node.shareUnreadableHint": "No se bloquea nada. Una ejecución de esos flujos simplemente se detendrá en ese paso para esa persona.",
180
189
  "node.versions": "Historial de versiones",
181
190
  "node.version": "Versión {sequence}",
@@ -227,8 +236,7 @@
227
236
  "tools.outputSchema": "Esquema del resultado",
228
237
  "tools.destructiveShort": "Destructiva",
229
238
  "tools.destructiveCount": "{count} destructivas",
230
- "tools.toolCount": "{count} herramientas",
231
- "tools.toolCountOne": "1 herramienta",
239
+ "tools.toolCount": "Herramientas: {count}",
232
240
  "tools.liveNote": "Esta lista es una consulta en vivo con tu propio acceso al portal. Intel no guarda ni las herramientas ni quién puede usarlas; ambas cosas las decide el portal.",
233
241
  "flows.select": "Elige un flujo o crea uno para abrir el editor visual.",
234
242
  "flows.inspect": "Elige un paso para editar su configuración.",
@@ -290,7 +298,11 @@
290
298
  "flows.publishPreviewFollows": "va con el flujo llamado",
291
299
  "flows.publishPreviewUnavailable": "El flujo llamado todavía no ha publicado nada, así que esto no se puede congelar.",
292
300
  "flows.publishConfirm": "Publicar",
293
- "flows.publishFailed": "La publicación ha fallado. Carga la versión más reciente e inténtalo de nuevo.",
301
+ "flows.publishFailed": "La publicación ha fallado. Inténtalo de nuevo.",
302
+ "flows.publishRefused": "No se ha publicado: {detail}",
303
+ "flows.publishConflict": "No se ha publicado: otra persona ha cambiado este flujo antes. Carga la versión más reciente e inténtalo de nuevo.",
304
+ "flows.publishNodeUnavailable": "No se ha publicado: {detail}. Pide acceso a él o haz que el paso apunte a otra cosa.",
305
+ "flows.publishToolUnavailable": "No se ha publicado: {detail}. Comprueba en el portal si todavía alcanzas ese servidor.",
294
306
  "flows.tool": "Servidor MCP",
295
307
  "flows.selectTool": "Elegir un servidor",
296
308
  "flows.operationFailed": "La operación del flujo ha fallado. Carga la versión más reciente e inténtalo de nuevo.",
@@ -300,7 +312,7 @@
300
312
  "flows.needs": "Qué necesita este flujo",
301
313
  "flows.needsNodes": "Documentos",
302
314
  "flows.needsTools": "Herramientas",
303
- "flows.needsInvalid": "{count} más que ya no existen — sustituye o quita esos pasos",
315
+ "flows.needsInvalid": "Lo que ya no está: {count} — sustituye o quita cada paso afectado",
304
316
  "flows.needsHidden": "{count} más que no puedes ver",
305
317
  "flows.needsEmpty": "Este flujo no lee ningún documento ni llama a ninguna herramienta.",
306
318
  "flows.needsFailed": "Lo que necesita este flujo no se ha podido cargar.",
@@ -344,7 +356,7 @@
344
356
  "reindex.running": "Enviando…",
345
357
  "reindex.cancel": "Cancelar",
346
358
  "reindex.close": "Cerrar",
347
- "reindex.queued": "{count} nodos están en cola. El índice se pone al día en segundo plano; mientras tanto la búsqueda sigue funcionando.",
359
+ "reindex.queued": "En cola: {count}. El índice se pone al día en segundo plano; mientras tanto la búsqueda sigue funcionando.",
348
360
  "reindex.failed": "No se inició la reconstrucción. Comprueba tu acceso e inténtalo de nuevo.",
349
361
  "auth.signOut": "Cerrar sesión",
350
362
  "auth.signOutFailed": "El cierre de sesión ha fallado. Comprueba tu conexión e inténtalo de nuevo.",
@@ -381,18 +393,18 @@
381
393
  "flows.toolFunctions": "Funciones",
382
394
  "flows.toolServersDisconnected": "No estás conectado al portal, así que no se puede elegir ningún servidor. Conéctalo primero en la configuración de herramientas.",
383
395
  "flows.toolAllowNone": "Todavía no hay ninguna función seleccionada, así que este paso no se puede publicar.",
384
- "flows.toolAllowUnknown": "Este servidor no ofrece ahora mismo {count} función(es) seleccionada(s).",
396
+ "flows.toolAllowUnknown": "La selección incluye lo que este servidor no ofrece ahora mismo: {count}.",
385
397
  "flows.toolStepNeedsServer": "Al paso «{label}» todavía le falta un servidor.",
386
398
  "flows.toolFunctionsFailed": "No se pudo cargar la lista de funciones, así que la selección puede estar incompleta.",
387
399
  "flows.publishPreviewToolUnavailable": "No alcanzas este servidor, así que la publicación será rechazada.",
388
400
  "flows.publishPreviewToolAllow": "Congelado en: {functions}",
389
- "flow.shareUnreadable": "Este flujo lee documentos que este acceso no cubre: {titles}.",
390
- "flow.shareUnreadableMore": "{count} más también quedan fuera de alcance, y no puedes verlos.",
391
- "flow.shareUnreadableHidden": "{count} documentos que lee este flujo quedan fuera de alcance con este acceso. No puedes verlos.",
401
+ "flow.shareUnreadable": "Este acceso no cubre lo que lee este flujo: {titles}.",
402
+ "flow.shareUnreadableMore": "Y {count} más que no puedes ver.",
403
+ "flow.shareUnreadableHidden": "Este acceso no cubre todo lo que lee este flujo: {count} que no puedes ver.",
392
404
  "flow.shareUnreadableHint": "No se bloquea nada. Una ejecución simplemente se detendrá en ese paso para esa persona.",
393
- "flow.shareUnrunnable": "Este flujo llama a flujos que este acceso no cubre: {titles}.",
394
- "flow.shareUnrunnableMore": "{count} más también quedan fuera de alcance, y no puedes verlos.",
395
- "flow.shareUnrunnableHidden": "{count} flujos a los que llama este quedan fuera de alcance con este acceso. No puedes verlos.",
405
+ "flow.shareUnrunnable": "Este acceso no cubre lo que invoca este flujo: {titles}.",
406
+ "flow.shareUnrunnableMore": "Y {count} más que no puedes ver.",
407
+ "flow.shareUnrunnableHidden": "Este acceso no cubre todo lo que invoca este flujo: {count} que no puedes ver.",
396
408
  "flow.shareUnrunnableHint": "Un acceso sobre un flujo llega solo a ese flujo. Comparte también esos flujos, o la carpeta donde están. Con «Validar» lo verán ellos mismos antes de ejecutarlo.",
397
409
  "flow.verbHint.read": "Abrir este flujo y leerlo",
398
410
  "flow.verbHint.write": "Cambiar este flujo",
@@ -18,7 +18,7 @@ import {
18
18
  ShieldCheck,
19
19
  } from "lucide-react";
20
20
  import type * as React from "react";
21
- import { useState } from "react";
21
+ import { useId, 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,6 +154,11 @@ 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();
157
162
 
158
163
  // Everything a change here can make stale. The row sits in one level of the tree, the open screen
159
164
  // reads the record, and the flat collections behind the search, the link picker and the relation
@@ -316,10 +321,40 @@ export function ResourceMenu({
316
321
  ⚠️ The one case where they differed is the table that has no header yet: `downloadCsv`
317
322
  refused it out loud, `tableCsv` joins no segments and writes a file of zero bytes
318
323
  (#534). That is the export's gap on every kind it can hit, not something this entry
319
- was covering — which is why it is fixed there and not by keeping a second entry. */}
320
- <DropdownMenuItem onSelect={() => exportBundle.mutate()}>
321
- <FolderDown aria-hidden="true" />
322
- {i18n.t("resource.export")}
324
+ was covering — which is why it is fixed there and not by keeping a second entry.
325
+
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 zip — read, 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>
323
358
  </DropdownMenuItem>
324
359
  {/* The other half of the same round trip, next to it rather than in the tree's plus
325
360
  (#346): one word in the menu, both sources under it. */}
@@ -491,31 +526,23 @@ function VersionHistory({ target, close }: { target: ResourceTarget; close(): vo
491
526
  }));
492
527
  },
493
528
  });
529
+ // ⚠️ A Modal, like every other panel in this menu — NOT an `absolute` drawer. `absolute` measures
530
+ // against the nearest positioned ancestor, and this menu has no say in what that is: on the title
531
+ // line it is the title row (`title-row.tsx`, `relative z-10`, around 100px tall), which clipped
532
+ // the list to its first entry on every viewport; in the folder listing it is the table container
533
+ // (`table.tsx`, `relative … overflow-x-auto`), which clips it differently again (#628). The list
534
+ // scrolls INSIDE the dialog instead, the way `NodeLinksPanel` further down does it.
494
535
  return (
495
- <aside
496
- aria-label={i18n.t("node.versions")}
497
- className="absolute bottom-0 right-0 top-0 z-10 w-80 overflow-y-auto border-l bg-card p-5 shadow-xl"
498
- >
499
- <div className="flex items-start justify-between gap-4">
500
- <h3 className="font-semibold">{i18n.t("node.versions")}</h3>
501
- <button
502
- type="button"
503
- onClick={close}
504
- aria-label={i18n.t("common.close")}
505
- className="rounded-md px-2 py-1 text-muted-foreground outline-none hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring"
506
- >
507
- ×
508
- </button>
509
- </div>
536
+ <Modal title={i18n.t("node.versions")} close={close}>
510
537
  {versions.isPending ? (
511
- <p className="mt-4 text-sm text-muted-foreground">{i18n.t("common.loading")}</p>
538
+ <p className="text-sm text-muted-foreground">{i18n.t("common.loading")}</p>
512
539
  ) : null}
513
540
  {versions.isError ? (
514
- <p role="alert" className="mt-4 text-sm text-destructive">
541
+ <p role="alert" className="text-sm text-destructive">
515
542
  {i18n.t(target.type === "flow" ? "flows.operationFailed" : "node.operationFailed")}
516
543
  </p>
517
544
  ) : null}
518
- <ol className="mt-4 space-y-3">
545
+ <ol className="max-h-72 space-y-3 overflow-y-auto">
519
546
  {versions.data?.map((version) => (
520
547
  <li key={version.id} className="rounded-md border p-3 text-sm">
521
548
  <span className="font-medium">
@@ -528,7 +555,7 @@ function VersionHistory({ target, close }: { target: ResourceTarget; close(): vo
528
555
  </li>
529
556
  ))}
530
557
  </ol>
531
- </aside>
558
+ </Modal>
532
559
  );
533
560
  }
534
561
 
@@ -787,27 +814,41 @@ function SharePanel({ target, close }: { target: ResourceTarget; close(): void }
787
814
  *
788
815
  * ⚠️ Taking back `organization` + `execute` on a folder is refused with `409
789
816
  * folder_execute_in_use` while flows from outside still call into it (`nodes.ts`,
790
- * `revokeGrant`) and the refusal's `detail` is the only place the CALLERS are named. Rendering
791
- * it as the generic "access was not changed" threw exactly that away and left the reader with a
792
- * dialog that refuses and will not say why, for the one grant that is hardest to undo.
817
+ * `revokeGrant`), and the CALLERS are what makes that refusal actionable. Rendering it as the
818
+ * generic "access was not changed" threw exactly that away and left the reader with a dialog that
819
+ * refuses and will not say why, for the one grant that is hardest to undo.
793
820
  *
794
- * ⚠️ The server's own words, on purpose. `callersDetail` names the flows this actor may see and
795
- * only COUNTS the rest, so it is actionable without becoming a directory of the tree — a
796
- * translated stand-in would either lose the names or need them separately, and the wire carries
797
- * prose rather than a list (#448). The lead-in sentence above it is translated; this line is the
798
- * evidence under it.
821
+ * ⚠️ Since #448 they arrive as data the flows this actor may see by name, the rest as a count —
822
+ * so the sentence is written here, in the reader's language, exactly like the `shareUnreadable`
823
+ * warning below it. The server still decides WHICH of them may be named; that is an authorization
824
+ * answer and nothing on this side recomputes it.
799
825
  */
800
- const revokeInUse =
801
- revoke.error instanceof IntelRequestError && revoke.error.code === "folder_execute_in_use"
802
- ? revoke.error.message
803
- : null;
826
+ const revokeError = revoke.error instanceof IntelRequestError ? revoke.error : null;
827
+ const revokeInUse = revokeError?.code === "folder_execute_in_use" ? revokeError : null;
804
828
 
805
829
  return (
806
830
  <Modal title={i18n.t("node.share")} close={close}>
807
831
  {revokeInUse ? (
808
832
  <div role="alert" className="mb-4 space-y-1 text-sm text-destructive">
809
833
  <p>{i18n.t("node.revokeInUse")}</p>
810
- <p className="text-xs">{revokeInUse}</p>
834
+ {/* An API that predates #448 sends no `callers`, and its sentence is then the only place
835
+ the names exist at all. Showing it beats showing nothing — losing the titles is the
836
+ failure this ticket is about. */}
837
+ {revokeInUse.callers === null ? (
838
+ <p className="text-xs">{revokeInUse.message}</p>
839
+ ) : (
840
+ <p className="text-xs">
841
+ {revokeInUse.callers.titles.length > 0
842
+ ? i18n.t("node.revokeInUseCallers", {
843
+ titles: revokeInUse.callers.titles.join(", "),
844
+ })
845
+ : i18n.t("node.revokeInUseCallersHidden", { count: revokeInUse.callers.hidden })}
846
+ {revokeInUse.callers.titles.length > 0 && revokeInUse.callers.hidden > 0
847
+ ? ` ${i18n.t("node.revokeInUseCallersMore", { count: revokeInUse.callers.hidden })}`
848
+ : ""}
849
+ {` ${i18n.t("node.revokeInUseHint")}`}
850
+ </p>
851
+ )}
811
852
  </div>
812
853
  ) : (
813
854
  (share.isError || revoke.isError || grants.isError) && (
@@ -415,10 +415,11 @@ function ServerGroup({
415
415
  // with it — otherwise Enter on a hit lands on a screen where nothing happened.
416
416
  const holdsRequested = group.items.some((item) => item.name === requested);
417
417
  const destructive = group.items.filter((item) => item.annotations.destructiveHint).length;
418
- const count =
419
- group.items.length === 1
420
- ? i18n.t("tools.toolCountOne")
421
- : i18n.t("tools.toolCount", { count: group.items.length });
418
+ // ⚠️ One key, not a `…One` beside a `…Many` (#605): the catalog carries a wording that fits every
419
+ // number, so nothing here branches on the count. The branch that used to stand here is what the
420
+ // rule in `packages/ui/CLAUDE.md` forbids — it puts the inflection into the component and into
421
+ // every future language.
422
+ const count = i18n.t("tools.toolCount", { count: group.items.length });
422
423
  // The badges beside the name read as one run-on word when a screen reader concatenates them
423
424
  // ("wiki1 tool"), so the control says its own name instead of being read off its contents.
424
425
  const spoken = [`${i18n.t("tools.origin")}: ${group.label}`, count];