@anchrd/intel-ui 0.2.1 → 0.4.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 (33) hide show
  1. package/components.json +7 -1
  2. package/package.json +3 -1
  3. package/src/app/app-sidebar/app-sidebar.tsx +56 -0
  4. package/src/app/app-tree/app-tree.tsx +427 -0
  5. package/src/app/app.tsx +84 -54
  6. package/src/app/header-actions/header-actions.tsx +15 -0
  7. package/src/app/header-search/header-search.tsx +291 -0
  8. package/src/app/sidebar-preferences/sidebar-preferences.ts +68 -0
  9. package/src/app/sidebar-preferences/sidebar-preferences.types.ts +15 -0
  10. package/src/app/sidebar-resize-handle/sidebar-resize-handle.tsx +85 -0
  11. package/src/app/user-footer/user-footer.tsx +76 -0
  12. package/src/components/ui/breadcrumb.tsx +102 -0
  13. package/src/components/ui/button.tsx +64 -0
  14. package/src/components/ui/collapsible.tsx +20 -0
  15. package/src/components/ui/command.tsx +160 -0
  16. package/src/components/ui/dialog.tsx +143 -0
  17. package/src/components/ui/dropdown-menu.tsx +84 -0
  18. package/src/components/ui/input.tsx +21 -0
  19. package/src/components/ui/separator.tsx +26 -0
  20. package/src/components/ui/sheet.tsx +136 -0
  21. package/src/components/ui/sidebar.tsx +693 -0
  22. package/src/components/ui/skeleton.tsx +13 -0
  23. package/src/components/ui/tooltip.tsx +51 -0
  24. package/src/data/intel-data-provider/intel-data-provider.ts +58 -39
  25. package/src/data/intel-data-provider/intel-data-provider.types.ts +17 -8
  26. package/src/flows/flows.tsx +59 -142
  27. package/src/hooks/use-mobile.ts +19 -0
  28. package/src/i18n/en.json +48 -29
  29. package/src/knowledge/knowledge.tsx +133 -423
  30. package/src/router/router.tsx +4 -0
  31. package/src/router/selection-search.ts +19 -0
  32. package/src/styles.css +54 -51
  33. package/src/tools/tools.tsx +175 -158
package/components.json CHANGED
@@ -3,7 +3,13 @@
3
3
  "style": "new-york",
4
4
  "rsc": false,
5
5
  "tsx": true,
6
- "tailwind": { "css": "src/styles.css", "baseColor": "neutral", "cssVariables": true },
6
+ "tailwind": {
7
+ "config": "",
8
+ "css": "src/styles.css",
9
+ "baseColor": "neutral",
10
+ "cssVariables": true,
11
+ "prefix": ""
12
+ },
7
13
  "aliases": {
8
14
  "components": "@/components",
9
15
  "utils": "@/lib/utils",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@anchrd/intel-ui",
3
- "version": "0.2.1",
3
+ "version": "0.4.0",
4
4
  "type": "module",
5
5
  "license": "UNLICENSED",
6
6
  "repository": {
@@ -40,9 +40,11 @@
40
40
  "@xyflow/react": "^12.11.2",
41
41
  "class-variance-authority": "^0.7.1",
42
42
  "clsx": "^2.1.1",
43
+ "cmdk": "^1.1.1",
43
44
  "colorjs.io": "^0.7.1",
44
45
  "graphology": "^0.26.0",
45
46
  "lucide-react": "^1.25.0",
47
+ "radix-ui": "^1.6.7",
46
48
  "react": "^19.2.0",
47
49
  "react-aria-components": "^1.19.0",
48
50
  "react-dom": "^19.2.0",
@@ -0,0 +1,56 @@
1
+ import { Link } from "@tanstack/react-router";
2
+ import { AppTree } from "@/app/app-tree/app-tree.tsx";
3
+ import { SidebarResizeHandle } from "@/app/sidebar-resize-handle/sidebar-resize-handle.tsx";
4
+ import { UserFooter } from "@/app/user-footer/user-footer.tsx";
5
+ import { AppLogo } from "@/branding/branding.tsx";
6
+ import {
7
+ Sidebar,
8
+ SidebarContent,
9
+ SidebarFooter,
10
+ SidebarHeader,
11
+ SidebarMenu,
12
+ SidebarMenuButton,
13
+ SidebarMenuItem,
14
+ } from "@/components/ui/sidebar";
15
+ import { useIntelRouterContext } from "@/router/router-context.ts";
16
+
17
+ // One tree is the whole navigation: documents and flows side by side in the folder they share, with
18
+ // no Knowledge or Flows entry above them (ADR-0004). Tools stay below, next to the user, because
19
+ // they come live from the portal and have no tree.
20
+ export function AppSidebar({
21
+ width,
22
+ onWidthChange,
23
+ }: {
24
+ width: number;
25
+ onWidthChange(width: number): void;
26
+ }) {
27
+ const { i18n } = useIntelRouterContext();
28
+
29
+ return (
30
+ <Sidebar collapsible="icon" variant="inset">
31
+ <SidebarHeader>
32
+ <SidebarMenu>
33
+ <SidebarMenuItem>
34
+ <SidebarMenuButton size="lg" asChild>
35
+ <Link to="/knowledge">
36
+ <AppLogo className="size-8" />
37
+ <span className="font-semibold">{i18n.t("app.name")}</span>
38
+ </Link>
39
+ </SidebarMenuButton>
40
+ </SidebarMenuItem>
41
+ </SidebarMenu>
42
+ </SidebarHeader>
43
+ <SidebarContent aria-label={i18n.t("nav.primary")}>
44
+ <AppTree />
45
+ </SidebarContent>
46
+ <SidebarFooter>
47
+ <UserFooter />
48
+ </SidebarFooter>
49
+ <SidebarResizeHandle
50
+ width={width}
51
+ label={i18n.t("shell.resizeSidebar")}
52
+ onWidthChange={onWidthChange}
53
+ />
54
+ </Sidebar>
55
+ );
56
+ }
@@ -0,0 +1,427 @@
1
+ import { useMutation, useQueries, useQueryClient } from "@tanstack/react-query";
2
+ import { useNavigate, useRouterState } from "@tanstack/react-router";
3
+ import {
4
+ ChevronRight,
5
+ FileText,
6
+ Folder,
7
+ FolderOpen,
8
+ Paperclip,
9
+ Plus,
10
+ Upload,
11
+ Workflow,
12
+ } from "lucide-react";
13
+ import { useRef, useState } from "react";
14
+ import {
15
+ DropdownMenu,
16
+ DropdownMenuContent,
17
+ DropdownMenuItem,
18
+ DropdownMenuTrigger,
19
+ } from "@/components/ui/dropdown-menu";
20
+ import {
21
+ SidebarGroup,
22
+ SidebarMenu,
23
+ SidebarMenuButton,
24
+ SidebarMenuItem,
25
+ SidebarMenuSkeleton,
26
+ } from "@/components/ui/sidebar";
27
+ import type { TreeEntry } from "@/data/intel-data-provider/intel-data-provider.types.ts";
28
+ import { Modal } from "@/modal/modal.tsx";
29
+ import { useIntelRouterContext } from "@/router/router-context.ts";
30
+ import { selectedFrom } from "@/router/selection-search.ts";
31
+
32
+ type NewKind = "folder" | "document" | "flow";
33
+ type Creating = { parentId: string | null; kind: NewKind };
34
+
35
+ const icons = {
36
+ folder: Folder,
37
+ document: FileText,
38
+ attachment: Paperclip,
39
+ flow: Workflow,
40
+ } as const;
41
+
42
+ // Attachments are inlined as base64, so the browser holds the file twice while it uploads.
43
+ const MaxUploadBytes = 15_000_000;
44
+
45
+ async function fileBase64(file: File): Promise<string> {
46
+ if (file.size > MaxUploadBytes) throw new Error("Attachment exceeds the 15 MB upload limit");
47
+ return await new Promise((resolve, reject) => {
48
+ const reader = new FileReader();
49
+ reader.onerror = () => reject(reader.error ?? new Error("Attachment could not be read"));
50
+ reader.onload = () => {
51
+ const result = reader.result;
52
+ if (typeof result !== "string" || !result.includes(",")) {
53
+ reject(new Error("Attachment could not be encoded"));
54
+ return;
55
+ }
56
+ resolve(result.slice(result.indexOf(",") + 1));
57
+ };
58
+ reader.readAsDataURL(file);
59
+ });
60
+ }
61
+
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];
66
+ }
67
+
68
+ export function AppTree() {
69
+ const { data, i18n } = useIntelRouterContext();
70
+ const queryClient = useQueryClient();
71
+ const navigate = useNavigate();
72
+ const [expanded, setExpanded] = useState<ReadonlySet<string>>(new Set());
73
+ const [creating, setCreating] = useState<Creating | null>(null);
74
+ const [uploadTo, setUploadTo] = useState<string | null>(null);
75
+ const uploadInput = useRef<HTMLInputElement>(null);
76
+
77
+ const location = useRouterState({
78
+ select: (state) => ({
79
+ pathname: state.location.pathname,
80
+ select: selectedFrom(state.location.search),
81
+ }),
82
+ });
83
+
84
+ // ⚠️ The whole point of #11: one query per level, and a level only exists while its folder is
85
+ // open. A recursive load would put an N+1 on every page of the app, because the tree is now on
86
+ // every page.
87
+ const parents: Array<string | null> = [null, ...expanded];
88
+ const levels = useQueries({
89
+ queries: parents.map((parentId) => ({
90
+ queryKey: levelKey(parentId),
91
+ queryFn: () => data.listTreeChildren(parentId),
92
+ })),
93
+ });
94
+ const levelFor = (parentId: string | null) => levels[parents.indexOf(parentId)];
95
+ const root = levels[0];
96
+
97
+ function toggle(id: string, open: boolean) {
98
+ setExpanded((current) => {
99
+ const next = new Set(current);
100
+ if (open) next.add(id);
101
+ else next.delete(id);
102
+ return next;
103
+ });
104
+ }
105
+
106
+ // ⚠️ The level a row appears in is not the only thing that goes stale. The header search reads
107
+ // the whole flow list, and the graph is what the link picker and the flow editor's Knowledge step
108
+ // offer — a row created here has to reach those too, or another screen keeps showing yesterday.
109
+ async function reveal(
110
+ parentId: string | null,
111
+ collection: "flows" | "knowledge-graph",
112
+ ): Promise<void> {
113
+ if (parentId !== null) toggle(parentId, true);
114
+ await Promise.all([
115
+ queryClient.invalidateQueries({ queryKey: levelKey(parentId) }),
116
+ queryClient.invalidateQueries({ queryKey: [collection] }),
117
+ ]);
118
+ }
119
+
120
+ const create = useMutation({
121
+ mutationFn: async ({ parentId, kind, title }: Creating & { title: string }) => {
122
+ if (kind === "flow") {
123
+ const flow = await data.createFlow({
124
+ parentId,
125
+ title,
126
+ description: null,
127
+ idempotencyKey: crypto.randomUUID(),
128
+ });
129
+ return { area: "/flows" as const, id: flow.id, parentId, collection: "flows" as const };
130
+ }
131
+ const node = await data.createKnowledge({
132
+ parentId,
133
+ kind,
134
+ title,
135
+ description: null,
136
+ contextPolicy: "relevant",
137
+ idempotencyKey: crypto.randomUUID(),
138
+ });
139
+ return {
140
+ area: "/knowledge" as const,
141
+ id: node.id,
142
+ parentId,
143
+ collection: "knowledge-graph" as const,
144
+ };
145
+ },
146
+ onSuccess: async (created) => {
147
+ setCreating(null);
148
+ await reveal(created.parentId, created.collection);
149
+ await navigate({ to: created.area, search: { select: created.id } });
150
+ },
151
+ });
152
+
153
+ const upload = useMutation({
154
+ mutationFn: async ({ parentId, file }: { parentId: string | null; file: File }) => {
155
+ const contentBase64 = await fileBase64(file);
156
+ const node = await data.createKnowledge({
157
+ parentId,
158
+ kind: "attachment",
159
+ title: file.name,
160
+ description: null,
161
+ contextPolicy: "relevant",
162
+ idempotencyKey: crypto.randomUUID(),
163
+ });
164
+ try {
165
+ await data.saveKnowledgeAttachment({
166
+ nodeId: node.id,
167
+ baseVersionId: null,
168
+ contentBase64,
169
+ mediaType: file.type || "application/octet-stream",
170
+ idempotencyKey: crypto.randomUUID(),
171
+ });
172
+ } catch (error) {
173
+ // An attachment node without bytes is a row nobody can open. Take it back rather than
174
+ // leaving it in the tree, but never let the cleanup hide the real failure.
175
+ const current = await data.getKnowledge(node.id).catch(() => null);
176
+ if (current?.version === null && current.node.updatedAt === node.updatedAt) {
177
+ await data
178
+ .archiveKnowledge({
179
+ nodeId: node.id,
180
+ baseUpdatedAt: current.node.updatedAt,
181
+ archived: true,
182
+ idempotencyKey: crypto.randomUUID(),
183
+ })
184
+ .catch(() => undefined);
185
+ }
186
+ throw error;
187
+ }
188
+ return { id: node.id, parentId };
189
+ },
190
+ onSuccess: async (created) => {
191
+ await reveal(created.parentId, "knowledge-graph");
192
+ await navigate({ to: "/knowledge", search: { select: created.id } });
193
+ },
194
+ });
195
+
196
+ function startUpload(parentId: string | null) {
197
+ setUploadTo(parentId);
198
+ uploadInput.current?.click();
199
+ }
200
+
201
+ function renderLevel(parentId: string | null, ancestors: ReadonlySet<string>, label?: string) {
202
+ const level = levelFor(parentId);
203
+ if (!level || level.isPending) {
204
+ return (
205
+ <SidebarMenu aria-label={label}>
206
+ {[0, 1, 2].map((row) => (
207
+ <SidebarMenuItem key={row}>
208
+ <SidebarMenuSkeleton showIcon />
209
+ </SidebarMenuItem>
210
+ ))}
211
+ </SidebarMenu>
212
+ );
213
+ }
214
+ if (level.isError) {
215
+ return (
216
+ <div role="alert" className="space-y-2 px-2 py-1.5 text-sm">
217
+ <p className="text-destructive">{i18n.t("tree.failed")}</p>
218
+ <button
219
+ type="button"
220
+ onClick={() => void level.refetch()}
221
+ className="rounded-md border px-2 py-1 text-xs outline-none hover:bg-sidebar-accent focus-visible:ring-2 focus-visible:ring-sidebar-ring"
222
+ >
223
+ {i18n.t("common.retry")}
224
+ </button>
225
+ </div>
226
+ );
227
+ }
228
+ // ⚠️ The cycle guard, in the form a per-level load needs: a broken `parentId` that points back
229
+ // up would otherwise open the same folder inside itself for as long as anyone keeps clicking.
230
+ // The recursion is gone, so the depth limit that used to save us is gone with it; what stops it
231
+ // now is that a row already on the path from the root is not rendered again.
232
+ const entries = level.data?.filter((entry) => !ancestors.has(entry.id)) ?? [];
233
+ 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>
238
+ );
239
+ }
240
+ return (
241
+ <SidebarMenu aria-label={label}>
242
+ {entries.map((entry) => renderRow(entry, ancestors))}
243
+ </SidebarMenu>
244
+ );
245
+ }
246
+
247
+ function renderRow(entry: TreeEntry, ancestors: ReadonlySet<string>) {
248
+ const isFolder = entry.kind === "folder";
249
+ const isOpen = isFolder && expanded.has(entry.id);
250
+ const Icon = isOpen ? FolderOpen : icons[entry.kind];
251
+ const area = entry.type === "flow" ? "/flows" : "/knowledge";
252
+ const isActive = location.select === entry.id && location.pathname === area;
253
+ // 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;
259
+
260
+ return (
261
+ <SidebarMenuItem key={entry.id} className="group/row">
262
+ <div className="flex items-center gap-0.5">
263
+ {isFolder ? (
264
+ <button
265
+ type="button"
266
+ 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"
270
+ >
271
+ <ChevronRight
272
+ aria-hidden="true"
273
+ className={`size-3.5 transition-transform ${isOpen ? "rotate-90" : ""}`}
274
+ />
275
+ </button>
276
+ ) : (
277
+ <span aria-hidden="true" className="size-5 shrink-0" />
278
+ )}
279
+ <SidebarMenuButton
280
+ isActive={isActive}
281
+ className="min-w-0 flex-1"
282
+ onClick={() => void navigate({ to: area, search: { select: entry.id } })}
283
+ >
284
+ <Icon aria-hidden="true" className="size-4 shrink-0" />
285
+ <span className="truncate">{entry.title}</span>
286
+ {/* The icon carries the type on screen; this carries it everywhere else, and it is why
287
+ a flow needs no suffix in its name to be told from a document. */}
288
+ <span className="sr-only">{i18n.t(`tree.kind.${entry.kind}`)}</span>
289
+ </SidebarMenuButton>
290
+ <AddMenu
291
+ label={i18n.t("tree.add", { title: entry.title })}
292
+ onSelect={(kind) => {
293
+ if (kind === "upload") startUpload(target);
294
+ else setCreating({ parentId: target, kind });
295
+ }}
296
+ />
297
+ </div>
298
+ {/* 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. */}
300
+ {isOpen ? (
301
+ <div className="ml-3.5 border-l border-sidebar-border pl-1.5">
302
+ {renderLevel(entry.id, new Set([...ancestors, entry.id]))}
303
+ </div>
304
+ ) : null}
305
+ </SidebarMenuItem>
306
+ );
307
+ }
308
+
309
+ return (
310
+ <SidebarGroup className="min-h-0 flex-1 overflow-y-auto">
311
+ {/* The tree is the navigation and carries no heading of its own (ADR-0004); the list is
312
+ named for assistive technology instead. */}
313
+ {renderLevel(null, new Set(), i18n.t("tree.label"))}
314
+ {create.isError || upload.isError ? (
315
+ <p role="alert" className="px-2 py-1.5 text-sm text-destructive">
316
+ {upload.isError ? i18n.t("tree.uploadFailed") : i18n.t("tree.createFailed")}
317
+ </p>
318
+ ) : 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. */}
321
+ {root?.isPending ? null : (
322
+ <div className="mt-1 flex items-center px-2">
323
+ <AddMenu
324
+ label={i18n.t("tree.addRoot")}
325
+ alwaysVisible
326
+ onSelect={(kind) => {
327
+ if (kind === "upload") startUpload(null);
328
+ else setCreating({ parentId: null, kind });
329
+ }}
330
+ />
331
+ </div>
332
+ )}
333
+ <input
334
+ ref={uploadInput}
335
+ type="file"
336
+ className="sr-only"
337
+ aria-label={i18n.t("tree.upload")}
338
+ onChange={(event) => {
339
+ const file = event.target.files?.[0];
340
+ if (file) upload.mutate({ parentId: uploadTo, file });
341
+ event.currentTarget.value = "";
342
+ }}
343
+ />
344
+ {creating ? (
345
+ <Modal title={i18n.t(`tree.new.${creating.kind}`)} close={() => setCreating(null)}>
346
+ <CreateForm
347
+ pending={create.isPending}
348
+ submit={(title) => create.mutate({ ...creating, title })}
349
+ />
350
+ </Modal>
351
+ ) : null}
352
+ </SidebarGroup>
353
+ );
354
+ }
355
+
356
+ // ⚠️ Hover alone would hide this from a keyboard and from touch entirely, which is the same as not
357
+ // having it. It stays in the tab order, focus makes it visible, and below `md` it is always shown.
358
+ function AddMenu({
359
+ label,
360
+ alwaysVisible = false,
361
+ onSelect,
362
+ }: {
363
+ label: string;
364
+ alwaysVisible?: boolean;
365
+ onSelect(kind: NewKind | "upload"): void;
366
+ }) {
367
+ const { i18n } = useIntelRouterContext();
368
+ const items: Array<{ kind: NewKind | "upload"; labelKey: string; Icon: typeof Plus }> = [
369
+ { kind: "folder", labelKey: "tree.new.folder", Icon: Folder },
370
+ { kind: "document", labelKey: "tree.new.document", Icon: FileText },
371
+ { kind: "upload", labelKey: "tree.new.upload", Icon: Upload },
372
+ { kind: "flow", labelKey: "tree.new.flow", Icon: Workflow },
373
+ ];
374
+ return (
375
+ <DropdownMenu>
376
+ <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
+ }`}
383
+ >
384
+ <Plus aria-hidden="true" className="size-4" />
385
+ </DropdownMenuTrigger>
386
+ <DropdownMenuContent align="start" className="w-44">
387
+ {items.map((item) => (
388
+ <DropdownMenuItem key={item.kind} onSelect={() => onSelect(item.kind)}>
389
+ <item.Icon aria-hidden="true" />
390
+ {i18n.t(item.labelKey)}
391
+ </DropdownMenuItem>
392
+ ))}
393
+ </DropdownMenuContent>
394
+ </DropdownMenu>
395
+ );
396
+ }
397
+
398
+ function CreateForm({ pending, submit }: { pending: boolean; submit(title: string): void }) {
399
+ const { i18n } = useIntelRouterContext();
400
+ const [title, setTitle] = useState("");
401
+ return (
402
+ <form
403
+ className="space-y-4"
404
+ onSubmit={(event) => {
405
+ event.preventDefault();
406
+ if (!pending) submit(title.trim());
407
+ }}
408
+ >
409
+ <label className="block text-sm font-medium">
410
+ {i18n.t("common.title")}
411
+ <input
412
+ required
413
+ value={title}
414
+ onChange={(event) => setTitle(event.target.value)}
415
+ 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
+ />
417
+ </label>
418
+ <button
419
+ type="submit"
420
+ disabled={pending || title.trim().length === 0}
421
+ 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
+ >
423
+ {pending ? i18n.t("common.saving") : i18n.t("common.create")}
424
+ </button>
425
+ </form>
426
+ );
427
+ }
package/src/app/app.tsx CHANGED
@@ -1,62 +1,92 @@
1
- import { useMutation } from "@tanstack/react-query";
2
- import { Link, Outlet } from "@tanstack/react-router";
3
- import { BookOpen, LogOut, Network, Wrench } from "lucide-react";
4
- import { AppLogo } from "@/branding/branding.tsx";
5
- import { loginPath } from "@/data/intel-data-provider/intel-data-provider.ts";
1
+ import { Link, Outlet, useRouterState } from "@tanstack/react-router";
2
+ import { useRef, useState } from "react";
3
+ import { AppSidebar } from "@/app/app-sidebar/app-sidebar.tsx";
4
+ import { HeaderSearch } from "@/app/header-search/header-search.tsx";
5
+ import { createSidebarPreferences } from "@/app/sidebar-preferences/sidebar-preferences.ts";
6
+ import {
7
+ Breadcrumb,
8
+ BreadcrumbItem,
9
+ BreadcrumbLink,
10
+ BreadcrumbList,
11
+ BreadcrumbPage,
12
+ BreadcrumbSeparator,
13
+ } from "@/components/ui/breadcrumb";
14
+ import { Separator } from "@/components/ui/separator";
15
+ import { SidebarInset, SidebarProvider, SidebarTrigger } from "@/components/ui/sidebar";
6
16
  import { useIntelRouterContext } from "@/router/router-context.ts";
7
17
 
18
+ const sections = [
19
+ { path: "/knowledge", labelKey: "nav.knowledge" },
20
+ { path: "/flows", labelKey: "nav.flows" },
21
+ { path: "/tools", labelKey: "nav.tools" },
22
+ ] as const;
23
+
8
24
  export function App() {
9
- const { data, i18n } = useIntelRouterContext();
10
- const logout = useMutation({
11
- mutationFn: () => data.logout(),
12
- onSuccess: () => window.location.assign(loginPath()),
13
- });
14
- const navigation = [
15
- { to: "/knowledge" as const, label: i18n.t("nav.knowledge"), icon: BookOpen },
16
- { to: "/flows" as const, label: i18n.t("nav.flows"), icon: Network },
17
- { to: "/tools" as const, label: i18n.t("nav.tools"), icon: Wrench },
18
- ];
25
+ const { i18n } = useIntelRouterContext();
26
+ const pathname = useRouterState({ select: (state) => state.location.pathname });
27
+ const current = sections.find(
28
+ (section) => pathname === section.path || pathname.startsWith(`${section.path}/`),
29
+ );
30
+
31
+ // Read once: the stored preference is the starting point, after which the shell's own state is
32
+ // the truth. Re-reading on every render would fight the drag.
33
+ const preferences = useRef(createSidebarPreferences()).current;
34
+ const stored = useRef(preferences.read()).current;
35
+ const [open, setOpen] = useState(stored.open);
36
+ const [width, setWidth] = useState(stored.width);
19
37
 
20
38
  return (
21
- <div className="min-h-screen bg-background text-foreground">
22
- <aside className="fixed inset-y-0 left-0 z-20 flex w-64 flex-col border-r border-sidebar-border bg-sidebar p-4 text-sidebar-foreground">
23
- <Link to="/knowledge" className="mb-8 flex items-center gap-3 px-2 font-semibold">
24
- <AppLogo className="size-9" />
25
- <span>{i18n.t("app.name")}</span>
26
- </Link>
27
- <nav aria-label={i18n.t("nav.primary")} className="space-y-1">
28
- {navigation.map(({ to, label, icon: Icon }) => (
29
- <Link
30
- key={to}
31
- to={to}
32
- activeProps={{ "aria-current": "page" }}
33
- className="flex w-full items-center gap-3 rounded-md px-3 py-2 text-left text-sm outline-none transition-colors hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 focus-visible:ring-sidebar-ring aria-[current=page]:bg-sidebar-accent aria-[current=page]:font-medium aria-[current=page]:text-sidebar-accent-foreground"
34
- >
35
- <Icon aria-hidden="true" className="size-4" />
36
- {label}
37
- </Link>
38
- ))}
39
- </nav>
40
- <div className="mt-auto space-y-2">
41
- {logout.isError ? (
42
- <p role="alert" className="px-3 text-sm text-destructive">
43
- {i18n.t("auth.signOutFailed")}
44
- </p>
45
- ) : null}
46
- <button
47
- type="button"
48
- onClick={() => logout.mutate()}
49
- disabled={logout.isPending}
50
- className="flex w-full items-center gap-3 rounded-md px-3 py-2 text-sm outline-none hover:bg-sidebar-accent focus-visible:ring-2 focus-visible:ring-sidebar-ring disabled:opacity-60"
51
- >
52
- <LogOut aria-hidden="true" className="size-4" />
53
- {i18n.t("auth.signOut")}
54
- </button>
39
+ <SidebarProvider
40
+ open={open}
41
+ onOpenChange={(next) => {
42
+ setOpen(next);
43
+ preferences.write({ open: next, width });
44
+ }}
45
+ style={{ "--sidebar-width": `${width}px` } as React.CSSProperties}
46
+ >
47
+ <AppSidebar
48
+ width={width}
49
+ onWidthChange={(next) => {
50
+ setWidth(next);
51
+ preferences.write({ open, width: next });
52
+ }}
53
+ />
54
+ {/* ⚠️ `min-w-0` is not cosmetic: the inset is a flex item beside the sidebar and carries
55
+ `min-width: auto`, so it never shrinks below its content's min-content width. One wide
56
+ child (a flow canvas, a table of raw JSON) would widen the page instead of scrolling
57
+ inside its own container. Learned in gate, GATE-76 D. */}
58
+ <SidebarInset className="min-w-0 md:border">
59
+ <header className="flex h-14 shrink-0 items-center gap-2 border-b px-4">
60
+ <SidebarTrigger className="-ml-1" aria-label={i18n.t("shell.toggleSidebar")} />
61
+ <Separator orientation="vertical" className="mr-2 data-[orientation=vertical]:h-4" />
62
+ <Breadcrumb>
63
+ <BreadcrumbList>
64
+ <BreadcrumbItem>
65
+ <BreadcrumbLink asChild>
66
+ <Link to="/knowledge">{i18n.t("app.name")}</Link>
67
+ </BreadcrumbLink>
68
+ </BreadcrumbItem>
69
+ {current ? (
70
+ <>
71
+ <BreadcrumbSeparator />
72
+ <BreadcrumbItem>
73
+ <BreadcrumbPage>{i18n.t(current.labelKey)}</BreadcrumbPage>
74
+ </BreadcrumbItem>
75
+ </>
76
+ ) : null}
77
+ </BreadcrumbList>
78
+ </Breadcrumb>
79
+ {/* The bar for area-owned actions. A screen renders into it through `HeaderActions`;
80
+ search is the shell's own, because it has to open from every screen alike and belongs
81
+ to none of them. It stays first, so a screen's actions line up to its right. */}
82
+ <div data-slot="header-actions" className="ml-auto flex items-center gap-2">
83
+ <HeaderSearch />
84
+ </div>
85
+ </header>
86
+ <div className="flex min-h-0 flex-1 flex-col overflow-auto">
87
+ <Outlet />
55
88
  </div>
56
- </aside>
57
- <main className="ml-64 min-h-screen">
58
- <Outlet />
59
- </main>
60
- </div>
89
+ </SidebarInset>
90
+ </SidebarProvider>
61
91
  );
62
92
  }