@anchrd/intel-ui 0.28.0 → 0.29.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.29.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.18.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
  ]);
@@ -285,6 +285,7 @@ function newNode(
285
285
  index: number,
286
286
  firstNodeId?: string,
287
287
  firstFlowId?: string,
288
+ firstServer?: string,
288
289
  ): FlowNode {
289
290
  const common = {
290
291
  id: `${kind}-${crypto.randomUUID()}`,
@@ -315,10 +316,13 @@ function newNode(
315
316
  return {
316
317
  ...common,
317
318
  kind,
319
+ // ⚠️ A server, not an empty string. `ToolServerHandle` has a minimum length, so a draft
320
+ // with `""` cannot be saved at all — the author would build a step and lose it at the next
321
+ // save with a raw field-path error. The tree link one line up takes the same way out.
318
322
  configuration: {
319
- toolName: "select-a-tool",
323
+ server: firstServer ?? "",
324
+ allow: null,
320
325
  fingerprint: null,
321
- arguments: {},
322
326
  },
323
327
  };
324
328
  case "condition":
@@ -361,9 +365,11 @@ function FlowsEditor() {
361
365
  const theme = useResolvedTheme();
362
366
  const queryClient = useQueryClient();
363
367
  const navigate = useNavigate();
364
- const tools = useQuery({
365
- queryKey: ["tools"],
366
- queryFn: () => data.listTools(),
368
+ // The servers a tool step can name (#489). The flat function list the picker used to show is
369
+ // what that ticket set out to remove; which function runs is decided while the flow runs.
370
+ const toolServers = useQuery({
371
+ queryKey: ["tool-servers"],
372
+ queryFn: () => data.listToolServers(),
367
373
  });
368
374
  // A tree link points at a node, so the editor needs candidates. The graph is the authorized
369
375
  // flat list of them; walking the folder tree for a picker would be the N+1 all over again.
@@ -519,6 +525,7 @@ function FlowsEditor() {
519
525
  nodes.length,
520
526
  nodeGraph.data?.nodes[0]?.id,
521
527
  callable.data?.items.find((entry) => entry.id !== selectedFlowId)?.id,
528
+ toolServers.data?.items[0]?.handle,
522
529
  );
523
530
  setNodes((current) => [
524
531
  ...current,
@@ -639,7 +646,7 @@ function FlowsEditor() {
639
646
  <NodeInspector
640
647
  node={selectedNode}
641
648
  update={updateNode}
642
- tools={tools.data?.items ?? []}
649
+ toolServers={toolServers.data?.items ?? []}
643
650
  flows={(callable.data?.items ?? []).filter(
644
651
  (entry) => entry.id !== selectedFlowId,
645
652
  )}
@@ -852,12 +859,12 @@ function PublishPreview({
852
859
  function NodeInspector({
853
860
  node,
854
861
  update,
855
- tools,
862
+ toolServers,
856
863
  flows,
857
864
  }: {
858
865
  node: CanvasNode | null;
859
866
  update(fn: (node: FlowNode) => FlowNode): void;
860
- tools: Array<{ name: string; title: string | null; fingerprint: string }>;
867
+ toolServers: Array<{ handle: string; name: string; toolCount: number }>;
861
868
  flows: Flow[];
862
869
  }) {
863
870
  const i18n = useI18n();
@@ -949,50 +956,36 @@ function NodeInspector({
949
956
  <div className="mt-4">
950
957
  <label className="block text-sm font-medium">
951
958
  {i18n.t("flows.tool")}
959
+ {/* ⚠️ The step names a SERVER since #489. This picker is the smallest thing that keeps
960
+ the field usable on the new model; the real one — server names from the portal plus
961
+ an optional narrowing to single functions — is #489's second package and is
962
+ deliberately not smuggled in here. */}
952
963
  <select
953
- value={contract.configuration.toolName}
964
+ value={contract.configuration.server}
954
965
  onChange={(event) => {
955
- const toolName = event.target.value;
966
+ const server = event.target.value;
956
967
  update((value) =>
957
968
  value.kind === "tool"
958
969
  ? {
959
970
  ...value,
960
- configuration: {
961
- ...value.configuration,
962
- toolName,
963
- fingerprint:
964
- tools.find((entry) => entry.name === toolName)?.fingerprint ?? null,
965
- },
971
+ // The fingerprint belongs to a surface that publishing freezes, and it is
972
+ // the server's to compute — a value guessed in the browser would be a
973
+ // second answer to a question the API already owns.
974
+ configuration: { ...value.configuration, server, fingerprint: null },
966
975
  }
967
976
  : value,
968
977
  );
969
978
  }}
970
979
  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
980
  >
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}
981
+ <option value="">{i18n.t("flows.selectTool")}</option>
982
+ {toolServers.map((entry) => (
983
+ <option key={entry.handle} value={entry.handle}>
984
+ {entry.name}
976
985
  </option>
977
986
  ))}
978
987
  </select>
979
988
  </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
989
  </div>
997
990
  )}
998
991
  </div>
@@ -1015,7 +1008,7 @@ function FlowNeeds({ flowId }: { flowId: string }) {
1015
1008
  needs.data !== undefined &&
1016
1009
  needs.data.nodes.length === 0 &&
1017
1010
  needs.data.hiddenNodes === 0 &&
1018
- needs.data.tools.length === 0;
1011
+ needs.data.servers.length === 0;
1019
1012
  return (
1020
1013
  <section aria-label={i18n.t("flows.needs")} className="mt-auto border-t p-5">
1021
1014
  <h2 className="font-semibold">{i18n.t("flows.needs")}</h2>
@@ -1052,13 +1045,13 @@ function FlowNeeds({ flowId }: { flowId: string }) {
1052
1045
  </ul>
1053
1046
  </>
1054
1047
  )}
1055
- {needs.data.tools.length > 0 && (
1048
+ {needs.data.servers.length > 0 && (
1056
1049
  <>
1057
1050
  <h3 className="mt-4 text-xs font-medium uppercase tracking-wide text-muted-foreground">
1058
1051
  {i18n.t("flows.needsTools")}
1059
1052
  </h3>
1060
1053
  <ul className="mt-2 space-y-1 text-sm">
1061
- {needs.data.tools.map((name) => (
1054
+ {needs.data.servers.map((name) => (
1062
1055
  <li key={name} className="flex items-center gap-2">
1063
1056
  <Wrench aria-hidden="true" className="size-3.5 shrink-0" />
1064
1057
  <span className="min-w-0 truncate">{name}</span>
@@ -1067,7 +1060,6 @@ function FlowNeeds({ flowId }: { flowId: string }) {
1067
1060
  </ul>
1068
1061
  </>
1069
1062
  )}
1070
- <p className="mt-4 text-xs text-muted-foreground">{i18n.t("flows.needsHint")}</p>
1071
1063
  </>
1072
1064
  )}
1073
1065
  </section>
@@ -1118,47 +1110,6 @@ function SubflowVersionField({
1118
1110
  );
1119
1111
  }
1120
1112
 
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
1113
  function Field({
1163
1114
  label,
1164
1115
  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
@@ -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",
@@ -281,7 +279,6 @@
281
279
  "flows.selectTool": "Wähle ein gefundenes Werkzeug",
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",
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",
@@ -281,7 +279,6 @@
281
279
  "flows.selectTool": "Select a discovered tool",
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",
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",
@@ -281,7 +279,6 @@
281
279
  "flows.selectTool": "Elige una herramienta descubierta",
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",
@@ -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
  }),