@anchrd/intel-ui 0.28.0 → 0.31.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.28.0",
3
+ "version": "0.31.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.17.0",
36
+ "@anchrd/intel-contract": "^0.19.0",
37
37
  "@blocknote/core": "^0.52.1",
38
38
  "@blocknote/react": "^0.52.1",
39
39
  "@blocknote/shadcn": "^0.52.1",
@@ -12,6 +12,7 @@ import {
12
12
  } from "lucide-react";
13
13
  import { useRef, useState } from "react";
14
14
  import {
15
+ allTreeLevelsKey,
15
16
  type MoveDestination,
16
17
  moveErrorKey,
17
18
  moveVerdict,
@@ -176,13 +177,18 @@ export function AppTree() {
176
177
  // ⚠️ The level a row appears in is not the only thing that goes stale. The header search reads
177
178
  // the whole flow list, and the graph is what the link picker and the flow editor's tree links
178
179
  // offer — a row created here has to reach those too, or another screen keeps showing yesterday.
180
+ //
181
+ // ⚠️ And the level written into is not enough either: the folder's own ROW says whether it can be
182
+ // opened at all, and that row stands one level higher (`allTreeLevelsKey`, #519). Without it the
183
+ // `toggle` above opens a folder whose arrow never appeared, so the first thing filed into an empty
184
+ // folder was invisible until the page was reloaded.
179
185
  async function reveal(
180
186
  parentId: string | null,
181
187
  collection: "flows" | "node-graph",
182
188
  ): Promise<void> {
183
189
  if (parentId !== null) toggle({ id: parentId, type: "folder" }, true);
184
190
  await Promise.all([
185
- queryClient.invalidateQueries({ queryKey: levelKey({ id: parentId, type: "folder" }) }),
191
+ queryClient.invalidateQueries({ queryKey: allTreeLevelsKey }),
186
192
  queryClient.invalidateQueries({ queryKey: [collection] }),
187
193
  // The graph view of that same level draws exactly this list, so it goes stale for the same
188
194
  // reason the level does (#19).
@@ -233,8 +239,19 @@ export function AppTree() {
233
239
  },
234
240
  onSuccess: async (created) => {
235
241
  setCreating(null);
236
- await reveal(created.parentId, created.collection);
237
- await navigate({ to: created.area, search: { select: created.id } });
242
+ // ⚠️ The screen first, the refreshes after. Since #519 `reveal` waits on EVERY mounted level,
243
+ // and a React Query retry is paused while the tab is not in front (#212) — awaiting that
244
+ // before navigating would leave somebody who just made a document sitting on the old screen
245
+ // for as long as one level takes to answer.
246
+ //
247
+ // ⚠️ `finally`, because both screens are lazy chunks: a failed chunk load throws out of
248
+ // `navigate`, and without this the tree, the collections and the graph would silently keep
249
+ // yesterday's rows for the rest of the session — the very state this ticket is about.
250
+ try {
251
+ await navigate({ to: created.area, search: { select: created.id } });
252
+ } finally {
253
+ await reveal(created.parentId, created.collection);
254
+ }
238
255
  },
239
256
  });
240
257
 
@@ -275,8 +292,12 @@ export function AppTree() {
275
292
  return { id: node.id, parentId };
276
293
  },
277
294
  onSuccess: async (created) => {
278
- await reveal(created.parentId, "node-graph");
279
- await navigate({ to: "/nodes", search: { select: created.id } });
295
+ // The order — and the `finally` — are the ones above, for the same two reasons.
296
+ try {
297
+ await navigate({ to: "/nodes", search: { select: created.id } });
298
+ } finally {
299
+ await reveal(created.parentId, "node-graph");
300
+ }
280
301
  },
281
302
  });
282
303
 
@@ -31,6 +31,33 @@ export function treeLevelKey(parentId: string | null): readonly unknown[] {
31
31
  return ["tree", parentId];
32
32
  }
33
33
 
34
+ /**
35
+ * Every level of the tree at once — what a write that changed what is IN a folder makes stale.
36
+ *
37
+ * ⚠️ Not the level that was written into: that is the obvious half, and it was the whole of #519.
38
+ * A folder ROW carries `openable` — since #59 the arrow only stands where there is something to
39
+ * open — and that is a statement about the folder's CONTENTS living one level ABOVE it. Refresh
40
+ * only the level below and the row keeps yesterday's answer: the first thing filed into an EMPTY
41
+ * folder has no arrow to appear under, `reveal` opens a level `renderRow` then never renders, and
42
+ * reloading the page was the only thing that read the row again. The other direction is the same
43
+ * bug from the other side — the last child moved or archived out leaves an arrow promising a level
44
+ * that is empty.
45
+ *
46
+ * ⚠️ A prefix rather than "the level, plus the level its folder row sits in", because that second
47
+ * level is not always the one the record names: since #429 a shared row also stands at the root
48
+ * (`levelsToClear`).
49
+ *
50
+ * ⚠️ What it costs is one request per MOUNTED level, and that is not the same as "per level on
51
+ * screen": `expanded` in `app-tree.tsx` is only ever added to, so a folder that was opened and has
52
+ * since left the tree — archived, moved away — keeps its query mounted for the rest of the session
53
+ * and is re-read by every write from then on. Nothing is wrong on screen; the requests are simply
54
+ * paid for. #521 prunes that list, and until it does, this is the honest bound.
55
+ *
56
+ * The same prefix is what `archive.tsx` has always used for the same reason: a row can come back
57
+ * anywhere, and the screen writing it does not know where.
58
+ */
59
+ export const allTreeLevelsKey: readonly unknown[] = ["tree"];
60
+
34
61
  /**
35
62
  * Which cached levels a move has to take the row out of.
36
63
  *
@@ -368,12 +395,11 @@ export function useTreeMove({
368
395
  for (const [key, value] of context?.snapshot ?? []) queryClient.setQueryData(key, value);
369
396
  },
370
397
  onSuccess: (_result, { destination }) => onMoved?.(destination),
371
- onSettled: async (_result, _error, { entry, level, destination }) => {
398
+ onSettled: async (_result, _error, { entry }) => {
372
399
  await Promise.all([
373
- ...levelsToClear(entry, level).map(
374
- async (from) => await queryClient.invalidateQueries({ queryKey: treeLevelKey(from) }),
375
- ),
376
- queryClient.invalidateQueries({ queryKey: treeLevelKey(destination.id) }),
400
+ // Both ends of the move and the rows they hang under: a folder that just received its first
401
+ // row needs its arrow, and one that lost its last has to give it back (#519).
402
+ queryClient.invalidateQueries({ queryKey: allTreeLevelsKey }),
377
403
  queryClient.invalidateQueries({
378
404
  queryKey: [entry.type === "flow" ? "flows" : "node-graph"],
379
405
  }),
@@ -3,6 +3,7 @@ import type { Node } from "@anchrd/intel-contract/node";
3
3
  import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
4
4
  import { ArchiveRestore, LoaderCircle, Trash2 } from "lucide-react";
5
5
  import { useState } from "react";
6
+ import { allTreeLevelsKey } from "@/app/tree-move/tree-move.tsx";
6
7
  import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
7
8
  import { useI18n } from "@/i18n/i18n-context.tsx";
8
9
  import { kindIcons } from "@/kind-icon.ts";
@@ -101,7 +102,7 @@ export function Archive() {
101
102
  queryClient.invalidateQueries({ queryKey: ["archive"] }),
102
103
  // The tree gets it back, so every level has to be asked again — the entry may sit
103
104
  // anywhere, and the archive does not know where.
104
- queryClient.invalidateQueries({ queryKey: ["tree"] }),
105
+ queryClient.invalidateQueries({ queryKey: allTreeLevelsKey }),
105
106
  queryClient.invalidateQueries({ queryKey: ["node-graph"] }),
106
107
  queryClient.invalidateQueries({ queryKey: ["flows"] }),
107
108
  ]);
@@ -126,7 +127,7 @@ export function Archive() {
126
127
  queryClient.invalidateQueries({ queryKey: ["archive"] }),
127
128
  // The tree and the graph did not show the node, but its DISAPPEARANCE can be visible
128
129
  // elsewhere: a reference to it no longer resolves afterwards.
129
- queryClient.invalidateQueries({ queryKey: ["tree"] }),
130
+ queryClient.invalidateQueries({ queryKey: allTreeLevelsKey }),
130
131
  queryClient.invalidateQueries({ queryKey: ["node-graph"] }),
131
132
  queryClient.invalidateQueries({ queryKey: ["flows"] }),
132
133
  ]);
@@ -1,6 +1,7 @@
1
1
  import type { Flow, FlowGraph, FlowNode } from "@anchrd/intel-contract/flow";
2
2
  import { flowNodeLayer } from "@anchrd/intel-contract/flow";
3
3
  import type { Node } from "@anchrd/intel-contract/node";
4
+ import { serverOf } from "@anchrd/intel-contract/tool";
4
5
  import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
5
6
  import { useNavigate, useRouterState } from "@tanstack/react-router";
6
7
  import {
@@ -285,6 +286,7 @@ function newNode(
285
286
  index: number,
286
287
  firstNodeId?: string,
287
288
  firstFlowId?: string,
289
+ firstServer?: string,
288
290
  ): FlowNode {
289
291
  const common = {
290
292
  id: `${kind}-${crypto.randomUUID()}`,
@@ -315,10 +317,13 @@ function newNode(
315
317
  return {
316
318
  ...common,
317
319
  kind,
320
+ // ⚠️ A server, not an empty string. `ToolServerHandle` has a minimum length, so a draft
321
+ // with `""` cannot be saved at all — the author would build a step and lose it at the next
322
+ // save with a raw field-path error. The tree link one line up takes the same way out.
318
323
  configuration: {
319
- toolName: "select-a-tool",
324
+ server: firstServer ?? "",
325
+ allow: null,
320
326
  fingerprint: null,
321
- arguments: {},
322
327
  },
323
328
  };
324
329
  case "condition":
@@ -361,9 +366,27 @@ function FlowsEditor() {
361
366
  const theme = useResolvedTheme();
362
367
  const queryClient = useQueryClient();
363
368
  const navigate = useNavigate();
369
+ // The servers a tool step can name (#489). The flat function list the picker used to show is
370
+ // what that ticket set out to remove; which function runs is decided while the flow runs.
371
+ // ⚠️ The flat function list is NOT what a step is chosen from any more (#489) — it is only what
372
+ // an author narrows a chosen server WITH. It is asked for regardless of whether a tool step is
373
+ // selected, because a query that starts when a panel opens shows a spinner inside the panel.
364
374
  const tools = useQuery({
365
375
  queryKey: ["tools"],
366
376
  queryFn: () => data.listTools(),
377
+ // ⚠️ No retry. This query only ENRICHES the panel — the server is already chosen without it —
378
+ // and a retry is paused while the tab is unfocused, so a deterministic failure would leave the
379
+ // function list empty for as long as the reader looks away, with nothing saying why (#212).
380
+ retry: false,
381
+ });
382
+ const toolServers = useQuery({
383
+ queryKey: ["tool-servers"],
384
+ queryFn: () => data.listToolServers(),
385
+ // ⚠️ No retry here either, and for a sharper reason than the list above: the panel shows
386
+ // NOTHING while this is pending. A deterministic 502 with an unfocused tab pauses the retry,
387
+ // and the author then sits in front of an empty server field with no sentence saying why —
388
+ // which is #212 exactly. `error` is a state this panel can speak; `pending` is not.
389
+ retry: false,
367
390
  });
368
391
  // A tree link points at a node, so the editor needs candidates. The graph is the authorized
369
392
  // flat list of them; walking the folder tree for a picker would be the N+1 all over again.
@@ -414,6 +437,13 @@ function FlowsEditor() {
414
437
  // guarantee that the loaded document is both the selected flow and settled.
415
438
  const documentReady = selectedFlowId !== null && document.data?.flow.id === selectedFlowId;
416
439
  const canMutate = documentReady && !document.isFetching;
440
+ // ⚠️ A tool step without a server cannot be saved at all — `ToolServerHandle` has a minimum
441
+ // length. Letting the request go out anyway answers with a generic failure that names no step, so
442
+ // the author learns "something is wrong" instead of "this one needs a server". The refusal
443
+ // belongs here, before the write (#517).
444
+ const stepsMissingServer = nodes.filter(
445
+ (node) => node.data.node.kind === "tool" && node.data.node.configuration.server === "",
446
+ );
417
447
  useEffect(() => {
418
448
  if (!document.data || document.data.flow.id !== selectedFlowId) return;
419
449
  const next = canvas(document.data.version?.graph ?? defaultGraph());
@@ -462,6 +492,7 @@ function FlowsEditor() {
462
492
  dirty={dirty}
463
493
  saving={save.isPending}
464
494
  canMutate={canMutate}
495
+ incomplete={stepsMissingServer.map((node) => ({ label: node.data.node.label }))}
465
496
  onSave={() => save.mutate()}
466
497
  onPublish={() => setPublishing(true)}
467
498
  />
@@ -519,6 +550,7 @@ function FlowsEditor() {
519
550
  nodes.length,
520
551
  nodeGraph.data?.nodes[0]?.id,
521
552
  callable.data?.items.find((entry) => entry.id !== selectedFlowId)?.id,
553
+ toolServers.data?.items[0]?.handle,
522
554
  );
523
555
  setNodes((current) => [
524
556
  ...current,
@@ -639,7 +671,19 @@ function FlowsEditor() {
639
671
  <NodeInspector
640
672
  node={selectedNode}
641
673
  update={updateNode}
642
- tools={tools.data?.items ?? []}
674
+ toolServers={toolServers.data?.items ?? []}
675
+ toolServersState={
676
+ toolServers.isPending
677
+ ? "pending"
678
+ : toolServers.isError
679
+ ? "error"
680
+ : toolServers.data?.portalConnected === false
681
+ ? "disconnected"
682
+ : "ready"
683
+ }
684
+ toolNames={(tools.data?.items ?? []).map((entry) => entry.name)}
685
+ toolNamesFailed={tools.isError}
686
+ toolNamesPending={tools.isPending}
643
687
  flows={(callable.data?.items ?? []).filter(
644
688
  (entry) => entry.id !== selectedFlowId,
645
689
  )}
@@ -700,6 +744,7 @@ function FlowTitle({
700
744
  dirty,
701
745
  saving,
702
746
  canMutate,
747
+ incomplete,
703
748
  onSave,
704
749
  onPublish,
705
750
  }: {
@@ -707,6 +752,9 @@ function FlowTitle({
707
752
  dirty: boolean;
708
753
  saving: boolean;
709
754
  canMutate: boolean;
755
+ // Tool steps that name no server yet. They cannot be saved, so the refusal is shown here rather
756
+ // than fetched from the server as a message that names no step.
757
+ incomplete: Array<{ label: string }>;
710
758
  onSave(): void;
711
759
  onPublish(): void;
712
760
  }) {
@@ -733,7 +781,16 @@ function FlowTitle({
733
781
  applies EVERYWHERE stands. But it says how THIS flow is shown, and so belongs in the line
734
782
  that names this flow. A flow is the only level with runs, and therefore the only one with
735
783
  three views (#35). */}
736
- <SaveButton dirty={dirty && canMutate} saving={saving} onSave={onSave} />
784
+ {incomplete.length > 0 && (
785
+ <p className="mr-2 text-xs text-destructive">
786
+ {i18n.t("flows.toolStepNeedsServer", { label: incomplete[0]?.label ?? "" })}
787
+ </p>
788
+ )}
789
+ <SaveButton
790
+ dirty={dirty && canMutate && incomplete.length === 0}
791
+ saving={saving}
792
+ onSave={onSave}
793
+ />
737
794
  <TooltipProvider delayDuration={300}>
738
795
  <Tooltip>
739
796
  <TooltipTrigger asChild>
@@ -797,10 +854,34 @@ function PublishPreview({
797
854
  ) : (
798
855
  <>
799
856
  <p className="text-sm text-muted-foreground">
800
- {preview.data.calls.length === 0
857
+ {preview.data.calls.length === 0 && preview.data.tools.length === 0
801
858
  ? i18n.t("flows.publishPreviewEmpty")
802
859
  : i18n.t("flows.publishPreviewIntro")}
803
860
  </p>
861
+ {/* ⚠️ The tool surface belongs in the preview because publishing FREEZES it: after this
862
+ confirmation the step is pinned to these functions and inherits nothing the provider
863
+ adds later. An author who is not shown it is asked to freeze something unseen (#517). */}
864
+ {preview.data.tools.length > 0 && (
865
+ <ul className="mt-4 space-y-2">
866
+ {preview.data.tools.map((tool) => (
867
+ <li key={tool.nodeId} className="rounded-md border p-3 text-sm">
868
+ <span className="block font-medium">{tool.nodeLabel}</span>
869
+ <span className="block text-xs text-muted-foreground">{tool.server}</span>
870
+ <span
871
+ className={`mt-1 block text-xs ${tool.available ? "text-muted-foreground" : "text-destructive"}`}
872
+ >
873
+ {!tool.available
874
+ ? i18n.t("flows.publishPreviewToolUnavailable")
875
+ : tool.allow === null
876
+ ? i18n.t("flows.toolAllowAll")
877
+ : i18n.t("flows.publishPreviewToolAllow", {
878
+ functions: tool.allow.join(", "),
879
+ })}
880
+ </span>
881
+ </li>
882
+ ))}
883
+ </ul>
884
+ )}
804
885
  <ul className="mt-4 space-y-2">
805
886
  {preview.data.calls.map((call) => {
806
887
  // ⚠️ One condition for the sentence and its colour. They were two, so a call that
@@ -852,12 +933,30 @@ function PublishPreview({
852
933
  function NodeInspector({
853
934
  node,
854
935
  update,
855
- tools,
936
+ toolServers,
937
+ toolServersState,
938
+ toolNames,
939
+ toolNamesFailed,
940
+ toolNamesPending,
856
941
  flows,
857
942
  }: {
858
943
  node: CanvasNode | null;
859
944
  update(fn: (node: FlowNode) => FlowNode): void;
860
- tools: Array<{ name: string; title: string | null; fingerprint: string }>;
945
+ toolServers: Array<{ handle: string; name: string; toolCount: number }>;
946
+ // ⚠️ FOUR answers, not two. A list that is still loading is not one that came back empty; a
947
+ // failure is neither; and a portal this user never connected is not a portal that reaches
948
+ // nothing. Drawing them all as "no servers" is the mistake #350 records for the tools screen —
949
+ // and the fourth one is the worst of them: it answers a SIGN-IN problem with a sentence about
950
+ // reach, in a place that offers no way to sign in.
951
+ toolServersState: "pending" | "error" | "disconnected" | "ready";
952
+ toolNames: string[];
953
+ // Told apart from "this server offers nothing": a function list that could not be loaded must not
954
+ // read as a narrowing that lost its functions.
955
+ toolNamesFailed: boolean;
956
+ // ⚠️ A query that has not answered is not one that answered "nothing". Without this a step opened
957
+ // while the catalog is still loading shows its allowed functions as missing — the panel accusing
958
+ // the author of a narrowing that broke, moments before the list arrives.
959
+ toolNamesPending: boolean;
861
960
  flows: Flow[];
862
961
  }) {
863
962
  const i18n = useI18n();
@@ -946,54 +1045,198 @@ function NodeInspector({
946
1045
  </div>
947
1046
  )}
948
1047
  {contract.kind === "tool" && (
949
- <div className="mt-4">
950
- <label className="block text-sm font-medium">
951
- {i18n.t("flows.tool")}
952
- <select
953
- value={contract.configuration.toolName}
954
- onChange={(event) => {
955
- const toolName = event.target.value;
956
- update((value) =>
957
- value.kind === "tool"
958
- ? {
959
- ...value,
960
- configuration: {
961
- ...value.configuration,
962
- toolName,
963
- fingerprint:
964
- tools.find((entry) => entry.name === toolName)?.fingerprint ?? null,
965
- },
966
- }
967
- : value,
968
- );
969
- }}
970
- className="mt-2 w-full rounded-md border bg-background px-3 py-2 outline-none focus-visible:ring-2 focus-visible:ring-ring"
971
- >
972
- <option value="select-a-tool">{i18n.t("flows.selectTool")}</option>
973
- {tools.map((entry) => (
974
- <option key={entry.name} value={entry.name}>
975
- {entry.title ?? entry.name}
976
- </option>
977
- ))}
978
- </select>
1048
+ <ToolStepFields
1049
+ configuration={contract.configuration}
1050
+ servers={toolServers}
1051
+ state={toolServersState}
1052
+ toolNames={toolNames}
1053
+ toolNamesFailed={toolNamesFailed}
1054
+ toolNamesPending={toolNamesPending}
1055
+ update={update}
1056
+ />
1057
+ )}
1058
+ </div>
1059
+ );
1060
+ }
1061
+
1062
+ /**
1063
+ * What a tool step may reach: one server, and optionally only some of its functions.
1064
+ *
1065
+ * ⚠️ The server comes first and the functions are a NARROWING, not the other way round (#489). The
1066
+ * old field offered every function of every server in one flat list — 85 entries here, portal
1067
+ * management tools among them — and asked the author to pick the one call they wanted before they
1068
+ * knew what the step would say. Which function runs is a run-time answer; which server may be
1069
+ * reached is an authoring decision, and that is the one this asks for.
1070
+ */
1071
+ export function ToolStepFields({
1072
+ configuration,
1073
+ servers,
1074
+ state,
1075
+ toolNames,
1076
+ toolNamesFailed,
1077
+ toolNamesPending,
1078
+ update,
1079
+ }: {
1080
+ configuration: { server: string; allow: string[] | null; fingerprint: string | null };
1081
+ servers: Array<{ handle: string; name: string; toolCount: number }>;
1082
+ state: "pending" | "error" | "disconnected" | "ready";
1083
+ toolNames: string[];
1084
+ toolNamesFailed: boolean;
1085
+ toolNamesPending: boolean;
1086
+ update(fn: (node: FlowNode) => FlowNode): void;
1087
+ }) {
1088
+ const i18n = useI18n();
1089
+ // ⚠️ The group's identity, not its data. Two steps on one screen with the same server would
1090
+ // otherwise share one radio group and unset each other — and the component is exported now, so
1091
+ // that invariant no longer lives in the file that guarantees it.
1092
+ const groupId = useId();
1093
+ const hintId = `${groupId}-hint`;
1094
+ // The one sentence this field has to say, or none. Derived once so the control and the paragraph
1095
+ // cannot disagree about whether there is something to point at.
1096
+ const hint =
1097
+ state === "error"
1098
+ ? ({ key: "flows.toolServersFailed", tone: "bad" } as const)
1099
+ : state === "disconnected"
1100
+ ? ({ key: "flows.toolServersDisconnected", tone: "bad" } as const)
1101
+ : state === "ready" && servers.length === 0
1102
+ ? ({ key: "flows.toolServersEmpty", tone: "plain" } as const)
1103
+ : null;
1104
+ const ownFunctions = toolNames.filter(
1105
+ (name) => configuration.server !== "" && serverOf(name, [configuration.server]) !== null,
1106
+ );
1107
+ // ⚠️ An allowed function the catalog does not carry is DRAWN, not dropped. Rendering only what
1108
+ // the catalog knows would let the panel and the saved graph disagree in silence: the author reads
1109
+ // "narrowed to nothing" while the step still names something. The API treats the same condition
1110
+ // as serious enough to refuse a publish, so the editor may not swallow it.
1111
+ // Nothing is "missing" until the catalog has actually answered.
1112
+ const missing = toolNamesPending
1113
+ ? []
1114
+ : (configuration.allow ?? []).filter((name) => !ownFunctions.includes(name));
1115
+ const functions = [...ownFunctions, ...missing];
1116
+
1117
+ function setTool(
1118
+ change: (current: { server: string; allow: string[] | null; fingerprint: string | null }) => {
1119
+ server: string;
1120
+ allow: string[] | null;
1121
+ fingerprint: string | null;
1122
+ },
1123
+ ) {
1124
+ update((value) =>
1125
+ value.kind === "tool" ? { ...value, configuration: change(value.configuration) } : value,
1126
+ );
1127
+ }
1128
+
1129
+ return (
1130
+ <div className="mt-4 space-y-3">
1131
+ <label className="block text-sm font-medium">
1132
+ {i18n.t("flows.tool")}
1133
+ <select
1134
+ value={configuration.server}
1135
+ // ⚠️ A disabled control drops out of the tab order, so the reason has to be ANNOUNCED
1136
+ // rather than merely printed beside it. It points at the hint only when the hint is
1137
+ // actually rendered — `pending` prints nothing on purpose, and a reference to an id that
1138
+ // does not exist is worse than none: a screen reader reads it as a broken relation.
1139
+ aria-describedby={hint === null ? undefined : hintId}
1140
+ disabled={state !== "ready" || servers.length === 0}
1141
+ onChange={(event) => {
1142
+ // ⚠️ Read out of the event NOW, not inside the updater. The updater runs later, and by
1143
+ // then this controlled select has been set back to the state's value — the change would
1144
+ // apply to itself and nothing would move.
1145
+ const server = event.target.value;
1146
+ // Changing the server drops the narrowing AND the frozen surface. Keeping `allow` would
1147
+ // leave functions of the old server behind — the contract refuses that outright — and
1148
+ // keeping the fingerprint would claim a surface nobody has confirmed.
1149
+ setTool(() => ({ server, allow: null, fingerprint: null }));
1150
+ }}
1151
+ className="mt-2 w-full rounded-md border bg-background px-3 py-2 outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:opacity-50"
1152
+ >
1153
+ <option value="">{i18n.t("flows.selectTool")}</option>
1154
+ {servers.map((entry) => (
1155
+ <option key={entry.handle} value={entry.handle}>
1156
+ {entry.name}
1157
+ </option>
1158
+ ))}
1159
+ </select>
1160
+ </label>
1161
+ {/* The three states of the list, told apart. Silence while loading; a reason when it failed;
1162
+ and "you reach none" only once that is actually known. */}
1163
+ {hint !== null && (
1164
+ <p
1165
+ id={hintId}
1166
+ className={`text-xs ${hint.tone === "bad" ? "text-destructive" : "text-muted-foreground"}`}
1167
+ >
1168
+ {i18n.t(hint.key)}
1169
+ </p>
1170
+ )}
1171
+ {configuration.server !== "" && (
1172
+ <fieldset className="space-y-2">
1173
+ <legend className="text-sm font-medium">{i18n.t("flows.toolFunctions")}</legend>
1174
+ <label className="flex items-center gap-2 text-sm">
1175
+ <input
1176
+ type="radio"
1177
+ name={groupId}
1178
+ checked={configuration.allow === null}
1179
+ onChange={() =>
1180
+ setTool((current) => ({ ...current, allow: null, fingerprint: null }))
1181
+ }
1182
+ className="size-4"
1183
+ />
1184
+ {i18n.t("flows.toolAllowAll")}
979
1185
  </label>
980
- <ToolArgumentsEditor
981
- key={contract.id}
982
- label={i18n.t("tools.arguments")}
983
- invalidLabel={i18n.t("tools.invalidJson")}
984
- initialValue={contract.configuration.arguments}
985
- onValid={(argumentsValue) => {
986
- update((value) =>
987
- value.kind === "tool"
988
- ? {
989
- ...value,
990
- configuration: { ...value.configuration, arguments: argumentsValue },
991
- }
992
- : value,
993
- );
994
- }}
995
- />
996
- </div>
1186
+ <label className="flex items-center gap-2 text-sm">
1187
+ <input
1188
+ type="radio"
1189
+ name={groupId}
1190
+ checked={configuration.allow !== null}
1191
+ onChange={() => setTool((current) => ({ ...current, allow: [], fingerprint: null }))}
1192
+ className="size-4"
1193
+ />
1194
+ {i18n.t("flows.toolAllowSome")}
1195
+ </label>
1196
+ {configuration.allow !== null && (
1197
+ <ul className="max-h-48 space-y-1 overflow-y-auto pl-6">
1198
+ {functions.map((name) => (
1199
+ <li key={name}>
1200
+ <label className="flex items-center gap-2 text-sm">
1201
+ <input
1202
+ type="checkbox"
1203
+ checked={configuration.allow?.includes(name) ?? false}
1204
+ onChange={(event) => {
1205
+ // Same reason as the select above: the checked flag is read now, the
1206
+ // updater runs later.
1207
+ const add = event.target.checked;
1208
+ setTool((current) => ({
1209
+ ...current,
1210
+ allow: add
1211
+ ? [...(current.allow ?? []), name]
1212
+ : (current.allow ?? []).filter((entry) => entry !== name),
1213
+ fingerprint: null,
1214
+ }));
1215
+ }}
1216
+ className="size-4"
1217
+ />
1218
+ <span className="min-w-0 truncate">{name}</span>
1219
+ </label>
1220
+ </li>
1221
+ ))}
1222
+ </ul>
1223
+ )}
1224
+ {/* Two sentences that only appear when they are true, and neither is the generic hint:
1225
+ a narrowing to nothing cannot be published, and a name the catalog does not carry is
1226
+ something the author has to see rather than lose. */}
1227
+ {configuration.allow?.length === 0 && (
1228
+ <p className="text-xs text-destructive">{i18n.t("flows.toolAllowNone")}</p>
1229
+ )}
1230
+ {toolNamesFailed && (
1231
+ <p className="text-xs text-destructive">{i18n.t("flows.toolFunctionsFailed")}</p>
1232
+ )}
1233
+ {!toolNamesFailed && missing.length > 0 && (
1234
+ <p className="text-xs text-destructive">
1235
+ {i18n.t("flows.toolAllowUnknown", { count: missing.length })}
1236
+ </p>
1237
+ )}
1238
+ <p className="text-xs text-muted-foreground">{i18n.t("flows.toolAllowHint")}</p>
1239
+ </fieldset>
997
1240
  )}
998
1241
  </div>
999
1242
  );
@@ -1015,7 +1258,7 @@ function FlowNeeds({ flowId }: { flowId: string }) {
1015
1258
  needs.data !== undefined &&
1016
1259
  needs.data.nodes.length === 0 &&
1017
1260
  needs.data.hiddenNodes === 0 &&
1018
- needs.data.tools.length === 0;
1261
+ needs.data.servers.length === 0;
1019
1262
  return (
1020
1263
  <section aria-label={i18n.t("flows.needs")} className="mt-auto border-t p-5">
1021
1264
  <h2 className="font-semibold">{i18n.t("flows.needs")}</h2>
@@ -1052,13 +1295,13 @@ function FlowNeeds({ flowId }: { flowId: string }) {
1052
1295
  </ul>
1053
1296
  </>
1054
1297
  )}
1055
- {needs.data.tools.length > 0 && (
1298
+ {needs.data.servers.length > 0 && (
1056
1299
  <>
1057
1300
  <h3 className="mt-4 text-xs font-medium uppercase tracking-wide text-muted-foreground">
1058
1301
  {i18n.t("flows.needsTools")}
1059
1302
  </h3>
1060
1303
  <ul className="mt-2 space-y-1 text-sm">
1061
- {needs.data.tools.map((name) => (
1304
+ {needs.data.servers.map((name) => (
1062
1305
  <li key={name} className="flex items-center gap-2">
1063
1306
  <Wrench aria-hidden="true" className="size-3.5 shrink-0" />
1064
1307
  <span className="min-w-0 truncate">{name}</span>
@@ -1067,7 +1310,6 @@ function FlowNeeds({ flowId }: { flowId: string }) {
1067
1310
  </ul>
1068
1311
  </>
1069
1312
  )}
1070
- <p className="mt-4 text-xs text-muted-foreground">{i18n.t("flows.needsHint")}</p>
1071
1313
  </>
1072
1314
  )}
1073
1315
  </section>
@@ -1118,47 +1360,6 @@ function SubflowVersionField({
1118
1360
  );
1119
1361
  }
1120
1362
 
1121
- function ToolArgumentsEditor({
1122
- label,
1123
- invalidLabel,
1124
- initialValue,
1125
- onValid,
1126
- }: {
1127
- label: string;
1128
- invalidLabel: string;
1129
- initialValue: Record<string, unknown>;
1130
- onValid(value: Record<string, unknown>): void;
1131
- }) {
1132
- const [text, setText] = useState(() => JSON.stringify(initialValue, null, 2));
1133
- const [invalid, setInvalid] = useState(false);
1134
- return (
1135
- <label className="mt-4 block text-sm font-medium">
1136
- {label}
1137
- <textarea
1138
- rows={6}
1139
- value={text}
1140
- onChange={(event) => {
1141
- const next = event.target.value;
1142
- setText(next);
1143
- try {
1144
- const parsed: unknown = JSON.parse(next);
1145
- if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
1146
- setInvalid(true);
1147
- return;
1148
- }
1149
- setInvalid(false);
1150
- onValid(parsed as Record<string, unknown>);
1151
- } catch {
1152
- setInvalid(true);
1153
- }
1154
- }}
1155
- className="mt-2 w-full resize-y rounded-md border bg-background px-3 py-2 font-mono text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring"
1156
- />
1157
- {invalid && <span className="mt-1 block text-xs text-destructive">{invalidLabel}</span>}
1158
- </label>
1159
- );
1160
- }
1161
-
1162
1363
  function Field({
1163
1364
  label,
1164
1365
  value,
@@ -34,8 +34,8 @@ function targetOf(entry: TreeEntry): ResourceTarget {
34
34
  * fetch separately show it differently the moment one of them is stale, and that is the kind of
35
35
  * difference a customer finds before anybody here does. The other half of the bargain is free —
36
36
  * every invalidation the tree already does after a create, a move or an archive lands here too, and
37
- * that now includes the ones this screen's own menu makes: `ResourceMenu` invalidates
38
- * `["tree", parentId]`, which is this level.
37
+ * that now includes the ones this screen's own menu makes: since #519 they all refresh every tree
38
+ * level (`allTreeLevelsKey`), and this level is one of them.
39
39
  *
40
40
  * ⚠️ There is no "Kind" column since #101. The icon says what a row is, in the same picture the
41
41
  * sidebar draws two centimetres to its left, and `sr-only` says it in words for everyone who does
package/src/i18n/de.json CHANGED
@@ -97,7 +97,7 @@
97
97
  "archive.restoreFailed": "Es wurde nicht wiederhergestellt. Vielleicht hat jemand anderes es geändert — lade neu und versuche es erneut.",
98
98
  "archive.purge": "{title} endgültig löschen",
99
99
  "archive.purge.title": "Endgültig löschen?",
100
- "archive.purge.body": "„{title}\u201c und alles, was dazugehört — jede Version, der Inhalt und die Einträge im Suchindex — ist danach weg. Das lässt sich nicht rückgängig machen.",
100
+ "archive.purge.body": "„{title} und alles, was dazugehört — jede Version, der Inhalt und die Einträge im Suchindex — ist danach weg. Das lässt sich nicht rückgängig machen.",
101
101
  "archive.purge.links.none": "Keine anderen Dokumente verweisen darauf.",
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.",
@@ -215,8 +215,6 @@
215
215
  "tools.toolCount": "{count} Werkzeuge",
216
216
  "tools.toolCountOne": "1 Werkzeug",
217
217
  "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.",
218
- "tools.arguments": "Argumente",
219
- "tools.invalidJson": "Die Argumente müssen ein JSON-Objekt in geschweiften Klammern sein; eine Liste oder ein einzelner Wert funktioniert nicht.",
220
218
  "flows.select": "Wähle einen Flow oder lege einen an, um den visuellen Editor zu öffnen.",
221
219
  "flows.inspect": "Wähle einen Schritt, um seine Konfiguration zu bearbeiten.",
222
220
  "flows.publish": "Veröffentlichen",
@@ -277,11 +275,10 @@
277
275
  "flows.publishPreviewUnavailable": "Der aufgerufene Flow hat noch nichts veröffentlicht, das lässt sich nicht einfrieren.",
278
276
  "flows.publishConfirm": "Veröffentlichen",
279
277
  "flows.publishFailed": "Das Veröffentlichen ist fehlgeschlagen. Lade die neueste Fassung und versuche es erneut.",
280
- "flows.tool": "MCP-Werkzeug",
281
- "flows.selectTool": "Wähle ein gefundenes Werkzeug",
278
+ "flows.tool": "MCP-Server",
279
+ "flows.selectTool": "Server auswählen",
282
280
  "flows.operationFailed": "Der Flow-Vorgang ist fehlgeschlagen. Lade die neueste Fassung und versuche es erneut.",
283
281
  "flows.needs": "Was dieser Flow braucht",
284
- "flows.needsHint": "Aus dem Graphen gelesen. Ob eine bestimmte Person davon etwas erreicht, entscheidet sich, wenn sie ihn ausführt.",
285
282
  "flows.needsNodes": "Dokumente",
286
283
  "flows.needsTools": "Werkzeuge",
287
284
  "flows.needsHidden": "{count} weitere, die du nicht sehen kannst",
@@ -354,5 +351,18 @@
354
351
  "common.noAccess": "Das ist für dich nicht verfügbar. Es existiert nicht, oder es ist nicht mehr für dich freigegeben.",
355
352
  "common.noPermission": "Dir fehlt die Berechtigung, das zu sehen.",
356
353
  "title.descriptionMore": "Mehr",
357
- "title.descriptionLess": "Weniger"
354
+ "title.descriptionLess": "Weniger",
355
+ "flows.toolServersEmpty": "Über das Portal erreichst du noch keinen MCP-Server.",
356
+ "flows.toolServersFailed": "Die Serverliste ließ sich nicht laden, daher lässt sich gerade keiner auswählen.",
357
+ "flows.toolAllowAll": "Der Agent darf jede Funktion nutzen",
358
+ "flows.toolAllowSome": "Den Agenten auf bestimmte Funktionen hinweisen",
359
+ "flows.toolAllowHint": "Intel sagt, wofür ein Schritt da ist; der Agent wählt den Aufruf und erreicht das Portal mit seinen eigenen Rechten.",
360
+ "flows.toolFunctions": "Funktionen",
361
+ "flows.toolServersDisconnected": "Du bist nicht mit dem Portal verbunden, daher lässt sich kein Server wählen. Verbinde es zuerst in den Werkzeug-Einstellungen.",
362
+ "flows.toolAllowNone": "Es ist noch keine Funktion ausgewählt, daher lässt sich dieser Schritt nicht veröffentlichen.",
363
+ "flows.toolAllowUnknown": "{count} ausgewählte Funktion(en) bietet dieser Server gerade nicht an.",
364
+ "flows.toolStepNeedsServer": "Dem Schritt „{label}“ fehlt noch ein Server.",
365
+ "flows.toolFunctionsFailed": "Die Funktionsliste ließ sich nicht laden, daher ist die Auswahl möglicherweise unvollständig.",
366
+ "flows.publishPreviewToolUnavailable": "Diesen Server erreichst du nicht, daher wird das Veröffentlichen abgelehnt.",
367
+ "flows.publishPreviewToolAllow": "Eingefroren auf: {functions}"
358
368
  }
package/src/i18n/en.json CHANGED
@@ -215,8 +215,6 @@
215
215
  "tools.toolCount": "{count} tools",
216
216
  "tools.toolCountOne": "1 tool",
217
217
  "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.",
218
- "tools.arguments": "Arguments",
219
- "tools.invalidJson": "The arguments must be a JSON object in curly braces; a list or a single value will not work.",
220
218
  "flows.select": "Select a flow or create one to open the visual editor.",
221
219
  "flows.inspect": "Select a node to edit its configuration.",
222
220
  "flows.publish": "Publish",
@@ -277,11 +275,10 @@
277
275
  "flows.publishPreviewUnavailable": "The called flow has published nothing yet, so this cannot be frozen.",
278
276
  "flows.publishConfirm": "Publish",
279
277
  "flows.publishFailed": "Publishing failed. Reload the latest version and try again.",
280
- "flows.tool": "MCP tool",
281
- "flows.selectTool": "Select a discovered tool",
278
+ "flows.tool": "MCP server",
279
+ "flows.selectTool": "Select a server",
282
280
  "flows.operationFailed": "The flow operation failed. Reload the latest version and try again.",
283
281
  "flows.needs": "What this flow needs",
284
- "flows.needsHint": "Read out of the graph. Whether a given person may reach any of it is decided when they run it.",
285
282
  "flows.needsNodes": "Documents",
286
283
  "flows.needsTools": "Tools",
287
284
  "flows.needsHidden": "{count} more you cannot see",
@@ -354,5 +351,18 @@
354
351
  "common.noAccess": "This is not available to you. It may not exist, or it may no longer be shared with you.",
355
352
  "common.noPermission": "You do not have permission to see this.",
356
353
  "title.descriptionMore": "More",
357
- "title.descriptionLess": "Less"
354
+ "title.descriptionLess": "Less",
355
+ "flows.toolServersEmpty": "You reach no MCP server through the portal yet.",
356
+ "flows.toolServersFailed": "The list of servers could not be loaded, so none can be chosen right now.",
357
+ "flows.toolAllowAll": "Let the agent use any function",
358
+ "flows.toolAllowSome": "Point the agent at specific functions",
359
+ "flows.toolAllowHint": "Intel says what a step is for; the agent chooses the call and reaches the portal with its own permissions.",
360
+ "flows.toolFunctions": "Functions",
361
+ "flows.toolServersDisconnected": "You are not connected to the portal, so no server can be chosen. Connect it in the tool settings first.",
362
+ "flows.toolAllowNone": "No function is selected yet, so this step cannot be published.",
363
+ "flows.toolAllowUnknown": "{count} selected function(s) are not offered by this server right now.",
364
+ "flows.toolStepNeedsServer": "The step “{label}” still needs a server.",
365
+ "flows.toolFunctionsFailed": "The list of functions could not be loaded, so the selection may be incomplete.",
366
+ "flows.publishPreviewToolUnavailable": "You do not reach this server, so publishing will be refused.",
367
+ "flows.publishPreviewToolAllow": "Frozen to: {functions}"
358
368
  }
package/src/i18n/es.json CHANGED
@@ -215,8 +215,6 @@
215
215
  "tools.toolCount": "{count} herramientas",
216
216
  "tools.toolCountOne": "1 herramienta",
217
217
  "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.",
218
- "tools.arguments": "Argumentos",
219
- "tools.invalidJson": "Los argumentos tienen que ser un objeto JSON entre llaves; una lista o un valor suelto no sirven.",
220
218
  "flows.select": "Elige un flujo o crea uno para abrir el editor visual.",
221
219
  "flows.inspect": "Elige un paso para editar su configuración.",
222
220
  "flows.publish": "Publicar",
@@ -277,11 +275,10 @@
277
275
  "flows.publishPreviewUnavailable": "El flujo llamado todavía no ha publicado nada, así que esto no se puede congelar.",
278
276
  "flows.publishConfirm": "Publicar",
279
277
  "flows.publishFailed": "La publicación ha fallado. Carga la versión más reciente e inténtalo de nuevo.",
280
- "flows.tool": "Herramienta MCP",
281
- "flows.selectTool": "Elige una herramienta descubierta",
278
+ "flows.tool": "Servidor MCP",
279
+ "flows.selectTool": "Elegir un servidor",
282
280
  "flows.operationFailed": "La operación del flujo ha fallado. Carga la versión más reciente e inténtalo de nuevo.",
283
281
  "flows.needs": "Qué necesita este flujo",
284
- "flows.needsHint": "Leído del grafo. Si una persona concreta llega a algo de esto se decide cuando lo ejecuta.",
285
282
  "flows.needsNodes": "Documentos",
286
283
  "flows.needsTools": "Herramientas",
287
284
  "flows.needsHidden": "{count} más que no puedes ver",
@@ -354,5 +351,18 @@
354
351
  "common.noAccess": "Esto no está disponible para ti. Puede que no exista o que ya no esté compartido contigo.",
355
352
  "common.noPermission": "No tienes permiso para ver esto.",
356
353
  "title.descriptionMore": "Más",
357
- "title.descriptionLess": "Menos"
354
+ "title.descriptionLess": "Menos",
355
+ "flows.toolServersEmpty": "Todavía no alcanzas ningún servidor MCP a través del portal.",
356
+ "flows.toolServersFailed": "No se pudo cargar la lista de servidores, así que ahora no se puede elegir ninguno.",
357
+ "flows.toolAllowAll": "El agente puede usar cualquier función",
358
+ "flows.toolAllowSome": "Indicar al agente funciones concretas",
359
+ "flows.toolAllowHint": "Intel dice para qué sirve un paso; el agente elige la llamada y accede al portal con sus propios permisos.",
360
+ "flows.toolFunctions": "Funciones",
361
+ "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.",
362
+ "flows.toolAllowNone": "Todavía no hay ninguna función seleccionada, así que este paso no se puede publicar.",
363
+ "flows.toolAllowUnknown": "Este servidor no ofrece ahora mismo {count} función(es) seleccionada(s).",
364
+ "flows.toolStepNeedsServer": "Al paso «{label}» todavía le falta un servidor.",
365
+ "flows.toolFunctionsFailed": "No se pudo cargar la lista de funciones, así que la selección puede estar incompleta.",
366
+ "flows.publishPreviewToolUnavailable": "No alcanzas este servidor, así que la publicación será rechazada.",
367
+ "flows.publishPreviewToolAllow": "Congelado en: {functions}"
358
368
  }
@@ -2,7 +2,7 @@ import { useMutation, useQueryClient } from "@tanstack/react-query";
2
2
  import { zipSync } from "fflate";
3
3
  import { useRef, useState } from "react";
4
4
  import { createPortal } from "react-dom";
5
- import { treeLevelKey } from "@/app/tree-move/tree-move.tsx";
5
+ import { allTreeLevelsKey } from "@/app/tree-move/tree-move.tsx";
6
6
  import { useI18n } from "@/i18n/i18n-context.tsx";
7
7
  import { useIntelRouterContext } from "@/router/router-context.ts";
8
8
 
@@ -93,7 +93,10 @@ export function useBundleImport(options: {
93
93
  onSuccess: async (imported) => {
94
94
  await options.onImported?.(imported.parentId);
95
95
  await Promise.all([
96
- queryClient.invalidateQueries({ queryKey: treeLevelKey(imported.parentId) }),
96
+ // ⚠️ Every level, not the one imported into: the target folder's own row carries whether it
97
+ // can be opened, and it stands one level higher (#519). An import into an empty folder was
98
+ // otherwise unreachable in the tree until the page was reloaded.
99
+ queryClient.invalidateQueries({ queryKey: allTreeLevelsKey }),
97
100
  queryClient.invalidateQueries({ queryKey: ["node-graph"] }),
98
101
  // The graph view of that same level draws exactly this list (#19).
99
102
  queryClient.invalidateQueries({ queryKey: ["relation-graph"] }),
@@ -20,7 +20,7 @@ import {
20
20
  import type * as React from "react";
21
21
  import { useState } from "react";
22
22
  import { AccessSummary } from "@/access-summary/access-summary.tsx";
23
- import { moveErrorKey, useTreeMove } from "@/app/tree-move/tree-move.tsx";
23
+ import { allTreeLevelsKey, moveErrorKey, useTreeMove } from "@/app/tree-move/tree-move.tsx";
24
24
  import {
25
25
  DropdownMenu,
26
26
  DropdownMenuContent,
@@ -80,10 +80,6 @@ function titleOf(target: ResourceTarget): string {
80
80
  return target.type === "node" ? target.node.title : target.flow.title;
81
81
  }
82
82
 
83
- function parentOfTarget(target: ResourceTarget): string | null {
84
- return target.type === "node" ? target.node.parentId : target.flow.parentId;
85
- }
86
-
87
83
  /**
88
84
  * Which sentence explains a verb.
89
85
  *
@@ -175,9 +171,14 @@ export function ResourceMenu({
175
171
  // reads the record, and the flat collections behind the search, the link picker and the relation
176
172
  // view all carry the title as well — a rename that only reached the tree would leave yesterday's
177
173
  // name standing on three other screens.
174
+ //
175
+ // ⚠️ Every level rather than the one this row sits in: archiving the last row of a folder takes
176
+ // that folder's arrow away, and the arrow is drawn from the folder's own row one level higher
177
+ // (`allTreeLevelsKey`, #519). Renaming needs no more than the one level — but two invalidations
178
+ // that differ per entry are how one of them goes missing.
178
179
  async function refresh(): Promise<void> {
179
180
  await Promise.all([
180
- queryClient.invalidateQueries({ queryKey: ["tree", parentOfTarget(target)] }),
181
+ queryClient.invalidateQueries({ queryKey: allTreeLevelsKey }),
181
182
  queryClient.invalidateQueries({
182
183
  queryKey: target.type === "flow" ? ["flow", id] : ["node", id],
183
184
  }),