@anchrd/intel-ui 0.20.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.20.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.14.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",
@@ -35,6 +35,7 @@ import {
35
35
  } from "@/components/ui/sidebar";
36
36
  import { flowEntry } from "@/data/intel-data-provider/intel-data-provider.ts";
37
37
  import type { TreeEntry } from "@/data/intel-data-provider/intel-data-provider.types.ts";
38
+ import { refusalOf } from "@/data/request-refusal/request-refusal.ts";
38
39
  import { useI18n } from "@/i18n/i18n-context.tsx";
39
40
  import { type ImportKind, useBundleImport } from "@/import-bundle/import-bundle.tsx";
40
41
  import { kindIcons } from "@/kind-icon.ts";
@@ -337,6 +338,19 @@ export function AppTree() {
337
338
  </SidebarMenu>
338
339
  );
339
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
+ }
340
354
  if (level.isError) {
341
355
  return (
342
356
  <div role="alert" className="space-y-2 px-2 py-1.5 text-sm">
@@ -376,15 +390,16 @@ export function AppTree() {
376
390
  }
377
391
  return (
378
392
  <SidebarMenu aria-label={label}>
379
- {entries.map((entry) => renderRow(entry, ancestors, parent.type === "flow"))}
393
+ {entries.map((entry) => renderRow(entry, parent, ancestors))}
380
394
  </SidebarMenu>
381
395
  );
382
396
  }
383
397
 
384
- // ⚠️ `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
385
399
  // (ADR-0004 §3, `levelKey`). Nothing there has a `parent_id` to rewrite, so those rows are neither
386
400
  // dragged nor dropped on — a move made there would silently be a move of the shared flow itself.
387
- 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";
388
403
  const isFolder = entry.kind === "folder";
389
404
  // ⚠️ A flow expands too, and what appears under it is what it calls — read from its graph, not
390
405
  // from `parent_id`. A flow reused by three callers therefore shows up under all three, which is
@@ -400,7 +415,23 @@ export function AppTree() {
400
415
  const area = entry.type === "flow" ? "/flows" : "/nodes";
401
416
  const isActive = location.select === entry.id && location.pathname === area;
402
417
  // A row's plus files into that row's place: inside a folder, beside anything else.
403
- 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;
404
435
  // Only a folder takes a drop, and only on a level that owns its rows. Everything else keeps the
405
436
  // default cursor while a drag is in progress, which is the answer "not here" without a word.
406
437
  const drop =
@@ -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
+ }
@@ -759,7 +759,14 @@ function FlowTitle({
759
759
  <TooltipContent>{publishLabel}</TooltipContent>
760
760
  </Tooltip>
761
761
  </TooltipProvider>
762
- <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
+ />
763
770
  </TitleRow>
764
771
  );
765
772
  }
package/src/i18n/de.json CHANGED
@@ -112,6 +112,14 @@
112
112
  "node.share": "Freigeben",
113
113
  "node.shareAction": "Zugriff geben",
114
114
  "node.email": "E-Mail-Adresse",
115
+ "node.emailHint": "Intel kann nicht prüfen, ob hinter dieser Adresse ein Konto steht. Die Freigabe greift, sobald sich jemand damit anmeldet — auch nach einer späteren Registrierung.",
116
+ "node.shareWith": "Wer Zugriff bekommt",
117
+ "node.shareWithEmail": "Eine E-Mail-Adresse",
118
+ "node.shareWithOrganization": "Alle in der Organisation",
119
+ "node.shareOrganizationHint": "Jedes angemeldete Mitglied der Organisation bekommt diesen Zugriff, auch wer später dazukommt.",
120
+ "node.shareLibraryWarning": "Ausführen für die ganze Organisation macht diesen Ordner zu einer Bibliothek: Flows von überall im Baum dürfen dann hineinrufen. Das Zurücknehmen wird abgelehnt, solange einer davon es noch tut.",
121
+ "node.revokeInUse": "Dieser Zugriff lässt sich noch nicht zurücknehmen, weil Flows außerhalb dieses Ordners noch hineinrufen.",
122
+ "node.someUser": "Ein Benutzerkonto",
115
123
  "node.verbs": "Was diese Freigabe erlaubt",
116
124
  "node.verb.read": "Lesen",
117
125
  "node.verb.write": "Schreiben",
@@ -167,7 +175,9 @@
167
175
  "tools.connectExpiredHelp": "Die Verbindung zum MCP-Portal der Firma muss innerhalb weniger Minuten abgeschlossen sein, und diese lief ab, während die Freigabe-Seite offen stand. Es ist nichts schiefgegangen und nichts wurde verweigert — starte erneut und bestätige die Server, wenn die Seite erscheint.",
168
176
  "tools.connectRetry": "Erneut starten",
169
177
  "tools.connectFailed": "Anmeldung am Portal fehlgeschlagen",
170
- "tools.connectFailedHelp": "Die Anmeldung am MCP-Portal der Firma brach ab, bevor sie beantwortet werden konnte. Das ist ein technischer Fehler, keine Entscheidung über deinen Zugriff — lade neu für einen weiteren Versuch, und sag der Administration Bescheid, wenn es bleibt.",
178
+ "tools.connectFailedHelp": "Die Anmeldung am MCP-Portal der Firma brach ab, bevor sie beantwortet werden konnte. Das ist ein technischer Fehler, keine Entscheidung über deinen Zugriff — starte sie erneut, und sag der Administration Bescheid, wenn es bleibt.",
179
+ "tools.connectUnknown": "Intel weiß nicht, ob du das Portal erreichst",
180
+ "tools.connectUnknownHelp": "In dieser Browsersitzung wurde eine Anmeldung am MCP-Portal der Firma gestartet, die nie mit einer Antwort zurückkam. Deshalb steht hier weder, dass du zugelassen wurdest, noch, dass du abgelehnt wurdest. Starte sie erneut; wenn es dabei bleibt, sag der Administration Bescheid.",
171
181
  "tools.origin": "Herkunft",
172
182
  "tools.originUnlisted": "Nicht von einem gelisteten Server",
173
183
  "tools.originUnlistedHelp": "Die Serverliste des Portals nennt für diese keinen Server. Seine eigenen Verwaltungswerkzeuge liegen hier — das Portal listet sich nicht selbst — und ebenso alles von einem Server, den die Liste nicht erwähnt hat.",
@@ -302,6 +312,7 @@
302
312
  "common.save": "Speichern",
303
313
  "common.saving": "Wird gespeichert …",
304
314
  "common.saved": "Gespeichert",
315
+ "common.saveNothing": "Nichts zu speichern",
305
316
  "common.saveDirty": "Ungespeicherte Änderungen speichern",
306
317
  "common.unsavedTitle": "Ungespeicherte Änderungen",
307
318
  "common.unsavedBody": "Hier gibt es Änderungen, die nicht gespeichert wurden. Wer jetzt geht, verwirft sie.",
@@ -310,5 +321,7 @@
310
321
  "common.loading": "Wird geladen …",
311
322
  "common.retry": "Erneut versuchen",
312
323
  "common.close": "Schließen",
313
- "common.unavailable": "Intel ist derzeit nicht verfügbar."
324
+ "common.unavailable": "Intel ist derzeit nicht verfügbar.",
325
+ "common.noAccess": "Das ist für dich nicht verfügbar. Es existiert nicht, oder es ist nicht mehr für dich freigegeben.",
326
+ "common.noPermission": "Dir fehlt die Berechtigung, das zu sehen."
314
327
  }
package/src/i18n/en.json CHANGED
@@ -112,6 +112,14 @@
112
112
  "node.share": "Share",
113
113
  "node.shareAction": "Grant access",
114
114
  "node.email": "Email address",
115
+ "node.emailHint": "Intel cannot check whether an account exists behind this address. The grant takes effect as soon as somebody signs in with it — including after they register.",
116
+ "node.shareWith": "Who gets access",
117
+ "node.shareWithEmail": "One email address",
118
+ "node.shareWithOrganization": "Everyone in the organization",
119
+ "node.shareOrganizationHint": "Every signed-in member of the organization gets this access, including people who join later.",
120
+ "node.shareLibraryWarning": "Run for the whole organization makes this folder a library: flows anywhere in the tree may then call into it. Taking it back is refused while any of them still does.",
121
+ "node.revokeInUse": "This access cannot be withdrawn yet, because flows outside this folder still call into it.",
122
+ "node.someUser": "A user account",
115
123
  "node.verbs": "What this grant allows",
116
124
  "node.verb.read": "Read",
117
125
  "node.verb.write": "Write",
@@ -167,7 +175,9 @@
167
175
  "tools.connectExpiredHelp": "The connection to the company MCP portal has to be completed within a few minutes, and this one ran out while the authorization screen was open. Nothing went wrong and nothing was refused — start it again and confirm the servers when the screen appears.",
168
176
  "tools.connectRetry": "Start again",
169
177
  "tools.connectFailed": "Portal sign-in failed",
170
- "tools.connectFailedHelp": "The sign-in to the company MCP portal broke before it could be answered. This is a technical fault, not a decision about your access — reload to try again, and tell an administrator if it stays.",
178
+ "tools.connectFailedHelp": "The sign-in to the company MCP portal broke before it could be answered. This is a technical fault, not a decision about your access — start it again, and tell an administrator if it stays.",
179
+ "tools.connectUnknown": "Intel does not know whether you reach the portal",
180
+ "tools.connectUnknownHelp": "A sign-in to the company MCP portal was started in this browser session and never came back with an answer, so nothing here says you were allowed or refused. Start it again; if it keeps ending here, tell an administrator.",
171
181
  "tools.origin": "Origin",
172
182
  "tools.originUnlisted": "Not from a listed server",
173
183
  "tools.originUnlistedHelp": "The portal's server list names no server for these. Its own management tools sit here — the portal does not list itself — and so does anything from a server the list did not mention.",
@@ -302,6 +312,7 @@
302
312
  "common.save": "Save",
303
313
  "common.saving": "Saving…",
304
314
  "common.saved": "Saved",
315
+ "common.saveNothing": "Nothing to save",
305
316
  "common.saveDirty": "Save unsaved changes",
306
317
  "common.unsavedTitle": "Unsaved changes",
307
318
  "common.unsavedBody": "This has changes that have not been saved. Leaving now discards them.",
@@ -310,5 +321,7 @@
310
321
  "common.loading": "Loading…",
311
322
  "common.retry": "Try again",
312
323
  "common.close": "Close",
313
- "common.unavailable": "Intel is currently unavailable."
324
+ "common.unavailable": "Intel is currently unavailable.",
325
+ "common.noAccess": "This is not available to you. It may not exist, or it may no longer be shared with you.",
326
+ "common.noPermission": "You do not have permission to see this."
314
327
  }
package/src/i18n/es.json CHANGED
@@ -112,6 +112,14 @@
112
112
  "node.share": "Compartir",
113
113
  "node.shareAction": "Conceder acceso",
114
114
  "node.email": "Dirección de correo",
115
+ "node.emailHint": "Intel no puede comprobar si existe una cuenta detrás de esta dirección. El permiso surte efecto en cuanto alguien inicie sesión con ella, incluso si se registra más tarde.",
116
+ "node.shareWith": "Quién obtiene acceso",
117
+ "node.shareWithEmail": "Una dirección de correo",
118
+ "node.shareWithOrganization": "Todos en la organización",
119
+ "node.shareOrganizationHint": "Todos los miembros de la organización que hayan iniciado sesión obtienen este acceso, incluidos quienes se incorporen más tarde.",
120
+ "node.shareLibraryWarning": "Ejecutar para toda la organización convierte esta carpeta en una biblioteca: los flujos de cualquier parte del árbol podrán invocarla. Retirarlo se rechaza mientras alguno siga haciéndolo.",
121
+ "node.revokeInUse": "Este acceso todavía no se puede retirar, porque hay flujos fuera de esta carpeta que siguen invocándola.",
122
+ "node.someUser": "Una cuenta de usuario",
115
123
  "node.verbs": "Qué permite este acceso",
116
124
  "node.verb.read": "Leer",
117
125
  "node.verb.write": "Escribir",
@@ -167,7 +175,9 @@
167
175
  "tools.connectExpiredHelp": "La conexión con el portal MCP de la empresa tiene que completarse en unos minutos, y esta se ha agotado mientras la pantalla de autorización estaba abierta. No ha fallado nada ni se ha denegado nada — vuelve a empezar y confirma los servidores cuando aparezca la pantalla.",
168
176
  "tools.connectRetry": "Empezar de nuevo",
169
177
  "tools.connectFailed": "El inicio de sesión en el portal ha fallado",
170
- "tools.connectFailedHelp": "El inicio de sesión en el portal MCP de la empresa se ha interrumpido antes de poder responderse. Es un fallo técnico, no una decisión sobre tu acceso — recarga para intentarlo otra vez y avisa a la administración si persiste.",
178
+ "tools.connectFailedHelp": "El inicio de sesión en el portal MCP de la empresa se ha interrumpido antes de poder responderse. Es un fallo técnico, no una decisión sobre tu acceso — vuelve a empezar y avisa a la administración si persiste.",
179
+ "tools.connectUnknown": "Intel no sabe si llegas al portal",
180
+ "tools.connectUnknownHelp": "En esta sesión del navegador se inició un acceso al portal MCP de la empresa que nunca volvió con una respuesta, así que aquí no dice ni que se te haya permitido ni que se te haya denegado. Vuelve a empezar; si sigue acabando aquí, avisa a la administración.",
171
181
  "tools.origin": "Origen",
172
182
  "tools.originUnlisted": "No procede de un servidor listado",
173
183
  "tools.originUnlistedHelp": "La lista de servidores del portal no nombra ningún servidor para estas. Aquí están sus propias herramientas de gestión — el portal no se lista a sí mismo — y también todo lo que venga de un servidor que la lista no mencionó.",
@@ -302,6 +312,7 @@
302
312
  "common.save": "Guardar",
303
313
  "common.saving": "Guardando…",
304
314
  "common.saved": "Guardado",
315
+ "common.saveNothing": "Nada que guardar",
305
316
  "common.saveDirty": "Guardar los cambios sin guardar",
306
317
  "common.unsavedTitle": "Cambios sin guardar",
307
318
  "common.unsavedBody": "Esto tiene cambios que no se han guardado. Si sales ahora, se descartan.",
@@ -310,5 +321,7 @@
310
321
  "common.loading": "Cargando…",
311
322
  "common.retry": "Intentarlo de nuevo",
312
323
  "common.close": "Cerrar",
313
- "common.unavailable": "Intel no está disponible en este momento."
324
+ "common.unavailable": "Intel no está disponible en este momento.",
325
+ "common.noAccess": "Esto no está disponible para ti. Puede que no exista o que ya no esté compartido contigo.",
326
+ "common.noPermission": "No tienes permiso para ver esto."
314
327
  }
package/src/main.tsx CHANGED
@@ -1,4 +1,4 @@
1
- import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
1
+ import { QueryClientProvider } from "@tanstack/react-query";
2
2
  import { RouterProvider } from "@tanstack/react-router";
3
3
  import { StrictMode } from "react";
4
4
  import { createRoot } from "react-dom/client";
@@ -6,6 +6,7 @@ import {
6
6
  createIntelDataProvider,
7
7
  loginPath,
8
8
  } from "@/data/intel-data-provider/intel-data-provider.ts";
9
+ import { createIntelQueryClient } from "@/data/query-client.ts";
9
10
  import { createBrowserSignIn, createSignInGuard } from "@/data/sign-in/sign-in.ts";
10
11
  import { I18nProvider } from "@/i18n/i18n-context.tsx";
11
12
  import { createLanguages } from "@/i18n/i18n-languages/i18n-languages.ts";
@@ -32,7 +33,7 @@ if (!root) throw new Error("Missing #root element");
32
33
  const languages = createLanguages({ store: window.localStorage });
33
34
  const signIn = createBrowserSignIn(loginPath);
34
35
  const mounted = createRoot(root);
35
- const queryClient = new QueryClient();
36
+ const queryClient = createIntelQueryClient();
36
37
 
37
38
  // The shell is painted from a function because a refused sign-in has to replace it, and that
38
39
  // refusal arrives from inside a fetch rather than from React.
@@ -120,6 +120,11 @@ export function NodeEditor({
120
120
  mediaType: BlockNoteMediaType,
121
121
  idempotencyKey: crypto.randomUUID(),
122
122
  });
123
+ // ⚠️ An answer that carries no version did not save anything (#432). Letting it through would
124
+ // move `savedSnapshot` forward, drop the unsaved warning and leave the reader free to close
125
+ // the tab — over a document the server still reports as empty. It belongs on the error path,
126
+ // which is the one place the screen already says that the writing failed.
127
+ if (!node.version) throw new Error("save_without_version");
123
128
  // ⚠️ The snapshot of what was SENT, not of what stands in the editor when the answer comes
124
129
  // back. Saving crosses the network, and typing during it is normal — taking the state at
125
130
  // arrival would call that typing saved and lose it at the next navigation.
@@ -162,7 +167,15 @@ export function NodeEditor({
162
167
  because this is where `dirty` lives — moving the state up to the screen would mean the
163
168
  screen tracking every keystroke in an editor it does not own. */}
164
169
  <ActionSlot name="title-actions">
165
- <SaveButton dirty={dirty} saving={save.isPending} onSave={() => save.mutate()} />
170
+ {/* ⚠️ `stored` comes from the document the screen holds, not from a flag this component
171
+ sets on success: it turns true when the answer that carried a version has reached the
172
+ query cache, and never a moment earlier (#432). */}
173
+ <SaveButton
174
+ dirty={dirty}
175
+ saving={save.isPending}
176
+ stored={document.version !== null}
177
+ onSave={() => save.mutate()}
178
+ />
166
179
  </ActionSlot>
167
180
  <UnsavedChangesGuard dirty={dirty} />
168
181
  {save.isError && (
@@ -6,6 +6,7 @@ import { lazy, Suspense, useState } from "react";
6
6
  import { ActionSlot } from "@/app/action-slot/action-slot.tsx";
7
7
  import { ViewToggle } from "@/app/view-toggle/view-toggle.tsx";
8
8
  import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
9
+ import { refusalOf } from "@/data/request-refusal/request-refusal.ts";
9
10
  import { FolderContents } from "@/folder-contents/folder-contents.tsx";
10
11
  import { GraphPane } from "@/graph-pane/graph-pane.tsx";
11
12
  import { useI18n } from "@/i18n/i18n-context.tsx";
@@ -39,6 +40,10 @@ export function Nodes() {
39
40
  enabled: Boolean(selectedId),
40
41
  });
41
42
  const selected = document.data?.node ?? null;
43
+ // ⚠️ Off the ERROR, not off the absence of data: a query that has not answered is not a query that
44
+ // answered "no" (`packages/ui/CLAUDE.md`). While the request is in flight there is no refusal to
45
+ // report, and the loading line below stays.
46
+ const refusal = refusalOf(document.error);
42
47
  // A folder shows its contents as a graph, and so does the root of the tree (#19). A document has
43
48
  // nothing to draw — which is why the switch is not offered on one rather than offered and empty.
44
49
  const graphable = selectedId === null || selected?.kind === "folder";
@@ -80,6 +85,21 @@ export function Nodes() {
80
85
  </div>
81
86
  ) : document.isPending ? (
82
87
  <p className="p-6 text-sm text-muted-foreground">{i18n.t("common.loading")}</p>
88
+ ) : refusal ? (
89
+ /* ⚠️ A refusal is not a failure, and the difference is the whole of #430. Somebody whose
90
+ share was revoked was left in front of "Loading…" — the worst answer of all, because it
91
+ says the program is still working. They get a sentence and no retry button: the answer
92
+ was final, and a button that changes nothing is an invitation to keep waiting.
93
+
94
+ ⚠️ One sentence for `404`, whichever of its two reasons applies. Intel answers the same
95
+ status for "no such node" and "not for you" on purpose — `requireVisible` in
96
+ `packages/api/src/nodes/nodes.ts` — and a screen that told them apart would undo that
97
+ from the other side. */
98
+ <div role="status" className="grid flex-1 place-items-center p-8 text-center text-sm">
99
+ <p className="max-w-sm text-muted-foreground">
100
+ {i18n.t(refusal === "no-permission" ? "common.noPermission" : "common.noAccess")}
101
+ </p>
102
+ </div>
83
103
  ) : document.isError || !selected ? (
84
104
  <div role="alert" className="grid flex-1 place-items-center p-8 text-center text-sm">
85
105
  <div className="space-y-3">
@@ -1,6 +1,6 @@
1
1
  import type { Flow, FlowValidation } from "@anchrd/intel-contract/flow";
2
2
  import type { Node } from "@anchrd/intel-contract/node";
3
- import type { ResourceVerb, UnreadableNodes } from "@anchrd/intel-contract/share";
3
+ import type { ResourceGrant, ResourceVerb, UnreadableNodes } from "@anchrd/intel-contract/share";
4
4
  import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
5
5
  import { useNavigate, useRouterState } from "@tanstack/react-router";
6
6
  import {
@@ -29,13 +29,14 @@ import {
29
29
  DropdownMenuSubTrigger,
30
30
  DropdownMenuTrigger,
31
31
  } from "@/components/ui/dropdown-menu";
32
- import { flowEntry } from "@/data/intel-data-provider/intel-data-provider.ts";
32
+ import { flowEntry, IntelRequestError } from "@/data/intel-data-provider/intel-data-provider.ts";
33
33
  import type { TreeEntry } from "@/data/intel-data-provider/intel-data-provider.types.ts";
34
34
  import { useI18n } from "@/i18n/i18n-context.tsx";
35
35
  import { useBundleImport } from "@/import-bundle/import-bundle.tsx";
36
36
  import { Modal } from "@/modal/modal.tsx";
37
37
  import { useIntelRouterContext } from "@/router/router-context.ts";
38
38
  import { selectedFrom } from "@/router/selection-search.ts";
39
+ import { useUserName } from "@/user-name/user-name.ts";
39
40
 
40
41
  /**
41
42
  * What a resource's own actions are, at the place the resource stands (#24).
@@ -576,10 +577,76 @@ function NodeLinksPanel({ node, close }: { node: Node; close(): void }) {
576
577
  // flows, which is where a permission decision belongs (ADR-0004 §2). Which verbs it offers is the
577
578
  // server's answer, not this component's: `execute` never appears on a document, because a document
578
579
  // has nothing to run.
580
+ /**
581
+ * One row of the grant list.
582
+ *
583
+ * ⚠️ It is a component rather than a line inside the `map` because of `useUserName`, and that hook
584
+ * is the point: until #431 a `user` grant printed its raw id — `2C9lEhT82upO82abPjHFAvs24a7Vdd3J`
585
+ * beside the word "Read" — which tells the reader nothing about who they let in and puts an
586
+ * identifier on a screen that has no reason to carry one. `user-name.ts` already had the rule
587
+ * ("never fall back to the id, show nothing instead", #258); this row was the last place breaking it.
588
+ *
589
+ * ⚠️ Intel can resolve exactly ONE id today: the signed-in person's. A foreign one is not "a name we
590
+ * could probably guess" but genuinely unanswerable — Intel has no user directory (#261) — so what
591
+ * stands there is what is true: an account, unnamed. The day #261 lands, `useUserName` starts
592
+ * answering and this row needs no change.
593
+ */
594
+ function GrantRow({ grant, onRevoke }: { grant: ResourceGrant; onRevoke(grantId: string): void }) {
595
+ const i18n = useI18n();
596
+ const userName = useUserName(grant.principal.type === "user" ? grant.principal.id : null);
597
+ // ⚠️ An `email` grant keeps showing the ADDRESS, even where a name is known, and that is a
598
+ // decision rather than an oversight (#431, review finding). The grant is bound to the address —
599
+ // `db-grants.ts` matches `lower(principal_id)` against whoever signs in — not to an account.
600
+ // Putting a name there would claim a binding that does not exist, and it would be wrong the moment
601
+ // somebody else verifies that address.
602
+ const who =
603
+ grant.principal.type === "email"
604
+ ? grant.principal.email
605
+ : grant.principal.type === "user"
606
+ ? (userName ?? i18n.t("node.someUser"))
607
+ : i18n.t("node.organization");
608
+ return (
609
+ <li className="flex items-center justify-between gap-3 rounded-md border p-3 text-sm">
610
+ <span className="min-w-0 truncate">
611
+ {who}
612
+ <span className="ml-2 text-xs text-muted-foreground">
613
+ {i18n.t(`node.verb.${grant.verb}`)}
614
+ </span>
615
+ </span>
616
+ <button
617
+ type="button"
618
+ onClick={() => onRevoke(grant.id)}
619
+ aria-label={i18n.t("node.revokeShare")}
620
+ className="rounded-md p-2 text-destructive outline-none hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring"
621
+ >
622
+ <Trash2 aria-hidden="true" className="size-4" />
623
+ </button>
624
+ </li>
625
+ );
626
+ }
627
+
628
+ /**
629
+ * Which of the three principals the dialog can SET (#431).
630
+ *
631
+ * ⚠️ `user` is deliberately absent, and it is a decision rather than an omission. Setting one means
632
+ * naming a Gate user id, and Intel has no way to turn a person into an id or an id into a person
633
+ * (#261) — so the only offer this dialog could make is a field for a raw ULID, which is worse than
634
+ * the address it would replace: unverifiable when typed and unreadable afterwards. It stays reachable
635
+ * over the API and over MCP, where the caller already holds the id. The day #261 lands it becomes a
636
+ * picker, not a text field.
637
+ *
638
+ * ⚠️ `organization` IS here, and the reason is the asymmetry it removes: the dialog could already
639
+ * DRAW an organization grant ("Everyone in the organization") and not make one, and MCP could make
640
+ * one all along — a person had strictly less reach than a model on the same tree. It needs nothing
641
+ * Intel does not have, because there is nothing to name.
642
+ */
643
+ type SharePrincipalKind = "email" | "organization";
644
+
579
645
  function SharePanel({ node, close }: { node: Node; close(): void }) {
580
646
  const { data } = useIntelRouterContext();
581
647
  const i18n = useI18n();
582
648
  const queryClient = useQueryClient();
649
+ const [principalKind, setPrincipalKind] = useState<SharePrincipalKind>("email");
583
650
  const [email, setEmail] = useState("");
584
651
  const [verbs, setVerbs] = useState<ResourceVerb[]>(["read"]);
585
652
  // What the grant just made does not cover. It survives the form being cleared, because it is the
@@ -600,7 +667,10 @@ function SharePanel({ node, close }: { node: Node; close(): void }) {
600
667
  last = (
601
668
  await data.shareNode({
602
669
  resourceId: node.id,
603
- principal: { type: "email", email },
670
+ principal:
671
+ principalKind === "organization"
672
+ ? { type: "organization" }
673
+ : { type: "email", email },
604
674
  verb,
605
675
  expiresAt: null,
606
676
  idempotencyKey: crypto.randomUUID(),
@@ -611,6 +681,10 @@ function SharePanel({ node, close }: { node: Node; close(): void }) {
611
681
  },
612
682
  onSuccess: (result) => {
613
683
  setEmail("");
684
+ // ⚠️ The choice is cleared with the address, not left standing. It survived a successful grant
685
+ // until the review of #431 caught it, and "everyone in the organization" is the one setting
686
+ // that must never be the quiet default for the NEXT grant somebody makes in the same dialog.
687
+ setPrincipalKind("email");
614
688
  setUnreadable(result);
615
689
  },
616
690
  // ⚠️ `onSettled`, not `onSuccess`. One request per verb means a run can end halfway: three verbs
@@ -633,12 +707,39 @@ function SharePanel({ node, close }: { node: Node; close(): void }) {
633
707
  },
634
708
  });
635
709
 
710
+ /**
711
+ * The one refusal in this dialog that names something the reader has to act on.
712
+ *
713
+ * ⚠️ Taking back `organization` + `execute` on a folder is refused with `409
714
+ * folder_execute_in_use` while flows from outside still call into it (`nodes.ts`,
715
+ * `revokeGrant`) — and the refusal's `detail` is the only place the CALLERS are named. Rendering
716
+ * it as the generic "access was not changed" threw exactly that away and left the reader with a
717
+ * dialog that refuses and will not say why, for the one grant that is hardest to undo.
718
+ *
719
+ * ⚠️ The server's own words, on purpose. `callersDetail` names the flows this actor may see and
720
+ * only COUNTS the rest, so it is actionable without becoming a directory of the tree — a
721
+ * translated stand-in would either lose the names or need them separately, and the wire carries
722
+ * prose rather than a list (#448). The lead-in sentence above it is translated; this line is the
723
+ * evidence under it.
724
+ */
725
+ const revokeInUse =
726
+ revoke.error instanceof IntelRequestError && revoke.error.code === "folder_execute_in_use"
727
+ ? revoke.error.message
728
+ : null;
729
+
636
730
  return (
637
731
  <Modal title={i18n.t("node.share")} close={close}>
638
- {(share.isError || revoke.isError || grants.isError) && (
639
- <p role="alert" className="mb-4 text-sm text-destructive">
640
- {i18n.t("node.shareFailed")}
641
- </p>
732
+ {revokeInUse ? (
733
+ <div role="alert" className="mb-4 space-y-1 text-sm text-destructive">
734
+ <p>{i18n.t("node.revokeInUse")}</p>
735
+ <p className="text-xs">{revokeInUse}</p>
736
+ </div>
737
+ ) : (
738
+ (share.isError || revoke.isError || grants.isError) && (
739
+ <p role="alert" className="mb-4 text-sm text-destructive">
740
+ {i18n.t("node.shareFailed")}
741
+ </p>
742
+ )
642
743
  )}
643
744
  {/* ⚠️ `status`, not `alert`, and beside the grant rather than in place of it: the access was
644
745
  given. A node reference across the folder edge is a possible failure, not a way around
@@ -660,29 +761,14 @@ function SharePanel({ node, close }: { node: Node; close(): void }) {
660
761
  )}
661
762
  <ul className="mb-5 max-h-44 space-y-2 overflow-y-auto">
662
763
  {grants.data?.items.map((grant) => (
663
- <li
764
+ <GrantRow
664
765
  key={grant.id}
665
- className="flex items-center justify-between gap-3 rounded-md border p-3 text-sm"
666
- >
667
- <span className="min-w-0 truncate">
668
- {grant.principal.type === "email"
669
- ? grant.principal.email
670
- : grant.principal.type === "user"
671
- ? grant.principal.id
672
- : i18n.t("node.organization")}
673
- <span className="ml-2 text-xs text-muted-foreground">
674
- {i18n.t(`node.verb.${grant.verb}`)}
675
- </span>
676
- </span>
677
- <button
678
- type="button"
679
- onClick={() => revoke.mutate(grant.id)}
680
- aria-label={i18n.t("node.revokeShare")}
681
- className="rounded-md p-2 text-destructive outline-none hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring"
682
- >
683
- <Trash2 aria-hidden="true" className="size-4" />
684
- </button>
685
- </li>
766
+ grant={grant}
767
+ onRevoke={(grantId) => {
768
+ share.reset();
769
+ revoke.mutate(grantId);
770
+ }}
771
+ />
686
772
  ))}
687
773
  {grants.data?.items.length === 0 && (
688
774
  <li className="text-sm text-muted-foreground">{i18n.t("node.noGrants")}</li>
@@ -691,20 +777,99 @@ function SharePanel({ node, close }: { node: Node; close(): void }) {
691
777
  <form
692
778
  onSubmit={(event) => {
693
779
  event.preventDefault();
780
+ // ⚠️ The other mutation's error is cleared before this one starts, in both directions. A
781
+ // refused withdrawal keeps its `409` until something resets it, and `revokeInUse` takes
782
+ // precedence over the general sentence — so without this, a share that failed AFTER a
783
+ // refused withdrawal said nothing at all, and the reader was left reading a sentence
784
+ // about the wrong action. That is the exact failure mode #430 removed one level up.
785
+ revoke.reset();
694
786
  share.mutate();
695
787
  }}
696
788
  className="space-y-4 border-t pt-5"
697
789
  >
698
- <label className="block text-sm font-medium">
699
- {i18n.t("node.email")}
700
- <input
701
- type="email"
702
- required
703
- value={email}
704
- onChange={(event) => setEmail(event.target.value)}
705
- className="mt-2 w-full rounded-md border bg-background px-3 py-2 outline-none focus-visible:ring-2 focus-visible:ring-ring"
706
- />
707
- </label>
790
+ {/* ⚠️ Two of the three principals, and the missing one is a decision — see
791
+ `SharePrincipalKind`. Radios rather than a select: there are two, and a reader has to see
792
+ that granting to the whole organization is a thing this dialog does at all. */}
793
+ <fieldset className="space-y-2">
794
+ <legend className="text-sm font-medium">{i18n.t("node.shareWith")}</legend>
795
+ {(["email", "organization"] as const).map((kind) => (
796
+ <label key={kind} className="flex items-center gap-2 text-sm">
797
+ <input
798
+ type="radio"
799
+ name="share-principal"
800
+ checked={principalKind === kind}
801
+ onChange={() => setPrincipalKind(kind)}
802
+ aria-describedby={kind === "email" ? "share-email-hint" : "share-organization-hint"}
803
+ className="size-4 border outline-none focus-visible:ring-2 focus-visible:ring-ring"
804
+ />
805
+ <span>
806
+ {i18n.t(kind === "email" ? "node.shareWithEmail" : "node.shareWithOrganization")}
807
+ </span>
808
+ </label>
809
+ ))}
810
+ </fieldset>
811
+ {principalKind === "email" ? (
812
+ <div>
813
+ {/* ⚠️ The hint stands OUTSIDE the label and is tied on with `aria-describedby`. Inside
814
+ it, its words would become part of the field's accessible NAME — a screen reader
815
+ would announce the whole paragraph as the label of the input, and every query by
816
+ label would have to spell it out too. */}
817
+ <label className="block text-sm font-medium">
818
+ {i18n.t("node.email")}
819
+ <input
820
+ type="email"
821
+ required
822
+ value={email}
823
+ onChange={(event) => setEmail(event.target.value)}
824
+ aria-describedby="share-email-hint"
825
+ className="mt-2 w-full rounded-md border bg-background px-3 py-2 outline-none focus-visible:ring-2 focus-visible:ring-ring"
826
+ />
827
+ </label>
828
+ {/* ⚠️ What this says is what Intel knows, and no more (#431). It cannot check whether an
829
+ address belongs to anybody — Gate owns identity and offers Intel no lookup (#261) —
830
+ and a dialog that stayed silent about that let a typo become a grant that is valid,
831
+ permanent and completely without effect, with nobody on either side to notice. Naming
832
+ the uncertainty is the honest half of what the ticket asks for; the other half needs
833
+ Gate. */}
834
+ <p id="share-email-hint" className="mt-1 text-xs text-muted-foreground">
835
+ {i18n.t("node.emailHint")}
836
+ </p>
837
+ </div>
838
+ ) : (
839
+ /* ⚠️ `aria-live` on the CONTAINER, which is already mounted whenever this branch is on
840
+ screen — not on the warning inside it. A live region has to exist before its content
841
+ changes, so putting it on a paragraph that appears at the same moment announces
842
+ nothing. The gap it closes: `Run` is ticked while the focus sits in the verb fieldset,
843
+ far from here, and the sentence about what is being made would otherwise arrive in
844
+ silence. (The other way in is covered already: `aria-describedby` on the radio reads
845
+ this whole box out when the radio takes focus.) */
846
+ <div
847
+ id="share-organization-hint"
848
+ aria-live="polite"
849
+ className="space-y-2 rounded-md border bg-muted p-3 text-xs"
850
+ >
851
+ <p className="text-muted-foreground">{i18n.t("node.shareOrganizationHint")}</p>
852
+ {/* ⚠️ `organization` + `execute` on a folder is not one grant among four: it is the
853
+ definition of a LIBRARY (ADR-0004 §3) — the one grant a flow may call across a folder
854
+ edge for, so flows from anywhere in the tree may then reach in. Two clicks in this
855
+ dialog now do what used to take an API call, and the reader has to be told what they
856
+ are about to make BEFORE they make it.
857
+
858
+ ⚠️ The second sentence is the one that matters most, because it is about the way
859
+ back: `revokeGrant` refuses with `folder_execute_in_use` while callers remain, so
860
+ this is the single hardest grant in the product to undo. Naming the consequence after
861
+ the fact would be the "wrong sentence" class anchrd/core#111 collects.
862
+
863
+ It is a warning and not a block, deliberately: the library is a documented, wanted
864
+ shape, MCP has always been able to make one, and refusing it here would put a person
865
+ back below a model on the same tree — the very asymmetry this ticket removed. */}
866
+ {verbs.includes("execute") ? (
867
+ <p role="note" className="font-medium text-destructive">
868
+ {i18n.t("node.shareLibraryWarning")}
869
+ </p>
870
+ ) : null}
871
+ </div>
872
+ )}
708
873
  <fieldset className="space-y-2">
709
874
  <legend className="text-sm font-medium">{i18n.t("node.verbs")}</legend>
710
875
  {(grants.data?.applicableVerbs ?? []).map((verb) => (
@@ -14,18 +14,28 @@ import { Modal } from "@/modal/modal.tsx";
14
14
  * ⚠️ The accessible name names the state as well — "Saved" and "Save unsaved changes" are two
15
15
  * different pieces of information, and a screen reader gets neither the fill nor the dot. The
16
16
  * visible word stays a prefix of it, so speech input still reaches the button by what it reads.
17
+ *
18
+ * ⚠️ At rest there are TWO states, not one, and `stored` is what tells them apart (#432). A
19
+ * document that has never been written has nothing to save either — and calling that "Saved" is the
20
+ * one sentence an editor must never say untruthfully: whoever reads it closes the tab. The button
21
+ * was already unclickable in that state, which is exactly why it went unnoticed for so long; the
22
+ * tooltip and the accessible name kept claiming a version that does not exist. "Nothing to save"
23
+ * says less and stays true in both readings.
17
24
  */
18
25
  export function SaveButton({
19
26
  dirty,
20
27
  saving,
28
+ stored,
21
29
  onSave,
22
30
  }: {
23
31
  dirty: boolean;
24
32
  saving: boolean;
33
+ /** Whether what is being edited has content on the server at all. */
34
+ stored: boolean;
25
35
  onSave(): void;
26
36
  }) {
27
37
  const i18n = useI18n();
28
- const label = i18n.t(dirty ? "common.saveDirty" : "common.saved");
38
+ const label = i18n.t(dirty ? "common.saveDirty" : stored ? "common.saved" : "common.saveNothing");
29
39
  return (
30
40
  <TooltipProvider delayDuration={300}>
31
41
  <Tooltip>
@@ -1,7 +1,15 @@
1
1
  import { serverOf, type ToolCapability, type ToolServer } from "@anchrd/intel-contract/tool";
2
2
  import { useQuery } from "@tanstack/react-query";
3
3
  import { useRouterState } from "@tanstack/react-router";
4
- import { AlertTriangle, ChevronRight, PlugZap, ShieldOff, Timer, Wrench } from "lucide-react";
4
+ import {
5
+ AlertTriangle,
6
+ ChevronRight,
7
+ HelpCircle,
8
+ PlugZap,
9
+ ShieldOff,
10
+ Timer,
11
+ Wrench,
12
+ } from "lucide-react";
5
13
  import * as React from "react";
6
14
  import { Button } from "@/components/ui/button";
7
15
  import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
@@ -65,8 +73,36 @@ function groupByServer(
65
73
  // visit. A second attempt after a refusal would be a redirect loop with an unchanging answer, and
66
74
  // the marker in the URL only survives until the next navigation — the browser session remembers it
67
75
  // instead (#60).
76
+ //
77
+ // ⚠️ It records that an attempt was STARTED, never that one was answered (#426). A sign-in that
78
+ // crashed on the way leaves exactly the same note as one that came back, so nothing may read it as
79
+ // a verdict about anybody: it only keeps the screen from walking in a circle on its own, and what
80
+ // the reader is told comes from the marker in the URL instead. That is the whole distinction — the
81
+ // note is never cleared to make a second attempt possible; the button is what makes one possible.
68
82
  const SignInAttemptKey = "intel.portal-sign-in-attempted";
69
83
 
84
+ /**
85
+ * What came back from the silent walk — and "nothing" is one of the answers (#426).
86
+ *
87
+ * The marker in the URL is the only thing that ever says why the walk ended, and it is absent in two
88
+ * situations that look identical from here: the attempt never ran, or it broke before it could
89
+ * return. Neither is a decision about access, so neither may borrow the sentence that describes one
90
+ * — the rule `packages/ui/CLAUDE.md` keeps from #350: **a missing statement is a reason to say less,
91
+ * never to refuse more.** Saying "no access" for `none` sent a reader hunting through portal
92
+ * policies while the sign-in had answered 500 (#425).
93
+ */
94
+ type PortalAnswer = "refused" | "expired" | "failed" | "none";
95
+
96
+ function portalAnswerOf(connectError: string | null): PortalAnswer {
97
+ if (connectError === null) return "none";
98
+ if (connectError === "portal_sign_in_expired") return "expired";
99
+ // The two codes Intel itself writes when somebody was actually turned away — by the portal, or by
100
+ // Gate before the portal was ever asked. Every other marker is machinery, not policy.
101
+ return connectError === "portal_sign_in_refused" || connectError === "permission_required"
102
+ ? "refused"
103
+ : "failed";
104
+ }
105
+
70
106
  // Reading `sessionStorage` throws outright in a few privacy modes, so the guard is a try, not a
71
107
  // feature check. Losing the note costs one extra redirect, and the marker the refusal leaves in the
72
108
  // URL still ends the walk — it must never cost the screen.
@@ -109,10 +145,30 @@ export function Tools() {
109
145
  typeof window === "undefined"
110
146
  ? null
111
147
  : new URLSearchParams(window.location.search).get("connectError");
112
- const refused = connectError !== null;
113
- const accessDenied =
114
- connectError === "portal_sign_in_refused" || connectError === "permission_required";
115
- const [signingIn, setSigningIn] = React.useState(false);
148
+ const answer = portalAnswerOf(connectError);
149
+ // What the screen DID about that answer, which is a third thing beside the catalog and the marker.
150
+ //
151
+ // ⚠️ Only `none` starts undecided. `useEffect` runs after the paint, so a state that always began
152
+ // at `deciding` would push a "Loading" frame in front of all four notices — and three of them are
153
+ // already settled at the first render, because their answer stands in the URL. It would flash at
154
+ // exactly the readers who just arrived out of a redirect. `none` is the one that genuinely has to
155
+ // wait: its walk starts one tick after the catalog arrives, and "we do not know" in between is a
156
+ // sentence the screen takes back immediately.
157
+ const [walk, setWalk] = React.useState<"deciding" | "running" | "stopped">(
158
+ answer === "none" ? "deciding" : "stopped",
159
+ );
160
+
161
+ const startSignIn = React.useCallback(() => {
162
+ // ⚠️ `setItem`, and the marker is not cleared anywhere on this path. Pressing the button STARTS
163
+ // an attempt, which is the only thing the marker ever claims — clearing it would buy the click
164
+ // nothing (no code in this path reads it) and cost the next visit: an attempt that dies without
165
+ // ever coming back with a marker would be followed by a silent redirect nobody asked for, on a
166
+ // screen that has just said it does not understand what is happening. The second attempt this
167
+ // browser session owes its reader is this button, never a re-armed guard.
168
+ attemptStore()?.setItem(SignInAttemptKey, "1");
169
+ setWalk("running");
170
+ data.startPortalSignIn("/tools");
171
+ }, [data]);
116
172
 
117
173
  // ⚠️ Nobody is asked to start this. The portal sign-in is a browser redirect, so the screen
118
174
  // begins it itself the moment the catalog says there is no portal session yet — that is the whole
@@ -126,11 +182,14 @@ export function Tools() {
126
182
  attempts?.removeItem(SignInAttemptKey);
127
183
  return;
128
184
  }
129
- if (refused || attempts?.getItem(SignInAttemptKey)) return;
185
+ if (answer !== "none" || attempts?.getItem(SignInAttemptKey)) {
186
+ setWalk("stopped");
187
+ return;
188
+ }
130
189
  attempts?.setItem(SignInAttemptKey, "1");
131
- setSigningIn(true);
190
+ setWalk("running");
132
191
  data.startPortalSignIn("/tools");
133
- }, [catalog.data, data, refused]);
192
+ }, [catalog.data, data, answer]);
134
193
 
135
194
  return (
136
195
  <div className="min-h-full p-8">
@@ -151,43 +210,12 @@ export function Tools() {
151
210
  </Button>
152
211
  </Notice>
153
212
  ) : !catalog.data.portalConnected ? (
154
- signingIn ? (
213
+ walk === "deciding" ? (
214
+ <p className="text-sm text-muted-foreground">{i18n.t("common.loading")}</p>
215
+ ) : walk === "running" ? (
155
216
  <p className="text-sm text-muted-foreground">{i18n.t("tools.signingIn")}</p>
156
- ) : connectError === "portal_sign_in_expired" ? (
157
- // Ran out of time on the consent screen. Nothing is broken and nobody was refused, so
158
- // neither of the other two sentences fits — and this one is the only case the reader
159
- // can fix alone, by starting over (#95).
160
- <Notice
161
- icon={<Timer aria-hidden="true" className="mx-auto size-8 text-muted-foreground" />}
162
- title={i18n.t("tools.connectExpired")}
163
- help={i18n.t("tools.connectExpiredHelp")}
164
- >
165
- <Button variant="outline" onClick={() => data.startPortalSignIn("/tools")}>
166
- {i18n.t("tools.connectRetry")}
167
- </Button>
168
- </Notice>
169
- ) : accessDenied || !refused ? (
170
- // The end of the silent walk for somebody no Access policy carries. It is a sentence
171
- // about access, not an invitation to connect: there is nothing they could click that
172
- // would change the answer, and what the portal replied is not repeated here.
173
- <Notice
174
- alert
175
- icon={
176
- <ShieldOff aria-hidden="true" className="mx-auto size-8 text-muted-foreground" />
177
- }
178
- title={i18n.t("tools.noAccess")}
179
- help={i18n.t("tools.noAccessHelp")}
180
- />
181
217
  ) : (
182
- // Every other marker is machinery, not policy: the sign-in broke before anyone could
183
- // be allowed or refused. Saying "no access" here sent the last reader hunting through
184
- // Access policies while the real fault sat in a failed client registration (#93).
185
- <Notice
186
- alert
187
- icon={<PlugZap aria-hidden="true" className="mx-auto size-8 text-destructive" />}
188
- title={i18n.t("tools.connectFailed")}
189
- help={i18n.t("tools.connectFailedHelp")}
190
- />
218
+ <PortalNotice answer={answer} i18n={i18n} onRetry={startSignIn} />
191
219
  )
192
220
  ) : catalog.data.items.length === 0 ? (
193
221
  <Notice
@@ -212,6 +240,81 @@ export function Tools() {
212
240
  );
213
241
  }
214
242
 
243
+ /**
244
+ * The end of the silent walk, in the words of whatever ended it — one sentence per answer, and no
245
+ * sentence shared between two of them (#426).
246
+ *
247
+ * ⚠️ Only `refused` is a statement about ACCESS, and only it is a dead end: there is nothing the
248
+ * reader could press that would change the answer, so nothing is offered. The other two are the
249
+ * machinery and the silence, and both are worth another attempt — which is the button the screen
250
+ * lacked entirely while the marker in `sessionStorage` made the first attempt the only one.
251
+ */
252
+ function PortalNotice({
253
+ answer,
254
+ i18n,
255
+ onRetry,
256
+ }: {
257
+ answer: PortalAnswer;
258
+ i18n: I18n;
259
+ onRetry: () => void;
260
+ }) {
261
+ if (answer === "refused") {
262
+ return (
263
+ <Notice
264
+ alert
265
+ icon={<ShieldOff aria-hidden="true" className="mx-auto size-8 text-muted-foreground" />}
266
+ title={i18n.t("tools.noAccess")}
267
+ help={i18n.t("tools.noAccessHelp")}
268
+ />
269
+ );
270
+ }
271
+ // Ran out of time on the consent screen. Nothing is broken and nobody was refused, so neither of
272
+ // the other sentences fits (#95).
273
+ if (answer === "expired") {
274
+ return (
275
+ <Notice
276
+ icon={<Timer aria-hidden="true" className="mx-auto size-8 text-muted-foreground" />}
277
+ title={i18n.t("tools.connectExpired")}
278
+ help={i18n.t("tools.connectExpiredHelp")}
279
+ >
280
+ <Button variant="outline" onClick={onRetry}>
281
+ {i18n.t("tools.connectRetry")}
282
+ </Button>
283
+ </Notice>
284
+ );
285
+ }
286
+ // A marker that is neither of the two access answers is machinery, not policy: the sign-in broke
287
+ // before anyone could be allowed or refused. Saying "no access" here sent the last reader hunting
288
+ // through Access policies while the real fault sat in a failed client registration (#93).
289
+ if (answer === "failed") {
290
+ return (
291
+ <Notice
292
+ alert
293
+ icon={<PlugZap aria-hidden="true" className="mx-auto size-8 text-destructive" />}
294
+ title={i18n.t("tools.connectFailed")}
295
+ help={i18n.t("tools.connectFailedHelp")}
296
+ >
297
+ <Button variant="outline" onClick={onRetry}>
298
+ {i18n.t("tools.connectRetry")}
299
+ </Button>
300
+ </Notice>
301
+ );
302
+ }
303
+ // No marker at all, and an attempt already made in this browser session: it never came back. The
304
+ // screen says exactly that — not "no access", which is a decision nobody made here.
305
+ return (
306
+ <Notice
307
+ icon={<HelpCircle aria-hidden="true" className="mx-auto size-8 text-muted-foreground" />}
308
+ title={i18n.t("tools.connectUnknown")}
309
+ help={i18n.t("tools.connectUnknownHelp")}
310
+ >
311
+ <Button variant="outline" onClick={onRetry}>
312
+ {i18n.t("tools.connectRetry")}
313
+ </Button>
314
+ </Notice>
315
+ );
316
+ }
317
+
215
318
  /**
216
319
  * The catalog, under the portal's servers where there are any.
217
320
  *