@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
@@ -2,15 +2,24 @@ import { useMutation, useQueries, useQueryClient } from "@tanstack/react-query";
2
2
  import { useNavigate, useRouterState } from "@tanstack/react-router";
3
3
  import {
4
4
  ChevronRight,
5
+ CornerLeftUp,
5
6
  FileText,
6
7
  Folder,
7
8
  FolderOpen,
8
9
  Paperclip,
9
10
  Plus,
11
+ Table,
10
12
  Upload,
11
13
  Workflow,
12
14
  } from "lucide-react";
13
15
  import { useRef, useState } from "react";
16
+ import {
17
+ type MoveDestination,
18
+ MoveDialog,
19
+ moveErrorKey,
20
+ moveVerdict,
21
+ parentOf,
22
+ } from "@/app/tree-move/tree-move.tsx";
14
23
  import {
15
24
  DropdownMenu,
16
25
  DropdownMenuContent,
@@ -24,18 +33,21 @@ import {
24
33
  SidebarMenuItem,
25
34
  SidebarMenuSkeleton,
26
35
  } from "@/components/ui/sidebar";
36
+ import { flowEntry } from "@/data/intel-data-provider/intel-data-provider.ts";
27
37
  import type { TreeEntry } from "@/data/intel-data-provider/intel-data-provider.types.ts";
28
38
  import { Modal } from "@/modal/modal.tsx";
39
+ import { ResourceMenu } from "@/resource-menu/resource-menu.tsx";
29
40
  import { useIntelRouterContext } from "@/router/router-context.ts";
30
41
  import { selectedFrom } from "@/router/selection-search.ts";
31
42
 
32
- type NewKind = "folder" | "document" | "flow";
43
+ type NewKind = "folder" | "document" | "table" | "flow";
33
44
  type Creating = { parentId: string | null; kind: NewKind };
34
45
 
35
46
  const icons = {
36
47
  folder: Folder,
37
48
  document: FileText,
38
49
  attachment: Paperclip,
50
+ table: Table,
39
51
  flow: Workflow,
40
52
  } as const;
41
53
 
@@ -59,20 +71,42 @@ async function fileBase64(file: File): Promise<string> {
59
71
  });
60
72
  }
61
73
 
62
- // One key per level of the tree. `null` is the root; every expanded folder adds one of its own, and
63
- // nothing else is ever asked for.
64
- function levelKey(parentId: string | null): readonly unknown[] {
65
- return ["tree", parentId];
74
+ // A level is either a folder of the shared tree or the flows one flow calls. The second kind is
75
+ // derived from that flow's graph, not from `parent_id` (ADR-0004 §3), which is why it is its own
76
+ // level rather than another folder.
77
+ type Level = { id: string | null; type: "folder" | "flow" };
78
+
79
+ // One key per level. `null` is the root; every expanded row adds one of its own, and nothing else is
80
+ // ever asked for.
81
+ function levelKey(level: Level): readonly unknown[] {
82
+ return level.type === "flow" ? ["flow-calls", level.id] : ["tree", level.id];
83
+ }
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 } };
66
91
  }
67
92
 
68
93
  export function AppTree() {
69
94
  const { data, i18n } = useIntelRouterContext();
70
95
  const queryClient = useQueryClient();
71
96
  const navigate = useNavigate();
72
- const [expanded, setExpanded] = useState<ReadonlySet<string>>(new Set());
97
+ const [expanded, setExpanded] = useState<readonly Level[]>([]);
73
98
  const [creating, setCreating] = useState<Creating | null>(null);
74
99
  const [uploadTo, setUploadTo] = useState<string | null>(null);
75
100
  const uploadInput = useRef<HTMLInputElement>(null);
101
+ const [dragged, setDragged] = useState<TreeEntry | null>(null);
102
+ const draggedRef = useRef<TreeEntry | null>(null);
103
+ // Which target the pointer is over: `undefined` for none, `null` for the root strip, an id for a
104
+ // folder row. Three answers, because "the root" and "nothing" are not the same drop.
105
+ const [over, setOver] = useState<string | null | undefined>(undefined);
106
+ const [moving, setMoving] = useState<{
107
+ entry: TreeEntry;
108
+ initial: MoveDestination | null;
109
+ } | null>(null);
76
110
 
77
111
  const location = useRouterState({
78
112
  select: (state) => ({
@@ -84,22 +118,27 @@ export function AppTree() {
84
118
  // ⚠️ The whole point of #11: one query per level, and a level only exists while its folder is
85
119
  // open. A recursive load would put an N+1 on every page of the app, because the tree is now on
86
120
  // every page.
87
- const parents: Array<string | null> = [null, ...expanded];
121
+ const parents: Level[] = [{ id: null, type: "folder" }, ...expanded];
88
122
  const levels = useQueries({
89
- queries: parents.map((parentId) => ({
90
- queryKey: levelKey(parentId),
91
- queryFn: () => data.listTreeChildren(parentId),
123
+ queries: parents.map((parent) => ({
124
+ queryKey: levelKey(parent),
125
+ queryFn: async () =>
126
+ parent.type === "flow" && parent.id !== null
127
+ ? // The same row a folder's level builds for a flow, from the same function: what hangs
128
+ // under an expanded flow differs in where the flows come from, never in what a flow row
129
+ // is (#30).
130
+ (await data.listFlowCalls(parent.id)).items.map(flowEntry)
131
+ : await data.listTreeChildren(parent.id),
92
132
  })),
93
133
  });
94
- const levelFor = (parentId: string | null) => levels[parents.indexOf(parentId)];
134
+ const levelFor = (level: Level) =>
135
+ levels[parents.findIndex((entry) => entry.id === level.id && entry.type === level.type)];
95
136
  const root = levels[0];
96
137
 
97
- function toggle(id: string, open: boolean) {
138
+ function toggle(level: Level & { id: string }, open: boolean) {
98
139
  setExpanded((current) => {
99
- const next = new Set(current);
100
- if (open) next.add(id);
101
- else next.delete(id);
102
- return next;
140
+ const rest = current.filter((entry) => entry.id !== level.id || entry.type !== level.type);
141
+ return open ? [...rest, level] : rest;
103
142
  });
104
143
  }
105
144
 
@@ -110,15 +149,23 @@ export function AppTree() {
110
149
  parentId: string | null,
111
150
  collection: "flows" | "knowledge-graph",
112
151
  ): Promise<void> {
113
- if (parentId !== null) toggle(parentId, true);
152
+ if (parentId !== null) toggle({ id: parentId, type: "folder" }, true);
114
153
  await Promise.all([
115
- queryClient.invalidateQueries({ queryKey: levelKey(parentId) }),
154
+ queryClient.invalidateQueries({ queryKey: levelKey({ id: parentId, type: "folder" }) }),
116
155
  queryClient.invalidateQueries({ queryKey: [collection] }),
156
+ // The graph view of that same level draws exactly this list, so it goes stale for the same
157
+ // reason the level does (#19).
158
+ queryClient.invalidateQueries({ queryKey: ["relation-graph"] }),
117
159
  ]);
118
160
  }
119
161
 
120
162
  const create = useMutation({
121
- mutationFn: async ({ parentId, kind, title }: Creating & { title: string }) => {
163
+ mutationFn: async ({
164
+ parentId,
165
+ kind,
166
+ title,
167
+ columns,
168
+ }: Creating & { title: string; columns: string[] }) => {
122
169
  if (kind === "flow") {
123
170
  const flow = await data.createFlow({
124
171
  parentId,
@@ -136,6 +183,17 @@ export function AppTree() {
136
183
  contextPolicy: "relevant",
137
184
  idempotencyKey: crypto.randomUUID(),
138
185
  });
186
+ // ⚠️ Two calls, because they are two things: the node is a row in the tree, the header is the
187
+ // contract every later append is measured against (#40). A table without a header would sit
188
+ // there refusing every append, so the second call happens here rather than being left to the
189
+ // person who created it.
190
+ if (kind === "table") {
191
+ await data.defineKnowledgeTable({
192
+ nodeId: node.id,
193
+ columns,
194
+ idempotencyKey: crypto.randomUUID(),
195
+ });
196
+ }
139
197
  return {
140
198
  area: "/knowledge" as const,
141
199
  id: node.id,
@@ -193,13 +251,143 @@ export function AppTree() {
193
251
  },
194
252
  });
195
253
 
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
+ // What a drop on this target would do. `undefined` means nothing is being dragged, so the target
335
+ // is not a target at all.
336
+ function verdictFor(
337
+ carried: TreeEntry | null,
338
+ targetId: string | null,
339
+ targetAncestors: ReadonlySet<string>,
340
+ ) {
341
+ if (!carried) return undefined;
342
+ return moveVerdict({
343
+ draggedId: carried.id,
344
+ draggedParentId: parentOf(carried),
345
+ targetId,
346
+ targetAncestors,
347
+ });
348
+ }
349
+
350
+ function dropHandlers(target: MoveDestination, ancestors: ReadonlySet<string>) {
351
+ // Two readings of the same question. The render-time one drives what is on screen; the handlers
352
+ // ask `draggedRef` instead, because a drag's events can all arrive before React has re-rendered
353
+ // and a closure over the previous render would then move the previous row.
354
+ const verdict = verdictFor(dragged, target.id, ancestors);
355
+ return {
356
+ verdict,
357
+ props: {
358
+ onDragOver: (event: React.DragEvent) => {
359
+ if (verdictFor(draggedRef.current, target.id, ancestors) !== "ok") {
360
+ event.dataTransfer.dropEffect = "none";
361
+ return;
362
+ }
363
+ // Without `preventDefault` the browser refuses the drop, so this is also what makes the
364
+ // "not allowed" cursor appear over every target the tree has ruled out.
365
+ event.preventDefault();
366
+ event.dataTransfer.dropEffect = "move";
367
+ setOver(target.id);
368
+ },
369
+ onDragLeave: () => setOver((current) => (current === target.id ? undefined : current)),
370
+ onDrop: (event: React.DragEvent) => {
371
+ event.preventDefault();
372
+ const carried = draggedRef.current;
373
+ setOver(undefined);
374
+ setDragged(null);
375
+ draggedRef.current = null;
376
+ if (carried && verdictFor(carried, target.id, ancestors) === "ok") {
377
+ startMove(carried, target);
378
+ }
379
+ },
380
+ },
381
+ };
382
+ }
383
+
196
384
  function startUpload(parentId: string | null) {
197
385
  setUploadTo(parentId);
198
386
  uploadInput.current?.click();
199
387
  }
200
388
 
201
- function renderLevel(parentId: string | null, ancestors: ReadonlySet<string>, label?: string) {
202
- const level = levelFor(parentId);
389
+ function renderLevel(parent: Level, ancestors: ReadonlySet<string>, label?: string) {
390
+ const level = levelFor(parent);
203
391
  if (!level || level.isPending) {
204
392
  return (
205
393
  <SidebarMenu aria-label={label}>
@@ -231,42 +419,100 @@ export function AppTree() {
231
419
  // now is that a row already on the path from the root is not rendered again.
232
420
  const entries = level.data?.filter((entry) => !ancestors.has(entry.id)) ?? [];
233
421
  if (entries.length === 0) {
234
- return (
235
- <p className="px-2 py-1.5 text-sm text-muted-foreground">
236
- {parentId === null ? i18n.t("tree.empty") : i18n.t("tree.emptyFolder")}
237
- </p>
422
+ // ⚠️ An open folder with nothing in it says nothing (#24): the missing row already is the
423
+ // answer, and the sentence used to repeat itself once per opened folder. The other two stay,
424
+ // and deliberately: the empty root is the whole of the navigation, so a blank sidebar on a
425
+ // fresh installation would leave a new user without any way in; and "this flow calls no other
426
+ // flow" is a different statement from "empty" — it is an answer, not filler.
427
+ const message =
428
+ parent.type === "flow"
429
+ ? i18n.t("tree.noCalls")
430
+ : parent.id === null
431
+ ? i18n.t("tree.empty")
432
+ : null;
433
+ // The folder keeps its list, empty. The structure is what carries "there is nothing here" to
434
+ // a screen reader — "list, 0 items" — which is the same answer the missing rows give a reader
435
+ // who can see them, and neither of them is a sentence.
436
+ return message === null ? (
437
+ <SidebarMenu aria-label={label} />
438
+ ) : (
439
+ <p className="px-2 py-1.5 text-sm text-muted-foreground">{message}</p>
238
440
  );
239
441
  }
240
442
  return (
241
443
  <SidebarMenu aria-label={label}>
242
- {entries.map((entry) => renderRow(entry, ancestors))}
444
+ {entries.map((entry) => renderRow(entry, ancestors, parent.type === "flow"))}
243
445
  </SidebarMenu>
244
446
  );
245
447
  }
246
448
 
247
- function renderRow(entry: TreeEntry, ancestors: ReadonlySet<string>) {
449
+ // ⚠️ `derived` is the level under a flow: its rows are that flow's calls, read out of its graph
450
+ // (ADR-0004 §3, `levelKey`). Nothing there has a `parent_id` to rewrite, so those rows are neither
451
+ // dragged nor dropped on — a move made there would silently be a move of the shared flow itself.
452
+ function renderRow(entry: TreeEntry, ancestors: ReadonlySet<string>, derived: boolean) {
248
453
  const isFolder = entry.kind === "folder";
249
- const isOpen = isFolder && expanded.has(entry.id);
250
- const Icon = isOpen ? FolderOpen : icons[entry.kind];
454
+ // ⚠️ A flow expands too, and what appears under it is what it calls — read from its graph, not
455
+ // from `parent_id`. A flow reused by three callers therefore shows up under all three, which is
456
+ // the answer to "what do I run, and how" (ADR-0004 §3).
457
+ const level: Level & { id: string } = { id: entry.id, type: isFolder ? "folder" : "flow" };
458
+ const expandable = isFolder || entry.type === "flow";
459
+ const isOpen =
460
+ expandable && expanded.some((open) => open.id === entry.id && open.type === level.type);
461
+ const Icon = isOpen && isFolder ? FolderOpen : icons[entry.kind];
251
462
  const area = entry.type === "flow" ? "/flows" : "/knowledge";
252
463
  const isActive = location.select === entry.id && location.pathname === area;
253
464
  // A row's plus files into that row's place: inside a folder, beside anything else.
254
- const target = isFolder
255
- ? entry.id
256
- : entry.type === "knowledge"
257
- ? entry.node.parentId
258
- : entry.flow.parentId;
465
+ const target = isFolder ? entry.id : parentOf(entry);
466
+ // Only a folder takes a drop, and only on a level that owns its rows. Everything else keeps the
467
+ // default cursor while a drag is in progress, which is the answer "not here" without a word.
468
+ const drop =
469
+ isFolder && !derived ? dropHandlers({ id: entry.id, title: entry.title }, ancestors) : null;
470
+ const isDragged = dragged?.id === entry.id;
471
+ // While a drag is in progress every row says which of the three it is: the row being carried,
472
+ // a folder that would take it, or something that would not. Silence on the last two is what
473
+ // turns a drag into a guess that only the drop answers.
474
+ const highlight =
475
+ dragged === null || isDragged
476
+ ? ""
477
+ : drop?.verdict === "ok"
478
+ ? over === entry.id
479
+ ? "bg-sidebar-accent ring-2 ring-sidebar-ring"
480
+ : "ring-1 ring-sidebar-border"
481
+ : "opacity-40";
259
482
 
260
483
  return (
261
484
  <SidebarMenuItem key={entry.id} className="group/row">
262
- <div className="flex items-center gap-0.5">
263
- {isFolder ? (
485
+ {/* ⚠️ The colouring sits on the ROW, not on the button inside it — the difference the
486
+ ticket is about. It used to hang on `SidebarMenuButton`, whose siblings the plus and this
487
+ menu are, so the grey stopped short of them: two icons standing outside the very row they
488
+ operate, with a pale strip left over on the right. Taken from gate, where the same
489
+ mistake was made and unmade three times (`service-tree.tsx`, GATE-76/-78/-81) — there the
490
+ row carries the fill and `flex-1` on the label pushes the actions to the end *inside* it.
491
+ The button keeps its own hover and active fill switched off rather than doubled, so the
492
+ row shows one surface instead of two overlapping ones. */}
493
+ <div
494
+ className={`flex items-center gap-0.5 rounded-md pr-1 transition-colors hover:bg-sidebar-accent hover:text-sidebar-accent-foreground ${
495
+ isActive ? "bg-sidebar-accent font-medium text-sidebar-accent-foreground" : ""
496
+ }`}
497
+ >
498
+ {expandable ? (
264
499
  <button
265
500
  type="button"
266
501
  aria-expanded={isOpen}
267
- aria-label={i18n.t(isOpen ? "tree.collapse" : "tree.expand", { title: entry.title })}
268
- onClick={() => toggle(entry.id, !isOpen)}
269
- className="shrink-0 rounded-md p-1 text-sidebar-foreground/70 outline-none hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 focus-visible:ring-sidebar-ring"
502
+ aria-label={i18n.t(
503
+ isFolder
504
+ ? isOpen
505
+ ? "tree.collapse"
506
+ : "tree.expand"
507
+ : isOpen
508
+ ? "tree.collapseCalls"
509
+ : "tree.expandCalls",
510
+ { title: entry.title },
511
+ )}
512
+ onClick={() => toggle(level, !isOpen)}
513
+ // The icons inside a coloured row need a step of their own, or their hover would be
514
+ // the row's colour on the row's colour and read as nothing at all.
515
+ className="shrink-0 rounded-md p-1 text-sidebar-foreground/70 outline-none hover:bg-sidebar-accent-foreground/10 focus-visible:ring-2 focus-visible:ring-sidebar-ring"
270
516
  >
271
517
  <ChevronRight
272
518
  aria-hidden="true"
@@ -276,9 +522,28 @@ export function AppTree() {
276
522
  ) : (
277
523
  <span aria-hidden="true" className="size-5 shrink-0" />
278
524
  )}
525
+ {/* The row's own control is what is grabbed and what is dropped on: a real button, so the
526
+ gesture rides on something that is already focusable and named rather than on a bare
527
+ div that assistive technology would have to be told about twice. */}
279
528
  <SidebarMenuButton
280
529
  isActive={isActive}
281
- className="min-w-0 flex-1"
530
+ draggable={!derived}
531
+ onDragStart={(event) => {
532
+ event.dataTransfer.effectAllowed = "move";
533
+ event.dataTransfer.setData("text/plain", entry.id);
534
+ draggedRef.current = entry;
535
+ setDragged(entry);
536
+ }}
537
+ onDragEnd={() => {
538
+ draggedRef.current = null;
539
+ setDragged(null);
540
+ setOver(undefined);
541
+ }}
542
+ {...(drop?.props ?? {})}
543
+ data-drop={
544
+ dragged === null ? undefined : isDragged ? "dragged" : (drop?.verdict ?? "none")
545
+ }
546
+ className={`min-w-0 flex-1 hover:bg-transparent data-[active=true]:bg-transparent ${derived ? "" : "cursor-grab active:cursor-grabbing"} ${isDragged ? "opacity-50" : ""} ${highlight}`}
282
547
  onClick={() => void navigate({ to: area, search: { select: entry.id } })}
283
548
  >
284
549
  <Icon aria-hidden="true" className="size-4 shrink-0" />
@@ -294,35 +559,86 @@ export function AppTree() {
294
559
  else setCreating({ parentId: target, kind });
295
560
  }}
296
561
  />
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
+ )}
297
573
  </div>
298
574
  {/* The same rows one indent deeper: one row component for every depth, so the plus on the
299
- fourth level is the same plus as on the first. */}
575
+ fourth level is the same plus as on the first. The list is named after the row it hangs
576
+ under — indentation says whose contents these are to anyone who can see it, and this is
577
+ the same sentence for anyone who cannot. */}
300
578
  {isOpen ? (
301
579
  <div className="ml-3.5 border-l border-sidebar-border pl-1.5">
302
- {renderLevel(entry.id, new Set([...ancestors, entry.id]))}
580
+ {renderLevel(
581
+ level,
582
+ new Set([...ancestors, entry.id]),
583
+ i18n.t(isFolder ? "tree.contents" : "tree.calls", { title: entry.title }),
584
+ )}
303
585
  </div>
304
586
  ) : null}
305
587
  </SidebarMenuItem>
306
588
  );
307
589
  }
308
590
 
591
+ const rootDrop = dropHandlers({ id: null, title: i18n.t("tree.move.root") }, new Set());
592
+
309
593
  return (
310
594
  <SidebarGroup className="min-h-0 flex-1 overflow-y-auto">
595
+ {/* The root has no row to drop on, so while something is being carried it gets one. It is the
596
+ only way back out of a folder, and it appears exactly when it can be used. */}
597
+ {rootDrop.verdict === undefined || dragged === null ? null : (
598
+ <button
599
+ type="button"
600
+ {...rootDrop.props}
601
+ onClick={() => startMove(dragged, { id: null, title: i18n.t("tree.move.root") })}
602
+ data-drop={rootDrop.verdict}
603
+ 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
+ rootDrop.verdict === "ok"
605
+ ? over === null
606
+ ? "bg-sidebar-accent ring-2 ring-sidebar-ring"
607
+ : "text-sidebar-foreground"
608
+ : "text-muted-foreground opacity-40"
609
+ }`}
610
+ >
611
+ <CornerLeftUp aria-hidden="true" className="size-4 shrink-0" />
612
+ {i18n.t("tree.move.dropRoot")}
613
+ </button>
614
+ )}
311
615
  {/* The tree is the navigation and carries no heading of its own (ADR-0004); the list is
312
616
  named for assistive technology instead. */}
313
- {renderLevel(null, new Set(), i18n.t("tree.label"))}
617
+ {renderLevel({ id: null, type: "folder" }, new Set(), i18n.t("tree.label"))}
314
618
  {create.isError || upload.isError ? (
315
619
  <p role="alert" className="px-2 py-1.5 text-sm text-destructive">
316
620
  {upload.isError ? i18n.t("tree.uploadFailed") : i18n.t("tree.createFailed")}
317
621
  </p>
318
622
  ) : null}
319
- {/* The root has no row of its own, so its plus lives here otherwise a tree could only ever
320
- grow inside the folders it already has. */}
623
+ {/* Four refusals, four sentences and by the time one is read the row is already back where
624
+ it started, because the rollback happens in `onError` rather than here. */}
625
+ {move.isError ? (
626
+ <p role="alert" className="px-2 py-1.5 text-sm text-destructive">
627
+ {i18n.t(moveErrorKey(move.error))}
628
+ </p>
629
+ ) : null}
630
+ {/* The root has no row of its own, so it gets one — otherwise a tree could only ever grow
631
+ inside the folders it already has, and the plus would stand loose under the list, attached
632
+ to nothing (#24). As a whole row it is also the one thing on screen when the tree is empty,
633
+ which is exactly when somebody needs to be told where to start. */}
321
634
  {root?.isPending ? null : (
322
- <div className="mt-1 flex items-center px-2">
635
+ <div className="mt-1 flex items-center gap-0.5">
636
+ {/* The gutter the disclosure arrows occupy, so the plus lines up with the row icons
637
+ above it rather than half a step to their left. */}
638
+ <span aria-hidden="true" className="size-5 shrink-0" />
323
639
  <AddMenu
324
640
  label={i18n.t("tree.addRoot")}
325
- alwaysVisible
641
+ variant="row"
326
642
  onSelect={(kind) => {
327
643
  if (kind === "upload") startUpload(null);
328
644
  else setCreating({ parentId: null, kind });
@@ -344,44 +660,61 @@ export function AppTree() {
344
660
  {creating ? (
345
661
  <Modal title={i18n.t(`tree.new.${creating.kind}`)} close={() => setCreating(null)}>
346
662
  <CreateForm
663
+ kind={creating.kind}
347
664
  pending={create.isPending}
348
- submit={(title) => create.mutate({ ...creating, title })}
665
+ submit={(title, columns) => create.mutate({ ...creating, title, columns })}
349
666
  />
350
667
  </Modal>
351
668
  ) : 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}
352
680
  </SidebarGroup>
353
681
  );
354
682
  }
355
683
 
356
684
  // ⚠️ Hover alone would hide this from a keyboard and from touch entirely, which is the same as not
357
685
  // having it. It stays in the tab order, focus makes it visible, and below `md` it is always shown.
686
+ //
687
+ // `row` is the root's form: a full-width row that says what it does, because the root has no title
688
+ // beside which a bare icon would mean anything.
358
689
  function AddMenu({
359
690
  label,
360
- alwaysVisible = false,
691
+ variant = "icon",
361
692
  onSelect,
362
693
  }: {
363
694
  label: string;
364
- alwaysVisible?: boolean;
695
+ variant?: "icon" | "row";
365
696
  onSelect(kind: NewKind | "upload"): void;
366
697
  }) {
367
698
  const { i18n } = useIntelRouterContext();
368
699
  const items: Array<{ kind: NewKind | "upload"; labelKey: string; Icon: typeof Plus }> = [
369
700
  { kind: "folder", labelKey: "tree.new.folder", Icon: Folder },
370
701
  { kind: "document", labelKey: "tree.new.document", Icon: FileText },
702
+ { kind: "table", labelKey: "tree.new.table", Icon: Table },
371
703
  { kind: "upload", labelKey: "tree.new.upload", Icon: Upload },
372
704
  { kind: "flow", labelKey: "tree.new.flow", Icon: Workflow },
373
705
  ];
374
706
  return (
375
707
  <DropdownMenu>
376
708
  <DropdownMenuTrigger
377
- aria-label={label}
378
- className={`shrink-0 rounded-md p-1 text-sidebar-foreground outline-none transition-opacity hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:opacity-100 focus-visible:ring-2 focus-visible:ring-sidebar-ring data-[state=open]:opacity-100 ${
379
- alwaysVisible
380
- ? ""
381
- : "group-focus-within/row:opacity-100 group-hover/row:opacity-100 md:opacity-0"
382
- }`}
709
+ {...(variant === "row" ? {} : { "aria-label": label })}
710
+ className={
711
+ variant === "row"
712
+ ? "flex min-w-0 flex-1 items-center gap-2 rounded-md px-2 py-1.5 text-left text-sm text-sidebar-foreground/70 outline-none transition-colors hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 focus-visible:ring-sidebar-ring data-[state=open]:bg-sidebar-accent"
713
+ : "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"
714
+ }
383
715
  >
384
- <Plus aria-hidden="true" className="size-4" />
716
+ <Plus aria-hidden="true" className="size-4 shrink-0" />
717
+ {variant === "row" ? <span className="truncate">{label}</span> : null}
385
718
  </DropdownMenuTrigger>
386
719
  <DropdownMenuContent align="start" className="w-44">
387
720
  {items.map((item) => (
@@ -395,15 +728,35 @@ function AddMenu({
395
728
  );
396
729
  }
397
730
 
398
- function CreateForm({ pending, submit }: { pending: boolean; submit(title: string): void }) {
731
+ // The column names of a new table, as one line. A comma-separated list is what a CSV header is,
732
+ // so the field looks like the thing it produces rather than like a form that hides it.
733
+ function columnsOf(value: string): string[] {
734
+ return value
735
+ .split(",")
736
+ .map((column) => column.trim())
737
+ .filter(Boolean);
738
+ }
739
+
740
+ function CreateForm({
741
+ kind,
742
+ pending,
743
+ submit,
744
+ }: {
745
+ kind: NewKind;
746
+ pending: boolean;
747
+ submit(title: string, columns: string[]): void;
748
+ }) {
399
749
  const { i18n } = useIntelRouterContext();
400
750
  const [title, setTitle] = useState("");
751
+ const [columns, setColumns] = useState("");
752
+ const parsedColumns = columnsOf(columns);
753
+ const incomplete = title.trim().length === 0 || (kind === "table" && parsedColumns.length === 0);
401
754
  return (
402
755
  <form
403
756
  className="space-y-4"
404
757
  onSubmit={(event) => {
405
758
  event.preventDefault();
406
- if (!pending) submit(title.trim());
759
+ if (!pending && !incomplete) submit(title.trim(), parsedColumns);
407
760
  }}
408
761
  >
409
762
  <label className="block text-sm font-medium">
@@ -415,9 +768,27 @@ function CreateForm({ pending, submit }: { pending: boolean; submit(title: strin
415
768
  className="mt-2 w-full rounded-md border bg-background px-3 py-2 outline-none focus-visible:ring-2 focus-visible:ring-ring"
416
769
  />
417
770
  </label>
771
+ {kind === "table" ? (
772
+ <label className="block text-sm font-medium">
773
+ {i18n.t("tree.table.columns")}
774
+ <input
775
+ required
776
+ value={columns}
777
+ onChange={(event) => setColumns(event.target.value)}
778
+ aria-describedby="create-table-columns-hint"
779
+ className="mt-2 w-full rounded-md border bg-background px-3 py-2 outline-none focus-visible:ring-2 focus-visible:ring-ring"
780
+ />
781
+ <span
782
+ id="create-table-columns-hint"
783
+ className="mt-1 block text-xs font-normal text-muted-foreground"
784
+ >
785
+ {i18n.t("tree.table.columnsHint")}
786
+ </span>
787
+ </label>
788
+ ) : null}
418
789
  <button
419
790
  type="submit"
420
- disabled={pending || title.trim().length === 0}
791
+ disabled={pending || incomplete}
421
792
  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"
422
793
  >
423
794
  {pending ? i18n.t("common.saving") : i18n.t("common.create")}