@iloveagents/foundry-web-ui 0.1.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 (88) hide show
  1. package/AGENTS.md +59 -0
  2. package/CLAUDE.md +1 -0
  3. package/LICENSE +21 -0
  4. package/README.md +41 -0
  5. package/package.json +61 -0
  6. package/src/__tests__/no-spaces-imports.test.ts +59 -0
  7. package/src/__tests__/open-core-guards.test.ts +307 -0
  8. package/src/__tests__/theme-runtime.test.ts +81 -0
  9. package/src/__tests__/tool-fallback.test.tsx +109 -0
  10. package/src/components/ag-ui-runtime-provider.tsx +59 -0
  11. package/src/components/app-brand.tsx +38 -0
  12. package/src/components/assistant-chat.tsx +423 -0
  13. package/src/components/chat-attachments.tsx +68 -0
  14. package/src/components/chat-bubble.tsx +339 -0
  15. package/src/components/chat-header.tsx +44 -0
  16. package/src/components/client-tool-executor.tsx +230 -0
  17. package/src/components/collection-empty-state.tsx +37 -0
  18. package/src/components/collection-filter-bar.tsx +168 -0
  19. package/src/components/collection-search-input.tsx +41 -0
  20. package/src/components/collection-skeleton.tsx +55 -0
  21. package/src/components/collection-sort-menu.tsx +62 -0
  22. package/src/components/collection-surface.tsx +46 -0
  23. package/src/components/collection-toolbar.tsx +33 -0
  24. package/src/components/collection-view-toggle.tsx +61 -0
  25. package/src/components/confirm-dialog.tsx +49 -0
  26. package/src/components/confirmation-card.tsx +32 -0
  27. package/src/components/context-badges.tsx +124 -0
  28. package/src/components/context-bar.tsx +202 -0
  29. package/src/components/form-dialog.tsx +56 -0
  30. package/src/components/global-selection-popover.tsx +135 -0
  31. package/src/components/infinite-scroll-sentinel.tsx +48 -0
  32. package/src/components/json-viewer.tsx +232 -0
  33. package/src/components/loading-indicator.tsx +40 -0
  34. package/src/components/markdown-text.tsx +379 -0
  35. package/src/components/name-dialog.tsx +87 -0
  36. package/src/components/selection-popover.tsx +156 -0
  37. package/src/components/show-document-tool-ui.tsx +90 -0
  38. package/src/components/sidebar.test.tsx +182 -0
  39. package/src/components/sidebar.tsx +1174 -0
  40. package/src/components/surface-card.tsx +45 -0
  41. package/src/components/theme-runtime-provider.tsx +93 -0
  42. package/src/components/theme-toggle.tsx +27 -0
  43. package/src/components/tool-call-card.tsx +126 -0
  44. package/src/components/tool-fallback.tsx +169 -0
  45. package/src/components/tool-panel-layout.tsx +36 -0
  46. package/src/components/tool-panel.tsx +233 -0
  47. package/src/components/tooltip-icon-button.tsx +28 -0
  48. package/src/components/user-menu.tsx +60 -0
  49. package/src/index.ts +170 -0
  50. package/src/lib/__tests__/ag-ui-adapter.test.ts +182 -0
  51. package/src/lib/__tests__/app-store.test.ts +330 -0
  52. package/src/lib/__tests__/attachment-adapter.test.ts +48 -0
  53. package/src/lib/__tests__/chat-bubble-store.test.ts +67 -0
  54. package/src/lib/__tests__/client-tools.test.ts +124 -0
  55. package/src/lib/__tests__/dev-store.test.ts +78 -0
  56. package/src/lib/__tests__/nav-config.test.ts +135 -0
  57. package/src/lib/__tests__/nav-dnd.test.ts +24 -0
  58. package/src/lib/__tests__/nav-store.test.ts +59 -0
  59. package/src/lib/__tests__/sidebar-store.test.ts +144 -0
  60. package/src/lib/__tests__/theme-store.test.ts +83 -0
  61. package/src/lib/__tests__/tool-panel-store.test.ts +61 -0
  62. package/src/lib/__tests__/use-page-context.test.ts +58 -0
  63. package/src/lib/ag-ui-adapter.ts +362 -0
  64. package/src/lib/app-store.ts +294 -0
  65. package/src/lib/attachment-adapter.ts +92 -0
  66. package/src/lib/auth-provider.tsx +144 -0
  67. package/src/lib/chat-bubble-store.ts +46 -0
  68. package/src/lib/client-tools.ts +293 -0
  69. package/src/lib/dev-store.ts +69 -0
  70. package/src/lib/nav-config.ts +292 -0
  71. package/src/lib/nav-dnd.ts +14 -0
  72. package/src/lib/nav-store.ts +76 -0
  73. package/src/lib/sidebar-store.ts +113 -0
  74. package/src/lib/theme-runtime.ts +660 -0
  75. package/src/lib/theme-store.ts +67 -0
  76. package/src/lib/tool-panel-store.ts +121 -0
  77. package/src/lib/use-new-conversation.test.tsx +124 -0
  78. package/src/lib/use-new-conversation.ts +33 -0
  79. package/src/lib/use-page-tools.ts +40 -0
  80. package/src/ui/collapsible.tsx +7 -0
  81. package/src/ui/dialog.tsx +109 -0
  82. package/src/ui/dropdown-menu.tsx +71 -0
  83. package/src/ui/popover.tsx +34 -0
  84. package/src/ui/select.tsx +15 -0
  85. package/src/ui/tooltip.tsx +30 -0
  86. package/src/vite-env.d.ts +1 -0
  87. package/tsconfig.json +16 -0
  88. package/vitest.config.ts +8 -0
@@ -0,0 +1,1174 @@
1
+ import { useCallback, useEffect, useRef, useState } from "react";
2
+ import { NavLink, useNavigate } from "react-router";
3
+ import { Ellipsis, Layers, PanelLeft, PanelLeftOpen, SquarePen, ChevronRight, GripVertical, Plus } from "lucide-react";
4
+ import { cn } from "@iloveagents/foundry-web-primitives";
5
+ import {
6
+ useSidebarStore,
7
+ RAIL_WIDTH,
8
+ SIDEBAR_MIN_WIDTH,
9
+ SIDEBAR_MAX_WIDTH,
10
+ } from "../lib/sidebar-store.ts";
11
+ import { useToolPanelStore } from "../lib/tool-panel-store.ts";
12
+ import { useNewConversation } from "../lib/use-new-conversation.ts";
13
+ import { useNavStore } from "../lib/nav-store.ts";
14
+ import { useAppStore } from "../lib/app-store.ts";
15
+ import type { NavItem, NavItemDnd } from "../lib/nav-config.ts";
16
+ import { getContainerDropZoneLabel, hasAnyDropTarget, hasExternalFilesData } from "../lib/nav-dnd.ts";
17
+ import { TooltipIconButton } from "./tooltip-icon-button.tsx";
18
+ import { Button } from "@iloveagents/foundry-web-primitives";
19
+ import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger } from "../ui/dropdown-menu.tsx";
20
+ import { AppBrand } from "./app-brand.tsx";
21
+
22
+ const ACTIVE_NAV_ROW_CLASS =
23
+ "border border-transparent bg-sidebar-selected shadow-none";
24
+ const ACTIVE_NAV_TEXT_CLASS = "font-semibold text-sidebar-foreground";
25
+ const ACTIVE_NAV_ICON_CLASS = "text-primary";
26
+ const ACTIVE_NAV_BADGE_CLASS =
27
+ "border border-sidebar-border bg-background/60 text-sidebar-foreground";
28
+ const NESTED_NAV_INDENTS = ["pl-5", "pl-7", "pl-9", "pl-11", "pl-13"] as const;
29
+
30
+ function getNestedNavIndent(depth: number) {
31
+ return NESTED_NAV_INDENTS[depth] ?? NESTED_NAV_INDENTS[NESTED_NAV_INDENTS.length - 1];
32
+ }
33
+
34
+ // --- Generic drag-and-drop via NavItem.dnd ---
35
+
36
+ /** Shared drag-and-drop props derived from NavItem.dnd (feature-provided). */
37
+ function useNavItemDnd(dnd?: NavItemDnd) {
38
+ const [isDragOver, setIsDragOver] = useState(false);
39
+ const dragCounter = useRef(0);
40
+
41
+ const isDraggable = dnd?.canDrag() ?? false;
42
+ const isDropTarget = dnd?.canDrop() ?? false;
43
+ const isFileDropTarget = dnd?.canDropFiles?.() ?? false;
44
+ const dndType = dnd?.type;
45
+
46
+ const hasExternalFiles = useCallback((e: React.DragEvent) => hasExternalFilesData(e.dataTransfer), []);
47
+
48
+ const hasMatchingDragData = useCallback(
49
+ (e: React.DragEvent) =>
50
+ Boolean(
51
+ (isDropTarget && dndType && e.dataTransfer.types.includes(dndType)) ||
52
+ (isFileDropTarget && hasExternalFiles(e)),
53
+ ),
54
+ [isDropTarget, dndType, isFileDropTarget, hasExternalFiles],
55
+ );
56
+
57
+ const onDragStart = useCallback(
58
+ (e: React.DragEvent) => {
59
+ if (!dnd) return;
60
+ const data = dnd.getData();
61
+ if (!data) return;
62
+ e.dataTransfer.setData(dnd.type, data);
63
+ e.dataTransfer.effectAllowed = "move";
64
+ },
65
+ [dnd],
66
+ );
67
+
68
+ const onDragEnter = useCallback(
69
+ (e: React.DragEvent) => {
70
+ if (!hasMatchingDragData(e)) return;
71
+ e.preventDefault();
72
+ e.stopPropagation();
73
+ dragCounter.current++;
74
+ setIsDragOver(true);
75
+ },
76
+ [hasMatchingDragData],
77
+ );
78
+
79
+ const onDragLeave = useCallback(
80
+ (e: React.DragEvent) => {
81
+ if (!hasAnyDropTarget(isDropTarget, isFileDropTarget)) return;
82
+ e.preventDefault();
83
+ dragCounter.current--;
84
+ if (dragCounter.current <= 0) {
85
+ dragCounter.current = 0;
86
+ setIsDragOver(false);
87
+ }
88
+ },
89
+ [isDropTarget, isFileDropTarget],
90
+ );
91
+
92
+ const onDragOver = useCallback(
93
+ (e: React.DragEvent) => {
94
+ if (!hasMatchingDragData(e)) return;
95
+ e.preventDefault();
96
+ e.stopPropagation();
97
+ e.dataTransfer.dropEffect = hasExternalFiles(e) ? "copy" : "move";
98
+ },
99
+ [hasMatchingDragData, hasExternalFiles],
100
+ );
101
+
102
+ const onDragEnterCapture = useCallback(
103
+ (e: React.DragEvent) => {
104
+ if (!hasMatchingDragData(e)) return;
105
+ e.preventDefault();
106
+ e.stopPropagation();
107
+ },
108
+ [hasMatchingDragData],
109
+ );
110
+
111
+ const onDragOverCapture = useCallback(
112
+ (e: React.DragEvent) => {
113
+ if (!hasMatchingDragData(e)) return;
114
+ e.preventDefault();
115
+ e.stopPropagation();
116
+ e.dataTransfer.dropEffect = hasExternalFiles(e) ? "copy" : "move";
117
+ },
118
+ [hasMatchingDragData, hasExternalFiles],
119
+ );
120
+
121
+ const onDrop = useCallback(
122
+ async (e: React.DragEvent) => {
123
+ if (!dnd) return;
124
+ const files = Array.from(e.dataTransfer.files ?? []);
125
+ const hasFiles = isFileDropTarget && files.length > 0 && dnd.onFileDrop;
126
+ const draggedData =
127
+ isDropTarget && dndType ? e.dataTransfer.getData(dndType) : "";
128
+ const hasEntityDrop = Boolean(isDropTarget && draggedData);
129
+ if (!hasFiles && !hasEntityDrop) return;
130
+ e.preventDefault();
131
+ e.stopPropagation();
132
+ dragCounter.current = 0;
133
+ setIsDragOver(false);
134
+ if (hasFiles) {
135
+ await dnd.onFileDrop!(files);
136
+ return;
137
+ }
138
+ await dnd.onDrop(draggedData);
139
+ },
140
+ [isDropTarget, isFileDropTarget, dnd, dndType],
141
+ );
142
+
143
+ return {
144
+ isDragOver,
145
+ draggableProps: isDraggable ? { draggable: true, onDragStart } : {},
146
+ dropTargetProps:
147
+ isDropTarget || isFileDropTarget
148
+ ? {
149
+ onDragEnterCapture,
150
+ onDragOverCapture,
151
+ onDragEnter,
152
+ onDragLeave,
153
+ onDragOver,
154
+ onDrop,
155
+ }
156
+ : {},
157
+ };
158
+ }
159
+
160
+ /** Drop zone rendered inside expanded containers so users can move items back to the container root. */
161
+ function ContainerDropZone({ dnd }: { dnd: NavItemDnd }) {
162
+ const [isDragOver, setIsDragOver] = useState(false);
163
+ const [isFileDragOver, setIsFileDragOver] = useState(false);
164
+ const dragCounter = useRef(0);
165
+ const acceptsFiles = dnd.canDropFiles?.() ?? false;
166
+ const acceptsEntities = dnd.canDrop();
167
+
168
+ const hasExternalFiles = (e: React.DragEvent) => hasExternalFilesData(e.dataTransfer);
169
+
170
+ const hasMatchingDragData = (e: React.DragEvent) =>
171
+ Boolean(
172
+ (acceptsEntities && e.dataTransfer.types.includes(dnd.type)) ||
173
+ (acceptsFiles && hasExternalFiles(e)),
174
+ );
175
+
176
+ return (
177
+ <div
178
+ onDragEnter={(e) => {
179
+ if (!hasMatchingDragData(e)) return;
180
+ e.preventDefault();
181
+ dragCounter.current++;
182
+ setIsFileDragOver(hasExternalFiles(e));
183
+ setIsDragOver(true);
184
+ }}
185
+ onDragLeave={() => {
186
+ dragCounter.current--;
187
+ if (dragCounter.current <= 0) {
188
+ dragCounter.current = 0;
189
+ setIsFileDragOver(false);
190
+ setIsDragOver(false);
191
+ }
192
+ }}
193
+ onDragOver={(e) => {
194
+ if (!hasMatchingDragData(e)) return;
195
+ e.preventDefault();
196
+ e.dataTransfer.dropEffect = hasExternalFiles(e) ? "copy" : "move";
197
+ }}
198
+ onDrop={async (e) => {
199
+ const files = Array.from(e.dataTransfer.files ?? []);
200
+ const draggedData = acceptsEntities ? e.dataTransfer.getData(dnd.type) : "";
201
+ const hasFiles = acceptsFiles && files.length > 0 && dnd.onFileDrop;
202
+ const hasEntityDrop = Boolean(acceptsEntities && draggedData);
203
+ if (!hasFiles && !hasEntityDrop) return;
204
+ e.preventDefault();
205
+ e.stopPropagation();
206
+ dragCounter.current = 0;
207
+ setIsFileDragOver(false);
208
+ setIsDragOver(false);
209
+ if (hasFiles) {
210
+ await dnd.onFileDrop!(files);
211
+ return;
212
+ }
213
+ await dnd.onDrop(draggedData);
214
+ }}
215
+ className={cn(
216
+ "mx-2 rounded-md transition-all overflow-hidden",
217
+ isDragOver
218
+ ? "h-7 bg-primary/10 border border-dashed border-primary/30 flex items-center justify-center"
219
+ : "h-1",
220
+ )}
221
+ >
222
+ {isDragOver && (
223
+ <span className="text-[10px] text-primary/70">
224
+ {getContainerDropZoneLabel(isFileDragOver)}
225
+ </span>
226
+ )}
227
+ </div>
228
+ );
229
+ }
230
+
231
+ function NavItemActions({ actions }: { actions: NonNullable<NavItem["actions"]> }) {
232
+ return (
233
+ <DropdownMenu>
234
+ <DropdownMenuTrigger asChild>
235
+ <Button
236
+ type="button"
237
+ variant="ghost"
238
+ size="icon"
239
+ className="size-7 rounded-md text-muted-foreground hover:bg-muted/80 hover:text-foreground"
240
+ onClick={(e) => {
241
+ e.preventDefault();
242
+ e.stopPropagation();
243
+ }}
244
+ aria-label="More actions"
245
+ >
246
+ <Ellipsis className="size-3.5" />
247
+ </Button>
248
+ </DropdownMenuTrigger>
249
+ <DropdownMenuContent align="end" className="w-56">
250
+ {actions.map((action) => (
251
+ <span key={action.label}>
252
+ {action.separator && <DropdownMenuSeparator />}
253
+ <DropdownMenuItem
254
+ onSelect={(e) => {
255
+ e.preventDefault();
256
+ void action.handler();
257
+ }}
258
+ className={cn("gap-2", action.destructive && "text-destructive focus:text-destructive")}
259
+ >
260
+ <action.icon className={cn("size-3.5", action.destructive ? "text-destructive" : "text-muted-foreground")} />
261
+ <span>{action.label}</span>
262
+ </DropdownMenuItem>
263
+ </span>
264
+ ))}
265
+ </DropdownMenuContent>
266
+ </DropdownMenu>
267
+ );
268
+ }
269
+
270
+ function NavRowControls({
271
+ item,
272
+ hasChildren,
273
+ isExpanded,
274
+ onToggleExpand,
275
+ }: {
276
+ item: NavItem;
277
+ hasChildren: boolean;
278
+ isExpanded: boolean;
279
+ onToggleExpand?: () => void;
280
+ }) {
281
+ const isDisabled = item.disabled === true;
282
+ const hasActions = !isDisabled && item.actions && item.actions.length > 0;
283
+
284
+ return (
285
+ <div className="absolute inset-y-0 right-1 flex items-center gap-1 px-1 opacity-0 translate-x-1 pointer-events-none transition-all group-hover:opacity-100 group-hover:translate-x-0 group-hover:pointer-events-auto group-focus-within:opacity-100 group-focus-within:translate-x-0 group-focus-within:pointer-events-auto">
286
+ {hasActions && <NavItemActions actions={item.actions!} />}
287
+ {hasChildren && (
288
+ <button
289
+ type="button"
290
+ onClick={(e) => {
291
+ e.preventDefault();
292
+ e.stopPropagation();
293
+ onToggleExpand?.();
294
+ }}
295
+ className="flex size-7 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-muted/80 hover:text-foreground"
296
+ aria-label={isExpanded ? "Collapse" : "Expand"}
297
+ >
298
+ <ChevronRight className={cn("size-3.5 transition-transform", isExpanded && "rotate-90")} />
299
+ </button>
300
+ )}
301
+ </div>
302
+ );
303
+ }
304
+
305
+ /** Leaf nav item with optional drag-and-drop support via NavItem.dnd. */
306
+ function NavLeafItem({
307
+ item,
308
+ isActive,
309
+ onClick,
310
+ depthPl = getNestedNavIndent(0),
311
+ }: {
312
+ item: NavItem;
313
+ isActive: boolean;
314
+ onClick: () => void;
315
+ depthPl?: string;
316
+ }) {
317
+ const isDisabled = item.disabled === true;
318
+ const hasActions = !isDisabled && item.actions && item.actions.length > 0;
319
+ const { draggableProps, dropTargetProps, isDragOver } = useNavItemDnd(item.dnd);
320
+
321
+ // Items with actions use the group layout (button + action controls)
322
+ if (hasActions) {
323
+ return (
324
+ <div
325
+ {...(isDisabled ? {} : dropTargetProps)}
326
+ {...(isDisabled ? {} : draggableProps)}
327
+ className={cn(
328
+ "group relative flex items-center w-full rounded-xl border border-transparent transition-colors",
329
+ isDisabled
330
+ ? "opacity-40"
331
+ : isActive
332
+ ? ACTIVE_NAV_ROW_CLASS
333
+ : "hover:bg-sidebar-accent/70",
334
+ isDragOver &&
335
+ !isDisabled &&
336
+ "ring-2 ring-inset ring-sidebar-ring/50 bg-sidebar-selected",
337
+ item.dimmed && !isDisabled && "opacity-50",
338
+ )}
339
+ >
340
+ <button
341
+ type="button"
342
+ disabled={isDisabled}
343
+ onClick={isDisabled ? undefined : onClick}
344
+ className={cn(
345
+ "flex items-center gap-2 flex-1 min-w-0 py-1.5 text-sm text-left",
346
+ depthPl,
347
+ "pr-3 transition-[padding] duration-150 group-hover:pr-10 group-focus-within:pr-10",
348
+ isDisabled
349
+ ? "text-sidebar-foreground/45 cursor-default"
350
+ : isActive
351
+ ? ACTIVE_NAV_TEXT_CLASS
352
+ : "text-sidebar-foreground/80",
353
+ )}
354
+ >
355
+ <item.icon
356
+ className={cn(
357
+ "size-3.5 shrink-0",
358
+ isActive && !isDisabled ? ACTIVE_NAV_ICON_CLASS : "text-sidebar-foreground/60",
359
+ )}
360
+ />
361
+ <span className="truncate">{item.label}</span>
362
+ {item.badge && (
363
+ <span
364
+ className={cn(
365
+ "shrink-0 rounded px-1 py-0.5 text-[10px] font-medium",
366
+ isActive ? ACTIVE_NAV_BADGE_CLASS : "bg-sidebar-accent text-sidebar-accent-foreground",
367
+ )}
368
+ >
369
+ {item.badge}
370
+ </span>
371
+ )}
372
+ </button>
373
+ <NavRowControls item={item} hasChildren={false} isExpanded={false} />
374
+ </div>
375
+ );
376
+ }
377
+
378
+ return (
379
+ <button
380
+ type="button"
381
+ disabled={isDisabled}
382
+ onClick={isDisabled ? undefined : onClick}
383
+ {...(isDisabled ? {} : draggableProps)}
384
+ {...(isDisabled ? {} : dropTargetProps)}
385
+ className={cn(
386
+ "flex items-center gap-2 w-full",
387
+ "rounded-lg border border-transparent pr-3 py-1.5 text-sm text-left",
388
+ depthPl,
389
+ isDisabled
390
+ ? "opacity-40 cursor-default"
391
+ : "text-sidebar-foreground/80 transition-colors hover:bg-sidebar-accent/70",
392
+ !isDisabled && isActive && ACTIVE_NAV_ROW_CLASS,
393
+ isDragOver &&
394
+ !isDisabled &&
395
+ "ring-2 ring-inset ring-sidebar-ring/50 bg-sidebar-selected",
396
+ item.dimmed && !isDisabled && "opacity-50",
397
+ )}
398
+ >
399
+ <item.icon
400
+ className={cn(
401
+ "size-3.5 shrink-0",
402
+ isActive && !isDisabled ? ACTIVE_NAV_ICON_CLASS : "text-sidebar-foreground/60",
403
+ )}
404
+ />
405
+ <span className={cn("truncate", isActive && !isDisabled && ACTIVE_NAV_TEXT_CLASS)}>{item.label}</span>
406
+ {item.badge && (
407
+ <span
408
+ className={cn(
409
+ "shrink-0 rounded px-1 py-0.5 text-[10px] font-medium",
410
+ isActive ? ACTIVE_NAV_BADGE_CLASS : "bg-sidebar-accent text-sidebar-accent-foreground",
411
+ )}
412
+ >
413
+ {item.badge}
414
+ </span>
415
+ )}
416
+ </button>
417
+ );
418
+ }
419
+
420
+ function SubNavItems({
421
+ items,
422
+ parentPath,
423
+ depth = 1,
424
+ }: {
425
+ items: NavItem[];
426
+ parentPath: string;
427
+ depth?: number;
428
+ }) {
429
+ const navigate = useNavigate();
430
+ const currentPage = useAppStore((s) => s.currentPage);
431
+ const setNavContext = useAppStore((s) => s.setNavContext);
432
+ const navContext = useAppStore((s) => s.navContext);
433
+ const isOnParent = currentPage === parentPath;
434
+ const [activeLabel, setActiveLabel] = useState(items[0]?.label ?? "");
435
+ const childDepthPl = getNestedNavIndent(depth);
436
+
437
+ // Children with unique paths (e.g., SPACES documents) use URL matching;
438
+ // children sharing the parent path (e.g., Documents sub-filters) use label matching.
439
+ const allShareParent = items.every((c) => c.to === parentPath);
440
+
441
+ return (
442
+ <div>
443
+ {items.map((child) => {
444
+ const hasNestedChildren = child.children && child.children.length > 0;
445
+
446
+ // Folders with children render as collapsible nested items
447
+ if (hasNestedChildren) {
448
+ return <NestedFolderItem key={child.to + child.label} item={child} depth={depth} />;
449
+ }
450
+
451
+ // Items sharing parent path use label matching (e.g., Documents sub-filters).
452
+ // Items with unique paths use URL matching. Items whose path equals the
453
+ // parent path (e.g., folders) are never highlighted on the parent page.
454
+ const isActive = allShareParent
455
+ ? isOnParent && activeLabel === child.label
456
+ : child.to !== parentPath && currentPage === child.to;
457
+ return (
458
+ <NavLeafItem
459
+ key={child.to + child.label}
460
+ item={child}
461
+ isActive={isActive}
462
+ depthPl={childDepthPl}
463
+ onClick={() => {
464
+ setActiveLabel(child.label);
465
+ setNavContext({
466
+ ...navContext,
467
+ label: child.label,
468
+ description: child.description ?? navContext.description,
469
+ meta: child.meta ?? {},
470
+ contextInstructions:
471
+ [navContext.contextInstructions, child.contextInstructions]
472
+ .filter(Boolean)
473
+ .join("\n") || null,
474
+ });
475
+ navigate(child.to);
476
+ }}
477
+ />
478
+ );
479
+ })}
480
+ </div>
481
+ );
482
+ }
483
+
484
+ /** Check if current page is inside a nav item's subtree. */
485
+ function isInSubtree(currentPage: string, item: NavItem): boolean {
486
+ if (currentPage === item.to) return true;
487
+ return item.children?.some((c) => isInSubtree(currentPage, c)) ?? false;
488
+ }
489
+
490
+ /** Nested folder with collapsible children (used inside SubNavItems). */
491
+ function NestedFolderItem({ item, depth }: { item: NavItem; depth: number }) {
492
+ const navigate = useNavigate();
493
+ const currentPage = useAppStore((s) => s.currentPage);
494
+ const setNavContext = useAppStore((s) => s.setNavContext);
495
+ const navContext = useAppStore((s) => s.navContext);
496
+ const isInsideSubtree = isInSubtree(currentPage, item);
497
+ const isActive = currentPage === item.to;
498
+ const isAncestorActive = isInsideSubtree && !isActive;
499
+ const [manualOverride, setManualOverride] = useState<boolean | null>(null);
500
+ const isExpanded = manualOverride ?? isInsideSubtree;
501
+ const { draggableProps, dropTargetProps, isDragOver } = useNavItemDnd(item.dnd);
502
+ const depthPl = getNestedNavIndent(depth);
503
+
504
+ const isDisabled = item.disabled === true;
505
+
506
+ return (
507
+ <div {...(isDisabled ? {} : dropTargetProps)}>
508
+ <div
509
+ {...(isDisabled ? {} : draggableProps)}
510
+ className={cn(
511
+ "group relative flex items-center w-full",
512
+ "rounded-xl border border-transparent transition-colors",
513
+ isDisabled
514
+ ? "opacity-40"
515
+ : isActive
516
+ ? ACTIVE_NAV_ROW_CLASS
517
+ : "hover:bg-sidebar-accent/70",
518
+ isDragOver &&
519
+ !isDisabled &&
520
+ "ring-2 ring-inset ring-sidebar-ring/50 bg-sidebar-selected",
521
+ item.dimmed && !isDisabled && "opacity-50",
522
+ )}
523
+ >
524
+ <button
525
+ type="button"
526
+ disabled={isDisabled}
527
+ onClick={() => {
528
+ if (isDisabled) return;
529
+ setManualOverride(true);
530
+ setNavContext({
531
+ ...navContext,
532
+ label: item.label,
533
+ description: item.description ?? navContext.description,
534
+ meta: item.meta ?? {},
535
+ contextInstructions:
536
+ [navContext.contextInstructions, item.contextInstructions]
537
+ .filter(Boolean)
538
+ .join("\n") || null,
539
+ });
540
+ navigate(item.to);
541
+ }}
542
+ className={cn(
543
+ "flex items-center gap-2 flex-1 min-w-0",
544
+ depthPl,
545
+ "pr-3 py-1.5 text-sm text-left transition-[padding] duration-150 group-hover:pr-16 group-focus-within:pr-16",
546
+ isDisabled
547
+ ? "text-sidebar-foreground/45 cursor-default"
548
+ : isActive
549
+ ? ACTIVE_NAV_TEXT_CLASS
550
+ : isAncestorActive
551
+ ? "text-sidebar-foreground font-medium"
552
+ : "text-sidebar-foreground/80",
553
+ )}
554
+ >
555
+ <item.icon
556
+ className={cn(
557
+ "size-3.5 shrink-0",
558
+ isActive && !isDisabled
559
+ ? ACTIVE_NAV_ICON_CLASS
560
+ : isAncestorActive
561
+ ? "text-sidebar-foreground/80"
562
+ : "text-sidebar-foreground/60",
563
+ )}
564
+ />
565
+ <span className="truncate">{item.label}</span>
566
+ {item.badge && (
567
+ <span
568
+ className={cn(
569
+ "shrink-0 rounded px-1 py-0.5 text-[10px] font-medium",
570
+ isActive ? ACTIVE_NAV_BADGE_CLASS : "bg-sidebar-accent text-sidebar-accent-foreground",
571
+ )}
572
+ >
573
+ {item.badge}
574
+ </span>
575
+ )}
576
+ </button>
577
+ <NavRowControls
578
+ item={item}
579
+ hasChildren
580
+ isExpanded={isExpanded}
581
+ onToggleExpand={() => setManualOverride(!isExpanded)}
582
+ />
583
+ </div>
584
+ {isExpanded && item.children && (
585
+ <div>
586
+ {item.children.map((child) => {
587
+ const hasNestedChildren = child.children && child.children.length > 0;
588
+ if (hasNestedChildren) {
589
+ return (
590
+ <NestedFolderItem key={child.to + child.label} item={child} depth={depth + 1} />
591
+ );
592
+ }
593
+ const isChildActive = currentPage === child.to;
594
+ return (
595
+ <NavLeafItem
596
+ key={child.to + child.label}
597
+ item={child}
598
+ isActive={isChildActive}
599
+ depthPl={getNestedNavIndent(depth + 1)}
600
+ onClick={() => {
601
+ setNavContext({
602
+ ...navContext,
603
+ label: child.label,
604
+ description: child.description ?? navContext.description,
605
+ meta: child.meta ?? {},
606
+ contextInstructions:
607
+ [navContext.contextInstructions, child.contextInstructions]
608
+ .filter(Boolean)
609
+ .join("\n") || null,
610
+ });
611
+ navigate(child.to);
612
+ }}
613
+ />
614
+ );
615
+ })}
616
+ {item.dnd?.canDrop() && <ContainerDropZone dnd={item.dnd} />}
617
+ </div>
618
+ )}
619
+ </div>
620
+ );
621
+ }
622
+
623
+ function CollapsibleNavItem({ item }: { item: NavItem }) {
624
+ const navigate = useNavigate();
625
+ const currentPage = useAppStore((s) => s.currentPage);
626
+ const setNavContext = useAppStore((s) => s.setNavContext);
627
+ const navContext = useAppStore((s) => s.navContext);
628
+ const isInsideSubtree = isInSubtree(currentPage, item);
629
+ const isActive = currentPage === item.to;
630
+ const isAncestorActive = isInsideSubtree && !isActive;
631
+ const [manualOverride, setManualOverride] = useState<boolean | null>(null);
632
+ const isExpanded = manualOverride ?? isInsideSubtree;
633
+ const hasChildren = item.children && item.children.length > 0;
634
+ const isDisabled = item.disabled === true;
635
+ const hasActions = !isDisabled && item.actions && item.actions.length > 0;
636
+ const { draggableProps, dropTargetProps, isDragOver } = useNavItemDnd(item.dnd);
637
+
638
+ if (hasChildren || hasActions) {
639
+ const inner = (selected: boolean, ancestor: boolean) => (
640
+ <div
641
+ className={cn(
642
+ "group relative flex items-center w-full rounded-xl border border-transparent transition-colors",
643
+ isDisabled
644
+ ? "opacity-40"
645
+ : selected
646
+ ? ACTIVE_NAV_ROW_CLASS
647
+ : "hover:bg-sidebar-accent/70",
648
+ isDragOver &&
649
+ !isDisabled &&
650
+ "ring-2 ring-inset ring-sidebar-ring/50 bg-sidebar-selected",
651
+ item.dimmed && !isDisabled && "opacity-50",
652
+ )}
653
+ >
654
+ <button
655
+ type="button"
656
+ onClick={() => {
657
+ if (isDisabled) return;
658
+ setNavContext({
659
+ ...navContext,
660
+ label: item.label,
661
+ description: item.description ?? navContext.description,
662
+ meta: item.meta ?? {},
663
+ contextInstructions:
664
+ [navContext.contextInstructions, item.contextInstructions]
665
+ .filter(Boolean)
666
+ .join("\n") || null,
667
+ });
668
+ if (hasChildren) setManualOverride(true);
669
+ navigate(item.to);
670
+ }}
671
+ className={cn(
672
+ "flex items-center gap-2 flex-1 min-w-0 px-3 py-2 text-sm text-left",
673
+ "pr-3 transition-[padding] duration-150 group-hover:pr-16 group-focus-within:pr-16",
674
+ isDisabled
675
+ ? "text-sidebar-foreground/45 cursor-default"
676
+ : selected
677
+ ? ACTIVE_NAV_TEXT_CLASS
678
+ : ancestor
679
+ ? "text-sidebar-foreground font-medium"
680
+ : "text-sidebar-foreground/85",
681
+ )}
682
+ disabled={isDisabled}
683
+ >
684
+ <item.icon
685
+ className={cn(
686
+ "size-4 shrink-0",
687
+ selected && !isDisabled
688
+ ? ACTIVE_NAV_ICON_CLASS
689
+ : ancestor
690
+ ? "text-sidebar-foreground/80"
691
+ : "text-sidebar-foreground/60",
692
+ )}
693
+ />
694
+ <span className="truncate">{item.label}</span>
695
+ {item.badge && (
696
+ <span
697
+ className={cn(
698
+ "shrink-0 rounded px-1 py-0.5 text-[10px] font-medium",
699
+ selected ? ACTIVE_NAV_BADGE_CLASS : "bg-sidebar-accent text-sidebar-accent-foreground",
700
+ )}
701
+ >
702
+ {item.badge}
703
+ </span>
704
+ )}
705
+ </button>
706
+ <NavRowControls
707
+ item={item}
708
+ hasChildren={Boolean(hasChildren)}
709
+ isExpanded={Boolean(isExpanded)}
710
+ onToggleExpand={hasChildren ? () => setManualOverride(!isExpanded) : undefined}
711
+ />
712
+ </div>
713
+ );
714
+
715
+ return (
716
+ <div {...(isDisabled ? {} : dropTargetProps)} {...(isDisabled ? {} : draggableProps)}>
717
+ {inner(isActive, isAncestorActive)}
718
+ {hasChildren && isExpanded && (
719
+ <>
720
+ <SubNavItems items={item.children!} parentPath={item.to} />
721
+ {item.dnd?.canDrop() && <ContainerDropZone dnd={item.dnd} />}
722
+ </>
723
+ )}
724
+ </div>
725
+ );
726
+ }
727
+
728
+ if (isDisabled) {
729
+ return (
730
+ <div
731
+ className={cn(
732
+ "flex items-center gap-2 w-full opacity-40",
733
+ "rounded-lg px-3 py-2 text-left text-sm text-sidebar-foreground/45",
734
+ )}
735
+ >
736
+ <item.icon className="size-4 shrink-0 text-sidebar-foreground/45" />
737
+ <span className="truncate">{item.label}</span>
738
+ </div>
739
+ );
740
+ }
741
+
742
+ return (
743
+ <NavLink
744
+ to={item.to}
745
+ {...draggableProps}
746
+ {...dropTargetProps}
747
+ className={({ isActive }) =>
748
+ cn(
749
+ "flex items-center gap-2 w-full",
750
+ "rounded-xl px-3 py-2 text-sm text-left",
751
+ "text-sidebar-foreground/85 transition-colors hover:bg-sidebar-accent/70",
752
+ isActive && cn(ACTIVE_NAV_ROW_CLASS, ACTIVE_NAV_TEXT_CLASS),
753
+ isDragOver && "bg-sidebar-selected ring-2 ring-inset ring-sidebar-ring/50",
754
+ item.dimmed && "opacity-50",
755
+ )
756
+ }
757
+ >
758
+ {({ isActive }) => (
759
+ <>
760
+ <item.icon
761
+ className={cn(
762
+ "size-4 shrink-0",
763
+ isActive ? ACTIVE_NAV_ICON_CLASS : "text-sidebar-foreground/60",
764
+ )}
765
+ />
766
+ <span className="truncate">{item.label}</span>
767
+ {item.badge && (
768
+ <span
769
+ className={cn(
770
+ "shrink-0 rounded px-1 py-0.5 text-[10px] font-medium",
771
+ isActive
772
+ ? ACTIVE_NAV_BADGE_CLASS
773
+ : "bg-sidebar-accent text-sidebar-accent-foreground",
774
+ )}
775
+ >
776
+ {item.badge}
777
+ </span>
778
+ )}
779
+ </>
780
+ )}
781
+ </NavLink>
782
+ );
783
+ }
784
+
785
+ function SidebarResizeHandle() {
786
+ const setWidth = useSidebarStore((s) => s.setWidth);
787
+ const width = useSidebarStore((s) => s.width);
788
+ const dragState = useRef<{ startX: number; startWidth: number; pointerId: number } | null>(null);
789
+
790
+ useEffect(() => {
791
+ const onPointerMove = (event: PointerEvent) => {
792
+ if (!dragState.current) return;
793
+ const nextWidth = dragState.current.startWidth + (event.clientX - dragState.current.startX);
794
+ setWidth(nextWidth);
795
+ };
796
+
797
+ const onPointerUp = (event: PointerEvent) => {
798
+ if (dragState.current && event.pointerId !== dragState.current.pointerId) return;
799
+ dragState.current = null;
800
+ document.body.style.cursor = "";
801
+ document.body.style.userSelect = "";
802
+ };
803
+
804
+ window.addEventListener("pointermove", onPointerMove);
805
+ window.addEventListener("pointerup", onPointerUp);
806
+ window.addEventListener("pointercancel", onPointerUp);
807
+ return () => {
808
+ window.removeEventListener("pointermove", onPointerMove);
809
+ window.removeEventListener("pointerup", onPointerUp);
810
+ window.removeEventListener("pointercancel", onPointerUp);
811
+ };
812
+ }, [setWidth]);
813
+
814
+ return (
815
+ <div
816
+ className="absolute inset-y-0 right-0 z-20 hidden w-3 cursor-col-resize md:block"
817
+ onPointerDown={(event) => {
818
+ event.preventDefault();
819
+ dragState.current = {
820
+ startX: event.clientX,
821
+ startWidth: width,
822
+ pointerId: event.pointerId,
823
+ };
824
+ document.body.style.cursor = "col-resize";
825
+ document.body.style.userSelect = "none";
826
+ }}
827
+ onKeyDown={(event) => {
828
+ if (event.key === "ArrowLeft") {
829
+ event.preventDefault();
830
+ setWidth(width - 16);
831
+ }
832
+ if (event.key === "ArrowRight") {
833
+ event.preventDefault();
834
+ setWidth(width + 16);
835
+ }
836
+ if (event.key === "Home") {
837
+ event.preventDefault();
838
+ setWidth(SIDEBAR_MIN_WIDTH);
839
+ }
840
+ if (event.key === "End") {
841
+ event.preventDefault();
842
+ setWidth(SIDEBAR_MAX_WIDTH);
843
+ }
844
+ }}
845
+ role="separator"
846
+ aria-orientation="vertical"
847
+ aria-label="Resize navigation sidebar"
848
+ aria-valuemin={SIDEBAR_MIN_WIDTH}
849
+ aria-valuemax={SIDEBAR_MAX_WIDTH}
850
+ aria-valuenow={Math.round(width)}
851
+ tabIndex={0}
852
+ >
853
+ <div className="absolute inset-y-6 right-0 flex w-3 items-center justify-center opacity-0 transition-opacity group-hover/sidebar:opacity-100">
854
+ <div className="flex h-14 w-1.5 items-center justify-center rounded-full bg-border/90 text-muted-foreground shadow-sm">
855
+ <GripVertical className="size-3" />
856
+ </div>
857
+ </div>
858
+ </div>
859
+ );
860
+ }
861
+
862
+ function ThreadIndicator() {
863
+ const threadActive = useAppStore((s) => s.threadActive);
864
+ if (!threadActive) return null;
865
+
866
+ return (
867
+ <div className="px-4 pb-2 flex items-center gap-2">
868
+ <span className="text-xs text-muted-foreground">Active thread</span>
869
+ <div className="size-1.5 rounded-full bg-primary animate-pulse" />
870
+ </div>
871
+ );
872
+ }
873
+
874
+ function CollapsedNavShortcut({
875
+ to,
876
+ label,
877
+ icon: Icon,
878
+ isActive,
879
+ }: {
880
+ to: string;
881
+ label: string;
882
+ icon: typeof Layers;
883
+ isActive: boolean;
884
+ }) {
885
+ return (
886
+ <NavLink key={to} to={to}>
887
+ <TooltipIconButton
888
+ tooltip={label}
889
+ side="right"
890
+ className={cn(isActive && "border border-transparent bg-sidebar-selected text-sidebar-foreground")}
891
+ >
892
+ <Icon className={cn("size-4", isActive && "text-primary")} />
893
+ </TooltipIconButton>
894
+ </NavLink>
895
+ );
896
+ }
897
+
898
+ function SidebarContent({ collapsed }: { collapsed: boolean }) {
899
+ const toggle = useSidebarStore((s) => s.toggle);
900
+ const handleNewConversation = useNewConversation();
901
+ const navConfig = useNavStore((s) => s.config);
902
+ const currentPage = useAppStore((s) => s.currentPage);
903
+ const navContext = useAppStore((s) => s.navContext);
904
+
905
+ const preventNativeFileDrop = useCallback((e: React.DragEvent) => {
906
+ if (!e.dataTransfer.types.includes("Files")) return;
907
+ e.preventDefault();
908
+ }, []);
909
+
910
+ if (collapsed) {
911
+ return (
912
+ <div className="flex flex-col items-center gap-1 py-3 flex-1">
913
+ <TooltipIconButton tooltip="Expand sidebar" side="right" onClick={toggle}>
914
+ <PanelLeftOpen className="size-5" />
915
+ </TooltipIconButton>
916
+ <TooltipIconButton tooltip="New Thread" side="right" onClick={handleNewConversation}>
917
+ <SquarePen className="size-4" />
918
+ </TooltipIconButton>
919
+ <div className="mt-4 flex flex-col items-center gap-1">
920
+ {navConfig.flatMap((group) => {
921
+ // Modules with a non-flat collapsed rail (e.g. Spaces' "header + active workspace")
922
+ // supply `collapsedRail`; the shell stays module-agnostic.
923
+ if (group.collapsedRail) {
924
+ const items = group.collapsedRail({
925
+ currentPage,
926
+ navContextMeta: navContext.meta ?? {},
927
+ });
928
+ return items.map(({ to, label, icon, isActive }) => (
929
+ <CollapsedNavShortcut
930
+ key={to}
931
+ to={to}
932
+ label={label}
933
+ icon={icon}
934
+ isActive={isActive}
935
+ />
936
+ ));
937
+ }
938
+ return group.items.map(({ to, label, icon }) => (
939
+ <CollapsedNavShortcut
940
+ key={to}
941
+ to={to}
942
+ label={label}
943
+ icon={icon}
944
+ isActive={currentPage === to}
945
+ />
946
+ ));
947
+ })}
948
+ </div>
949
+ </div>
950
+ );
951
+ }
952
+
953
+ return (
954
+ <div className="flex flex-col flex-1 min-h-0">
955
+ {/* Header */}
956
+ <div className="flex items-center justify-between px-3 py-3">
957
+ <NavLink to="/" className="flex items-center gap-2 hover:opacity-80 transition-opacity">
958
+ <AppBrand
959
+ iconClassName="text-primary"
960
+ labelClassName="truncate text-sm font-semibold text-sidebar-foreground"
961
+ />
962
+ </NavLink>
963
+ <TooltipIconButton tooltip="Collapse sidebar" side="right" onClick={toggle}>
964
+ <PanelLeft className="size-5" />
965
+ </TooltipIconButton>
966
+ </div>
967
+
968
+ {/* New Thread */}
969
+ <div className="px-1 mb-0.5">
970
+ <button
971
+ type="button"
972
+ onClick={handleNewConversation}
973
+ className={cn(
974
+ "flex items-center gap-2 w-full",
975
+ "rounded-xl px-3 py-2 text-sm text-left",
976
+ "text-sidebar-foreground/85 transition-colors hover:bg-sidebar-accent/70",
977
+ )}
978
+ >
979
+ <SquarePen className="size-4 shrink-0 text-sidebar-foreground/60" />
980
+ New Thread
981
+ </button>
982
+ </div>
983
+
984
+ <ThreadIndicator />
985
+
986
+ {/* Navigation groups */}
987
+ <nav
988
+ className="flex-1 overflow-y-auto px-1 pb-16"
989
+ onDragOverCapture={preventNativeFileDrop}
990
+ onDropCapture={preventNativeFileDrop}
991
+ >
992
+ {navConfig.map((group) => (
993
+ <div key={group.label}>
994
+ {!group.hideLabel && (
995
+ <div className="px-3 py-2">
996
+ {group.to ? (
997
+ <div className="flex items-center justify-between rounded-lg py-1.5 transition-colors hover:bg-sidebar-accent/70">
998
+ <NavLink to={group.to} end className="min-w-0 flex-1">
999
+ {({ isActive }) => (
1000
+ <span
1001
+ className={cn(
1002
+ "block text-xs font-medium uppercase tracking-wider",
1003
+ isActive
1004
+ ? "text-sidebar-selected-foreground"
1005
+ : "text-sidebar-foreground/65 hover:text-sidebar-foreground",
1006
+ )}
1007
+ >
1008
+ {group.label}
1009
+ </span>
1010
+ )}
1011
+ </NavLink>
1012
+ {group.createActions && group.createActions.length > 0 && (
1013
+ <DropdownMenu>
1014
+ <DropdownMenuTrigger asChild>
1015
+ <button
1016
+ type="button"
1017
+ className="inline-flex size-6 items-center justify-center text-sidebar-foreground/65 transition-colors hover:text-sidebar-foreground"
1018
+ aria-label={`Create in ${group.label}`}
1019
+ >
1020
+ <Plus className="size-4" />
1021
+ </button>
1022
+ </DropdownMenuTrigger>
1023
+ <DropdownMenuContent align="end" className="w-48">
1024
+ {group.createActions.map((action) => (
1025
+ <DropdownMenuItem
1026
+ key={action.id}
1027
+ onSelect={(e) => {
1028
+ e.preventDefault();
1029
+ action.handler();
1030
+ }}
1031
+ >
1032
+ {action.label}
1033
+ </DropdownMenuItem>
1034
+ ))}
1035
+ </DropdownMenuContent>
1036
+ </DropdownMenu>
1037
+ )}
1038
+ </div>
1039
+ ) : (
1040
+ <span className="text-xs font-medium uppercase tracking-wider text-sidebar-foreground/65">
1041
+ {group.label}
1042
+ </span>
1043
+ )}
1044
+ </div>
1045
+ )}
1046
+ {group.items.map((item) => (
1047
+ <CollapsibleNavItem key={item.to + item.label} item={item} />
1048
+ ))}
1049
+ </div>
1050
+ ))}
1051
+ </nav>
1052
+
1053
+ {/* Footer actions — registered by feature modules */}
1054
+ <SidebarFooterActions />
1055
+ </div>
1056
+ );
1057
+ }
1058
+
1059
+ function SidebarFooterActions() {
1060
+ const footerActions = useNavStore((s) => s.footerActions);
1061
+ if (footerActions.length === 0) return null;
1062
+
1063
+ return (
1064
+ <div className="shrink-0 space-y-0.5 px-3 py-2">
1065
+ {footerActions.map((action) => (
1066
+ <button
1067
+ key={action.id}
1068
+ type="button"
1069
+ onClick={action.onToggle}
1070
+ className={cn(
1071
+ "w-full flex items-center gap-2 px-2 py-1.5 rounded-md",
1072
+ "text-xs text-sidebar-foreground/70 transition-colors hover:bg-sidebar-accent hover:text-sidebar-foreground",
1073
+ )}
1074
+ >
1075
+ <span
1076
+ className={cn(
1077
+ "size-2 rounded-full border",
1078
+ action.active
1079
+ ? "border-sidebar-primary bg-sidebar-primary"
1080
+ : "border-sidebar-foreground/30",
1081
+ )}
1082
+ />
1083
+ {action.active ? (action.activeLabel ?? action.label) : action.label}
1084
+ </button>
1085
+ ))}
1086
+ </div>
1087
+ );
1088
+ }
1089
+
1090
+ /** Mobile drawer overlay */
1091
+ function MobileDrawer() {
1092
+ const isMobileOpen = useSidebarStore((s) => s.isMobileOpen);
1093
+ const closeMobile = useSidebarStore((s) => s.closeMobile);
1094
+ const isToolPanelOpen = useToolPanelStore((s) => s.isOpen);
1095
+ const width = useSidebarStore((s) => s.width);
1096
+ const drawerWidth = Math.min(width, typeof window !== "undefined" ? window.innerWidth - 24 : width);
1097
+
1098
+ // Close on Escape — but only if ToolPanel is not open (it has its own Escape handler)
1099
+ useEffect(() => {
1100
+ if (!isMobileOpen) return;
1101
+
1102
+ const handleEscape = (e: KeyboardEvent) => {
1103
+ if (e.key === "Escape" && !isToolPanelOpen) {
1104
+ closeMobile();
1105
+ }
1106
+ };
1107
+
1108
+ window.addEventListener("keydown", handleEscape);
1109
+ return () => window.removeEventListener("keydown", handleEscape);
1110
+ }, [isMobileOpen, isToolPanelOpen, closeMobile]);
1111
+
1112
+ return (
1113
+ <>
1114
+ {/* Backdrop */}
1115
+ <div
1116
+ className={cn(
1117
+ "fixed inset-0 z-50 bg-black/50 transition-opacity duration-200",
1118
+ isMobileOpen ? "opacity-100" : "opacity-0 pointer-events-none",
1119
+ )}
1120
+ onClick={closeMobile}
1121
+ aria-hidden="true"
1122
+ />
1123
+
1124
+ {/* Drawer */}
1125
+ <aside
1126
+ className={cn(
1127
+ "fixed top-0 left-0 z-50 h-dvh",
1128
+ "flex flex-col",
1129
+ "border-r border-sidebar-border/60 bg-sidebar text-sidebar-foreground shadow-xl",
1130
+ "transition-transform duration-200 ease-in-out",
1131
+ isMobileOpen ? "translate-x-0" : "-translate-x-full",
1132
+ )}
1133
+ style={{ width: drawerWidth }}
1134
+ role="complementary"
1135
+ aria-label="Navigation sidebar"
1136
+ >
1137
+ <SidebarContent collapsed={false} />
1138
+ </aside>
1139
+ </>
1140
+ );
1141
+ }
1142
+
1143
+ /** Desktop inline sidebar (expanded or collapsed rail) */
1144
+ function DesktopSidebar() {
1145
+ const isOpen = useSidebarStore((s) => s.isOpen);
1146
+ const width = useSidebarStore((s) => s.width);
1147
+
1148
+ return (
1149
+ <aside
1150
+ className={cn(
1151
+ "group/sidebar relative hidden md:flex flex-col shrink-0",
1152
+ "h-full",
1153
+ "border-r border-sidebar-border/60 bg-sidebar text-sidebar-foreground",
1154
+ "transition-[width] duration-200 ease-in-out",
1155
+ "overflow-hidden",
1156
+ )}
1157
+ style={{ width: isOpen ? width : RAIL_WIDTH }}
1158
+ role="complementary"
1159
+ aria-label="Navigation sidebar"
1160
+ >
1161
+ <SidebarContent collapsed={!isOpen} />
1162
+ {isOpen && <SidebarResizeHandle />}
1163
+ </aside>
1164
+ );
1165
+ }
1166
+
1167
+ export function Sidebar() {
1168
+ return (
1169
+ <>
1170
+ <MobileDrawer />
1171
+ <DesktopSidebar />
1172
+ </>
1173
+ );
1174
+ }