@anchrd/intel-ui 0.5.0 → 0.7.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@anchrd/intel-ui",
3
- "version": "0.5.0",
3
+ "version": "0.7.0",
4
4
  "type": "module",
5
5
  "license": "UNLICENSED",
6
6
  "repository": {
@@ -28,7 +28,7 @@
28
28
  "typecheck": "tsc --noEmit"
29
29
  },
30
30
  "dependencies": {
31
- "@anchrd/intel-contract": "^0.2.0",
31
+ "@anchrd/intel-contract": "^0.3.0",
32
32
  "@blocknote/core": "^0.52.1",
33
33
  "@blocknote/react": "^0.52.1",
34
34
  "@blocknote/shadcn": "^0.52.1",
@@ -10,6 +10,10 @@ import { createPortal } from "react-dom";
10
10
  // ⚠️ The slot lives above the component that fills it, so it is not in the document while that
11
11
  // component first renders. Looking it up in an effect costs one extra render and is the only order
12
12
  // that works; reading it during render finds nothing on the first paint.
13
+ //
14
+ // ⚠️ A portal only ever appends, so the slot decides where its content sits and mount order decides
15
+ // nothing. That is why the shell gives the screens a box of their own next to the search rather
16
+ // than one shared list (see `app.tsx`, #56).
13
17
  export function ActionSlot({
14
18
  name = "header-actions",
15
19
  children,
@@ -15,10 +15,11 @@ import {
15
15
  import { useRef, useState } from "react";
16
16
  import {
17
17
  type MoveDestination,
18
- MoveDialog,
19
18
  moveErrorKey,
20
19
  moveVerdict,
21
20
  parentOf,
21
+ treeLevelKey,
22
+ useTreeMove,
22
23
  } from "@/app/tree-move/tree-move.tsx";
23
24
  import {
24
25
  DropdownMenu,
@@ -36,7 +37,6 @@ import {
36
37
  import { flowEntry } from "@/data/intel-data-provider/intel-data-provider.ts";
37
38
  import type { TreeEntry } from "@/data/intel-data-provider/intel-data-provider.types.ts";
38
39
  import { Modal } from "@/modal/modal.tsx";
39
- import { ResourceMenu } from "@/resource-menu/resource-menu.tsx";
40
40
  import { useIntelRouterContext } from "@/router/router-context.ts";
41
41
  import { selectedFrom } from "@/router/selection-search.ts";
42
42
 
@@ -79,16 +79,13 @@ type Level = { id: string | null; type: "folder" | "flow" };
79
79
  // One key per level. `null` is the root; every expanded row adds one of its own, and nothing else is
80
80
  // ever asked for.
81
81
  function levelKey(level: Level): readonly unknown[] {
82
- return level.type === "flow" ? ["flow-calls", level.id] : ["tree", level.id];
82
+ return level.type === "flow" ? ["flow-calls", level.id] : treeLevelKey(level.id);
83
83
  }
84
84
 
85
- // The optimistic row, filed where it is about to land. Its own parent has to travel with it, or the
86
- // plus on the moved row would still file into the folder it just left.
87
- function withParent(entry: TreeEntry, parentId: string | null): TreeEntry {
88
- return entry.type === "knowledge"
89
- ? { ...entry, node: { ...entry.node, parentId } }
90
- : { ...entry, flow: { ...entry.flow, parentId } };
91
- }
85
+ // What a row puts on the clipboard for anybody outside the tree. Its own media type rather than
86
+ // `text/plain`, so a drop target that means "move this row" and one that means "make a node for
87
+ // this" cannot be confused by the same payload.
88
+ export const TreeEntryMediaType = "application/x-intel-tree-entry";
92
89
 
93
90
  export function AppTree() {
94
91
  const { data, i18n } = useIntelRouterContext();
@@ -103,10 +100,14 @@ export function AppTree() {
103
100
  // Which target the pointer is over: `undefined` for none, `null` for the root strip, an id for a
104
101
  // folder row. Three answers, because "the root" and "nothing" are not the same drop.
105
102
  const [over, setOver] = useState<string | null | undefined>(undefined);
106
- const [moving, setMoving] = useState<{
107
- entry: TreeEntry;
108
- initial: MoveDestination | null;
109
- } | null>(null);
103
+ // The drop's half of the move. The other half is the folder picker in the title line's menu, and
104
+ // both go through the same mutation (#58, `useTreeMove`) — the tree only adds what a tree can add,
105
+ // which is opening the folder the row just landed in.
106
+ const move = useTreeMove({
107
+ onMoved: (destination) => {
108
+ if (destination.id !== null) toggle({ id: destination.id, type: "folder" }, true);
109
+ },
110
+ });
110
111
 
111
112
  const location = useRouterState({
112
113
  select: (state) => ({
@@ -180,7 +181,6 @@ export function AppTree() {
180
181
  kind,
181
182
  title,
182
183
  description: null,
183
- contextPolicy: "relevant",
184
184
  idempotencyKey: crypto.randomUUID(),
185
185
  });
186
186
  // ⚠️ Two calls, because they are two things: the node is a row in the tree, the header is the
@@ -216,7 +216,6 @@ export function AppTree() {
216
216
  kind: "attachment",
217
217
  title: file.name,
218
218
  description: null,
219
- contextPolicy: "relevant",
220
219
  idempotencyKey: crypto.randomUUID(),
221
220
  });
222
221
  try {
@@ -251,86 +250,6 @@ export function AppTree() {
251
250
  },
252
251
  });
253
252
 
254
- // ⚠️ Optimistic, never authoritative. The row is lifted out of one level and dropped into the
255
- // other before the server answers, and every refusal — 403, `parent_not_folder`, `move_cycle`,
256
- // `update_conflict` — puts both levels back exactly as they were and then re-reads them, so a
257
- // rejected move cannot leave a row standing twice or nowhere at all.
258
- const move = useMutation({
259
- mutationFn: async ({
260
- entry,
261
- destination,
262
- }: {
263
- entry: TreeEntry;
264
- destination: MoveDestination;
265
- }) => {
266
- // `baseUpdatedAt` travels with the move: it is what turns a concurrent edit into a 409 the
267
- // view can act on instead of an overwrite nobody notices.
268
- if (entry.type === "flow") {
269
- await data.updateFlow({
270
- flowId: entry.id,
271
- baseUpdatedAt: entry.flow.updatedAt,
272
- parentId: destination.id,
273
- idempotencyKey: crypto.randomUUID(),
274
- });
275
- } else {
276
- await data.updateKnowledge({
277
- nodeId: entry.id,
278
- baseUpdatedAt: entry.node.updatedAt,
279
- parentId: destination.id,
280
- idempotencyKey: crypto.randomUUID(),
281
- });
282
- }
283
- },
284
- onMutate: async ({ entry, destination }) => {
285
- const fromKey = levelKey({ id: parentOf(entry), type: "folder" });
286
- const toKey = levelKey({ id: destination.id, type: "folder" });
287
- await Promise.all([
288
- queryClient.cancelQueries({ queryKey: fromKey }),
289
- queryClient.cancelQueries({ queryKey: toKey }),
290
- ]);
291
- const snapshot = [
292
- [fromKey, queryClient.getQueryData<TreeEntry[]>(fromKey)],
293
- [toKey, queryClient.getQueryData<TreeEntry[]>(toKey)],
294
- ] as const;
295
- queryClient.setQueryData<TreeEntry[]>(fromKey, (current) =>
296
- current?.filter((row) => row.id !== entry.id),
297
- );
298
- // A level nobody has opened stays unloaded: writing one here would show a folder's contents
299
- // that were never read.
300
- queryClient.setQueryData<TreeEntry[]>(toKey, (current) =>
301
- current === undefined
302
- ? current
303
- : [...current.filter((row) => row.id !== entry.id), withParent(entry, destination.id)],
304
- );
305
- return { snapshot };
306
- },
307
- onError: (_error, _variables, context) => {
308
- for (const [key, value] of context?.snapshot ?? []) queryClient.setQueryData(key, value);
309
- },
310
- onSuccess: (_result, { destination }) => {
311
- if (destination.id !== null) toggle({ id: destination.id, type: "folder" }, true);
312
- },
313
- onSettled: async (_result, _error, { entry, destination }) => {
314
- await Promise.all([
315
- queryClient.invalidateQueries({
316
- queryKey: levelKey({ id: parentOf(entry), type: "folder" }),
317
- }),
318
- queryClient.invalidateQueries({
319
- queryKey: levelKey({ id: destination.id, type: "folder" }),
320
- }),
321
- queryClient.invalidateQueries({
322
- queryKey: [entry.type === "flow" ? "flows" : "knowledge-graph"],
323
- }),
324
- queryClient.invalidateQueries({ queryKey: ["relation-graph"] }),
325
- ]);
326
- },
327
- });
328
-
329
- function startMove(entry: TreeEntry, destination: MoveDestination | null) {
330
- move.reset();
331
- setMoving({ entry, initial: destination });
332
- }
333
-
334
253
  // What a drop on this target would do. `undefined` means nothing is being dragged, so the target
335
254
  // is not a target at all.
336
255
  function verdictFor(
@@ -374,7 +293,7 @@ export function AppTree() {
374
293
  setDragged(null);
375
294
  draggedRef.current = null;
376
295
  if (carried && verdictFor(carried, target.id, ancestors) === "ok") {
377
- startMove(carried, target);
296
+ move.start(carried, target);
378
297
  }
379
298
  },
380
299
  },
@@ -529,8 +448,17 @@ export function AppTree() {
529
448
  isActive={isActive}
530
449
  draggable={!derived}
531
450
  onDragStart={(event) => {
532
- event.dataTransfer.effectAllowed = "move";
451
+ // ⚠️ Two formats, one gesture. `text/plain` is what a drop inside the tree reads —
452
+ // that is a MOVE, and it only ever needed the id. The flow canvas needs the kind as
453
+ // well, to know which node to make, and resolving an id there would mean a second
454
+ // read of something the drag already knows. `effectAllowed` says both are allowed;
455
+ // each drop target picks the one it means (#75).
456
+ event.dataTransfer.effectAllowed = "copyMove";
533
457
  event.dataTransfer.setData("text/plain", entry.id);
458
+ event.dataTransfer.setData(
459
+ TreeEntryMediaType,
460
+ JSON.stringify({ id: entry.id, kind: entry.kind, title: entry.title }),
461
+ );
534
462
  draggedRef.current = entry;
535
463
  setDragged(entry);
536
464
  }}
@@ -559,17 +487,12 @@ export function AppTree() {
559
487
  else setCreating({ parentId: target, kind });
560
488
  }}
561
489
  />
562
- {/* ⚠️ Dragging is a pointer gesture and nothing else: no keyboard, no screen reader, no
563
- touch worth the name. A capability that exists only for a mouse is missing for
564
- everyone else, so moving has a second, equal route this menu's folder picker. #27
565
- opened the menu with that one item; the rest of the row's actions moved in here from
566
- the header, which is what #24 is. A row under a flow is what that flow calls, read out
567
- of its graph rather than from `parent_id` (ADR-0004 §3): it has no place of its own to
568
- be moved from, so it gets no move — and no menu, since renaming the shared flow from
569
- under one of its callers would rename it for all of them without saying so. */}
570
- {derived ? null : (
571
- <ResourceMenu target={entry} variant="row" onMove={() => startMove(entry, null)} />
572
- )}
490
+ {/* ⚠️ Nothing else. The three-dot menu stood here until #58 and does not any more: two
491
+ buttons per row put ten of them into a 240px column, and one then reads buttons instead
492
+ of titles the tree is for reading. Every action it held is in the title line of the
493
+ thing itself, which every row opens by being clicked, including a folder, whose screen
494
+ is nothing but that line. Do not put a second one back here: the point of the menu is
495
+ that it is always in the same place, and two places are not one. */}
573
496
  </div>
574
497
  {/* The same rows one indent deeper: one row component for every depth, so the plus on the
575
498
  fourth level is the same plus as on the first. The list is named after the row it hangs
@@ -598,7 +521,7 @@ export function AppTree() {
598
521
  <button
599
522
  type="button"
600
523
  {...rootDrop.props}
601
- onClick={() => startMove(dragged, { id: null, title: i18n.t("tree.move.root") })}
524
+ onClick={() => move.start(dragged, { id: null, title: i18n.t("tree.move.root") })}
602
525
  data-drop={rootDrop.verdict}
603
526
  className={`mb-1 flex w-full items-center gap-1 rounded-md border border-dashed px-2 py-1.5 text-left text-sm outline-none focus-visible:ring-2 focus-visible:ring-sidebar-ring ${
604
527
  rootDrop.verdict === "ok"
@@ -622,7 +545,7 @@ export function AppTree() {
622
545
  ) : null}
623
546
  {/* Four refusals, four sentences — and by the time one is read the row is already back where
624
547
  it started, because the rollback happens in `onError` rather than here. */}
625
- {move.isError ? (
548
+ {move.error ? (
626
549
  <p role="alert" className="px-2 py-1.5 text-sm text-destructive">
627
550
  {i18n.t(moveErrorKey(move.error))}
628
551
  </p>
@@ -666,17 +589,7 @@ export function AppTree() {
666
589
  />
667
590
  </Modal>
668
591
  ) : null}
669
- {moving ? (
670
- <MoveDialog
671
- entry={moving.entry}
672
- initial={moving.initial}
673
- close={() => setMoving(null)}
674
- submit={(destination) => {
675
- setMoving(null);
676
- move.mutate({ entry: moving.entry, destination });
677
- }}
678
- />
679
- ) : null}
592
+ {move.dialog}
680
593
  </SidebarGroup>
681
594
  );
682
595
  }
package/src/app/app.tsx CHANGED
@@ -91,10 +91,21 @@ export function App() {
91
91
  ) : null}
92
92
  </BreadcrumbList>
93
93
  </Breadcrumb>
94
- {/* The bar for area-owned actions. A screen renders into it through `ActionSlot`;
95
- search is the shell's own, because it has to open from every screen alike and belongs
96
- to none of them. It stays first, so a screen's actions line up to its right. */}
97
- <div data-slot="header-actions" className="ml-auto flex items-center gap-2">
94
+ {/* The bar for area-owned actions. A screen renders into the inner box through
95
+ `ActionSlot`; search is the shell's own, because it has to open from every screen
96
+ alike and belongs to none of them. It stays last, against the right edge, and a
97
+ screen's actions line up to its left: the search is the one thing in this bar that is
98
+ on every screen, so it is the one whose place must not depend on what a screen happens
99
+ to bring. Pinned the other way round it was the constant that moved (#56).
100
+
101
+ ⚠️ Two boxes rather than one list, and that is the whole of the mechanism: a portal
102
+ only ever appends to its container, so with the search a sibling of the portalled
103
+ actions a screen that mounts them later — Flows does, once a flow is selected — would
104
+ land to its right. Giving the screens a container of their own takes mount order out
105
+ of the question entirely. `data-slot="header-actions"` therefore names the inner box:
106
+ it is the name every `ActionSlot` looks up. */}
107
+ <div data-slot="header-bar" className="ml-auto flex items-center gap-2">
108
+ <div data-slot="header-actions" className="flex items-center gap-2" />
98
109
  <HeaderSearch />
99
110
  </div>
100
111
  </header>
@@ -1,5 +1,6 @@
1
- import { useQuery } from "@tanstack/react-query";
1
+ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
2
2
  import { ChevronLeft, Folder } from "lucide-react";
3
+ import type * as React from "react";
3
4
  import { useState } from "react";
4
5
  import type { TreeEntry } from "@/data/intel-data-provider/intel-data-provider.types.ts";
5
6
  import { Modal } from "@/modal/modal.tsx";
@@ -17,6 +18,20 @@ export function parentOf(entry: TreeEntry): string | null {
17
18
  return entry.type === "knowledge" ? entry.node.parentId : entry.flow.parentId;
18
19
  }
19
20
 
21
+ // One level of the shared tree, as a query key. The move writes into two of them and the picker
22
+ // reads a third, so the shape is stated once rather than spelled out at each of those places.
23
+ export function treeLevelKey(parentId: string | null): readonly unknown[] {
24
+ return ["tree", parentId];
25
+ }
26
+
27
+ // The optimistic row, filed where it is about to land. Its own parent has to travel with it, or the
28
+ // plus on the moved row would still file into the folder it just left.
29
+ function withParent(entry: TreeEntry, parentId: string | null): TreeEntry {
30
+ return entry.type === "knowledge"
31
+ ? { ...entry, node: { ...entry.node, parentId } }
32
+ : { ...entry, flow: { ...entry.flow, parentId } };
33
+ }
34
+
20
35
  /**
21
36
  * ⚠️ The trap this ticket is built around: a node must not travel into its own descendants. The
22
37
  * service refuses it (`move_cycle`, `knowledge.ts:421`), but a refusal that only arrives once the
@@ -195,3 +210,122 @@ export function MoveDialog({
195
210
  </Modal>
196
211
  );
197
212
  }
213
+
214
+ /**
215
+ * Moving a row, wherever it is asked for.
216
+ *
217
+ * ⚠️ It is asked for from two places now (#58): a drop on a folder in the tree, and the menu in the
218
+ * title line — the keyboard route, which since #58 is the only one the menu still has anywhere. Both
219
+ * are the same move, so both take it from here rather than each carrying its own mutation. Two
220
+ * copies of an optimistic update that rewrites two cached levels is how a rejected move ends up
221
+ * leaving a row standing twice.
222
+ *
223
+ * ⚠️ Optimistic, never authoritative. The row is lifted out of one level and dropped into the other
224
+ * before the server answers, and every refusal — 403, `parent_not_folder`, `move_cycle`,
225
+ * `update_conflict` — puts both levels back exactly as they were and then re-reads them.
226
+ *
227
+ * The caller renders `dialog` where it likes and words `error` itself with `moveErrorKey`: the tree
228
+ * says it in the sidebar, the menu over the screen, and neither position belongs to the move.
229
+ */
230
+ export function useTreeMove({
231
+ onMoved,
232
+ }: {
233
+ // What the caller wants to do with the destination once the move is through — the tree opens that
234
+ // folder so the row is where the eye follows it. Nobody else has a tree to open.
235
+ onMoved?: ((destination: MoveDestination) => void) | undefined;
236
+ } = {}): {
237
+ start(entry: TreeEntry, destination: MoveDestination | null): void;
238
+ error: unknown;
239
+ dialog: React.ReactNode;
240
+ } {
241
+ const { data } = useIntelRouterContext();
242
+ const queryClient = useQueryClient();
243
+ const [moving, setMoving] = useState<{
244
+ entry: TreeEntry;
245
+ initial: MoveDestination | null;
246
+ } | null>(null);
247
+
248
+ const move = useMutation({
249
+ mutationFn: async ({
250
+ entry,
251
+ destination,
252
+ }: {
253
+ entry: TreeEntry;
254
+ destination: MoveDestination;
255
+ }) => {
256
+ // `baseUpdatedAt` travels with the move: it is what turns a concurrent edit into a 409 the
257
+ // view can act on instead of an overwrite nobody notices.
258
+ if (entry.type === "flow") {
259
+ await data.updateFlow({
260
+ flowId: entry.id,
261
+ baseUpdatedAt: entry.flow.updatedAt,
262
+ parentId: destination.id,
263
+ idempotencyKey: crypto.randomUUID(),
264
+ });
265
+ } else {
266
+ await data.updateKnowledge({
267
+ nodeId: entry.id,
268
+ baseUpdatedAt: entry.node.updatedAt,
269
+ parentId: destination.id,
270
+ idempotencyKey: crypto.randomUUID(),
271
+ });
272
+ }
273
+ },
274
+ onMutate: async ({ entry, destination }) => {
275
+ const fromKey = treeLevelKey(parentOf(entry));
276
+ const toKey = treeLevelKey(destination.id);
277
+ await Promise.all([
278
+ queryClient.cancelQueries({ queryKey: fromKey }),
279
+ queryClient.cancelQueries({ queryKey: toKey }),
280
+ ]);
281
+ const snapshot = [
282
+ [fromKey, queryClient.getQueryData<TreeEntry[]>(fromKey)],
283
+ [toKey, queryClient.getQueryData<TreeEntry[]>(toKey)],
284
+ ] as const;
285
+ queryClient.setQueryData<TreeEntry[]>(fromKey, (current) =>
286
+ current?.filter((row) => row.id !== entry.id),
287
+ );
288
+ // A level nobody has opened stays unloaded: writing one here would show a folder's contents
289
+ // that were never read.
290
+ queryClient.setQueryData<TreeEntry[]>(toKey, (current) =>
291
+ current === undefined
292
+ ? current
293
+ : [...current.filter((row) => row.id !== entry.id), withParent(entry, destination.id)],
294
+ );
295
+ return { snapshot };
296
+ },
297
+ onError: (_error, _variables, context) => {
298
+ for (const [key, value] of context?.snapshot ?? []) queryClient.setQueryData(key, value);
299
+ },
300
+ onSuccess: (_result, { destination }) => onMoved?.(destination),
301
+ onSettled: async (_result, _error, { entry, destination }) => {
302
+ await Promise.all([
303
+ queryClient.invalidateQueries({ queryKey: treeLevelKey(parentOf(entry)) }),
304
+ queryClient.invalidateQueries({ queryKey: treeLevelKey(destination.id) }),
305
+ queryClient.invalidateQueries({
306
+ queryKey: [entry.type === "flow" ? "flows" : "knowledge-graph"],
307
+ }),
308
+ queryClient.invalidateQueries({ queryKey: ["relation-graph"] }),
309
+ ]);
310
+ },
311
+ });
312
+
313
+ return {
314
+ start(entry, destination) {
315
+ move.reset();
316
+ setMoving({ entry, initial: destination });
317
+ },
318
+ error: move.isError ? move.error : null,
319
+ dialog: moving ? (
320
+ <MoveDialog
321
+ entry={moving.entry}
322
+ initial={moving.initial}
323
+ close={() => setMoving(null)}
324
+ submit={(destination) => {
325
+ setMoving(null);
326
+ move.mutate({ entry: moving.entry, destination });
327
+ }}
328
+ />
329
+ ) : null,
330
+ };
331
+ }
@@ -0,0 +1,82 @@
1
+ import type * as React from "react";
2
+ import { cn } from "@/lib/utils.ts";
3
+
4
+ // The shadcn table primitive. A real `<table>` and not a grid of divs: the row and column
5
+ // relationships are what a screen reader reads out, and no `role` patch on a div reproduces them
6
+ // as reliably as the element that means it.
7
+ //
8
+ // ⚠️ The horizontal scroll lives on the wrapper, not on the page. A wide table has to be able to
9
+ // scroll inside its own box, or the whole layout scrolls sideways with it.
10
+ function Table({ className, ...props }: React.ComponentProps<"table">) {
11
+ return (
12
+ <div data-slot="table-container" className="relative w-full overflow-x-auto">
13
+ <table
14
+ data-slot="table"
15
+ className={cn("w-full caption-bottom text-sm", className)}
16
+ {...props}
17
+ />
18
+ </div>
19
+ );
20
+ }
21
+
22
+ function TableHeader({ className, ...props }: React.ComponentProps<"thead">) {
23
+ return <thead data-slot="table-header" className={cn("[&_tr]:border-b", className)} {...props} />;
24
+ }
25
+
26
+ function TableBody({ className, ...props }: React.ComponentProps<"tbody">) {
27
+ return (
28
+ <tbody
29
+ data-slot="table-body"
30
+ className={cn("[&_tr:last-child]:border-0", className)}
31
+ {...props}
32
+ />
33
+ );
34
+ }
35
+
36
+ function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
37
+ return (
38
+ <tr
39
+ data-slot="table-row"
40
+ className={cn(
41
+ "border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted",
42
+ className,
43
+ )}
44
+ {...props}
45
+ />
46
+ );
47
+ }
48
+
49
+ function TableHead({ className, ...props }: React.ComponentProps<"th">) {
50
+ return (
51
+ <th
52
+ data-slot="table-head"
53
+ className={cn(
54
+ "h-10 px-2 text-left align-middle font-medium text-muted-foreground whitespace-nowrap",
55
+ className,
56
+ )}
57
+ {...props}
58
+ />
59
+ );
60
+ }
61
+
62
+ function TableCell({ className, ...props }: React.ComponentProps<"td">) {
63
+ return (
64
+ <td
65
+ data-slot="table-cell"
66
+ className={cn("p-2 align-middle whitespace-nowrap", className)}
67
+ {...props}
68
+ />
69
+ );
70
+ }
71
+
72
+ function TableCaption({ className, ...props }: React.ComponentProps<"caption">) {
73
+ return (
74
+ <caption
75
+ data-slot="table-caption"
76
+ className={cn("mt-4 text-sm text-muted-foreground", className)}
77
+ {...props}
78
+ />
79
+ );
80
+ }
81
+
82
+ export { Table, TableBody, TableCaption, TableCell, TableHead, TableHeader, TableRow };
@@ -14,6 +14,7 @@ import {
14
14
  FlowRunHistory,
15
15
  FlowRunList,
16
16
  FlowRunStep,
17
+ FlowValidation,
17
18
  KnowledgeDocument,
18
19
  KnowledgeGraph,
19
20
  KnowledgeLinkList,
@@ -84,10 +85,20 @@ export function flowEntry(flow: Flow): TreeEntry {
84
85
  }
85
86
 
86
87
  export function createIntelDataProvider(
87
- deps: { fetch?: typeof fetch; baseUrl?: string; onUnauthorized?(): void } = {},
88
+ deps: {
89
+ fetch?: typeof fetch;
90
+ baseUrl?: string;
91
+ onUnauthorized?(): void;
92
+ navigate?(url: string): void;
93
+ } = {},
88
94
  ): IntelDataProvider {
89
95
  const doFetch = deps.fetch ?? fetch;
90
96
  const baseUrl = deps.baseUrl?.replace(/\/$/, "") ?? "";
97
+ const navigate =
98
+ deps.navigate ??
99
+ ((url: string) => {
100
+ if (typeof window !== "undefined") window.location.assign(url);
101
+ });
91
102
  const unauthorized =
92
103
  deps.onUnauthorized ??
93
104
  (() => {
@@ -306,6 +317,9 @@ export function createIntelDataProvider(
306
317
  async getFlow(flowId) {
307
318
  return await request(`/flows/${encodeURIComponent(flowId)}`, FlowDocument);
308
319
  },
320
+ async validateFlow(flowId) {
321
+ return await request(`/flows/${encodeURIComponent(flowId)}/validation`, FlowValidation);
322
+ },
309
323
  async listFlowCalls(flowId) {
310
324
  return await request(`/flows/${encodeURIComponent(flowId)}/calls`, FlowList);
311
325
  },
@@ -400,8 +414,10 @@ export function createIntelDataProvider(
400
414
  return await request("/tools", ToolCatalog);
401
415
  },
402
416
 
403
- portalConnectUrl(returnTo = "/tools") {
404
- return `${baseUrl}/auth/connect?returnTo=${encodeURIComponent(returnTo)}`;
417
+ // The Worker serves the connect route, so this navigation belongs to the data layer for the
418
+ // same reason `loginPath` does. `silent=1` is what makes it a redirect nobody has to watch.
419
+ startPortalSignIn(returnTo = "/tools") {
420
+ navigate(`${baseUrl}/auth/connect?returnTo=${encodeURIComponent(returnTo)}&silent=1`);
405
421
  },
406
422
  async logout() {
407
423
  const response = await doFetch(`${baseUrl}/auth/logout`, {
@@ -14,6 +14,7 @@ import type {
14
14
  FlowRunHistory,
15
15
  FlowRunList,
16
16
  FlowRunStep,
17
+ FlowValidation,
17
18
  KnowledgeDocument,
18
19
  KnowledgeGraph,
19
20
  KnowledgeLinkList,
@@ -77,7 +78,10 @@ export interface IntelDataProvider {
77
78
  appendKnowledgeTableRows(
78
79
  input: AppendKnowledgeTableRowsInput,
79
80
  ): Promise<AppendKnowledgeTableRowsResult>;
80
- portalConnectUrl(returnTo?: string): string;
81
+ // Walks the browser through the portal's OAuth flow without asking anybody anything: Gate is the
82
+ // identity provider Cloudflare Access consumes, so a signed-in person is already known there
83
+ // (#60). It navigates away — the caller renders no button for it and gets no answer back.
84
+ startPortalSignIn(returnTo?: string): void;
81
85
  listKnowledgeVersions(nodeId: string): Promise<KnowledgeVersionList>;
82
86
  updateKnowledge(input: UpdateKnowledgeNodeInput): Promise<KnowledgeNode>;
83
87
  archiveKnowledge(input: ArchiveKnowledgeNodeInput): Promise<KnowledgeNode>;
@@ -92,6 +96,8 @@ export interface IntelDataProvider {
92
96
  // What a flow calls, read out of its graph. It answers a different question from `listTreeChildren`
93
97
  // and deliberately gives a different answer: a shared sub-flow is listed under every caller.
94
98
  listFlowCalls(flowId: string): Promise<FlowList>;
99
+ // Would this flow start for me, right now. A question, not an action: nothing is created (#72).
100
+ validateFlow(flowId: string): Promise<FlowValidation>;
95
101
  // What accesses what, for one level of the shared tree: a folder for its contents, a flow for
96
102
  // itself. ⚠️ Only nodes the signed-in user may see come back, and one they may not is absent
97
103
  // altogether — never a placeholder, because the edge into one would already say that it exists.