@anchrd/intel-ui 0.19.0 → 0.21.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/README.md CHANGED
@@ -43,6 +43,39 @@ Point `intel.json` at your files:
43
43
  Missing assets, non-SVG branding and incomplete language catalogs fail the build rather than
44
44
  silently producing a half-branded application.
45
45
 
46
+ ## What an empty screen means
47
+
48
+ Most "the UI is broken" reports are one of four situations, and the application distinguishes them
49
+ rather than showing one blank page for all of them. The full explanation of the two permission
50
+ layers is in the
51
+ [`@anchrd/intel-api` README](https://www.npmjs.com/package/@anchrd/intel-api); what follows is what
52
+ each one looks like here.
53
+
54
+ | On screen | What it is |
55
+ |---|---|
56
+ | "You do not have permission to see this." | The Gate capability is missing — `nodes.read` and friends |
57
+ | An empty tree, no error | Nothing has been shared with this person yet |
58
+ | "This is not available to you. It may not exist, or it may no longer be shared with you." | A node that was reachable and is not any more, or never existed — Intel deliberately does not say which |
59
+ | Skeletons that never resolve | Should no longer happen: a refusal ends the loading state on the first answer, and no button offers a retry that cannot change anything |
60
+
61
+ A node somebody shares appears in the recipient's sidebar on its own, at the top level, without
62
+ naming the folders above it. Nobody needs to be sent a link.
63
+
64
+ The sharing dialog grants to an email address or to the whole organization. It never shows a raw
65
+ user id: where Intel cannot name an account it says so. ⚠️ Choosing **execute for the whole
66
+ organization** on a folder makes that folder a library that flows from anywhere may call into, and
67
+ withdrawing it is refused while a caller remains — the dialog says so before the click.
68
+
69
+ The Tools screen tells four situations apart — refused, expired, broken, and started-but-never-
70
+ answered — and offers a second attempt everywhere a second attempt could change the answer.
71
+
72
+ The editor answers "Nothing to save" rather than "Saved" when the write produced no new version.
73
+ The two are different outcomes and only one of them means the text is safe to walk away from.
74
+
75
+ ⚠️ The editor opens **empty** on content it cannot parse. Documents written over HTTP or MCP have to
76
+ carry the BlockNote media type to be editable in the browser; see the
77
+ [`@anchrd/intel-contract` README](https://www.npmjs.com/package/@anchrd/intel-contract).
78
+
46
79
  ## What is inside
47
80
 
48
81
  Vite, React, TanStack Router and Query, with shadcn components. The node editor uses BlockNote
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@anchrd/intel-ui",
3
- "version": "0.19.0",
3
+ "version": "0.21.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.13.0",
36
+ "@anchrd/intel-contract": "^0.15.0",
37
37
  "@blocknote/core": "^0.52.1",
38
38
  "@blocknote/react": "^0.52.1",
39
39
  "@blocknote/shadcn": "^0.52.1",
@@ -1,3 +1,4 @@
1
+ import type { NodeKind } from "@anchrd/intel-contract/node";
1
2
  import { useMutation, useQueries, useQueryClient } from "@tanstack/react-query";
2
3
  import { useNavigate, useRouterState } from "@tanstack/react-router";
3
4
  import {
@@ -34,6 +35,7 @@ import {
34
35
  } from "@/components/ui/sidebar";
35
36
  import { flowEntry } from "@/data/intel-data-provider/intel-data-provider.ts";
36
37
  import type { TreeEntry } from "@/data/intel-data-provider/intel-data-provider.types.ts";
38
+ import { refusalOf } from "@/data/request-refusal/request-refusal.ts";
37
39
  import { useI18n } from "@/i18n/i18n-context.tsx";
38
40
  import { type ImportKind, useBundleImport } from "@/import-bundle/import-bundle.tsx";
39
41
  import { kindIcons } from "@/kind-icon.ts";
@@ -41,7 +43,15 @@ import { Modal } from "@/modal/modal.tsx";
41
43
  import { useIntelRouterContext } from "@/router/router-context.ts";
42
44
  import { selectedFrom } from "@/router/selection-search.ts";
43
45
 
44
- type NewKind = "folder" | "document" | "table" | "flow";
46
+ /**
47
+ * What the plus can MAKE — derived from `NodeKind`, never written out again (#395).
48
+ *
49
+ * ⚠️ `attachment` is excluded because an attachment is uploaded rather than created, and `flow` is
50
+ * added because a flow shares the tree without being a node (ADR-0004 §1). Both are decisions about
51
+ * this menu; the LIST of node kinds is not, and a copy of it here goes stale in the direction that
52
+ * already bit us — the parked kinds stood in such a union long after nothing could make one.
53
+ */
54
+ type NewKind = Exclude<NodeKind, "attachment"> | "flow";
45
55
  type Creating = { parentId: string | null; kind: NewKind };
46
56
 
47
57
  // Attachments are inlined as base64, so the browser holds the file twice while it uploads.
@@ -328,6 +338,19 @@ export function AppTree() {
328
338
  </SidebarMenu>
329
339
  );
330
340
  }
341
+ // ⚠️ A refusal, not a failure (#430). While this branch did not exist, a reader whose share had
342
+ // been revoked — or who holds no `intel.nodes.read` at all — watched the three skeleton bars
343
+ // above for as long as the retries lasted, and the retries are paused whenever the tab is not
344
+ // in front. Neither of those two cases is worth trying again, so what stands here is a sentence
345
+ // and no button.
346
+ const refusal = refusalOf(level.error);
347
+ if (refusal) {
348
+ return (
349
+ <p role="status" className="px-2 py-1.5 text-sm text-muted-foreground">
350
+ {i18n.t(refusal === "no-permission" ? "common.noPermission" : "common.noAccess")}
351
+ </p>
352
+ );
353
+ }
331
354
  if (level.isError) {
332
355
  return (
333
356
  <div role="alert" className="space-y-2 px-2 py-1.5 text-sm">
@@ -367,15 +390,16 @@ export function AppTree() {
367
390
  }
368
391
  return (
369
392
  <SidebarMenu aria-label={label}>
370
- {entries.map((entry) => renderRow(entry, ancestors, parent.type === "flow"))}
393
+ {entries.map((entry) => renderRow(entry, parent, ancestors))}
371
394
  </SidebarMenu>
372
395
  );
373
396
  }
374
397
 
375
- // ⚠️ `derived` is the level under a flow: its rows are that flow's calls, read out of its graph
398
+ // ⚠️ A level under a FLOW is derived: its rows are that flow's calls, read out of its graph
376
399
  // (ADR-0004 §3, `levelKey`). Nothing there has a `parent_id` to rewrite, so those rows are neither
377
400
  // dragged nor dropped on — a move made there would silently be a move of the shared flow itself.
378
- function renderRow(entry: TreeEntry, ancestors: ReadonlySet<string>, derived: boolean) {
401
+ function renderRow(entry: TreeEntry, parent: Level, ancestors: ReadonlySet<string>) {
402
+ const derived = parent.type === "flow";
379
403
  const isFolder = entry.kind === "folder";
380
404
  // ⚠️ A flow expands too, and what appears under it is what it calls — read from its graph, not
381
405
  // from `parent_id`. A flow reused by three callers therefore shows up under all three, which is
@@ -391,7 +415,23 @@ export function AppTree() {
391
415
  const area = entry.type === "flow" ? "/flows" : "/nodes";
392
416
  const isActive = location.select === entry.id && location.pathname === area;
393
417
  // A row's plus files into that row's place: inside a folder, beside anything else.
394
- const target = isFolder ? entry.id : parentOf(entry);
418
+ //
419
+ // ⚠️ "Beside it" is the level this row is shown IN, not the parent its own record names. Since
420
+ // #429 the root level also carries rows whose `parentId` points at a folder this reader may not
421
+ // see — a shared document is filed inside the sharer's folder, and only the share reaches down
422
+ // to it. Filing beside such a row through that id would try to write into a folder the reader
423
+ // cannot even open, and the refusal would arrive as a bare "could not be created".
424
+ //
425
+ // ⚠️ A derived level is the exception, because it has no folder to be filed in: what a flow
426
+ // calls is read from its graph, so the record's own parent is the only answer available there.
427
+ //
428
+ // ⚠️ It is the only one, not a safe one. A called flow may be a LIBRARY flow — `execute` without
429
+ // `read`, which ADR-0004 §2/§3 allows on purpose — and its folder is then one this reader may
430
+ // not open at all. The plus there can still be refused, exactly as the plus on any read-only
431
+ // folder row elsewhere in the tree has always been able to be. That is the older question of
432
+ // what a plus should do where writing is not allowed, and it is not this ticket's: what #429
433
+ // changed is a plus pointing at a folder that is not even the level being shown.
434
+ const target = isFolder ? entry.id : derived ? parentOf(entry) : parent.id;
395
435
  // Only a folder takes a drop, and only on a level that owns its rows. Everything else keeps the
396
436
  // default cursor while a drag is in progress, which is the answer "not here" without a word.
397
437
  const drop =
@@ -1,4 +1,5 @@
1
- import type { Flow, ToolCapability } from "@anchrd/intel-contract";
1
+ import type { Flow } from "@anchrd/intel-contract/flow";
2
+ import type { ToolCapability } from "@anchrd/intel-contract/tool";
2
3
  import { useQuery } from "@tanstack/react-query";
3
4
  import { useNavigate } from "@tanstack/react-router";
4
5
  import { FileText, Search, Workflow, Wrench } from "lucide-react";
@@ -0,0 +1,79 @@
1
+ import { useMutation } from "@tanstack/react-query";
2
+ import { Button } from "@/components/ui/button";
3
+ import {
4
+ Dialog,
5
+ DialogContent,
6
+ DialogDescription,
7
+ DialogFooter,
8
+ DialogHeader,
9
+ DialogTitle,
10
+ } from "@/components/ui/dialog";
11
+ import { useI18n } from "@/i18n/i18n-context.tsx";
12
+ import { useIntelRouterContext } from "@/router/router-context.ts";
13
+
14
+ /**
15
+ * Rebuilding the search index, asked for before it happens (#416).
16
+ *
17
+ * ⚠️ Two clicks, not one, and the reason is not that it is dangerous — nothing is lost, the index is
18
+ * derived from D1 and R2 and can always be built again. It is that it is EXPENSIVE and invisible:
19
+ * every node is read and embedded again, which costs money and minutes, and the screen looks exactly
20
+ * the same afterwards. A one-click button next to "Preferences" invites a second press when the
21
+ * first one seems to have done nothing.
22
+ *
23
+ * ⚠️ It reports what was QUEUED, never "done". The answer comes back the moment the work is handed
24
+ * to the queue, and the index catches up behind it — writing "finished" here would be a sentence
25
+ * about something this screen cannot see. The number is the honest part: it says how much was
26
+ * accepted, so a repeat press can be compared against it.
27
+ *
28
+ * The dialog is opened from outside, like the settings one: its trigger is a `DropdownMenuItem`, and
29
+ * Radix unmounts the menu content in the same frame the item is chosen.
30
+ */
31
+ interface ReindexDialogProps {
32
+ open: boolean;
33
+ onOpenChange(open: boolean): void;
34
+ }
35
+
36
+ export function ReindexDialog({ open, onOpenChange }: ReindexDialogProps) {
37
+ const i18n = useI18n();
38
+ const { data } = useIntelRouterContext();
39
+ const reindex = useMutation({ mutationFn: () => data.reindexNodes() });
40
+
41
+ return (
42
+ <Dialog
43
+ open={open}
44
+ onOpenChange={(next) => {
45
+ // Closing forgets the last answer, so re-opening never shows a count from an earlier run as
46
+ // if it were this one's.
47
+ if (!next) reindex.reset();
48
+ onOpenChange(next);
49
+ }}
50
+ >
51
+ <DialogContent className="sm:max-w-md">
52
+ <DialogHeader>
53
+ <DialogTitle>{i18n.t("reindex.title")}</DialogTitle>
54
+ <DialogDescription>{i18n.t("reindex.description")}</DialogDescription>
55
+ </DialogHeader>
56
+ {reindex.isSuccess ? (
57
+ <p role="status" className="text-sm">
58
+ {i18n.t("reindex.queued", { count: reindex.data.queued })}
59
+ </p>
60
+ ) : null}
61
+ {reindex.isError ? (
62
+ <p role="alert" className="text-sm text-destructive">
63
+ {i18n.t("reindex.failed")}
64
+ </p>
65
+ ) : null}
66
+ <DialogFooter>
67
+ <Button variant="outline" onClick={() => onOpenChange(false)}>
68
+ {i18n.t(reindex.isSuccess ? "reindex.close" : "reindex.cancel")}
69
+ </Button>
70
+ {reindex.isSuccess ? null : (
71
+ <Button disabled={reindex.isPending} onClick={() => reindex.mutate()}>
72
+ {i18n.t(reindex.isPending ? "reindex.running" : "reindex.confirm")}
73
+ </Button>
74
+ )}
75
+ </DialogFooter>
76
+ </DialogContent>
77
+ </Dialog>
78
+ );
79
+ }
@@ -4,6 +4,7 @@ import {
4
4
  Archive as ArchiveIcon,
5
5
  ChevronsUpDown,
6
6
  LogOut,
7
+ RefreshCw,
7
8
  Settings,
8
9
  User,
9
10
  Wrench,
@@ -20,6 +21,7 @@ import { SidebarMenu, SidebarMenuItem } from "@/components/ui/sidebar";
20
21
  import { loginPath } from "@/data/intel-data-provider/intel-data-provider.ts";
21
22
  import { useI18n } from "@/i18n/i18n-context.tsx";
22
23
  import { useIntelRouterContext } from "@/router/router-context.ts";
24
+ import { ReindexDialog } from "../reindex-dialog/reindex-dialog.tsx";
23
25
  import { SettingsDialog } from "../settings-dialog/settings-dialog.tsx";
24
26
 
25
27
  /**
@@ -41,6 +43,7 @@ export function UserFooter() {
41
43
  // The state lives here, not in the dialog: its trigger is a DropdownMenuItem, and Radix unmounts
42
44
  // the menu content when an item is chosen — a dialog nested in there would go with it.
43
45
  const [settingsOpen, setSettingsOpen] = useState(false);
46
+ const [reindexOpen, setReindexOpen] = useState(false);
44
47
  const session = useQuery({ queryKey: ["session"], queryFn: () => data.getSession() });
45
48
  const logout = useMutation({
46
49
  mutationFn: () => data.logout(),
@@ -103,6 +106,20 @@ export function UserFooter() {
103
106
  <Settings aria-hidden="true" />
104
107
  {i18n.t("settings.title")}
105
108
  </DropdownMenuItem>
109
+ {/* ⚠️ Drawn only for an administrator, and that is a DRAWING decision — `POST
110
+ /nodes/reindex` asks Gate for `intel/admin` itself and would refuse this row's press
111
+ just the same (#416). Hiding it is not the check; it is not offering everybody a
112
+ door they cannot open. Its own separator, because the rows above belong to the
113
+ person and this one belongs to the installation. */}
114
+ {session.data?.isAdmin ? (
115
+ <>
116
+ <DropdownMenuSeparator />
117
+ <DropdownMenuItem onSelect={() => setReindexOpen(true)}>
118
+ <RefreshCw aria-hidden="true" />
119
+ {i18n.t("reindex.title")}
120
+ </DropdownMenuItem>
121
+ </>
122
+ ) : null}
106
123
  <DropdownMenuSeparator />
107
124
  <DropdownMenuItem
108
125
  disabled={logout.isPending}
@@ -116,6 +133,8 @@ export function UserFooter() {
116
133
  </DropdownMenu>
117
134
  {/* ⚠️ Outside the DropdownMenu on purpose — see the state above. */}
118
135
  <SettingsDialog open={settingsOpen} onOpenChange={setSettingsOpen} />
136
+ {/* ⚠️ Outside the DropdownMenu for the same reason as the settings dialog. */}
137
+ <ReindexDialog open={reindexOpen} onOpenChange={setReindexOpen} />
119
138
  </SidebarMenuItem>
120
139
  {/* ⚠️ Outside the menu on purpose. Choosing sign-out closes the popup, so a refusal rendered
121
140
  inside it would be gone in the same frame it was written — the one message that must
@@ -1,4 +1,5 @@
1
- import type { Flow, Node } from "@anchrd/intel-contract";
1
+ import type { Flow } from "@anchrd/intel-contract/flow";
2
+ import type { Node } from "@anchrd/intel-contract/node";
2
3
  import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
3
4
  import { ArchiveRestore } from "lucide-react";
4
5
  import { useI18n } from "@/i18n/i18n-context.tsx";
@@ -1,24 +1,33 @@
1
+ import { ProblemDetails, SessionUser } from "@anchrd/intel-contract";
2
+ import { BundleImportResult } from "@anchrd/intel-contract/bundle";
1
3
  import {
2
- AppendTableRowsInput,
3
- AppendTableRowsResult,
4
4
  ArchiveFlowInput,
5
- ArchiveNodeInput,
6
- BundleImportResult,
7
- CompleteFlowRunStepInput,
8
5
  CreateFlowInput,
9
- CreateNodeInput,
10
- DefineTableInput,
11
6
  Flow,
12
7
  FlowDocument,
13
8
  FlowList,
14
9
  FlowPublishPreview,
15
10
  FlowRequirements,
11
+ FlowValidation,
12
+ ListFlowsInput,
13
+ PreviewFlowPublishInput,
14
+ PublishFlowInput,
15
+ RelationGraph,
16
+ RelationGraphInput,
17
+ SaveFlowVersionInput,
18
+ UpdateFlowInput,
19
+ } from "@anchrd/intel-contract/flow";
20
+ import {
21
+ CompleteFlowRunStepInput,
16
22
  FlowRunHistory,
17
23
  FlowRunList,
18
24
  FlowRunStep,
19
- FlowValidation,
20
25
  ListFlowRunsInput,
21
- ListFlowsInput,
26
+ StartFlowRunInput,
27
+ } from "@anchrd/intel-contract/flow-run";
28
+ import {
29
+ ArchiveNodeInput,
30
+ CreateNodeInput,
22
31
  ListNodesInput,
23
32
  Node,
24
33
  NodeDocument,
@@ -27,30 +36,28 @@ import {
27
36
  NodeList,
28
37
  NodeTable,
29
38
  NodeVersionList,
30
- PreviewFlowPublishInput,
31
- ProblemDetails,
32
- PublishFlowInput,
33
- RelationGraph,
34
- RelationGraphInput,
39
+ ReindexResult,
35
40
  ResolveNodeLinksInput,
36
41
  ResolveNodeLinksResult,
37
- ResourceGrantList,
38
- RevokeGrantInput,
39
- RevokeGrantResult,
40
42
  SaveAttachmentInput,
41
- SaveFlowVersionInput,
42
43
  SaveNodeVersionInput,
43
44
  SearchInput,
44
45
  SearchResult,
45
- SessionUser,
46
+ UpdateNodeInput,
47
+ } from "@anchrd/intel-contract/node";
48
+ import {
49
+ ResourceGrantList,
50
+ RevokeGrantInput,
51
+ RevokeGrantResult,
46
52
  ShareInput,
47
53
  ShareResult,
48
- StartFlowRunInput,
49
- ToolCatalog,
50
- ToolServerCatalog,
51
- UpdateFlowInput,
52
- UpdateNodeInput,
53
- } from "@anchrd/intel-contract";
54
+ } from "@anchrd/intel-contract/share";
55
+ import {
56
+ AppendTableRowsInput,
57
+ AppendTableRowsResult,
58
+ DefineTableInput,
59
+ } from "@anchrd/intel-contract/table";
60
+ import { ToolCatalog, ToolServerCatalog } from "@anchrd/intel-contract/tool";
54
61
  import type { z } from "zod";
55
62
  import { createBrowserSignIn } from "@/data/sign-in/sign-in.ts";
56
63
  import type { IntelDataProvider, TreeEntry } from "./intel-data-provider.types.ts";
@@ -187,6 +194,9 @@ export function createIntelDataProvider(
187
194
  async getSession() {
188
195
  return await request("/session", SessionUser);
189
196
  },
197
+ async reindexNodes() {
198
+ return await request("/nodes/reindex", ReindexResult, { method: "POST", body: "{}" });
199
+ },
190
200
  listNodes,
191
201
  async listTreeChildren(parentId) {
192
202
  // Both sides of one folder, asked for in parallel and merged here rather than on the server:
@@ -439,10 +449,14 @@ export function createIntelDataProvider(
439
449
  },
440
450
  async completeFlowStep(input) {
441
451
  const parsed = CompleteFlowRunStepInput.parse(input);
442
- return await request(`/flow-runs/${encodeURIComponent(parsed.runId)}/complete`, FlowRunStep, {
443
- method: "POST",
444
- body: JSON.stringify(parsed),
445
- });
452
+ return await request(
453
+ `/flow-runs/${encodeURIComponent(parsed.runId)}/steps/complete`,
454
+ FlowRunStep,
455
+ {
456
+ method: "POST",
457
+ body: JSON.stringify(parsed),
458
+ },
459
+ );
446
460
  },
447
461
  async listTools() {
448
462
  return await request("/tools", ToolCatalog);
@@ -1,24 +1,33 @@
1
+ import type { SessionUser } from "@anchrd/intel-contract";
2
+ import type { BundleImportResult } from "@anchrd/intel-contract/bundle";
1
3
  import type {
2
- AppendTableRowsInput,
3
- AppendTableRowsResult,
4
4
  ArchiveFlowInput,
5
- ArchiveNodeInput,
6
- BundleImportResult,
7
- CompleteFlowRunStepInput,
8
5
  CreateFlowInput,
9
- CreateNodeInput,
10
- DefineTableInput,
11
6
  Flow,
12
7
  FlowDocument,
13
8
  FlowList,
14
9
  FlowPublishPreview,
15
10
  FlowRequirements,
11
+ FlowValidation,
12
+ ListFlowsInput,
13
+ PreviewFlowPublishInput,
14
+ PublishFlowInput,
15
+ RelationGraph,
16
+ RelationGraphScope,
17
+ SaveFlowVersionInput,
18
+ UpdateFlowInput,
19
+ } from "@anchrd/intel-contract/flow";
20
+ import type {
21
+ CompleteFlowRunStepInput,
16
22
  FlowRunHistory,
17
23
  FlowRunList,
18
24
  FlowRunStep,
19
- FlowValidation,
20
25
  ListFlowRunsInput,
21
- ListFlowsInput,
26
+ StartFlowRunInput,
27
+ } from "@anchrd/intel-contract/flow-run";
28
+ import type {
29
+ ArchiveNodeInput,
30
+ CreateNodeInput,
22
31
  ListNodesInput,
23
32
  Node,
24
33
  NodeDocument,
@@ -28,29 +37,28 @@ import type {
28
37
  NodeList,
29
38
  NodeTable,
30
39
  NodeVersionList,
31
- PreviewFlowPublishInput,
32
- PublishFlowInput,
33
- RelationGraph,
34
- RelationGraphScope,
40
+ ReindexResult,
35
41
  ResolveNodeLinksInput,
36
42
  ResolveNodeLinksResult,
37
- ResourceGrantList,
38
- RevokeGrantInput,
39
- RevokeGrantResult,
40
43
  SaveAttachmentInput,
41
- SaveFlowVersionInput,
42
44
  SaveNodeVersionInput,
43
45
  SearchInput,
44
46
  SearchResult,
45
- SessionUser,
47
+ UpdateNodeInput,
48
+ } from "@anchrd/intel-contract/node";
49
+ import type {
50
+ ResourceGrantList,
51
+ RevokeGrantInput,
52
+ RevokeGrantResult,
46
53
  ShareInput,
47
54
  ShareResult,
48
- StartFlowRunInput,
49
- ToolCatalog,
50
- ToolServerCatalog,
51
- UpdateFlowInput,
52
- UpdateNodeInput,
53
- } from "@anchrd/intel-contract";
55
+ } from "@anchrd/intel-contract/share";
56
+ import type {
57
+ AppendTableRowsInput,
58
+ AppendTableRowsResult,
59
+ DefineTableInput,
60
+ } from "@anchrd/intel-contract/table";
61
+ import type { ToolCatalog, ToolServerCatalog } from "@anchrd/intel-contract/tool";
54
62
 
55
63
  // One row of the shared tree. Nodes and Flows share the folder, not their nature (ADR-0004), so
56
64
  // this is a union that keeps each side's record whole — never a merged "node" that is a bit of both.
@@ -70,6 +78,14 @@ export type TreeEntry =
70
78
 
71
79
  export interface IntelDataProvider {
72
80
  getSession(): Promise<SessionUser>;
81
+ /**
82
+ * Rebuild the whole search index (`intel/admin`).
83
+ *
84
+ * ⚠️ Answers as soon as the work is QUEUED, not when it is done — `queued` counts the nodes that
85
+ * will be read again, and the index catches up behind it. A screen that said "finished" here
86
+ * would be reporting the wrong thing.
87
+ */
88
+ reindexNodes(): Promise<ReindexResult>;
73
89
  listNodes(input?: Partial<ListNodesInput>): Promise<NodeList>;
74
90
  // One level of the shared tree: the documents and the flows filed in the same folder, in one
75
91
  // sorted list. Per level rather than recursive, so opening a folder is what costs a request.
@@ -0,0 +1,18 @@
1
+ import { QueryClient } from "@tanstack/react-query";
2
+ import { retryRequest } from "@/data/request-refusal/request-refusal.ts";
3
+
4
+ /**
5
+ * The one query client the application runs on.
6
+ *
7
+ * ⚠️ It exists as a function rather than as the literal `new QueryClient()` that stood in `main.tsx`
8
+ * so the policy in it can be exercised by a test (#430). A default nothing renders under is a
9
+ * default nothing checks, and this one decides how long a screen stays on "Loading…".
10
+ *
11
+ * ⚠️ Flat, and without a `.unit.ts` of its own on purpose: the only thing to assert here is that the
12
+ * returned client carries `retryRequest`, and what that predicate DOES is already covered next door
13
+ * in `request-refusal.unit.ts` — twice over, because two screen tests render against this very
14
+ * client. A test that restated the wiring would be the "pure wiring" the repository rules exclude.
15
+ */
16
+ export function createIntelQueryClient(): QueryClient {
17
+ return new QueryClient({ defaultOptions: { queries: { retry: retryRequest } } });
18
+ }
@@ -0,0 +1,51 @@
1
+ import { IntelRequestError } from "@/data/intel-data-provider/intel-data-provider.ts";
2
+
3
+ /**
4
+ * What a failed request already settles, as opposed to what it might answer differently next time.
5
+ *
6
+ * - `not-available` is Intel's `404`. It is deliberately the same answer for "there is no such node"
7
+ * and "you may not reach this node" — `requireVisible` in `packages/api/src/nodes/nodes.ts`, held
8
+ * in place by "answers 404 for a foreign version and for an unreadable node" in `nodes.int.ts`.
9
+ * A screen must therefore not guess between them either: one sentence covers both, and that is why
10
+ * `no-permission` is a separate value rather than prose that could be shown for a `404` by mistake.
11
+ * - `no-permission` is a `403`. The server said the word itself, so repeating it reveals nothing.
12
+ * - `null` is everything else: a timeout, a `502`, a network that came back. Those are worth trying
13
+ * again, and the screen keeps its retry button for them.
14
+ */
15
+ export type Refusal = "not-available" | "no-permission";
16
+
17
+ /** The HTTP status the data provider recorded, or `null` where the failure carried none. */
18
+ export function statusOf(error: unknown): number | null {
19
+ return error instanceof IntelRequestError ? error.status : null;
20
+ }
21
+
22
+ export function refusalOf(error: unknown): Refusal | null {
23
+ switch (statusOf(error)) {
24
+ case 403:
25
+ return "no-permission";
26
+ case 404:
27
+ return "not-available";
28
+ default:
29
+ return null;
30
+ }
31
+ }
32
+
33
+ /**
34
+ * The retry policy every query in Intel runs under.
35
+ *
36
+ * ⚠️ A refusal is deterministic, and repeating it is what left the screen on "Loading…" after a
37
+ * share was revoked (#430). React Query's default is three retries with a growing delay, so a `404`
38
+ * kept the reader in front of a moving loading state for seconds — and a retry is **paused while
39
+ * the tab is unfocused** (`packages/ui/CLAUDE.md`, #212), so "seconds" has no upper bound whenever
40
+ * the person looked somewhere else. The answer was there the whole time.
41
+ *
42
+ * ⚠️ `408` and `429` are the two `4xx` that DO change on their own — a timeout and a rate limit both
43
+ * say "not now" rather than "not you" — so they keep the retries. Everything else in the range is
44
+ * about this request as it was made, and making it again cannot alter that.
45
+ */
46
+ export function retryRequest(failureCount: number, error: unknown): boolean {
47
+ const status = statusOf(error);
48
+ const deterministic =
49
+ status !== null && status >= 400 && status < 500 && status !== 408 && status !== 429;
50
+ return !deterministic && failureCount < 3;
51
+ }
@@ -1,4 +1,4 @@
1
- import { DocumentLinkInlineType } from "@anchrd/intel-contract";
1
+ import { DocumentLinkInlineType } from "@anchrd/intel-contract/node";
2
2
  import { BlockNoteSchema } from "@blocknote/core";
3
3
  import { createReactInlineContentSpec } from "@blocknote/react";
4
4
  import { FileText, Link2Off } from "lucide-react";
@@ -1,4 +1,4 @@
1
- import type { NodeKind } from "@anchrd/intel-contract";
1
+ import type { NodeKind } from "@anchrd/intel-contract/node";
2
2
  import { useQuery } from "@tanstack/react-query";
3
3
  import { ChevronRight, Folder, Search } from "lucide-react";
4
4
  import { useMemo, useState } from "react";
@@ -1,4 +1,4 @@
1
- import type { FlowRunSummary } from "@anchrd/intel-contract";
1
+ 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";
@@ -1,5 +1,6 @@
1
- import type { Flow, FlowGraph, FlowNode, Node } from "@anchrd/intel-contract";
2
- import { flowNodeLayer } from "@anchrd/intel-contract";
1
+ import type { Flow, FlowGraph, FlowNode } from "@anchrd/intel-contract/flow";
2
+ import { flowNodeLayer } from "@anchrd/intel-contract/flow";
3
+ import type { Node } from "@anchrd/intel-contract/node";
3
4
  import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
4
5
  import { useNavigate, useRouterState } from "@tanstack/react-router";
5
6
  import {
@@ -758,7 +759,14 @@ function FlowTitle({
758
759
  <TooltipContent>{publishLabel}</TooltipContent>
759
760
  </Tooltip>
760
761
  </TooltipProvider>
761
- <SaveButton dirty={dirty && canMutate} saving={saving} onSave={onSave} />
762
+ {/* A draft that was never saved has no version, and the button says so rather than claiming
763
+ one (#432) — the same distinction the publish button next to it already draws. */}
764
+ <SaveButton
765
+ dirty={dirty && canMutate}
766
+ saving={saving}
767
+ stored={flow.currentVersionId !== null}
768
+ onSave={onSave}
769
+ />
762
770
  </TitleRow>
763
771
  );
764
772
  }
@@ -1,4 +1,4 @@
1
- import type { FlowNode } from "@anchrd/intel-contract";
1
+ import type { FlowNode } from "@anchrd/intel-contract/flow";
2
2
  import {
3
3
  CircleDot,
4
4
  CirclePlay,
@@ -1,4 +1,4 @@
1
- import { flowNodeLayer } from "@anchrd/intel-contract";
1
+ import { flowNodeLayer } from "@anchrd/intel-contract/flow";
2
2
  import { Plus, X } from "lucide-react";
3
3
  import { Fragment, useId, useRef, useState } from "react";
4
4
  import { type NodeIcon, nodeIcon } from "@/flows/node-icon/node-icon.ts";
@@ -1,4 +1,4 @@
1
- import type { FlowNode } from "@anchrd/intel-contract";
1
+ import type { FlowNode } from "@anchrd/intel-contract/flow";
2
2
 
3
3
  export type PaletteKind = FlowNode["kind"];
4
4