@anchrd/intel-ui 0.33.0 → 0.35.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.33.0",
3
+ "version": "0.35.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.20.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);
@@ -150,7 +154,47 @@ export function AppTree() {
150
154
  return calls.items.map((flow) => flowEntry(flow, openable.has(flow.id)));
151
155
  }
152
156
 
153
- const parents: Level[] = [{ id: null, type: "folder" }, ...expanded];
157
+ /**
158
+ * The levels that still have a row to hang on — `expanded` minus everything the tree no longer
159
+ * shows (#521).
160
+ *
161
+ * ⚠️ `expanded` is only ever added to. A folder that was opened and has since left the tree —
162
+ * archived, moved away, its share revoked — used to keep its level in `useQueries` for the rest
163
+ * of the session, and every write from then on re-read a list nothing draws. Nothing was wrong on
164
+ * screen (`isOpen` needs the row, so the level was never rendered); the requests were simply paid
165
+ * for, and the number grew with the length of the SESSION rather than with what is on screen.
166
+ *
167
+ * ⚠️ Read from the cache rather than from `levels` below, and that is not a shortcut: the answer
168
+ * is needed BEFORE `useQueries` is told what to load, and deriving it from the results afterwards
169
+ * can only correct the list one render too late — by which time the request has been made.
170
+ *
171
+ * ⚠️ A walk down from the root, not "is the row anywhere": a level whose own row is gone must not
172
+ * keep vouching for the levels under it, or a whole opened branch would survive its top.
173
+ *
174
+ * ⚠️ A row is enough, and `openable` is deliberately NOT part of it although `isOpen` checks it.
175
+ * `reveal` opens a folder BEFORE the invalidation that gives it its arrow, so a folder that was
176
+ * empty a moment ago still says `openable: false` at that instant — judging on it would close the
177
+ * folder #519 exists to have opened. A refetch does not make a level unknown either: TanStack
178
+ * keeps the previous data while it refetches, so an ordinary invalidation closes nothing.
179
+ */
180
+ const rowLevel = (entry: TreeEntry) =>
181
+ `${entry.kind === "folder" ? "folder" : "flow"}:${entry.id}`;
182
+ const rootLevel: Level = { id: null, type: "folder" };
183
+ const open: Level[] = [];
184
+ let frontier: Level[] = [rootLevel];
185
+ let waiting = [...expanded];
186
+ while (frontier.length > 0 && waiting.length > 0) {
187
+ const shown = new Set(
188
+ frontier.flatMap((level) =>
189
+ (queryClient.getQueryData<TreeEntry[]>(levelKey(level)) ?? []).map(rowLevel),
190
+ ),
191
+ );
192
+ frontier = waiting.filter((level) => shown.has(`${level.type}:${level.id}`));
193
+ waiting = waiting.filter((level) => !frontier.includes(level));
194
+ open.push(...frontier);
195
+ }
196
+
197
+ const parents: Level[] = [rootLevel, ...open];
154
198
  const levels = useQueries({
155
199
  queries: parents.map((parent) => ({
156
200
  queryKey: levelKey(parent),
@@ -167,10 +211,19 @@ export function AppTree() {
167
211
  levels[parents.findIndex((entry) => entry.id === level.id && entry.type === level.type)];
168
212
  const root = levels[0];
169
213
 
170
- function toggle(level: Level & { id: string }, open: boolean) {
214
+ // ⚠️ The list the reader opened is also trimmed here, and not only where it is read: `open`
215
+ // above keeps a departed level from being LOADED, but the entry itself would otherwise sit in
216
+ // `expanded` until the tab is closed — and a folder that comes back would come back open, which
217
+ // is a claim about a gesture nobody made in this tree. A gesture is where trimming is free: it
218
+ // is an event, so there is no render to loop through.
219
+ function toggle(level: Level & { id: string }, wanted: boolean) {
171
220
  setExpanded((current) => {
172
- const rest = current.filter((entry) => entry.id !== level.id || entry.type !== level.type);
173
- return open ? [...rest, level] : rest;
221
+ const rest = current.filter(
222
+ (entry) =>
223
+ (entry.id !== level.id || entry.type !== level.type) &&
224
+ open.some((live) => live.id === entry.id && live.type === entry.type),
225
+ );
226
+ return wanted ? [...rest, level] : rest;
174
227
  });
175
228
  }
176
229
 
@@ -441,8 +494,10 @@ export function AppTree() {
441
494
  // reader may see" (#59). The difference is an arrow that keeps its promise — before, it stood
442
495
  // on every folder and every flow, empty ones included.
443
496
  const expandable = entry.openable;
497
+ // ⚠️ `open`, not `expanded`: what is drawn and what is loaded answer the same question, or the
498
+ // tree would draw a level it never asked for (#521).
444
499
  const isOpen =
445
- expandable && expanded.some((open) => open.id === entry.id && open.type === level.type);
500
+ expandable && open.some((live) => live.id === entry.id && live.type === level.type);
446
501
  const Icon = isOpen && isFolder ? FolderOpen : kindIcons[entry.kind];
447
502
  const area = entry.type === "flow" ? "/flows" : "/nodes";
448
503
  const isActive = location.select === entry.id && location.pathname === area;
@@ -575,6 +630,21 @@ export function AppTree() {
575
630
  putting `draggable` on this form control is precisely the Safari failure from #483. */}
576
631
  <SidebarMenuButton
577
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}
578
648
  data-drop={
579
649
  dragged === null ? undefined : isDragged ? "dragged" : (drop?.verdict ?? "none")
580
650
  }
@@ -622,8 +692,30 @@ export function AppTree() {
622
692
 
623
693
  return (
624
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>
625
709
  {/* The root has no row to drop on, so while something is being carried it gets one. It is the
626
- 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. */}
627
719
  {rootDrop.verdict === undefined || dragged === null ? null : (
628
720
  <button
629
721
  type="button"
@@ -640,7 +732,7 @@ export function AppTree() {
640
732
  });
641
733
  }}
642
734
  data-drop={rootDrop.verdict}
643
- 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 ${
644
736
  rootDrop.verdict === "ok"
645
737
  ? over === null
646
738
  ? "bg-sidebar-accent ring-2 ring-sidebar-ring"
@@ -652,9 +744,6 @@ export function AppTree() {
652
744
  {i18n.t("tree.move.dropRoot")}
653
745
  </button>
654
746
  )}
655
- {/* The tree is the navigation and carries no heading of its own (ADR-0004); the list is
656
- named for assistive technology instead. */}
657
- {renderLevel({ id: null, type: "folder" }, new Set(), i18n.t("tree.label"))}
658
747
  {create.isError || upload.isError || bundleImport.isError ? (
659
748
  <p role="alert" className="px-2 py-1.5 text-sm text-destructive">
660
749
  {bundleImport.isError
@@ -47,11 +47,11 @@ export function treeLevelKey(parentId: string | null): readonly unknown[] {
47
47
  * level is not always the one the record names: since #429 a shared row also stands at the root
48
48
  * (`levelsToClear`).
49
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.
50
+ * ⚠️ What it costs is one request per level the reader has OPEN — bounded by the screen, not by how
51
+ * long the tab has been sitting there. `app-tree.tsx` loads a level only while the row it hangs on
52
+ * still stands in the level above it, so a folder that was opened and has since left the tree —
53
+ * archived, moved away, its share revoked takes its query with it rather than being re-read by
54
+ * every write for the rest of the session.
55
55
  *
56
56
  * The same prefix is what `archive.tsx` has always used for the same reason: a row can come back
57
57
  * anywhere, and the screen writing it does not know where.
@@ -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;
@@ -0,0 +1,36 @@
1
+ import type { Refusal } from "@/data/request-refusal/request-refusal.ts";
2
+ import { useI18n } from "@/i18n/i18n-context.tsx";
3
+
4
+ /**
5
+ * What a refusal is told with, on every surface that has one.
6
+ *
7
+ * ⚠️ A refusal is not a failure, and the difference is the whole of #430. Somebody whose share was
8
+ * revoked was left in front of "Loading…" — the worst answer of all, because it says the program is
9
+ * still working. They get a sentence and no retry button: the answer was final, and a button that
10
+ * changes nothing is an invitation to keep waiting.
11
+ *
12
+ * ⚠️ One sentence for `404`, whichever of its two reasons applies. Intel answers the same status
13
+ * for "no such node" and "not for you" on purpose — `requireVisible` in
14
+ * `packages/api/src/nodes/nodes.ts` — and a screen that told them apart would undo that from the
15
+ * other side.
16
+ *
17
+ * ⚠️ One function rather than a block per branch, because two answers to one question on one
18
+ * surface IS #445. A screen's halves — the empty level, the selected item, the view beside it — are
19
+ * asked the same thing, and copies drift.
20
+ *
21
+ * ⚠️ **Why this became shared here and not one copy earlier.** `nodes.tsx` (#445) and `flows.tsx`
22
+ * (#557) each carried an identical version, and #557 wrote its reason on the second one: this
23
+ * repository builds an abstraction after the third time it hurts, not the second. #568 is the third
24
+ * time — `?view=graph` and `?view=runs` are the third and fourth surface asking for the same
25
+ * sentence — so what would have been a third copy is this file. The rule did not bend; it was met.
26
+ */
27
+ export function RefusalNotice({ refusal }: { refusal: Refusal }) {
28
+ const i18n = useI18n();
29
+ return (
30
+ <div role="status" className="grid flex-1 place-items-center p-8 text-center text-sm">
31
+ <p className="max-w-sm text-muted-foreground">
32
+ {i18n.t(refusal === "no-permission" ? "common.noPermission" : "common.noAccess")}
33
+ </p>
34
+ </div>
35
+ );
36
+ }
@@ -92,6 +92,24 @@ export function EntryPicker({
92
92
  }, [wanted]);
93
93
  const eligible = useMemo(() => nodes.filter(pickable), [nodes, pickable]);
94
94
 
95
+ /**
96
+ * Whether an entry stands on the picker's start level.
97
+ *
98
+ * ⚠️ The root is not `parentId === null` but the upper edge of what the reader may see — the same
99
+ * rule #429 gave the server in `levelPredicate`, at the second surface (#447). A recipient whose
100
+ * only access is a NESTED share carries a `parentId` pointing at a folder that is not in the list
101
+ * at all: under the old rule their start level was empty and STAYED empty, because the folder
102
+ * that would open it can never be clicked. Only searching reached them.
103
+ *
104
+ * ⚠️ Nothing is added by this. The list is what `getNodeGraph()` — and `listFlows()` where flows
105
+ * were asked for — answered, and the answer is already the authorized one. What changes is only
106
+ * which level an entry stands on.
107
+ */
108
+ const rooted = useMemo(() => {
109
+ const present = new Set(nodes.map((node) => node.id));
110
+ return (entry: PickerEntry) => entry.parentId === null || !present.has(entry.parentId);
111
+ }, [nodes]);
112
+
95
113
  // Searching looks at every eligible node wherever it sits; browsing looks at one level. The two
96
114
  // are the same list seen two ways, which is why a result can be picked from either.
97
115
  const searching = query.trim().length > 0;
@@ -100,8 +118,10 @@ export function EntryPicker({
100
118
  const needle = query.trim().toLowerCase();
101
119
  return eligible.filter((node) => node.title.toLowerCase().includes(needle)).slice(0, 50);
102
120
  }
103
- return nodes.filter((node) => node.parentId === openFolder);
104
- }, [searching, query, eligible, nodes, openFolder]);
121
+ return nodes.filter((node) =>
122
+ openFolder === null ? rooted(node) : node.parentId === openFolder,
123
+ );
124
+ }, [searching, query, eligible, nodes, openFolder, rooted]);
105
125
 
106
126
  // The way back up, as the chain of folders that leads to the open one.
107
127
  const trail = useMemo(() => {
@@ -111,10 +131,13 @@ export function EntryPicker({
111
131
  const folder = nodes.find((node) => node.id === current);
112
132
  if (!folder) break;
113
133
  chain.unshift(folder);
114
- current = folder.parentId;
134
+ // ⚠️ The chain ends where the start level begins, and by the same rule that decides it. A
135
+ // folder at the upper edge has an ancestor the reader cannot see; walking into it would look
136
+ // for a level that does not exist here, and the "top" button above leads to this one.
137
+ current = rooted(folder) ? null : folder.parentId;
115
138
  }
116
139
  return chain;
117
- }, [openFolder, nodes]);
140
+ }, [openFolder, nodes, rooted]);
118
141
 
119
142
  const chosen = nodes.find((node) => node.id === value) ?? null;
120
143
 
@@ -2,6 +2,8 @@ import type { FlowRunSummary } from "@anchrd/intel-contract/flow-run";
2
2
  import { useInfiniteQuery, useQuery } from "@tanstack/react-query";
3
3
  import { ChevronDown, ChevronRight, CornerDownRight } from "lucide-react";
4
4
  import { useState } from "react";
5
+ import { RefusalNotice } from "@/data/request-refusal/refusal-notice.tsx";
6
+ import { refusalOf } from "@/data/request-refusal/request-refusal.ts";
5
7
  import type { I18n } from "@/i18n/i18n.types.ts";
6
8
  import { useI18n } from "@/i18n/i18n-context.tsx";
7
9
  import { useIntelRouterContext } from "@/router/router-context.ts";
@@ -45,6 +47,11 @@ export function FlowRuns({ flowId }: { flowId: string }) {
45
47
  getNextPageParam: (last) => last.nextCursor,
46
48
  });
47
49
  const items = runs.data?.pages.flatMap((page) => page.items) ?? [];
50
+ // ⚠️ This list's own refusal, told here rather than upstream (#568). The screen above answers
51
+ // whether the flow may be opened; `listFlowRuns` answers whether its history may be read, and it
52
+ // can refuse on its own. Everything that is not a refusal keeps the sentence and the button
53
+ // below — a `502` really can answer differently next time.
54
+ const refusal = refusalOf(runs.error);
48
55
 
49
56
  return (
50
57
  <section aria-label={i18n.t("runs.title")} className="flex min-h-0 flex-1 flex-col">
@@ -68,7 +75,8 @@ export function FlowRuns({ flowId }: { flowId: string }) {
68
75
  {runs.isPending && (
69
76
  <p className="text-sm text-muted-foreground">{i18n.t("common.loading")}</p>
70
77
  )}
71
- {runs.isError && (
78
+ {refusal && <RefusalNotice refusal={refusal} />}
79
+ {runs.isError && !refusal && (
72
80
  <div role="alert" className="space-y-3 text-sm">
73
81
  <p className="text-destructive">{i18n.t("runs.failedToLoad")}</p>
74
82
  <button