@anchrd/intel-ui 0.18.0 → 0.20.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 (78) hide show
  1. package/package.json +2 -9
  2. package/src/app/app-tree/app-tree.tsx +68 -147
  3. package/src/app/header-search/header-search.tsx +2 -1
  4. package/src/app/reindex-dialog/reindex-dialog.tsx +79 -0
  5. package/src/app/settings-dialog/settings-dialog.tsx +10 -32
  6. package/src/app/user-footer/user-footer.tsx +19 -0
  7. package/src/archive/archive.tsx +2 -1
  8. package/src/data/intel-data-provider/intel-data-provider.ts +42 -191
  9. package/src/data/intel-data-provider/intel-data-provider.types.ts +36 -120
  10. package/src/document-link/document-link.tsx +1 -1
  11. package/src/entry-picker/entry-picker.tsx +7 -7
  12. package/src/flow-runs/flow-runs.tsx +1 -1
  13. package/src/flows/flows.tsx +3 -2
  14. package/src/flows/node-icon/node-icon.ts +1 -1
  15. package/src/flows/node-palette/node-palette.tsx +1 -1
  16. package/src/flows/node-palette/node-palette.types.ts +1 -1
  17. package/src/graph-pane/graph-pane.tsx +1 -1
  18. package/src/i18n/de.json +14 -217
  19. package/src/i18n/en.json +14 -217
  20. package/src/i18n/es.json +14 -217
  21. package/src/import-bundle/import-bundle.tsx +152 -0
  22. package/src/kind-icon.ts +1 -15
  23. package/src/main.tsx +7 -10
  24. package/src/node-editor/node-editor.tsx +1 -1
  25. package/src/node-graph/graph-notice.tsx +1 -1
  26. package/src/node-graph/node-graph.ts +1 -1
  27. package/src/node-graph/node-graph.tsx +1 -1
  28. package/src/node-table/node-table.tsx +3 -3
  29. package/src/nodes/nodes.tsx +1 -35
  30. package/src/resource-menu/resource-menu.tsx +45 -21
  31. package/src/section-hint/section-hint.tsx +2 -2
  32. package/src/title-row/title-row.tsx +5 -22
  33. package/src/tools/tools.tsx +1 -3
  34. package/vite.config.ts +0 -4
  35. package/src/agent/agent-avatar/agent-avatar.tsx +0 -34
  36. package/src/agent/agent-calendar/agent-calendar.tsx +0 -160
  37. package/src/agent/agent-chat/agent-chat.tsx +0 -118
  38. package/src/agent/agent-costs/agent-costs.ts +0 -95
  39. package/src/agent/agent-cron/agent-cron.ts +0 -68
  40. package/src/agent/agent-definition/agent-definition.ts +0 -96
  41. package/src/agent/agent-delegation-notice/agent-delegation-notice.ts +0 -48
  42. package/src/agent/agent-entry-title/agent-entry-title.ts +0 -72
  43. package/src/agent/agent-log/agent-log.tsx +0 -308
  44. package/src/agent/agent-models/agent-models.ts +0 -166
  45. package/src/agent/agent-models/agent-models.types.ts +0 -24
  46. package/src/agent/agent-profile/agent-profile.tsx +0 -1006
  47. package/src/agent/agent-state/agent-state.ts +0 -85
  48. package/src/agent/agent-status-dot/agent-status-dot.ts +0 -73
  49. package/src/agent/agent.tsx +0 -507
  50. package/src/board/board-calendar/board-calendar.tsx +0 -89
  51. package/src/board/board-card/board-card.tsx +0 -106
  52. package/src/board/board-data/board-data.ts +0 -241
  53. package/src/board/board-data/board-data.types.ts +0 -63
  54. package/src/board/board-detail/board-detail.tsx +0 -629
  55. package/src/board/board-gantt/board-gantt.ts +0 -545
  56. package/src/board/board-gantt/board-gantt.tsx +0 -286
  57. package/src/board/board-graph/board-graph.ts +0 -174
  58. package/src/board/board-graph/board-graph.tsx +0 -168
  59. package/src/board/board-items/board-items.ts +0 -183
  60. package/src/board/board-kanban/board-kanban.ts +0 -137
  61. package/src/board/board-kanban/board-kanban.tsx +0 -257
  62. package/src/board/board-status/board-status.ts +0 -59
  63. package/src/board/board-statuses/board-statuses.ts +0 -63
  64. package/src/board/board-statuses/board-statuses.tsx +0 -228
  65. package/src/board/board-table/board-table.ts +0 -33
  66. package/src/board/board-table/board-table.tsx +0 -413
  67. package/src/board/board-views/board-views.tsx +0 -68
  68. package/src/board/board-views/board-views.types.ts +0 -29
  69. package/src/board/board.tsx +0 -251
  70. package/src/components/ui/item-calendar.tsx +0 -181
  71. package/src/components/ui/item-gantt.tsx +0 -463
  72. package/src/components/ui/kanban.tsx +0 -282
  73. package/src/data/agent-runtime/agent-runtime.ts +0 -131
  74. package/src/hooks/use-capabilities.ts +0 -22
  75. package/src/hooks/use-model-catalog.ts +0 -26
  76. package/src/timezone/timezone-combobox/timezone-combobox.tsx +0 -149
  77. package/src/timezone/timezone-context.tsx +0 -65
  78. package/src/timezone/timezone.ts +0 -174
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@anchrd/intel-ui",
3
- "version": "0.18.0",
3
+ "version": "0.20.0",
4
4
  "type": "module",
5
5
  "license": "UNLICENSED",
6
6
  "repository": {
@@ -33,27 +33,20 @@
33
33
  "typecheck": "tsc --noEmit"
34
34
  },
35
35
  "dependencies": {
36
- "@anchrd/intel-contract": "^0.12.0",
37
- "@assistant-ui/react": "^0.15.4",
38
- "@assistant-ui/react-ai-sdk": "^1.4.4",
36
+ "@anchrd/intel-contract": "^0.14.0",
39
37
  "@blocknote/core": "^0.52.1",
40
38
  "@blocknote/react": "^0.52.1",
41
39
  "@blocknote/shadcn": "^0.52.1",
42
- "@dnd-kit/core": "^6.3.1",
43
- "@dnd-kit/sortable": "^10.0.0",
44
- "@dnd-kit/utilities": "^3.2.2",
45
40
  "@react-sigma/core": "^5.0.6",
46
41
  "@tailwindcss/vite": "^4.3.3",
47
42
  "@tanstack/react-query": "^5.101.4",
48
43
  "@tanstack/react-router": "^1.170.18",
49
- "@tanstack/react-table": "^9.1.0",
50
44
  "@vitejs/plugin-react": "^6.0.4",
51
45
  "@xyflow/react": "^12.11.2",
52
46
  "class-variance-authority": "^0.7.1",
53
47
  "clsx": "^2.1.1",
54
48
  "cmdk": "^1.1.1",
55
49
  "colorjs.io": "^0.7.1",
56
- "croner": "^10.0.1",
57
50
  "fflate": "^0.8.3",
58
51
  "graphology": "^0.26.0",
59
52
  "lucide-react": "^1.25.0",
@@ -1,21 +1,16 @@
1
+ import type { NodeKind } from "@anchrd/intel-contract/node";
1
2
  import { useMutation, useQueries, useQueryClient } from "@tanstack/react-query";
2
3
  import { useNavigate, useRouterState } from "@tanstack/react-router";
3
- import { zipSync } from "fflate";
4
4
  import {
5
5
  ChevronRight,
6
6
  CornerLeftUp,
7
7
  FileArchive,
8
- FileText,
9
- Folder,
10
8
  FolderOpen,
11
9
  FolderUp,
12
10
  Plus,
13
- Table,
14
11
  Upload,
15
- Workflow,
16
12
  } from "lucide-react";
17
13
  import { useRef, useState } from "react";
18
- import { defaultModel } from "@/agent/agent-models/agent-models.ts";
19
14
  import {
20
15
  type MoveDestination,
21
16
  moveErrorKey,
@@ -28,6 +23,7 @@ import {
28
23
  DropdownMenu,
29
24
  DropdownMenuContent,
30
25
  DropdownMenuItem,
26
+ DropdownMenuSeparator,
31
27
  DropdownMenuTrigger,
32
28
  } from "@/components/ui/dropdown-menu";
33
29
  import {
@@ -39,40 +35,27 @@ import {
39
35
  } from "@/components/ui/sidebar";
40
36
  import { flowEntry } from "@/data/intel-data-provider/intel-data-provider.ts";
41
37
  import type { TreeEntry } from "@/data/intel-data-provider/intel-data-provider.types.ts";
42
- import { useCapabilities } from "@/hooks/use-capabilities.ts";
43
38
  import { useI18n } from "@/i18n/i18n-context.tsx";
39
+ import { type ImportKind, useBundleImport } from "@/import-bundle/import-bundle.tsx";
44
40
  import { kindIcons } from "@/kind-icon.ts";
45
41
  import { Modal } from "@/modal/modal.tsx";
46
42
  import { useIntelRouterContext } from "@/router/router-context.ts";
47
43
  import { selectedFrom } from "@/router/selection-search.ts";
48
44
 
49
- type NewKind = "folder" | "document" | "table" | "flow" | "agent";
50
- // The two ways a bundle arrives (#137): an existing zip, or a picked folder the browser hands over
51
- // file by file and this component zips before it travels — one wire format, one server door.
52
- type ImportKind = "importZip" | "importFolder";
45
+ /**
46
+ * What the plus can MAKE derived from `NodeKind`, never written out again (#395).
47
+ *
48
+ * ⚠️ `attachment` is excluded because an attachment is uploaded rather than created, and `flow` is
49
+ * added because a flow shares the tree without being a node (ADR-0004 §1). Both are decisions about
50
+ * this menu; the LIST of node kinds is not, and a copy of it here goes stale in the direction that
51
+ * already bit us — the parked kinds stood in such a union long after nothing could make one.
52
+ */
53
+ type NewKind = Exclude<NodeKind, "attachment"> | "flow";
53
54
  type Creating = { parentId: string | null; kind: NewKind };
54
55
 
55
56
  // Attachments are inlined as base64, so the browser holds the file twice while it uploads.
56
57
  const MaxUploadBytes = 15_000_000;
57
58
 
58
- // The server refuses anything larger anyway (#137); refusing here saves uploading it first.
59
- const MaxImportBytes = 64 * 1024 * 1024;
60
-
61
- // A picked directory as the zip the import endpoint reads. `webkitRelativePath` carries the folder
62
- // structure, with the picked folder itself as the first segment — kept, so the folder lands as a
63
- // folder rather than spilling its contents into the target.
64
- async function zipOfDirectory(files: readonly File[]): Promise<Blob> {
65
- const entries: Record<string, Uint8Array> = {};
66
- let total = 0;
67
- for (const file of files) {
68
- const path = file.webkitRelativePath || file.name;
69
- total += file.size;
70
- if (total > MaxImportBytes) throw new Error("Import exceeds the 64 MB limit");
71
- entries[path] = new Uint8Array(await file.arrayBuffer());
72
- }
73
- return new Blob([zipSync(entries).slice().buffer], { type: "application/zip" });
74
- }
75
-
76
59
  async function fileBase64(file: File): Promise<string> {
77
60
  if (file.size > MaxUploadBytes) throw new Error("Attachment exceeds the 15 MB upload limit");
78
61
  return await new Promise((resolve, reject) => {
@@ -115,9 +98,6 @@ export function AppTree() {
115
98
  const [creating, setCreating] = useState<Creating | null>(null);
116
99
  const [uploadTo, setUploadTo] = useState<string | null>(null);
117
100
  const uploadInput = useRef<HTMLInputElement>(null);
118
- const [importTo, setImportTo] = useState<string | null>(null);
119
- const importZipInput = useRef<HTMLInputElement>(null);
120
- const importFolderInput = useRef<HTMLInputElement>(null);
121
101
  const [dragged, setDragged] = useState<TreeEntry | null>(null);
122
102
  const draggedRef = useRef<TreeEntry | null>(null);
123
103
  // Which target the pointer is over: `undefined` for none, `null` for the root strip, an id for a
@@ -131,6 +111,14 @@ export function AppTree() {
131
111
  if (destination.id !== null) toggle({ id: destination.id, type: "folder" }, true);
132
112
  },
133
113
  });
114
+ // ⚠️ The root's import, and only the root's. A folder imports through its own three-dot menu
115
+ // (#346); the tree keeps this one because the top level has no resource whose menu could hold it.
116
+ const bundleImport = useBundleImport({
117
+ where: i18n.t("tree.move.root"),
118
+ onImported: (parentId) => {
119
+ if (parentId !== null) toggle({ id: parentId, type: "folder" }, true);
120
+ },
121
+ });
134
122
 
135
123
  const location = useRouterState({
136
124
  select: (state) => ({
@@ -207,26 +195,6 @@ export function AppTree() {
207
195
  });
208
196
  return { area: "/flows" as const, id: flow.id, parentId, collection: "flows" as const };
209
197
  }
210
- // ⚠️ Its own endpoint, not `createNodes`: an agent is created WITH its first definition
211
- // version, because a definition names the model and there is no agent without one. The
212
- // create dialog does not ask for it — `defaultModel()` answers, and the profile changes it.
213
- if (kind === "agent") {
214
- const created = await data.createAgent({
215
- parentId,
216
- title,
217
- description: null,
218
- // A new agent has no tools: giving it any is a choice made in the profile, out of what
219
- // this person reaches in the portal (D30).
220
- definition: { references: [], schedules: [], model: defaultModel(), tools: null },
221
- idempotencyKey: crypto.randomUUID(),
222
- });
223
- return {
224
- area: "/nodes" as const,
225
- id: created.node.id,
226
- parentId,
227
- collection: "node-graph" as const,
228
- };
229
- }
230
198
  const node = await data.createNodes({
231
199
  parentId,
232
200
  kind,
@@ -301,26 +269,6 @@ export function AppTree() {
301
269
  },
302
270
  });
303
271
 
304
- // One import, one request, one answer (#137): the server creates the whole subtree or nothing,
305
- // so this mutation never has a partial state to clean up after — unlike the attachment upload
306
- // above, whose two steps can strand a node.
307
- const importBundle = useMutation({
308
- mutationFn: async ({ parentId, zip }: { parentId: string | null; zip: Blob }) => {
309
- if (zip.size > MaxImportBytes) throw new Error("Import exceeds the 64 MB limit");
310
- const result = await data.importNodeBundle({
311
- nodeId: parentId,
312
- zip,
313
- idempotencyKey: crypto.randomUUID(),
314
- });
315
- return { parentId, result };
316
- },
317
- onSuccess: async (imported) => {
318
- // A bundle can carry flows, so the flow collections go stale alongside the node ones.
319
- await reveal(imported.parentId, "node-graph");
320
- await queryClient.invalidateQueries({ queryKey: ["flows"] });
321
- },
322
- });
323
-
324
272
  // What a drop on this target would do. `undefined` means nothing is being dragged, so the target
325
273
  // is not a target at all.
326
274
  function verdictFor(
@@ -376,11 +324,6 @@ export function AppTree() {
376
324
  uploadInput.current?.click();
377
325
  }
378
326
 
379
- function startImport(parentId: string | null, kind: ImportKind) {
380
- setImportTo(parentId);
381
- (kind === "importZip" ? importZipInput : importFolderInput).current?.click();
382
- }
383
-
384
327
  function renderLevel(parent: Level, ancestors: ReadonlySet<string>, label?: string) {
385
328
  const level = levelFor(parent);
386
329
  if (!level || level.isPending) {
@@ -566,7 +509,6 @@ export function AppTree() {
566
509
  label={i18n.t("tree.add", { title: entry.title })}
567
510
  onSelect={(kind) => {
568
511
  if (kind === "upload") startUpload(target);
569
- else if (kind === "importZip" || kind === "importFolder") startImport(target, kind);
570
512
  else setCreating({ parentId: target, kind });
571
513
  }}
572
514
  />
@@ -621,9 +563,9 @@ export function AppTree() {
621
563
  {/* The tree is the navigation and carries no heading of its own (ADR-0004); the list is
622
564
  named for assistive technology instead. */}
623
565
  {renderLevel({ id: null, type: "folder" }, new Set(), i18n.t("tree.label"))}
624
- {create.isError || upload.isError || importBundle.isError ? (
566
+ {create.isError || upload.isError || bundleImport.isError ? (
625
567
  <p role="alert" className="px-2 py-1.5 text-sm text-destructive">
626
- {importBundle.isError
568
+ {bundleImport.isError
627
569
  ? i18n.t("tree.importFailed")
628
570
  : upload.isError
629
571
  ? i18n.t("tree.uploadFailed")
@@ -651,7 +593,8 @@ export function AppTree() {
651
593
  variant="row"
652
594
  onSelect={(kind) => {
653
595
  if (kind === "upload") startUpload(null);
654
- else if (kind === "importZip" || kind === "importFolder") startImport(null, kind);
596
+ else if (kind === "importZip" || kind === "importFolder")
597
+ bundleImport.start(null, kind);
655
598
  else setCreating({ parentId: null, kind });
656
599
  }}
657
600
  />
@@ -668,38 +611,7 @@ export function AppTree() {
668
611
  event.currentTarget.value = "";
669
612
  }}
670
613
  />
671
- <input
672
- ref={importZipInput}
673
- type="file"
674
- accept=".zip,application/zip"
675
- className="sr-only"
676
- aria-label={i18n.t("tree.new.importZip")}
677
- onChange={(event) => {
678
- const file = event.target.files?.[0];
679
- if (file) importBundle.mutate({ parentId: importTo, zip: file });
680
- event.currentTarget.value = "";
681
- }}
682
- />
683
- {/* `webkitdirectory` is the one way a browser hands over a folder; React does not know the
684
- attribute, so it is spread past the type checker rather than invented as a prop. */}
685
- <input
686
- ref={importFolderInput}
687
- type="file"
688
- multiple
689
- {...({ webkitdirectory: "" } as object)}
690
- className="sr-only"
691
- aria-label={i18n.t("tree.new.importFolder")}
692
- onChange={(event) => {
693
- const files = Array.from(event.target.files ?? []);
694
- const parentId = importTo;
695
- if (files.length > 0) {
696
- void zipOfDirectory(files)
697
- .then((zip) => importBundle.mutate({ parentId, zip }))
698
- .catch(() => importBundle.mutate({ parentId, zip: new Blob([]) }));
699
- }
700
- event.currentTarget.value = "";
701
- }}
702
- />
614
+ {bundleImport.inputs}
703
615
  {creating ? (
704
616
  <Modal title={i18n.t(`tree.new.${creating.kind}`)} close={() => setCreating(null)}>
705
617
  <CreateForm
@@ -719,40 +631,27 @@ export function AppTree() {
719
631
  //
720
632
  // `row` is the root's form: a full-width row that says what it does, because the root has no title
721
633
  // beside which a bare icon would mean anything.
722
- function AddMenu({
723
- label,
724
- variant = "icon",
725
- onSelect,
726
- }: {
727
- label: string;
728
- variant?: "icon" | "row";
729
- onSelect(kind: NewKind | "upload" | ImportKind): void;
730
- }) {
634
+ //
635
+ // ⚠️ The two variants do not offer the same things, and the type says so rather than a comment: only
636
+ // the root can emit an import (#346), because only the root has no three-dot menu to hold one. A
637
+ // single wide signature would leave the folder row with a branch nothing can reach.
638
+ type AddMenuProps =
639
+ | { label: string; variant: "row"; onSelect(kind: NewKind | "upload" | ImportKind): void }
640
+ | { label: string; variant?: "icon"; onSelect(kind: NewKind | "upload"): void };
641
+
642
+ function AddMenu(props: AddMenuProps) {
643
+ const { label, onSelect } = props;
644
+ const variant = props.variant ?? "icon";
731
645
  const i18n = useI18n();
732
- // Whether this deployment runs an agent runtime (#190). Until the answer arrives the item is
733
- // absent rather than provisional: a menu entry that vanished after being seen was a lie, one that
734
- // appears a beat late was merely loading.
735
- const agentRuntime = useCapabilities().data?.agentRuntime === true;
736
- const items: Array<{
737
- kind: NewKind | "upload" | ImportKind;
738
- labelKey: string;
739
- Icon: typeof Plus;
740
- }> = [
741
- { kind: "folder", labelKey: "tree.new.folder", Icon: Folder },
742
- { kind: "document", labelKey: "tree.new.document", Icon: FileText },
743
- { kind: "table", labelKey: "tree.new.table", Icon: Table },
744
- { kind: "upload", labelKey: "tree.new.upload", Icon: Upload },
745
- { kind: "flow", labelKey: "tree.new.flow", Icon: Workflow },
746
- // ⚠️ The same icon the tree draws for an agent row, taken from the one kind-icon map rather
747
- // than named again here — a second `Bot` beside it is how the two start disagreeing.
748
- // Offered only where a runtime exists to run what would be created (#190).
749
- ...(agentRuntime
750
- ? [{ kind: "agent" as const, labelKey: "tree.new.agent", Icon: kindIcons.agent }]
751
- : []),
752
- // The bundle round trip's second half (#137): what the export produced, or a plain folder from
753
- // the machine, lands here as a new subtree.
754
- { kind: "importZip", labelKey: "tree.new.importZip", Icon: FileArchive },
755
- { kind: "importFolder", labelKey: "tree.new.importFolder", Icon: FolderUp },
646
+ // ⚠️ Three groups, and the separators are the answer to what the plus promises: four kinds are
647
+ // made here, an upload brings one thing in from the machine, an import brings a whole subtree
648
+ // (#346). The icons come from the one kind-icon map rather than being named again — a second
649
+ // `Folder` beside it is how the tree row and this menu start disagreeing.
650
+ const kinds: Array<{ kind: NewKind; labelKey: string; Icon: typeof Plus }> = [
651
+ { kind: "folder", labelKey: "tree.new.folder", Icon: kindIcons.folder },
652
+ { kind: "document", labelKey: "tree.new.document", Icon: kindIcons.document },
653
+ { kind: "table", labelKey: "tree.new.table", Icon: kindIcons.table },
654
+ { kind: "flow", labelKey: "tree.new.flow", Icon: kindIcons.flow },
756
655
  ];
757
656
  return (
758
657
  <DropdownMenu>
@@ -768,12 +667,34 @@ function AddMenu({
768
667
  {variant === "row" ? <span className="truncate">{label}</span> : null}
769
668
  </DropdownMenuTrigger>
770
669
  <DropdownMenuContent align="start" className="w-44">
771
- {items.map((item) => (
670
+ {kinds.map((item) => (
772
671
  <DropdownMenuItem key={item.kind} onSelect={() => onSelect(item.kind)}>
773
672
  <item.Icon aria-hidden="true" />
774
673
  {i18n.t(item.labelKey)}
775
674
  </DropdownMenuItem>
776
675
  ))}
676
+ <DropdownMenuSeparator />
677
+ <DropdownMenuItem onSelect={() => onSelect("upload")}>
678
+ <Upload aria-hidden="true" />
679
+ {i18n.t("tree.new.upload")}
680
+ </DropdownMenuItem>
681
+ {/* ⚠️ The root only, and that is not an omission at a folder row: a folder imports through
682
+ its own three-dot menu, beside the export it is the other half of (#136/#137). The root
683
+ has no resource and therefore no such menu, so dropping it here would put the top level
684
+ out of reach of an import altogether (#346). */}
685
+ {props.variant === "row" ? (
686
+ <>
687
+ <DropdownMenuSeparator />
688
+ <DropdownMenuItem onSelect={() => props.onSelect("importZip")}>
689
+ <FileArchive aria-hidden="true" />
690
+ {i18n.t("tree.new.importZip")}
691
+ </DropdownMenuItem>
692
+ <DropdownMenuItem onSelect={() => props.onSelect("importFolder")}>
693
+ <FolderUp aria-hidden="true" />
694
+ {i18n.t("tree.new.importFolder")}
695
+ </DropdownMenuItem>
696
+ </>
697
+ ) : null}
777
698
  </DropdownMenuContent>
778
699
  </DropdownMenu>
779
700
  );
@@ -1,4 +1,5 @@
1
- import type { Flow, ToolCapability } from "@anchrd/intel-contract";
1
+ import type { Flow } from "@anchrd/intel-contract/flow";
2
+ import type { ToolCapability } from "@anchrd/intel-contract/tool";
2
3
  import { useQuery } from "@tanstack/react-query";
3
4
  import { useNavigate } from "@tanstack/react-router";
4
5
  import { FileText, Search, Workflow, Wrench } from "lucide-react";
@@ -0,0 +1,79 @@
1
+ import { useMutation } from "@tanstack/react-query";
2
+ import { Button } from "@/components/ui/button";
3
+ import {
4
+ Dialog,
5
+ DialogContent,
6
+ DialogDescription,
7
+ DialogFooter,
8
+ DialogHeader,
9
+ DialogTitle,
10
+ } from "@/components/ui/dialog";
11
+ import { useI18n } from "@/i18n/i18n-context.tsx";
12
+ import { useIntelRouterContext } from "@/router/router-context.ts";
13
+
14
+ /**
15
+ * Rebuilding the search index, asked for before it happens (#416).
16
+ *
17
+ * ⚠️ Two clicks, not one, and the reason is not that it is dangerous — nothing is lost, the index is
18
+ * derived from D1 and R2 and can always be built again. It is that it is EXPENSIVE and invisible:
19
+ * every node is read and embedded again, which costs money and minutes, and the screen looks exactly
20
+ * the same afterwards. A one-click button next to "Preferences" invites a second press when the
21
+ * first one seems to have done nothing.
22
+ *
23
+ * ⚠️ It reports what was QUEUED, never "done". The answer comes back the moment the work is handed
24
+ * to the queue, and the index catches up behind it — writing "finished" here would be a sentence
25
+ * about something this screen cannot see. The number is the honest part: it says how much was
26
+ * accepted, so a repeat press can be compared against it.
27
+ *
28
+ * The dialog is opened from outside, like the settings one: its trigger is a `DropdownMenuItem`, and
29
+ * Radix unmounts the menu content in the same frame the item is chosen.
30
+ */
31
+ interface ReindexDialogProps {
32
+ open: boolean;
33
+ onOpenChange(open: boolean): void;
34
+ }
35
+
36
+ export function ReindexDialog({ open, onOpenChange }: ReindexDialogProps) {
37
+ const i18n = useI18n();
38
+ const { data } = useIntelRouterContext();
39
+ const reindex = useMutation({ mutationFn: () => data.reindexNodes() });
40
+
41
+ return (
42
+ <Dialog
43
+ open={open}
44
+ onOpenChange={(next) => {
45
+ // Closing forgets the last answer, so re-opening never shows a count from an earlier run as
46
+ // if it were this one's.
47
+ if (!next) reindex.reset();
48
+ onOpenChange(next);
49
+ }}
50
+ >
51
+ <DialogContent className="sm:max-w-md">
52
+ <DialogHeader>
53
+ <DialogTitle>{i18n.t("reindex.title")}</DialogTitle>
54
+ <DialogDescription>{i18n.t("reindex.description")}</DialogDescription>
55
+ </DialogHeader>
56
+ {reindex.isSuccess ? (
57
+ <p role="status" className="text-sm">
58
+ {i18n.t("reindex.queued", { count: reindex.data.queued })}
59
+ </p>
60
+ ) : null}
61
+ {reindex.isError ? (
62
+ <p role="alert" className="text-sm text-destructive">
63
+ {i18n.t("reindex.failed")}
64
+ </p>
65
+ ) : null}
66
+ <DialogFooter>
67
+ <Button variant="outline" onClick={() => onOpenChange(false)}>
68
+ {i18n.t(reindex.isSuccess ? "reindex.close" : "reindex.cancel")}
69
+ </Button>
70
+ {reindex.isSuccess ? null : (
71
+ <Button disabled={reindex.isPending} onClick={() => reindex.mutate()}>
72
+ {i18n.t(reindex.isPending ? "reindex.running" : "reindex.confirm")}
73
+ </Button>
74
+ )}
75
+ </DialogFooter>
76
+ </DialogContent>
77
+ </Dialog>
78
+ );
79
+ }
@@ -14,16 +14,19 @@ import {
14
14
  } from "@/components/ui/select";
15
15
  import { useI18n, useLanguage } from "@/i18n/i18n-context.tsx";
16
16
  import { languageName } from "@/i18n/i18n-languages/i18n-languages.ts";
17
- import { SectionHint } from "@/section-hint/section-hint.tsx";
18
17
  import { type ThemeChoice, ThemeChoices } from "@/theme/theme.ts";
19
18
  import { useTheme } from "@/theme/theme-context.tsx";
20
- import { TimezoneCombobox } from "@/timezone/timezone-combobox/timezone-combobox.tsx";
21
- import { useTimezoneSelection } from "@/timezone/timezone-context.tsx";
22
19
 
23
- // The settings that belong to the person rather than to the installation: language, appearance and
24
- // timezone. All three are client state — they live in localStorage, not in the Intel HTTP surface —
25
- // so they do not belong on a screen with a save button. A choice applies at once; a dialog that
26
- // previews and only commits on "Save" would carry three states too many for three dropdowns.
20
+ // The settings that belong to the person rather than to the installation: language and appearance.
21
+ // Both are client state — they live in localStorage, not in the Intel HTTP surface — so they do not
22
+ // belong on a screen with a save button. A choice applies at once; a dialog that previews and only
23
+ // commits on "Save" would carry two states too many for two dropdowns.
24
+ //
25
+ // ⚠️ There was a third, the reader's timezone, and it left with the Agents (#388). It was a
26
+ // suggestion for a schedule and the lens its calendar was read through (#228); with both gone
27
+ // nothing read the value, and a preference that changes nothing on screen is worse than none —
28
+ // it invites the reader to set it and then contradicts them. Reading times in a chosen zone is a
29
+ // feature, and it would come back as one.
27
30
  //
28
31
  // The dialog is opened from outside (the user footer holds the state), because its trigger is a
29
32
  // DropdownMenuItem: Radix unmounts the menu content when an item is chosen and would take a dialog
@@ -37,7 +40,6 @@ export function SettingsDialog({ open, onOpenChange }: SettingsDialogProps) {
37
40
  const i18n = useI18n();
38
41
  const language = useLanguage();
39
42
  const theme = useTheme();
40
- const timezone = useTimezoneSelection();
41
43
 
42
44
  return (
43
45
  <Dialog open={open} onOpenChange={onOpenChange}>
@@ -93,30 +95,6 @@ export function SettingsDialog({ open, onOpenChange }: SettingsDialogProps) {
93
95
  </Select>
94
96
  </div>
95
97
  ) : null}
96
- {timezone ? (
97
- <div className="grid gap-2">
98
- {/* ⚠️ Behind the ⓘ (#249), and the SAME component the agent profile uses — three
99
- lines of prose under a dropdown explained something once and then pushed the
100
- dialog apart on every open. The `sr-only` copy below is not a duplicate: Radix
101
- puts a tooltip's content in the accessibility tree only while it is open, so the
102
- field would silently lose the description `aria-describedby` points at. */}
103
- <div className="flex items-center gap-1.5">
104
- <label className="text-sm font-medium" htmlFor="settings-timezone">
105
- {i18n.t("settings.timezone")}
106
- </label>
107
- <SectionHint hint={i18n.t("settings.timezoneHint")} />
108
- </div>
109
- <TimezoneCombobox
110
- id="settings-timezone"
111
- value={timezone.current}
112
- onChange={timezone.select}
113
- describedBy="settings-timezone-hint"
114
- />
115
- <p id="settings-timezone-hint" className="sr-only">
116
- {i18n.t("settings.timezoneHint")}
117
- </p>
118
- </div>
119
- ) : null}
120
98
  </div>
121
99
  </DialogContent>
122
100
  </Dialog>
@@ -4,6 +4,7 @@ import {
4
4
  Archive as ArchiveIcon,
5
5
  ChevronsUpDown,
6
6
  LogOut,
7
+ RefreshCw,
7
8
  Settings,
8
9
  User,
9
10
  Wrench,
@@ -20,6 +21,7 @@ import { SidebarMenu, SidebarMenuItem } from "@/components/ui/sidebar";
20
21
  import { loginPath } from "@/data/intel-data-provider/intel-data-provider.ts";
21
22
  import { useI18n } from "@/i18n/i18n-context.tsx";
22
23
  import { useIntelRouterContext } from "@/router/router-context.ts";
24
+ import { ReindexDialog } from "../reindex-dialog/reindex-dialog.tsx";
23
25
  import { SettingsDialog } from "../settings-dialog/settings-dialog.tsx";
24
26
 
25
27
  /**
@@ -41,6 +43,7 @@ export function UserFooter() {
41
43
  // The state lives here, not in the dialog: its trigger is a DropdownMenuItem, and Radix unmounts
42
44
  // the menu content when an item is chosen — a dialog nested in there would go with it.
43
45
  const [settingsOpen, setSettingsOpen] = useState(false);
46
+ const [reindexOpen, setReindexOpen] = useState(false);
44
47
  const session = useQuery({ queryKey: ["session"], queryFn: () => data.getSession() });
45
48
  const logout = useMutation({
46
49
  mutationFn: () => data.logout(),
@@ -103,6 +106,20 @@ export function UserFooter() {
103
106
  <Settings aria-hidden="true" />
104
107
  {i18n.t("settings.title")}
105
108
  </DropdownMenuItem>
109
+ {/* ⚠️ Drawn only for an administrator, and that is a DRAWING decision — `POST
110
+ /nodes/reindex` asks Gate for `intel/admin` itself and would refuse this row's press
111
+ just the same (#416). Hiding it is not the check; it is not offering everybody a
112
+ door they cannot open. Its own separator, because the rows above belong to the
113
+ person and this one belongs to the installation. */}
114
+ {session.data?.isAdmin ? (
115
+ <>
116
+ <DropdownMenuSeparator />
117
+ <DropdownMenuItem onSelect={() => setReindexOpen(true)}>
118
+ <RefreshCw aria-hidden="true" />
119
+ {i18n.t("reindex.title")}
120
+ </DropdownMenuItem>
121
+ </>
122
+ ) : null}
106
123
  <DropdownMenuSeparator />
107
124
  <DropdownMenuItem
108
125
  disabled={logout.isPending}
@@ -116,6 +133,8 @@ export function UserFooter() {
116
133
  </DropdownMenu>
117
134
  {/* ⚠️ Outside the DropdownMenu on purpose — see the state above. */}
118
135
  <SettingsDialog open={settingsOpen} onOpenChange={setSettingsOpen} />
136
+ {/* ⚠️ Outside the DropdownMenu for the same reason as the settings dialog. */}
137
+ <ReindexDialog open={reindexOpen} onOpenChange={setReindexOpen} />
119
138
  </SidebarMenuItem>
120
139
  {/* ⚠️ Outside the menu on purpose. Choosing sign-out closes the popup, so a refusal rendered
121
140
  inside it would be gone in the same frame it was written — the one message that must
@@ -1,4 +1,5 @@
1
- import type { Flow, Node } from "@anchrd/intel-contract";
1
+ import type { Flow } from "@anchrd/intel-contract/flow";
2
+ import type { Node } from "@anchrd/intel-contract/node";
2
3
  import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
3
4
  import { ArchiveRestore } from "lucide-react";
4
5
  import { useI18n } from "@/i18n/i18n-context.tsx";