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