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