@anchrd/intel-ui 0.22.0 → 0.25.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.
@@ -0,0 +1,109 @@
1
+ import { useQuery } from "@tanstack/react-query";
2
+ import { RouterProvider } from "@tanstack/react-router";
3
+ import { useSyncExternalStore } from "react";
4
+ import type { IntelDataProvider } from "@/data/intel-data-provider/intel-data-provider.types.ts";
5
+ import { useI18n } from "@/i18n/i18n-context.tsx";
6
+ import { SignInRefused } from "@/sign-in-refused/sign-in-refused.tsx";
7
+ import type { AppRootDeps, RefusalStore } from "./app-root.types.ts";
8
+
9
+ /**
10
+ * The one decision this application makes before it draws anything: **is there a session?**
11
+ *
12
+ * ⚠️ Without a session there is NO SHELL — not the sidebar, not the header, not the search (#456).
13
+ * It used to paint first and ask afterwards: the `RouterProvider` started, the shell stood, the
14
+ * queries went out, and only when the first of them came back 401 did the redirect happen. Between
15
+ * "painted" and "redirected" lay a full network round trip in which a stranger saw the application.
16
+ * A slow answer stretched that window arbitrarily, and a route that fires no query at all never
17
+ * closed it.
18
+ *
19
+ * ⚠️ What is NOT claimed: that data would become visible. The api answers 401 and the lists stay
20
+ * empty. But that one server answer was then the ONLY defence — the client had none. Any surface
21
+ * that later renders something before the first request (a cache, a `localStorage`, an optimistic
22
+ * value) would break it without meaning to. And an application that looks as if one were inside it
23
+ * and contains nothing is the wrong first impression from a product whose business is access
24
+ * control.
25
+ *
26
+ * ⚠️ **This is the file #130 asked for.** The logic sat in `main.tsx`, `main.tsx` had no test, and
27
+ * therefore none of the eleven tests written for #118 ever touched it: *"the entry point is exactly
28
+ * the place where 'this is only wiring' sounds most plausible and costs most."* The decision now
29
+ * lives somewhere a test can call.
30
+ */
31
+ export function AppRoot({ data, router, refusal }: AppRootDeps) {
32
+ const refused = useSyncExternalStore(refusal.subscribe, refusal.isRefused, refusal.isRefused);
33
+ if (refused) return <SignInRefused />;
34
+ return <SessionGate data={data} router={router} />;
35
+ }
36
+
37
+ /**
38
+ * ⚠️ Its own component, and not merged into `AppRoot`: the hook below may only run once a
39
+ * `QueryClientProvider` is above it, and `refused` has to be answerable without one — the refusal
40
+ * screen is what stands there when nothing else works.
41
+ */
42
+ function SessionGate({ data, router }: { data: IntelDataProvider; router: AppRootDeps["router"] }) {
43
+ const i18n = useI18n();
44
+ // ⚠️ `retry: false`, deliberately against the client's default policy: this answer is
45
+ // authoritative in both directions. A 401 is not a hiccup to be retried — the provider's
46
+ // `onUnauthorized` is already taking the reader to Gate while this promise rejects — and a retry
47
+ // here would additionally be PAUSED while the tab is unfocused (`packages/ui/CLAUDE.md`), which
48
+ // is how a guard turns into a permanent "Loading" for somebody looking elsewhere.
49
+ //
50
+ // The key is the one `UserFooter` reads, so the shell's own session query is already answered
51
+ // when it mounts. One question, asked once.
52
+ const session = useQuery({
53
+ queryKey: ["session"],
54
+ queryFn: () => data.getSession(),
55
+ retry: false,
56
+ staleTime: Number.POSITIVE_INFINITY,
57
+ });
58
+
59
+ // ⚠️ Both the waiting and the failing case render NO shell. The failing one is not the same as
60
+ // "not signed in": a 401 has already sent the reader away by the time it lands here, so what is
61
+ // left is a surface that could not be reached at all — and saying that is better than a spinner
62
+ // that never ends. Neither of them draws a sidebar.
63
+ if (session.isPending) {
64
+ return (
65
+ <div className="grid min-h-dvh place-items-center bg-background p-8">
66
+ <p className="text-sm text-muted-foreground">{i18n.t("common.loading")}</p>
67
+ </div>
68
+ );
69
+ }
70
+ if (session.isError) {
71
+ return (
72
+ <div role="alert" className="grid min-h-dvh place-items-center bg-background p-8">
73
+ <div className="max-w-prose text-center">
74
+ <p className="text-sm text-muted-foreground">{i18n.t("signIn.unreachable")}</p>
75
+ <button
76
+ type="button"
77
+ onClick={() => window.location.reload()}
78
+ className="mt-6 rounded-md border px-4 py-2 text-sm font-medium outline-none hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring"
79
+ >
80
+ {i18n.t("common.retry")}
81
+ </button>
82
+ </div>
83
+ </div>
84
+ );
85
+ }
86
+ return <RouterProvider router={router} />;
87
+ }
88
+
89
+ /**
90
+ * ⚠️ Latching, and that is not an optimisation: once the surface has turned down a freshly issued
91
+ * credential, later 401s say nothing new. Without the latch every request the page still had in
92
+ * flight would notify again, and the screen would flicker once per answer.
93
+ */
94
+ export function createRefusalStore(): RefusalStore {
95
+ let refused = false;
96
+ const listeners = new Set<() => void>();
97
+ return {
98
+ subscribe(listener) {
99
+ listeners.add(listener);
100
+ return () => listeners.delete(listener);
101
+ },
102
+ isRefused: () => refused,
103
+ refuse() {
104
+ if (refused) return;
105
+ refused = true;
106
+ for (const listener of listeners) listener();
107
+ },
108
+ };
109
+ }
@@ -0,0 +1,27 @@
1
+ import type { IntelDataProvider } from "@/data/intel-data-provider/intel-data-provider.types.ts";
2
+ import type { createIntelRouter } from "@/router/router.tsx";
3
+
4
+ /**
5
+ * Whether the surface has turned down a freshly issued credential (#118) — a store rather than a
6
+ * module-level `let`, and that is the whole point of #456.
7
+ *
8
+ * The refusal arrives from inside a `fetch`, not from React: the data provider calls
9
+ * `onUnauthorized`, and something on screen has to change because of it. That used to be a
10
+ * `let refused = false` in `main.tsx` with a hand-written repaint — a decision in bare module body,
11
+ * which is exactly what nothing could call and therefore what no test ever touched (#130).
12
+ */
13
+ export interface RefusalStore {
14
+ subscribe(listener: () => void): () => void;
15
+ isRefused(): boolean;
16
+ refuse(): void;
17
+ }
18
+
19
+ export type IntelRouter = ReturnType<typeof createIntelRouter>;
20
+
21
+ export interface AppRootDeps {
22
+ // ⚠️ The provider is handed in rather than read from the router context: that context only exists
23
+ // INSIDE `RouterProvider`, and the whole point of the gate is to decide before it mounts.
24
+ data: IntelDataProvider;
25
+ router: IntelRouter;
26
+ refusal: RefusalStore;
27
+ }
@@ -1,10 +1,14 @@
1
1
  import type { Flow } from "@anchrd/intel-contract/flow";
2
2
  import type { Node } from "@anchrd/intel-contract/node";
3
3
  import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
4
- import { ArchiveRestore } from "lucide-react";
4
+ import { ArchiveRestore, LoaderCircle, Trash2 } from "lucide-react";
5
+ import { useState } from "react";
6
+ import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
5
7
  import { useI18n } from "@/i18n/i18n-context.tsx";
6
8
  import { kindIcons } from "@/kind-icon.ts";
9
+ import { Modal } from "@/modal/modal.tsx";
7
10
  import { useIntelRouterContext } from "@/router/router-context.ts";
11
+ import { useDateTime } from "@/time/time-context.tsx";
8
12
 
9
13
  // One archived thing, whichever side of the tree it came from. The two records stay apart
10
14
  // everywhere else (ADR-0004); here they are one list because "what did I throw away" is one
@@ -16,11 +20,16 @@ interface ArchivedEntry {
16
20
  archivedAt: string;
17
21
  updatedAt: string;
18
22
  restore(): Promise<unknown>;
23
+ // Both sides of the tree can be purged (#457) — each through its own door, because node and flow
24
+ // stay separate everywhere else (ADR-0004).
25
+ purge(): Promise<{ purged: true; title: string }>;
26
+ previewPurge?(): Promise<{ inboundLinks: number; totalItems: number }>;
19
27
  }
20
28
 
21
29
  export function Archive() {
22
30
  const { data } = useIntelRouterContext();
23
31
  const i18n = useI18n();
32
+ const dateTime = useDateTime();
24
33
  const queryClient = useQueryClient();
25
34
 
26
35
  const archived = useQuery({
@@ -31,8 +40,10 @@ export function Archive() {
31
40
  data.listFlows({ archivedOnly: true }),
32
41
  ]);
33
42
  const entries: ArchivedEntry[] = [
34
- ...nodes.items.flatMap((node) =>
35
- node.archivedAt
43
+ ...nodes.items.flatMap((node) => {
44
+ // Stable across reloads: after D1 commits, only this key can reach an R2 cleanup receipt.
45
+ const purgeKey = `purge-${node.id}`;
46
+ return node.archivedAt
36
47
  ? [
37
48
  {
38
49
  id: node.id,
@@ -47,10 +58,12 @@ export function Archive() {
47
58
  archived: false,
48
59
  idempotencyKey: crypto.randomUUID(),
49
60
  }),
61
+ purge: () => data.purgeNode({ nodeId: node.id, idempotencyKey: purgeKey }),
62
+ previewPurge: () => data.previewNodePurge({ nodeId: node.id }),
50
63
  } satisfies ArchivedEntry,
51
64
  ]
52
- : [],
53
- ),
65
+ : [];
66
+ }),
54
67
  ...flows.items.flatMap((flow: Flow) =>
55
68
  flow.archivedAt
56
69
  ? [
@@ -67,6 +80,7 @@ export function Archive() {
67
80
  archived: false,
68
81
  idempotencyKey: crypto.randomUUID(),
69
82
  }),
83
+ purge: () => data.purgeFlow({ flowId: flow.id }),
70
84
  } satisfies ArchivedEntry,
71
85
  ]
72
86
  : [],
@@ -95,6 +109,31 @@ export function Archive() {
95
109
  },
96
110
  });
97
111
 
112
+ // ⚠️ The confirmation is a real dialog, not a `window.confirm` — it has to name the thing and say
113
+ // that it does not come back. The state lives here because the row underneath disappears the
114
+ // moment the purge goes through.
115
+ const [confirming, setConfirming] = useState<ArchivedEntry | null>(null);
116
+ const preview = useQuery({
117
+ queryKey: ["purge-preview", confirming?.id],
118
+ queryFn: async () => await confirming?.previewPurge?.(),
119
+ enabled: Boolean(confirming?.previewPurge),
120
+ retry: false,
121
+ });
122
+ const purge = useMutation({
123
+ mutationFn: async (entry: ArchivedEntry) => await entry.purge(),
124
+ onSuccess: async () => {
125
+ setConfirming(null);
126
+ await Promise.all([
127
+ queryClient.invalidateQueries({ queryKey: ["archive"] }),
128
+ // The tree and the graph did not show the node, but its DISAPPEARANCE can be visible
129
+ // elsewhere: a reference to it no longer resolves afterwards.
130
+ queryClient.invalidateQueries({ queryKey: ["tree"] }),
131
+ queryClient.invalidateQueries({ queryKey: ["node-graph"] }),
132
+ queryClient.invalidateQueries({ queryKey: ["flows"] }),
133
+ ]);
134
+ },
135
+ });
136
+
98
137
  return (
99
138
  <div className="flex min-h-0 flex-1 flex-col">
100
139
  <div className="border-b px-6 py-4">
@@ -114,6 +153,13 @@ export function Archive() {
114
153
  {i18n.t("archive.restoreFailed")}
115
154
  </p>
116
155
  ) : null}
156
+ {/* ⚠️ The refusal stands HERE and not in the dialog: it carries the reason — folder not
157
+ empty, a flow still uses it — and it has to stay readable after the dialog is closed. */}
158
+ {purge.isError ? (
159
+ <p role="alert" className="mb-4 text-sm text-destructive">
160
+ {i18n.t("archive.purgeFailed")}
161
+ </p>
162
+ ) : null}
117
163
  {archived.data?.length === 0 ? (
118
164
  <p className="text-sm text-muted-foreground">{i18n.t("archive.empty")}</p>
119
165
  ) : null}
@@ -135,23 +181,110 @@ export function Archive() {
135
181
  <span className="grid min-w-0 flex-1 leading-tight">
136
182
  <span className="truncate text-sm font-medium">{entry.title}</span>
137
183
  <span className="truncate text-xs text-muted-foreground">
138
- {i18n.t("archive.archivedAt", { when: entry.archivedAt.slice(0, 10) })}
184
+ {i18n.t("archive.archivedAt", { when: dateTime.at(entry.archivedAt) })}
139
185
  </span>
140
186
  </span>
187
+ {/* Both actions stay permanently visible: the archive is the one place where getting
188
+ an item back must never depend on discovering a hover-only control. */}
189
+ <TooltipProvider delayDuration={300}>
190
+ <Tooltip>
191
+ {/* The wrapper keeps the tooltip available while the button is disabled. */}
192
+ <TooltipTrigger asChild>
193
+ <span className="inline-flex shrink-0">
194
+ <button
195
+ type="button"
196
+ disabled={restore.isPending}
197
+ onClick={() => restore.mutate(entry)}
198
+ aria-label={i18n.t(
199
+ restore.isPending && restore.variables?.id === entry.id
200
+ ? "archive.restoring"
201
+ : "archive.restore",
202
+ { title: entry.title },
203
+ )}
204
+ aria-busy={restore.isPending && restore.variables?.id === entry.id}
205
+ className="inline-flex size-8 shrink-0 items-center justify-center rounded-md border outline-none hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring disabled:opacity-60"
206
+ >
207
+ {restore.isPending && restore.variables?.id === entry.id ? (
208
+ <LoaderCircle aria-hidden="true" className="size-4 animate-spin" />
209
+ ) : (
210
+ <ArchiveRestore aria-hidden="true" className="size-4" />
211
+ )}
212
+ </button>
213
+ </span>
214
+ </TooltipTrigger>
215
+ <TooltipContent>
216
+ {i18n.t(
217
+ restore.isPending && restore.variables?.id === entry.id
218
+ ? "archive.restoringAction"
219
+ : "archive.restoreAction",
220
+ )}
221
+ </TooltipContent>
222
+ </Tooltip>
223
+ </TooltipProvider>
141
224
  <button
142
225
  type="button"
143
- disabled={restore.isPending}
144
- onClick={() => restore.mutate(entry)}
145
- aria-label={i18n.t("archive.restore", { title: entry.title })}
146
- className="inline-flex h-8 shrink-0 items-center gap-2 rounded-md border px-3 text-sm font-medium outline-none hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring disabled:opacity-60"
226
+ disabled={purge.isPending}
227
+ onClick={() => setConfirming(entry)}
228
+ aria-label={i18n.t("archive.purge", { title: entry.title })}
229
+ className="inline-flex size-8 shrink-0 items-center justify-center rounded-md border text-destructive outline-none hover:bg-destructive/10 focus-visible:ring-2 focus-visible:ring-ring disabled:opacity-60"
147
230
  >
148
- <ArchiveRestore aria-hidden="true" className="size-4" />
149
- {i18n.t("archive.restoreAction")}
231
+ <Trash2 aria-hidden="true" className="size-4" />
150
232
  </button>
151
233
  </li>
152
234
  ))}
153
235
  </ul>
154
236
  </div>
237
+ {confirming ? (
238
+ <Modal title={i18n.t("archive.purge.title")} close={() => setConfirming(null)}>
239
+ <p className="text-sm text-muted-foreground">
240
+ {i18n.t("archive.purge.body", { title: confirming.title })}
241
+ </p>
242
+ {confirming.previewPurge && preview.data ? (
243
+ <div className="mt-2 space-y-2 text-sm text-muted-foreground">
244
+ <p>{i18n.t("archive.purge.items", { count: preview.data.totalItems })}</p>
245
+ <p>
246
+ {preview.data.inboundLinks === 0
247
+ ? i18n.t("archive.purge.links.none")
248
+ : preview.data.inboundLinks === 1
249
+ ? i18n.t("archive.purge.links.one")
250
+ : i18n.t("archive.purge.links.many", { count: preview.data.inboundLinks })}
251
+ </p>
252
+ </div>
253
+ ) : null}
254
+ {confirming.previewPurge && preview.isPending ? (
255
+ <p className="mt-2 text-sm text-muted-foreground">
256
+ {i18n.t("archive.purge.previewLoading")}
257
+ </p>
258
+ ) : null}
259
+ {confirming.previewPurge && preview.isError ? (
260
+ <p role="alert" className="mt-2 text-sm text-destructive">
261
+ {i18n.t("archive.purge.previewFailed")}
262
+ </p>
263
+ ) : null}
264
+ <p className="mt-2 text-sm text-muted-foreground">{i18n.t("archive.purge.grants")}</p>
265
+ <div className="mt-6 flex justify-end gap-2">
266
+ <button
267
+ type="button"
268
+ onClick={() => setConfirming(null)}
269
+ className="inline-flex h-8 items-center rounded-md border px-3 text-sm font-medium outline-none hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring"
270
+ >
271
+ {i18n.t("archive.purge.cancel")}
272
+ </button>
273
+ <button
274
+ type="button"
275
+ disabled={
276
+ purge.isPending ||
277
+ (Boolean(confirming.previewPurge) && (preview.isPending || preview.isError))
278
+ }
279
+ onClick={() => purge.mutate(confirming)}
280
+ className="inline-flex h-8 items-center gap-2 rounded-md bg-destructive px-3 text-sm font-medium text-destructive-foreground outline-none hover:bg-destructive/90 focus-visible:ring-2 focus-visible:ring-ring disabled:opacity-60"
281
+ >
282
+ <Trash2 aria-hidden="true" className="size-4" />
283
+ {i18n.t("archive.purge.confirm")}
284
+ </button>
285
+ </div>
286
+ </Modal>
287
+ ) : null}
155
288
  </div>
156
289
  );
157
290
  }
@@ -12,6 +12,8 @@ import {
12
12
  ListFlowsInput,
13
13
  PreviewFlowPublishInput,
14
14
  PublishFlowInput,
15
+ PurgeFlowInput,
16
+ PurgeFlowResult,
15
17
  RelationGraph,
16
18
  RelationGraphInput,
17
19
  SaveFlowVersionInput,
@@ -36,6 +38,10 @@ import {
36
38
  NodeList,
37
39
  NodeTable,
38
40
  NodeVersionList,
41
+ PurgeNodeInput,
42
+ PurgeNodePreview,
43
+ PurgeNodePreviewInput,
44
+ PurgeNodeResult,
39
45
  ReindexResult,
40
46
  ResolveNodeLinksInput,
41
47
  ResolveNodeLinksResult,
@@ -46,6 +52,7 @@ import {
46
52
  UpdateNodeInput,
47
53
  } from "@anchrd/intel-contract/node";
48
54
  import {
55
+ ResourceAccessList,
49
56
  ResourceGrantList,
50
57
  RevokeGrantInput,
51
58
  RevokeGrantResult,
@@ -326,6 +333,26 @@ export function createIntelDataProvider(
326
333
  body: JSON.stringify(parsed),
327
334
  });
328
335
  },
336
+ async purgeNode(input) {
337
+ const parsed = PurgeNodeInput.parse(input);
338
+ return await request(`/nodes/${encodeURIComponent(parsed.nodeId)}`, PurgeNodeResult, {
339
+ method: "DELETE",
340
+ body: JSON.stringify(parsed),
341
+ });
342
+ },
343
+ async previewNodePurge(input) {
344
+ const parsed = PurgeNodePreviewInput.parse(input);
345
+ return await request(
346
+ `/nodes/${encodeURIComponent(parsed.nodeId)}/purge-preview`,
347
+ PurgeNodePreview,
348
+ );
349
+ },
350
+ async purgeFlow(input) {
351
+ const parsed = PurgeFlowInput.parse(input);
352
+ return await request(`/flows/${encodeURIComponent(parsed.flowId)}`, PurgeFlowResult, {
353
+ method: "DELETE",
354
+ });
355
+ },
329
356
  async searchNodes(input) {
330
357
  return await request("/nodes/search", SearchResult, {
331
358
  method: "POST",
@@ -335,6 +362,12 @@ export function createIntelDataProvider(
335
362
  async listGrants(resourceId) {
336
363
  return await request(`/nodes/${encodeURIComponent(resourceId)}/grants`, ResourceGrantList);
337
364
  },
365
+ async listEffectiveAccess(resourceId) {
366
+ return await request(
367
+ `/nodes/${encodeURIComponent(resourceId)}/effective-access`,
368
+ ResourceAccessList,
369
+ );
370
+ },
338
371
  async shareNode(input) {
339
372
  const parsed = ShareInput.parse(input);
340
373
  return await request(`/nodes/${encodeURIComponent(parsed.resourceId)}/grants`, ShareResult, {
@@ -12,6 +12,8 @@ import type {
12
12
  ListFlowsInput,
13
13
  PreviewFlowPublishInput,
14
14
  PublishFlowInput,
15
+ PurgeFlowInput,
16
+ PurgeFlowResult,
15
17
  RelationGraph,
16
18
  RelationGraphScope,
17
19
  SaveFlowVersionInput,
@@ -37,6 +39,9 @@ import type {
37
39
  NodeList,
38
40
  NodeTable,
39
41
  NodeVersionList,
42
+ PurgeNodeInput,
43
+ PurgeNodePreviewInput,
44
+ PurgeNodeResult,
40
45
  ReindexResult,
41
46
  ResolveNodeLinksInput,
42
47
  ResolveNodeLinksResult,
@@ -125,8 +130,18 @@ export interface IntelDataProvider {
125
130
  listNodeVersions(nodeId: string): Promise<NodeVersionList>;
126
131
  updateNode(input: UpdateNodeInput): Promise<Node>;
127
132
  archiveNode(input: ArchiveNodeInput): Promise<Node>;
133
+ // ⚠️ The one way across this seam after which nothing is really left (#457). The title comes back
134
+ // because nothing can look it up afterwards.
135
+ purgeNode(input: PurgeNodeInput): Promise<PurgeNodeResult>;
136
+ previewNodePurge(
137
+ input: PurgeNodePreviewInput,
138
+ ): Promise<import("@anchrd/intel-contract/node").PurgeNodePreview>;
139
+ purgeFlow(input: PurgeFlowInput): Promise<PurgeFlowResult>;
128
140
  searchNodes(input: SearchInput): Promise<SearchResult>;
129
141
  listGrants(resourceId: string): Promise<ResourceGrantList>;
142
+ listEffectiveAccess(
143
+ resourceId: string,
144
+ ): Promise<import("@anchrd/intel-contract/share").ResourceAccessList>;
130
145
  // The grant, and what the grant does not cover: the documents the flows in this folder read that
131
146
  // the new principal still cannot. A warning, never a refusal (ADR-0004 §4).
132
147
  shareNode(input: ShareInput): Promise<ShareResult>;
@@ -5,6 +5,7 @@ import { useState } from "react";
5
5
  import type { I18n } from "@/i18n/i18n.types.ts";
6
6
  import { useI18n } from "@/i18n/i18n-context.tsx";
7
7
  import { useIntelRouterContext } from "@/router/router-context.ts";
8
+ import { useDateTime } from "@/time/time-context.tsx";
8
9
 
9
10
  // One page is what a person reads before deciding, not what a database can return. The server caps
10
11
  // it at fifty; twenty is what fits on a screen without scrolling past the answer.
@@ -115,6 +116,7 @@ export function FlowRuns({ flowId }: { flowId: string }) {
115
116
 
116
117
  function RunRow({ run, open, toggle }: { run: FlowRunSummary; open: boolean; toggle(): void }) {
117
118
  const i18n = useI18n();
119
+ const dateTime = useDateTime();
118
120
  const failed = run.status === "failed";
119
121
  const Chevron = open ? ChevronDown : ChevronRight;
120
122
  return (
@@ -133,7 +135,7 @@ function RunRow({ run, open, toggle }: { run: FlowRunSummary; open: boolean; tog
133
135
  >
134
136
  {i18n.t(`runs.status.${run.status}`)}
135
137
  </span>
136
- <span>{new Date(run.startedAt).toLocaleString(i18n.locale)}</span>
138
+ <span>{dateTime.at(run.startedAt)}</span>
137
139
  <span className="text-muted-foreground">
138
140
  {run.durationMs === null
139
141
  ? i18n.t("runs.stillRunning")
@@ -24,7 +24,6 @@ import {
24
24
  } from "@xyflow/react";
25
25
  import { FileSearch, Info, Send, Wrench } from "lucide-react";
26
26
  import { useEffect, useId, useMemo, useState } from "react";
27
- import { ActionSlot } from "@/app/action-slot/action-slot.tsx";
28
27
  import { TreeEntryMediaType } from "@/app/app-tree/app-tree.tsx";
29
28
  import { ViewToggle } from "@/app/view-toggle/view-toggle.tsx";
30
29
  import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
@@ -457,16 +456,6 @@ function FlowsEditor() {
457
456
 
458
457
  return (
459
458
  <div className="flex h-full min-h-0 flex-col">
460
- {/* Only the view switch belongs to the area: it says how the thing the breadcrumb names is
461
- being shown, and it is mounted only where there is a flow to draw — a switch with nothing
462
- to point at is what #19 rules out for the header. A flow is the one level that has runs,
463
- so it is the one that offers the third view (#35). Everything that acts on the flow itself
464
- left this bar for the flow's own title line below (#24). */}
465
- {selectedFlowId ? (
466
- <ActionSlot>
467
- <ViewToggle views={["editor", "graph", "runs"]} />
468
- </ActionSlot>
469
- ) : null}
470
459
  {selectedFlowId && document.data ? (
471
460
  <FlowTitle
472
461
  flow={document.data.flow}
@@ -736,6 +725,11 @@ function FlowTitle({
736
725
  // hit without looking is worth more than the primary button being outermost. `TitleRow` is what
737
726
  // enforces that — nothing passed in here can get past the menu.
738
727
  <TitleRow title={flow.title} description={flow.description} target={{ type: "flow", flow }}>
728
+ {/* ⚠️ #454: the toggle used to stand in the global header, beside the search — where what
729
+ applies EVERYWHERE stands. But it says how THIS flow is shown, and so belongs in the line
730
+ that names this flow. A flow is the only level with runs, and therefore the only one with
731
+ three views (#35). */}
732
+ <ViewToggle views={["editor", "graph", "runs"]} />
739
733
  <TooltipProvider delayDuration={300}>
740
734
  <Tooltip>
741
735
  <TooltipTrigger asChild>
@@ -1,5 +1,6 @@
1
1
  import { useQuery } from "@tanstack/react-query";
2
2
  import { useNavigate } from "@tanstack/react-router";
3
+ import { ResourceAccessSummary } from "@/access-summary/access-summary.tsx";
3
4
  import { treeLevelKey } from "@/app/tree-move/tree-move.tsx";
4
5
  import {
5
6
  Table,
@@ -14,6 +15,7 @@ import { useI18n } from "@/i18n/i18n-context.tsx";
14
15
  import { kindIcons } from "@/kind-icon.ts";
15
16
  import { ResourceMenu, type ResourceTarget } from "@/resource-menu/resource-menu.tsx";
16
17
  import { useIntelRouterContext } from "@/router/router-context.ts";
18
+ import { useDateTime } from "@/time/time-context.tsx";
17
19
 
18
20
  // The same record the menu is handed everywhere else, read off a level row. A re-labelling, never a
19
21
  // second source of truth — a rename works on the whole record, not on a summary of it.
@@ -41,6 +43,7 @@ function targetOf(entry: TreeEntry): ResourceTarget {
41
43
  export function FolderContents({ folderId }: { folderId: string }) {
42
44
  const { data } = useIntelRouterContext();
43
45
  const i18n = useI18n();
46
+ const dateTime = useDateTime();
44
47
  const navigate = useNavigate();
45
48
  const level = useQuery({
46
49
  queryKey: treeLevelKey(folderId),
@@ -82,6 +85,9 @@ export function FolderContents({ folderId }: { folderId: string }) {
82
85
  <TableHead className="h-9 px-4 text-right text-xs tracking-wide uppercase">
83
86
  {i18n.t("node.changed")}
84
87
  </TableHead>
88
+ <TableHead className="h-9 px-4 text-xs tracking-wide uppercase">
89
+ {i18n.t("node.access")}
90
+ </TableHead>
85
91
  {/* A `<th>` with nothing in it leaves the column unnamed for anyone reading the table
86
92
  by its headers. The word is there; only the eye is spared it. */}
87
93
  <TableHead className="h-9 w-14">
@@ -126,7 +132,15 @@ export function FolderContents({ folderId }: { folderId: string }) {
126
132
  </button>
127
133
  </TableCell>
128
134
  <TableCell className="px-4 text-right text-sm text-muted-foreground tabular-nums">
129
- {new Date(changed).toLocaleDateString(i18n.locale)}
135
+ {dateTime.on(changed)}
136
+ </TableCell>
137
+ <TableCell className="px-4">
138
+ <ResourceAccessSummary
139
+ resourceId={
140
+ entry.type === "flow" ? (entry.flow.parentId ?? folderId) : entry.id
141
+ }
142
+ ownerId={entry.type === "flow" ? entry.flow.ownerId : entry.node.ownerId}
143
+ />
130
144
  </TableCell>
131
145
  {/* ⚠️ The same menu as the title line of the open thing, not a shorter one built
132
146
  for here. #58 collected every action in one place so that place is always the