@anchrd/intel-ui 0.32.0 → 0.34.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.32.0",
3
+ "version": "0.34.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.21.0",
37
37
  "@blocknote/core": "^0.52.1",
38
38
  "@blocknote/react": "^0.52.1",
39
39
  "@blocknote/shadcn": "^0.52.1",
@@ -150,7 +150,47 @@ export function AppTree() {
150
150
  return calls.items.map((flow) => flowEntry(flow, openable.has(flow.id)));
151
151
  }
152
152
 
153
- const parents: Level[] = [{ id: null, type: "folder" }, ...expanded];
153
+ /**
154
+ * The levels that still have a row to hang on — `expanded` minus everything the tree no longer
155
+ * shows (#521).
156
+ *
157
+ * ⚠️ `expanded` is only ever added to. A folder that was opened and has since left the tree —
158
+ * archived, moved away, its share revoked — used to keep its level in `useQueries` for the rest
159
+ * of the session, and every write from then on re-read a list nothing draws. Nothing was wrong on
160
+ * screen (`isOpen` needs the row, so the level was never rendered); the requests were simply paid
161
+ * for, and the number grew with the length of the SESSION rather than with what is on screen.
162
+ *
163
+ * ⚠️ Read from the cache rather than from `levels` below, and that is not a shortcut: the answer
164
+ * is needed BEFORE `useQueries` is told what to load, and deriving it from the results afterwards
165
+ * can only correct the list one render too late — by which time the request has been made.
166
+ *
167
+ * ⚠️ A walk down from the root, not "is the row anywhere": a level whose own row is gone must not
168
+ * keep vouching for the levels under it, or a whole opened branch would survive its top.
169
+ *
170
+ * ⚠️ A row is enough, and `openable` is deliberately NOT part of it although `isOpen` checks it.
171
+ * `reveal` opens a folder BEFORE the invalidation that gives it its arrow, so a folder that was
172
+ * empty a moment ago still says `openable: false` at that instant — judging on it would close the
173
+ * folder #519 exists to have opened. A refetch does not make a level unknown either: TanStack
174
+ * keeps the previous data while it refetches, so an ordinary invalidation closes nothing.
175
+ */
176
+ const rowLevel = (entry: TreeEntry) =>
177
+ `${entry.kind === "folder" ? "folder" : "flow"}:${entry.id}`;
178
+ const rootLevel: Level = { id: null, type: "folder" };
179
+ const open: Level[] = [];
180
+ let frontier: Level[] = [rootLevel];
181
+ let waiting = [...expanded];
182
+ while (frontier.length > 0 && waiting.length > 0) {
183
+ const shown = new Set(
184
+ frontier.flatMap((level) =>
185
+ (queryClient.getQueryData<TreeEntry[]>(levelKey(level)) ?? []).map(rowLevel),
186
+ ),
187
+ );
188
+ frontier = waiting.filter((level) => shown.has(`${level.type}:${level.id}`));
189
+ waiting = waiting.filter((level) => !frontier.includes(level));
190
+ open.push(...frontier);
191
+ }
192
+
193
+ const parents: Level[] = [rootLevel, ...open];
154
194
  const levels = useQueries({
155
195
  queries: parents.map((parent) => ({
156
196
  queryKey: levelKey(parent),
@@ -167,10 +207,19 @@ export function AppTree() {
167
207
  levels[parents.findIndex((entry) => entry.id === level.id && entry.type === level.type)];
168
208
  const root = levels[0];
169
209
 
170
- function toggle(level: Level & { id: string }, open: boolean) {
210
+ // ⚠️ The list the reader opened is also trimmed here, and not only where it is read: `open`
211
+ // above keeps a departed level from being LOADED, but the entry itself would otherwise sit in
212
+ // `expanded` until the tab is closed — and a folder that comes back would come back open, which
213
+ // is a claim about a gesture nobody made in this tree. A gesture is where trimming is free: it
214
+ // is an event, so there is no render to loop through.
215
+ function toggle(level: Level & { id: string }, wanted: boolean) {
171
216
  setExpanded((current) => {
172
- const rest = current.filter((entry) => entry.id !== level.id || entry.type !== level.type);
173
- return open ? [...rest, level] : rest;
217
+ const rest = current.filter(
218
+ (entry) =>
219
+ (entry.id !== level.id || entry.type !== level.type) &&
220
+ open.some((live) => live.id === entry.id && live.type === entry.type),
221
+ );
222
+ return wanted ? [...rest, level] : rest;
174
223
  });
175
224
  }
176
225
 
@@ -441,8 +490,10 @@ export function AppTree() {
441
490
  // reader may see" (#59). The difference is an arrow that keeps its promise — before, it stood
442
491
  // on every folder and every flow, empty ones included.
443
492
  const expandable = entry.openable;
493
+ // ⚠️ `open`, not `expanded`: what is drawn and what is loaded answer the same question, or the
494
+ // tree would draw a level it never asked for (#521).
444
495
  const isOpen =
445
- expandable && expanded.some((open) => open.id === entry.id && open.type === level.type);
496
+ expandable && open.some((live) => live.id === entry.id && live.type === level.type);
446
497
  const Icon = isOpen && isFolder ? FolderOpen : kindIcons[entry.kind];
447
498
  const area = entry.type === "flow" ? "/flows" : "/nodes";
448
499
  const isActive = location.select === entry.id && location.pathname === area;
@@ -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.
@@ -66,6 +66,7 @@ import {
66
66
  AppendTableRowsInput,
67
67
  AppendTableRowsResult,
68
68
  DefineTableInput,
69
+ RedefineTableInput,
69
70
  } from "@anchrd/intel-contract/table";
70
71
  import { ToolCatalog, ToolServerCatalog } from "@anchrd/intel-contract/tool";
71
72
  import type { z } from "zod";
@@ -319,6 +320,16 @@ export function createIntelDataProvider(
319
320
  { method: "POST", body: JSON.stringify(parsed) },
320
321
  );
321
322
  },
323
+ // ⚠️ `PATCH`, where the two writes above are `POST`. The route is the same address as the
324
+ // header itself, and this changes it — `POST /nodes/:id/table` is the one that WRITES a header
325
+ // and refuses on a table that already has one.
326
+ async redefineTable(input) {
327
+ const parsed = RedefineTableInput.parse(input);
328
+ return await request(`/nodes/${encodeURIComponent(parsed.nodeId)}/table`, NodeTable, {
329
+ method: "PATCH",
330
+ body: JSON.stringify(parsed),
331
+ });
332
+ },
322
333
  async listNodeVersions(nodeId) {
323
334
  return await request(`/nodes/${encodeURIComponent(nodeId)}/versions`, NodeVersionList);
324
335
  },
@@ -65,6 +65,7 @@ import type {
65
65
  AppendTableRowsInput,
66
66
  AppendTableRowsResult,
67
67
  DefineTableInput,
68
+ RedefineTableInput,
68
69
  } from "@anchrd/intel-contract/table";
69
70
  import type { ToolCatalog, ToolServerCatalog } from "@anchrd/intel-contract/tool";
70
71
 
@@ -125,6 +126,14 @@ export interface IntelDataProvider {
125
126
  getNodeTable(nodeId: string): Promise<NodeTable>;
126
127
  defineTable(input: DefineTableInput): Promise<NodeTable>;
127
128
  appendTableRows(input: AppendTableRowsInput): Promise<AppendTableRowsResult>;
129
+ // Changing the header of a table that already has one (#135). The mapping is explicit — every new
130
+ // column names the current one whose cells fill it, or `null` for an empty one — because a new
131
+ // header without it would reinterpret every stored row under names nobody matched to the old ones.
132
+ //
133
+ // ⚠️ `baseVersionId` is the `versionId` the mapping was written against, and the server refuses
134
+ // with `version_conflict` when the table has moved on. Not optional: a mapping onto a header
135
+ // somebody else has since changed would move the wrong cells, silently.
136
+ redefineTable(input: RedefineTableInput): Promise<NodeTable>;
128
137
 
129
138
  // Walks the browser through the portal's OAuth flow without asking anybody anything: Gate is the
130
139
  // identity provider Cloudflare Access consumes, so a signed-in person is already known there
@@ -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