@anchrd/intel-ui 0.4.0 → 0.6.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.
Files changed (37) hide show
  1. package/package.json +1 -1
  2. package/src/app/action-slot/action-slot.tsx +27 -0
  3. package/src/app/app-sidebar/app-sidebar.tsx +39 -24
  4. package/src/app/app-tree/app-tree.tsx +332 -60
  5. package/src/app/app.tsx +31 -5
  6. package/src/app/sidebar-resize-handle/sidebar-resize-handle.tsx +2 -1
  7. package/src/app/tree-move/tree-move.tsx +331 -0
  8. package/src/app/user-footer/user-footer.tsx +73 -46
  9. package/src/app/view-toggle/view-toggle.tsx +77 -0
  10. package/src/blocknote-view/blocknote-view.tsx +19 -2
  11. package/src/branding/favicon.default.svg +2 -2
  12. package/src/branding/favicon.svg +2 -2
  13. package/src/components/ui/dropdown-menu.tsx +78 -0
  14. package/src/data/intel-data-provider/intel-data-provider.ts +135 -57
  15. package/src/data/intel-data-provider/intel-data-provider.types.ts +51 -13
  16. package/src/document-link/document-link.tsx +132 -0
  17. package/src/flow-runs/flow-runs.tsx +225 -0
  18. package/src/flows/flows.tsx +665 -271
  19. package/src/flows/node-icon/node-icon.ts +28 -0
  20. package/src/flows/node-palette/node-palette.tsx +200 -0
  21. package/src/flows/node-palette/node-palette.types.ts +15 -0
  22. package/src/graph-pane/graph-pane.tsx +44 -0
  23. package/src/i18n/en.json +144 -29
  24. package/src/knowledge/knowledge.tsx +91 -367
  25. package/src/knowledge-editor/knowledge-editor.tsx +169 -21
  26. package/src/knowledge-graph/knowledge-graph.ts +26 -24
  27. package/src/knowledge-graph/knowledge-graph.tsx +33 -24
  28. package/src/knowledge-table/knowledge-table.tsx +141 -0
  29. package/src/main.tsx +2 -2
  30. package/src/resource-menu/resource-menu.tsx +615 -0
  31. package/src/router/selection-search.ts +27 -3
  32. package/src/save-button/save-button.tsx +103 -0
  33. package/src/styles.css +37 -0
  34. package/src/theme/theme.ts +24 -0
  35. package/src/title-row/title-row.tsx +49 -0
  36. package/src/tools/tools.tsx +57 -38
  37. package/src/app/header-actions/header-actions.tsx +0 -15
@@ -0,0 +1,615 @@
1
+ import type {
2
+ ContextPolicy,
3
+ Flow,
4
+ KnowledgeNode,
5
+ ResourceVerb,
6
+ UnreadableKnowledge,
7
+ } from "@anchrd/intel-contract";
8
+ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
9
+ import { useNavigate, useRouterState } from "@tanstack/react-router";
10
+ import { Archive, CornerLeftUp, Ellipsis, Link2, Pencil, Share2, Trash2 } from "lucide-react";
11
+ import type * as React from "react";
12
+ import { useState } from "react";
13
+ import { moveErrorKey, useTreeMove } from "@/app/tree-move/tree-move.tsx";
14
+ import {
15
+ DropdownMenu,
16
+ DropdownMenuContent,
17
+ DropdownMenuItem,
18
+ DropdownMenuRadioGroup,
19
+ DropdownMenuRadioItem,
20
+ DropdownMenuSeparator,
21
+ DropdownMenuSub,
22
+ DropdownMenuSubContent,
23
+ DropdownMenuSubTrigger,
24
+ DropdownMenuTrigger,
25
+ } from "@/components/ui/dropdown-menu";
26
+ import { flowEntry } from "@/data/intel-data-provider/intel-data-provider.ts";
27
+ import type { TreeEntry } from "@/data/intel-data-provider/intel-data-provider.types.ts";
28
+ import { Modal } from "@/modal/modal.tsx";
29
+ import { useIntelRouterContext } from "@/router/router-context.ts";
30
+ import { selectedFrom } from "@/router/selection-search.ts";
31
+
32
+ /**
33
+ * What a resource's own actions are, at the place the resource stands (#24).
34
+ *
35
+ * ⚠️ Since #58 there is exactly one such place: the title line of the open document, table,
36
+ * attachment, folder or flow. The tree row used to carry the same menu, which put ten buttons into a
37
+ * 240px column and made the sidebar something to read buttons in rather than titles. Everything the
38
+ * row menu could do is reached here — including moving, which is why this component owns the folder
39
+ * picker rather than being handed one.
40
+ *
41
+ * ⚠️ An entry that does not apply is absent, never disabled: a folder has no retrieval mode, a flow
42
+ * is shared through the folder it is filed in rather than on its own (ADR-0004 §2). A greyed-out row
43
+ * still promises something is there.
44
+ */
45
+ export type ResourceTarget =
46
+ | { type: "knowledge"; node: KnowledgeNode }
47
+ | { type: "flow"; flow: Flow };
48
+
49
+ // The same thing as a tree row, because that is what a move works on: a row with a place in the
50
+ // shared tree. The two shapes are the same record seen from two screens, so this is a re-labelling
51
+ // and never a second source of truth.
52
+ function entryOf(target: ResourceTarget): TreeEntry {
53
+ return target.type === "flow"
54
+ ? flowEntry(target.flow)
55
+ : {
56
+ type: "knowledge",
57
+ id: target.node.id,
58
+ title: target.node.title,
59
+ kind: target.node.kind,
60
+ node: target.node,
61
+ };
62
+ }
63
+
64
+ function idOf(target: ResourceTarget): string {
65
+ return target.type === "knowledge" ? target.node.id : target.flow.id;
66
+ }
67
+
68
+ function titleOf(target: ResourceTarget): string {
69
+ return target.type === "knowledge" ? target.node.title : target.flow.title;
70
+ }
71
+
72
+ function parentOfTarget(target: ResourceTarget): string | null {
73
+ return target.type === "knowledge" ? target.node.parentId : target.flow.parentId;
74
+ }
75
+
76
+ // ⚠️ The same reading `moveErrorKey` does for a move, for the changes this menu makes. A conflict is
77
+ // the one that must not be swallowed: `baseUpdatedAt` turns a parallel edit into a refusal, and a
78
+ // refusal nobody words is indistinguishable from a change that went through.
79
+ export function resourceErrorKey(error: unknown): string {
80
+ const code =
81
+ typeof error === "object" && error !== null && "code" in error
82
+ ? String((error as { code: unknown }).code)
83
+ : null;
84
+ switch (code) {
85
+ case "update_conflict":
86
+ case "flow_update_conflict":
87
+ return "resource.conflict";
88
+ case "knowledge_forbidden":
89
+ case "flow_edit_forbidden":
90
+ return "resource.forbidden";
91
+ default:
92
+ return "resource.failed";
93
+ }
94
+ }
95
+
96
+ const contextPolicies: readonly ContextPolicy[] = ["pinned", "relevant", "explicit"];
97
+
98
+ /**
99
+ * The three-dot menu.
100
+ *
101
+ * `variant` is only how the trigger is painted. `title` is the one in use: a plain icon button at
102
+ * the end of a title line, put there by `TitleRow` and by nobody else, so it is always the last
103
+ * thing in that line (#53). `row` is the sidebar form — hidden until the row is hovered or something
104
+ * in it takes focus — and is unused since #58; it is kept because the trigger's two paintings are
105
+ * the only difference between the two places, and re-deriving it would be the harder half.
106
+ */
107
+ export function ResourceMenu({
108
+ target,
109
+ variant,
110
+ }: {
111
+ target: ResourceTarget;
112
+ variant: "row" | "title";
113
+ }) {
114
+ const { data, i18n } = useIntelRouterContext();
115
+ const queryClient = useQueryClient();
116
+ const navigate = useNavigate();
117
+ const [renaming, setRenaming] = useState(false);
118
+ const [sharing, setSharing] = useState(false);
119
+ const [linksOpen, setLinksOpen] = useState(false);
120
+ const selected = useRouterState({ select: (state) => selectedFrom(state.location.search) });
121
+ // ⚠️ Dragging is a pointer gesture and nothing else: no keyboard, no screen reader, no touch worth
122
+ // the name. #27 gave moving a second, equal route through a folder picker, and that route lived in
123
+ // the tree row's menu. With the row menu gone (#58) it lives here, or it does not exist.
124
+ const move = useTreeMove();
125
+
126
+ const id = idOf(target);
127
+ const title = titleOf(target);
128
+ const node = target.type === "knowledge" ? target.node : null;
129
+ // A folder holds things; it is not itself retrieved, and nothing is written into it that could
130
+ // link anywhere. Both entries are therefore absent on one rather than offered and inert.
131
+ const retrievable = node !== null && node.kind !== "folder";
132
+ const linkable = retrievable;
133
+
134
+ // Everything a change here can make stale. The row sits in one level of the tree, the open screen
135
+ // reads the record, and the flat collections behind the search, the link picker and the relation
136
+ // view all carry the title as well — a rename that only reached the tree would leave yesterday's
137
+ // name standing on three other screens.
138
+ async function refresh(): Promise<void> {
139
+ await Promise.all([
140
+ queryClient.invalidateQueries({ queryKey: ["tree", parentOfTarget(target)] }),
141
+ queryClient.invalidateQueries({
142
+ queryKey: target.type === "flow" ? ["flow", id] : ["knowledge", id],
143
+ }),
144
+ queryClient.invalidateQueries({
145
+ queryKey: [target.type === "flow" ? "flows" : "knowledge-graph"],
146
+ }),
147
+ queryClient.invalidateQueries({ queryKey: ["relation-graph"] }),
148
+ ]);
149
+ }
150
+
151
+ // ⚠️ `baseUpdatedAt` is not optional decoration. Renaming takes the same conflict route as every
152
+ // other change, or two people renaming at once end with one of them silently overwritten.
153
+ const rename = useMutation({
154
+ mutationFn: async (next: string) => {
155
+ if (target.type === "flow") {
156
+ await data.updateFlow({
157
+ flowId: target.flow.id,
158
+ baseUpdatedAt: target.flow.updatedAt,
159
+ title: next,
160
+ idempotencyKey: crypto.randomUUID(),
161
+ });
162
+ return;
163
+ }
164
+ await data.updateKnowledge({
165
+ nodeId: target.node.id,
166
+ baseUpdatedAt: target.node.updatedAt,
167
+ title: next,
168
+ idempotencyKey: crypto.randomUUID(),
169
+ });
170
+ },
171
+ onSuccess: async () => {
172
+ setRenaming(false);
173
+ await refresh();
174
+ },
175
+ });
176
+
177
+ const archive = useMutation({
178
+ mutationFn: async () => {
179
+ if (target.type === "flow") throw new Error("A flow cannot be archived");
180
+ await data.archiveKnowledge({
181
+ nodeId: target.node.id,
182
+ baseUpdatedAt: target.node.updatedAt,
183
+ archived: true,
184
+ idempotencyKey: crypto.randomUUID(),
185
+ });
186
+ },
187
+ onSuccess: async () => {
188
+ await refresh();
189
+ // The row is gone from the tree. If it was also what the screen was showing, the screen has
190
+ // nothing left to show either.
191
+ if (selected === id) await navigate({ to: "/knowledge", search: {} });
192
+ },
193
+ });
194
+
195
+ const setContextPolicy = useMutation({
196
+ mutationFn: async (contextPolicy: ContextPolicy) => {
197
+ if (target.type === "flow") throw new Error("A flow has no retrieval mode");
198
+ await data.updateKnowledge({
199
+ nodeId: target.node.id,
200
+ baseUpdatedAt: target.node.updatedAt,
201
+ contextPolicy,
202
+ idempotencyKey: crypto.randomUUID(),
203
+ });
204
+ },
205
+ onSuccess: refresh,
206
+ });
207
+
208
+ const failure = rename.isError
209
+ ? null // the rename dialog words its own refusal, beside the field that caused it
210
+ : archive.isError
211
+ ? archive.error
212
+ : setContextPolicy.isError
213
+ ? setContextPolicy.error
214
+ : undefined;
215
+
216
+ return (
217
+ <>
218
+ <DropdownMenu>
219
+ <DropdownMenuTrigger
220
+ data-resource-menu=""
221
+ aria-label={i18n.t("resource.menu", { title })}
222
+ className={
223
+ variant === "row"
224
+ ? "shrink-0 rounded-md p-1 text-sidebar-foreground outline-none transition-opacity hover:bg-sidebar-accent-foreground/10 focus-visible:opacity-100 focus-visible:ring-2 focus-visible:ring-sidebar-ring data-[state=open]:opacity-100 group-focus-within/row:opacity-100 group-hover/row:opacity-100 md:opacity-0"
225
+ : "inline-flex size-8 shrink-0 items-center justify-center rounded-md border bg-background outline-none hover:bg-accent focus-visible:ring-2 focus-visible:ring-ring"
226
+ }
227
+ >
228
+ <Ellipsis aria-hidden="true" className="size-4" />
229
+ </DropdownMenuTrigger>
230
+ <DropdownMenuContent align="start" className="w-52">
231
+ <DropdownMenuItem onSelect={() => setRenaming(true)}>
232
+ <Pencil aria-hidden="true" />
233
+ {i18n.t("resource.rename")}
234
+ </DropdownMenuItem>
235
+ <DropdownMenuItem onSelect={() => move.start(entryOf(target), null)}>
236
+ <CornerLeftUp aria-hidden="true" />
237
+ {i18n.t("tree.move.action")}
238
+ </DropdownMenuItem>
239
+ {retrievable || linkable || node ? <DropdownMenuSeparator /> : null}
240
+ {/* ⚠️ A submenu with a checked value, not an embedded `select`. A form control inside a
241
+ menu takes the keyboard away from the menu that contains it, and the current value is
242
+ then only readable by opening a second widget. */}
243
+ {retrievable && node ? (
244
+ <DropdownMenuSub>
245
+ <DropdownMenuSubTrigger>{i18n.t("knowledge.contextPolicy")}</DropdownMenuSubTrigger>
246
+ <DropdownMenuSubContent>
247
+ <DropdownMenuRadioGroup
248
+ value={node.contextPolicy}
249
+ onValueChange={(value) => setContextPolicy.mutate(value as ContextPolicy)}
250
+ >
251
+ {contextPolicies.map((policy) => (
252
+ <DropdownMenuRadioItem key={policy} value={policy}>
253
+ {i18n.t(`knowledge.context.${policy}`)}
254
+ </DropdownMenuRadioItem>
255
+ ))}
256
+ </DropdownMenuRadioGroup>
257
+ </DropdownMenuSubContent>
258
+ </DropdownMenuSub>
259
+ ) : null}
260
+ {linkable ? (
261
+ <DropdownMenuItem onSelect={() => setLinksOpen(true)}>
262
+ <Link2 aria-hidden="true" />
263
+ {i18n.t("knowledge.links")}
264
+ </DropdownMenuItem>
265
+ ) : null}
266
+ {/* A flow has no share of its own: it is reached through the folder it is filed in, and a
267
+ narrower grant beside the folder's would break the rule that a flow only calls flows in
268
+ its own subtree (ADR-0004 §2). */}
269
+ {node ? (
270
+ <DropdownMenuItem onSelect={() => setSharing(true)}>
271
+ <Share2 aria-hidden="true" />
272
+ {i18n.t("knowledge.share")}
273
+ </DropdownMenuItem>
274
+ ) : null}
275
+ {node ? (
276
+ <>
277
+ <DropdownMenuSeparator />
278
+ <DropdownMenuItem
279
+ onSelect={() => archive.mutate()}
280
+ className="text-destructive focus:text-destructive"
281
+ >
282
+ <Archive aria-hidden="true" />
283
+ {i18n.t("knowledge.archive")}
284
+ </DropdownMenuItem>
285
+ </>
286
+ ) : null}
287
+ </DropdownMenuContent>
288
+ </DropdownMenu>
289
+ {/* The menu closes on selection, so a refusal has nowhere to live inside it. It stands over
290
+ the screen instead, where it is read whatever the title line asked for. */}
291
+ {failure !== undefined && failure !== null ? (
292
+ <MenuFailure>{i18n.t(resourceErrorKey(failure))}</MenuFailure>
293
+ ) : null}
294
+ {/* ⚠️ A move has four refusals of its own and they are told apart by `moveErrorKey`, not by
295
+ `resourceErrorKey`: "that is not a folder" and "that would be a loop" have no equivalent
296
+ among the changes above, and one shared sentence would leave the reader guessing. */}
297
+ {move.error ? <MenuFailure>{i18n.t(moveErrorKey(move.error))}</MenuFailure> : null}
298
+ {move.dialog}
299
+ {renaming ? (
300
+ <RenameDialog
301
+ title={title}
302
+ pending={rename.isPending}
303
+ error={rename.isError ? rename.error : null}
304
+ close={() => {
305
+ rename.reset();
306
+ setRenaming(false);
307
+ }}
308
+ submit={(next) => rename.mutate(next)}
309
+ />
310
+ ) : null}
311
+ {sharing && node ? <ShareKnowledge node={node} close={() => setSharing(false)} /> : null}
312
+ {linksOpen && node ? <KnowledgeLinks node={node} close={() => setLinksOpen(false)} /> : null}
313
+ </>
314
+ );
315
+ }
316
+
317
+ // One refusal, over the screen rather than in the menu that is already gone by the time it arrives.
318
+ function MenuFailure({ children }: { children: React.ReactNode }) {
319
+ return (
320
+ <p
321
+ role="alert"
322
+ className="fixed inset-x-0 bottom-5 z-50 mx-auto w-fit max-w-md rounded-lg border border-destructive/30 bg-card px-4 py-3 text-sm text-destructive shadow-xl"
323
+ >
324
+ {children}
325
+ </p>
326
+ );
327
+ }
328
+
329
+ function RenameDialog({
330
+ title,
331
+ pending,
332
+ error,
333
+ close,
334
+ submit,
335
+ }: {
336
+ title: string;
337
+ pending: boolean;
338
+ error: unknown;
339
+ close(): void;
340
+ submit(title: string): void;
341
+ }) {
342
+ const { i18n } = useIntelRouterContext();
343
+ const [next, setNext] = useState(title);
344
+ const trimmed = next.trim();
345
+ return (
346
+ <Modal title={i18n.t("resource.renameTitle", { title })} close={close}>
347
+ <form
348
+ className="space-y-4"
349
+ onSubmit={(event) => {
350
+ event.preventDefault();
351
+ if (!pending && trimmed.length > 0 && trimmed !== title) submit(trimmed);
352
+ }}
353
+ >
354
+ {error ? (
355
+ <p role="alert" className="text-sm text-destructive">
356
+ {i18n.t(resourceErrorKey(error))}
357
+ </p>
358
+ ) : null}
359
+ <label className="block text-sm font-medium">
360
+ {i18n.t("common.title")}
361
+ <input
362
+ required
363
+ // biome-ignore lint/a11y/noAutofocus: the dialog opens to take this one value
364
+ autoFocus
365
+ value={next}
366
+ onChange={(event) => setNext(event.target.value)}
367
+ className="mt-2 w-full rounded-md border bg-background px-3 py-2 outline-none focus-visible:ring-2 focus-visible:ring-ring"
368
+ />
369
+ </label>
370
+ <button
371
+ type="submit"
372
+ disabled={pending || trimmed.length === 0 || trimmed === title}
373
+ className="w-full rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground outline-none hover:bg-primary/90 focus-visible:ring-2 focus-visible:ring-ring disabled:opacity-50"
374
+ >
375
+ {pending ? i18n.t("common.saving") : i18n.t("common.save")}
376
+ </button>
377
+ </form>
378
+ </Modal>
379
+ );
380
+ }
381
+
382
+ /**
383
+ * What links to this document and what it links to — as a reading, not as a form (#41).
384
+ *
385
+ * ⚠️ There is nothing to create here any more. A relationship is written in the text of the
386
+ * document it belongs to, and this shows the result. The backlinks are the half that could never be
387
+ * written here anyway: "who points at me" is not something the author of this document decides.
388
+ *
389
+ * ⚠️ Every row is a link whose two ends this reader may see — the server filters the list through
390
+ * the same predicate as every other read. A relationship reaching into a part of the tree they may
391
+ * not enter is absent rather than shown without a name.
392
+ */
393
+ function KnowledgeLinks({ node, close }: { node: KnowledgeNode; close(): void }) {
394
+ const { data, i18n } = useIntelRouterContext();
395
+ const navigate = useNavigate();
396
+ const links = useQuery({
397
+ queryKey: ["knowledge-links", node.id],
398
+ queryFn: () => data.listKnowledgeLinks(node.id),
399
+ });
400
+ // The dialog needs the names of the documents at the other end, and the graph is the one
401
+ // authorized list of them the UI can ask for without walking the whole tree again. It is asked
402
+ // for here rather than by the screen, because here is where it is read.
403
+ const knowledgeGraph = useQuery({
404
+ queryKey: ["knowledge-graph"],
405
+ queryFn: () => data.getKnowledgeGraph(),
406
+ });
407
+ const titles = new Map(
408
+ (knowledgeGraph.data?.nodes ?? []).map((candidate) => [candidate.id, candidate.title]),
409
+ );
410
+
411
+ return (
412
+ <Modal title={i18n.t("knowledge.links")} close={close}>
413
+ <p className="mb-4 text-sm text-muted-foreground">{i18n.t("knowledge.linksHelp")}</p>
414
+ <ul className="max-h-72 space-y-2 overflow-y-auto">
415
+ {links.data?.items.map((link) => {
416
+ const outgoing = link.sourceNodeId === node.id;
417
+ const otherId = outgoing ? link.targetNodeId : link.sourceNodeId;
418
+ return (
419
+ <li key={link.id}>
420
+ <button
421
+ type="button"
422
+ onClick={() => {
423
+ close();
424
+ void navigate({ to: "/knowledge", search: { select: otherId } });
425
+ }}
426
+ className="flex w-full items-center gap-3 rounded-md border p-3 text-left outline-none hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring"
427
+ >
428
+ <span className="min-w-0 text-sm">
429
+ <span className="block truncate font-medium">
430
+ {titles.get(otherId) ?? otherId}
431
+ </span>
432
+ <span className="text-xs text-muted-foreground">
433
+ {outgoing ? i18n.t("knowledge.linkOutgoing") : i18n.t("knowledge.linkIncoming")}
434
+ </span>
435
+ </span>
436
+ </button>
437
+ </li>
438
+ );
439
+ })}
440
+ {links.data?.items.length === 0 && (
441
+ <li className="text-sm text-muted-foreground">{i18n.t("knowledge.noLinks")}</li>
442
+ )}
443
+ </ul>
444
+ </Modal>
445
+ );
446
+ }
447
+
448
+ // The one share dialog in the product. It sits on the folder that holds the documents and the
449
+ // flows, which is where a permission decision belongs (ADR-0004 §2). Which verbs it offers is the
450
+ // server's answer, not this component's: `execute` never appears on a document, because a document
451
+ // has nothing to run.
452
+ function ShareKnowledge({ node, close }: { node: KnowledgeNode; close(): void }) {
453
+ const { data, i18n } = useIntelRouterContext();
454
+ const queryClient = useQueryClient();
455
+ const [email, setEmail] = useState("");
456
+ const [verbs, setVerbs] = useState<ResourceVerb[]>(["read"]);
457
+ // What the grant just made does not cover. It survives the form being cleared, because it is the
458
+ // answer to the question the user just asked and they need a moment to read it.
459
+ const [unreadable, setUnreadable] = useState<UnreadableKnowledge | null>(null);
460
+ const grants = useQuery({
461
+ queryKey: ["knowledge-grants", node.id],
462
+ queryFn: () => data.listKnowledgeGrants(node.id),
463
+ });
464
+ const share = useMutation({
465
+ // One request per verb, because one grant is one verb. The keys differ so a retry of the whole
466
+ // form replays each verb on its own rather than collapsing them into one.
467
+ mutationFn: async () => {
468
+ let last: UnreadableKnowledge | null = null;
469
+ for (const verb of verbs) {
470
+ // The last verb's answer is the one kept: every request describes the access in force after
471
+ // it, so the newest is the only one still true.
472
+ last = (
473
+ await data.shareKnowledge({
474
+ resourceId: node.id,
475
+ principal: { type: "email", email },
476
+ verb,
477
+ expiresAt: null,
478
+ idempotencyKey: crypto.randomUUID(),
479
+ })
480
+ ).unreadable;
481
+ }
482
+ return last;
483
+ },
484
+ onSuccess: (result) => {
485
+ setEmail("");
486
+ setUnreadable(result);
487
+ },
488
+ // ⚠️ `onSettled`, not `onSuccess`. One request per verb means a run can end halfway: three verbs
489
+ // granted, the fourth refused. On `onSuccess` the list would then still show the state from
490
+ // before, and the user would read a permission picture that is not the one in force. Of all the
491
+ // things to be silently wrong about, access is the worst.
492
+ onSettled: async () => {
493
+ await queryClient.invalidateQueries({ queryKey: ["knowledge-grants", node.id] });
494
+ },
495
+ });
496
+ const revoke = useMutation({
497
+ mutationFn: (grantId: string) =>
498
+ data.revokeKnowledgeGrant({
499
+ resourceId: node.id,
500
+ grantId,
501
+ idempotencyKey: crypto.randomUUID(),
502
+ }),
503
+ onSettled: async () => {
504
+ await queryClient.invalidateQueries({ queryKey: ["knowledge-grants", node.id] });
505
+ },
506
+ });
507
+
508
+ return (
509
+ <Modal title={i18n.t("knowledge.share")} close={close}>
510
+ {(share.isError || revoke.isError || grants.isError) && (
511
+ <p role="alert" className="mb-4 text-sm text-destructive">
512
+ {i18n.t("knowledge.shareFailed")}
513
+ </p>
514
+ )}
515
+ {/* ⚠️ `status`, not `alert`, and beside the grant rather than in place of it: the access was
516
+ given. A knowledge reference across the folder edge is a possible failure, not a way around
517
+ permissions, so nothing here blocks anything (ADR-0004 §4). */}
518
+ {unreadable && (unreadable.titles.length > 0 || unreadable.hidden > 0) && (
519
+ <div role="status" className="mb-4 rounded-md border bg-muted p-3 text-sm">
520
+ {unreadable.titles.length > 0 ? (
521
+ <p>
522
+ {i18n.t("knowledge.shareUnreadable", { titles: unreadable.titles.join(", ") })}
523
+ {unreadable.hidden > 0
524
+ ? ` ${i18n.t("knowledge.shareUnreadableMore", { count: unreadable.hidden })}`
525
+ : ""}
526
+ </p>
527
+ ) : (
528
+ <p>{i18n.t("knowledge.shareUnreadableHidden", { count: unreadable.hidden })}</p>
529
+ )}
530
+ <p className="mt-1 text-xs text-muted-foreground">
531
+ {i18n.t("knowledge.shareUnreadableHint")}
532
+ </p>
533
+ </div>
534
+ )}
535
+ <ul className="mb-5 max-h-44 space-y-2 overflow-y-auto">
536
+ {grants.data?.items.map((grant) => (
537
+ <li
538
+ key={grant.id}
539
+ className="flex items-center justify-between gap-3 rounded-md border p-3 text-sm"
540
+ >
541
+ <span className="min-w-0 truncate">
542
+ {grant.principal.type === "email"
543
+ ? grant.principal.email
544
+ : grant.principal.type === "user"
545
+ ? grant.principal.id
546
+ : i18n.t("knowledge.organization")}
547
+ <span className="ml-2 text-xs text-muted-foreground">
548
+ {i18n.t(`knowledge.verb.${grant.verb}`)}
549
+ </span>
550
+ </span>
551
+ <button
552
+ type="button"
553
+ onClick={() => revoke.mutate(grant.id)}
554
+ aria-label={i18n.t("knowledge.revokeShare")}
555
+ className="rounded-md p-2 text-destructive outline-none hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring"
556
+ >
557
+ <Trash2 aria-hidden="true" className="size-4" />
558
+ </button>
559
+ </li>
560
+ ))}
561
+ {grants.data?.items.length === 0 && (
562
+ <li className="text-sm text-muted-foreground">{i18n.t("knowledge.noGrants")}</li>
563
+ )}
564
+ </ul>
565
+ <form
566
+ onSubmit={(event) => {
567
+ event.preventDefault();
568
+ share.mutate();
569
+ }}
570
+ className="space-y-4 border-t pt-5"
571
+ >
572
+ <label className="block text-sm font-medium">
573
+ {i18n.t("knowledge.email")}
574
+ <input
575
+ type="email"
576
+ required
577
+ value={email}
578
+ onChange={(event) => setEmail(event.target.value)}
579
+ className="mt-2 w-full rounded-md border bg-background px-3 py-2 outline-none focus-visible:ring-2 focus-visible:ring-ring"
580
+ />
581
+ </label>
582
+ <fieldset className="space-y-2">
583
+ <legend className="text-sm font-medium">{i18n.t("knowledge.verbs")}</legend>
584
+ {(grants.data?.applicableVerbs ?? []).map((verb) => (
585
+ <label key={verb} className="flex items-center gap-2 text-sm">
586
+ <input
587
+ type="checkbox"
588
+ checked={verbs.includes(verb)}
589
+ onChange={(event) =>
590
+ setVerbs((current) =>
591
+ event.target.checked
592
+ ? [...current, verb]
593
+ : current.filter((candidate) => candidate !== verb),
594
+ )
595
+ }
596
+ className="size-4 rounded border outline-none focus-visible:ring-2 focus-visible:ring-ring"
597
+ />
598
+ <span>{i18n.t(`knowledge.verb.${verb}`)}</span>
599
+ <span className="text-xs text-muted-foreground">
600
+ {i18n.t(`knowledge.verbHint.${verb}`)}
601
+ </span>
602
+ </label>
603
+ ))}
604
+ </fieldset>
605
+ <button
606
+ type="submit"
607
+ disabled={share.isPending || verbs.length === 0}
608
+ className="w-full rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground outline-none hover:bg-primary/90 focus-visible:ring-2 focus-visible:ring-ring disabled:opacity-50"
609
+ >
610
+ {i18n.t("knowledge.shareAction")}
611
+ </button>
612
+ </form>
613
+ </Modal>
614
+ );
615
+ }
@@ -1,14 +1,29 @@
1
- export type SelectionSearch = Record<string, unknown> & { select?: string };
1
+ // The ways one selected level can be shown. `editor` is the absence of the parameter rather than a
2
+ // value of its own, so a plain link is always the editor and every link written before #35 keeps
3
+ // meaning what it meant.
4
+ export const IntelViews = ["editor", "graph", "runs"] as const;
5
+ export type IntelView = (typeof IntelViews)[number];
6
+
7
+ export type SelectionSearch = Record<string, unknown> & {
8
+ select?: string;
9
+ view?: Exclude<IntelView, "editor">;
10
+ };
2
11
 
3
12
  // One search parameter carries "open this one" into an area: the header search names a hit, the
4
13
  // screen for that area selects it. Keeping it in the URL is what makes a hit reachable with Enter
5
14
  // alone — the dialog never reaches into a screen's own state.
6
15
  //
16
+ // `view` is the second: which way the selected level is shown — the editor, the relation graph
17
+ // (#19), or the runs of the selected flow (#35). It sits in the URL for the same reason `select`
18
+ // does: what someone is looking at should survive a reload and be linkable.
19
+ //
7
20
  // ⚠️ Unknown parameters are passed through rather than dropped. The portal's connect flow returns
8
21
  // to `/tools` with its own marker, and a validator that kept only what it knows would delete it.
9
22
  export function selectionSearch(search: Record<string, unknown>): SelectionSearch {
10
- const { select, ...rest } = search;
11
- return typeof select === "string" && select.length > 0 ? { ...rest, select } : rest;
23
+ const { select, view, ...rest } = search;
24
+ const withSelect =
25
+ typeof select === "string" && select.length > 0 ? { ...rest, select } : { ...rest };
26
+ return view === "graph" || view === "runs" ? { ...withSelect, view } : withSelect;
12
27
  }
13
28
 
14
29
  // Screens are lazy route components with no route object at hand, so they read the parameter off
@@ -17,3 +32,12 @@ export function selectedFrom(search: unknown): string | null {
17
32
  const select = (search as { select?: unknown } | null)?.select;
18
33
  return typeof select === "string" && select.length > 0 ? select : null;
19
34
  }
35
+
36
+ export function viewFrom(search: unknown): IntelView {
37
+ const view = (search as { view?: unknown } | null)?.view;
38
+ return view === "graph" || view === "runs" ? view : "editor";
39
+ }
40
+
41
+ export function graphViewFrom(search: unknown): boolean {
42
+ return viewFrom(search) === "graph";
43
+ }