@agent-native/toolkit 0.19.7 → 0.19.8-nightly-20260910195009

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.
@@ -0,0 +1,1000 @@
1
+ import {
2
+ IconChevronDown,
3
+ IconLayoutSidebarLeftCollapse,
4
+ IconLayoutSidebarLeftExpand,
5
+ IconMessageCircle,
6
+ } from "@tabler/icons-react";
7
+ import {
8
+ createContext,
9
+ forwardRef,
10
+ isValidElement,
11
+ useCallback,
12
+ useContext,
13
+ useMemo,
14
+ useState,
15
+ type ComponentType,
16
+ type HTMLAttributes,
17
+ type MouseEvent,
18
+ type ReactNode,
19
+ } from "react";
20
+
21
+ import {
22
+ Collapsible,
23
+ CollapsibleContent,
24
+ CollapsibleTrigger,
25
+ } from "../ui/collapsible.js";
26
+ import {
27
+ Tooltip,
28
+ TooltipContent,
29
+ TooltipProvider,
30
+ TooltipTrigger,
31
+ } from "../ui/tooltip.js";
32
+ import { cn } from "../utils.js";
33
+ import { usePersistentSidebarCollapsed } from "./use-persistent-sidebar-collapsed.js";
34
+
35
+ // ---------------------------------------------------------------------------
36
+ // Context
37
+ // ---------------------------------------------------------------------------
38
+
39
+ export type AppSidebarLinkComponent = ComponentType<{
40
+ to?: string;
41
+ href?: string;
42
+ className?: string;
43
+ onClick?: (event: MouseEvent) => void;
44
+ children?: ReactNode;
45
+ "aria-label"?: string;
46
+ }>;
47
+
48
+ export interface AppSidebarContextValue {
49
+ collapsed: boolean;
50
+ setCollapsed: (collapsed: boolean | ((current: boolean) => boolean)) => void;
51
+ toggleCollapsed: () => void;
52
+ isMobile: boolean;
53
+ /** Router-aware link. Defaults to a native `<a href>`. */
54
+ LinkComponent: AppSidebarLinkComponent;
55
+ }
56
+
57
+ const AppSidebarContext = createContext<AppSidebarContextValue | null>(null);
58
+
59
+ function NativeSidebarLink({
60
+ to,
61
+ href,
62
+ className,
63
+ onClick,
64
+ children,
65
+ "aria-label": ariaLabel,
66
+ }: {
67
+ to?: string;
68
+ href?: string;
69
+ className?: string;
70
+ onClick?: (event: MouseEvent) => void;
71
+ children?: ReactNode;
72
+ "aria-label"?: string;
73
+ }) {
74
+ return (
75
+ <a
76
+ href={to ?? href}
77
+ className={className}
78
+ onClick={onClick}
79
+ aria-label={ariaLabel}
80
+ >
81
+ {children}
82
+ </a>
83
+ );
84
+ }
85
+
86
+ const defaultSidebarContextValue: AppSidebarContextValue = {
87
+ collapsed: false,
88
+ setCollapsed: () => {},
89
+ toggleCollapsed: () => {},
90
+ isMobile: false,
91
+ LinkComponent: NativeSidebarLink,
92
+ };
93
+
94
+ export function useAppSidebar(): AppSidebarContextValue {
95
+ const context = useContext(AppSidebarContext);
96
+ return context ?? defaultSidebarContextValue;
97
+ }
98
+
99
+ function renderSidebarIcon(
100
+ icon?: ComponentType<{ className?: string }> | ReactNode,
101
+ className = "size-4 shrink-0 text-primary",
102
+ ): ReactNode {
103
+ if (!icon) return null;
104
+ if (isValidElement(icon)) {
105
+ return icon;
106
+ }
107
+ const IconComponent = icon as ComponentType<{ className?: string }>;
108
+ return <IconComponent className={className} />;
109
+ }
110
+
111
+ // ---------------------------------------------------------------------------
112
+ // Item Definition Types
113
+ // ---------------------------------------------------------------------------
114
+
115
+ export interface AppSidebarItemDefinition {
116
+ id?: string;
117
+ to?: string;
118
+ href?: string;
119
+ label: string;
120
+ icon?: ComponentType<{ className?: string }> | ReactNode;
121
+ active?: boolean;
122
+ count?: number | string;
123
+ badge?: ReactNode;
124
+ actions?: ReactNode;
125
+ onClick?: (event: MouseEvent) => void;
126
+ children?: ReactNode;
127
+ }
128
+
129
+ // ---------------------------------------------------------------------------
130
+ // Header
131
+ // ---------------------------------------------------------------------------
132
+
133
+ export interface AppSidebarHeaderProps extends HTMLAttributes<HTMLDivElement> {
134
+ brandName?: ReactNode;
135
+ brandHref?: string;
136
+ brandIcon?: ReactNode;
137
+ brandLink?: ReactNode;
138
+ badge?: ReactNode;
139
+ onBrandClick?: (event: MouseEvent) => void;
140
+ collapsed?: boolean;
141
+ }
142
+
143
+ export const AppSidebarHeader = forwardRef<
144
+ HTMLDivElement,
145
+ AppSidebarHeaderProps
146
+ >(
147
+ (
148
+ {
149
+ brandName,
150
+ brandHref = "/",
151
+ brandIcon,
152
+ brandLink,
153
+ badge,
154
+ onBrandClick,
155
+ collapsed: propCollapsed,
156
+ className,
157
+ children,
158
+ ...props
159
+ },
160
+ ref,
161
+ ) => {
162
+ const context = useAppSidebar();
163
+ const collapsed = propCollapsed ?? context.collapsed;
164
+ const LinkComponent = context.LinkComponent;
165
+
166
+ return (
167
+ <div
168
+ ref={ref}
169
+ data-sidebar-header
170
+ className={cn(
171
+ "flex h-14 shrink-0 items-center border-b border-border",
172
+ collapsed ? "flex-col justify-center gap-0.5 px-2" : "gap-2 px-4",
173
+ className,
174
+ )}
175
+ {...props}
176
+ >
177
+ {brandLink ?? (
178
+ <LinkComponent
179
+ to={brandHref}
180
+ href={brandHref}
181
+ aria-label={typeof brandName === "string" ? brandName : undefined}
182
+ onClick={onBrandClick}
183
+ className={cn(
184
+ "flex min-w-0 items-center gap-2 rounded text-start outline-none focus-visible:ring-2 focus-visible:ring-ring",
185
+ collapsed ? "size-8 justify-center" : "shrink-0",
186
+ )}
187
+ >
188
+ {brandIcon}
189
+ {!collapsed && brandName && (
190
+ <span className="truncate text-sm font-semibold text-primary">
191
+ {brandName}
192
+ </span>
193
+ )}
194
+ </LinkComponent>
195
+ )}
196
+ {badge}
197
+ {children}
198
+ </div>
199
+ );
200
+ },
201
+ );
202
+ AppSidebarHeader.displayName = "AppSidebarHeader";
203
+
204
+ // ---------------------------------------------------------------------------
205
+ // Nav Item
206
+ // ---------------------------------------------------------------------------
207
+
208
+ export interface AppSidebarNavItemProps extends HTMLAttributes<HTMLDivElement> {
209
+ label: ReactNode;
210
+ icon?: ComponentType<{ className?: string }> | ReactNode;
211
+ to?: string;
212
+ href?: string;
213
+ active?: boolean;
214
+ count?: number | string;
215
+ badge?: ReactNode;
216
+ actions?: ReactNode;
217
+ onClick?: (event: MouseEvent) => void;
218
+ asChild?: boolean;
219
+ tooltipSide?: "right" | "top" | "bottom" | "left";
220
+ }
221
+
222
+ export const AppSidebarNavItem = forwardRef<
223
+ HTMLDivElement,
224
+ AppSidebarNavItemProps
225
+ >(
226
+ (
227
+ {
228
+ label,
229
+ icon,
230
+ to,
231
+ href,
232
+ active = false,
233
+ count,
234
+ badge,
235
+ actions,
236
+ onClick,
237
+ asChild = false,
238
+ tooltipSide = "right",
239
+ className,
240
+ children,
241
+ ...props
242
+ },
243
+ ref,
244
+ ) => {
245
+ const { collapsed, LinkComponent } = useAppSidebar();
246
+ const linkHref = to ?? href;
247
+
248
+ if (collapsed) {
249
+ const tooltipLabel = typeof label === "string" ? label : undefined;
250
+ const content = asChild ? (
251
+ children
252
+ ) : linkHref ? (
253
+ <LinkComponent
254
+ to={to}
255
+ href={href ?? to}
256
+ aria-label={tooltipLabel}
257
+ onClick={onClick}
258
+ className={cn(
259
+ "flex size-9 items-center justify-center rounded-md text-primary hover:bg-accent/60 hover:text-primary",
260
+ active &&
261
+ "bg-primary/10 text-primary hover:bg-primary/10 hover:text-primary",
262
+ className,
263
+ )}
264
+ >
265
+ {renderSidebarIcon(icon)}
266
+ </LinkComponent>
267
+ ) : (
268
+ <button
269
+ type="button"
270
+ aria-label={tooltipLabel}
271
+ onClick={onClick}
272
+ className={cn(
273
+ "flex size-9 items-center justify-center rounded-md text-primary hover:bg-accent/60 hover:text-primary",
274
+ active &&
275
+ "bg-primary/10 text-primary hover:bg-primary/10 hover:text-primary",
276
+ className,
277
+ )}
278
+ >
279
+ {renderSidebarIcon(icon)}
280
+ </button>
281
+ );
282
+
283
+ return (
284
+ <Tooltip>
285
+ <TooltipTrigger asChild>{content}</TooltipTrigger>
286
+ <TooltipContent side={tooltipSide}>{label}</TooltipContent>
287
+ </Tooltip>
288
+ );
289
+ }
290
+
291
+ if (asChild) {
292
+ return (
293
+ <div
294
+ ref={ref}
295
+ className={cn(
296
+ "group flex items-center rounded",
297
+ active
298
+ ? "bg-primary/10 font-medium text-primary"
299
+ : "text-primary hover:bg-accent/60",
300
+ className,
301
+ )}
302
+ {...props}
303
+ >
304
+ {children}
305
+ {actions}
306
+ </div>
307
+ );
308
+ }
309
+
310
+ const innerContent = (
311
+ <>
312
+ {renderSidebarIcon(icon)}
313
+ <span className="flex-1 truncate text-primary">{label}</span>
314
+ {count !== undefined &&
315
+ (typeof count === "number" ? count > 0 : Boolean(count)) && (
316
+ <span className="shrink-0 tabular-nums text-[11px] text-primary/80">
317
+ {count}
318
+ </span>
319
+ )}
320
+ {badge}
321
+ </>
322
+ );
323
+
324
+ return (
325
+ <div
326
+ ref={ref}
327
+ className={cn(
328
+ "group flex items-center rounded",
329
+ active
330
+ ? "bg-primary/10 font-medium text-primary"
331
+ : "text-primary hover:bg-accent/60",
332
+ className,
333
+ )}
334
+ {...props}
335
+ >
336
+ {linkHref ? (
337
+ <LinkComponent
338
+ to={to}
339
+ href={href ?? to}
340
+ onClick={onClick}
341
+ className="flex min-w-0 flex-1 items-center gap-2 px-2 py-1.5 text-xs text-primary"
342
+ >
343
+ {innerContent}
344
+ </LinkComponent>
345
+ ) : (
346
+ <button
347
+ type="button"
348
+ onClick={onClick}
349
+ className="flex min-w-0 flex-1 items-center gap-2 px-2 py-1.5 text-xs text-primary"
350
+ >
351
+ {innerContent}
352
+ </button>
353
+ )}
354
+ {actions}
355
+ </div>
356
+ );
357
+ },
358
+ );
359
+ AppSidebarNavItem.displayName = "AppSidebarNavItem";
360
+
361
+ // ---------------------------------------------------------------------------
362
+ // Nav Group (Collapsible)
363
+ // ---------------------------------------------------------------------------
364
+
365
+ export interface AppSidebarNavGroupProps extends HTMLAttributes<HTMLDivElement> {
366
+ label: ReactNode;
367
+ icon?: ComponentType<{ className?: string }> | ReactNode;
368
+ to?: string;
369
+ href?: string;
370
+ active?: boolean;
371
+ count?: number | string;
372
+ open?: boolean;
373
+ onOpenChange?: (open: boolean) => void;
374
+ defaultOpen?: boolean;
375
+ actions?: ReactNode;
376
+ children: ReactNode;
377
+ }
378
+
379
+ export const AppSidebarNavGroup = forwardRef<
380
+ HTMLDivElement,
381
+ AppSidebarNavGroupProps
382
+ >(
383
+ (
384
+ {
385
+ label,
386
+ icon,
387
+ to,
388
+ href,
389
+ active = false,
390
+ count,
391
+ open: controlledOpen,
392
+ onOpenChange: controlledOnOpenChange,
393
+ defaultOpen = false,
394
+ actions,
395
+ children,
396
+ className,
397
+ ...props
398
+ },
399
+ ref,
400
+ ) => {
401
+ const { collapsed, LinkComponent } = useAppSidebar();
402
+ const [uncontrolledOpen, setUncontrolledOpen] = useState(defaultOpen);
403
+ const open = controlledOpen ?? uncontrolledOpen;
404
+ const onOpenChange = controlledOnOpenChange ?? setUncontrolledOpen;
405
+ const linkHref = to ?? href;
406
+
407
+ if (collapsed) {
408
+ const tooltipLabel = typeof label === "string" ? label : undefined;
409
+ return (
410
+ <Tooltip>
411
+ <TooltipTrigger asChild>
412
+ <LinkComponent
413
+ to={to}
414
+ href={href ?? to ?? "#"}
415
+ aria-label={tooltipLabel}
416
+ className={cn(
417
+ "flex size-9 items-center justify-center rounded-md text-primary hover:bg-accent/60 hover:text-primary",
418
+ active &&
419
+ "bg-primary/10 text-primary hover:bg-primary/10 hover:text-primary",
420
+ className,
421
+ )}
422
+ >
423
+ {renderSidebarIcon(icon)}
424
+ </LinkComponent>
425
+ </TooltipTrigger>
426
+ <TooltipContent side="right">{label}</TooltipContent>
427
+ </Tooltip>
428
+ );
429
+ }
430
+
431
+ return (
432
+ <Collapsible
433
+ open={open}
434
+ onOpenChange={onOpenChange}
435
+ className={cn("group/sidebar-nav", className)}
436
+ {...props}
437
+ >
438
+ <div
439
+ ref={ref}
440
+ className={cn(
441
+ "group flex items-center rounded",
442
+ active
443
+ ? "bg-primary/10 font-medium text-primary"
444
+ : "text-primary hover:bg-accent/60",
445
+ )}
446
+ >
447
+ {linkHref ? (
448
+ <LinkComponent
449
+ to={to}
450
+ href={href ?? to}
451
+ className="flex min-w-0 flex-1 items-center gap-2 px-2 py-1.5 text-xs text-primary"
452
+ >
453
+ {renderSidebarIcon(icon)}
454
+ <span className="flex-1 truncate text-primary">{label}</span>
455
+ {count !== undefined &&
456
+ (typeof count === "number" ? count > 0 : Boolean(count)) && (
457
+ <span className="shrink-0 tabular-nums text-[11px] text-primary/80">
458
+ {count}
459
+ </span>
460
+ )}
461
+ </LinkComponent>
462
+ ) : (
463
+ <div className="flex min-w-0 flex-1 items-center gap-2 px-2 py-1.5 text-xs text-primary">
464
+ {renderSidebarIcon(icon)}
465
+ <span className="flex-1 truncate text-primary">{label}</span>
466
+ {count !== undefined &&
467
+ (typeof count === "number" ? count > 0 : Boolean(count)) && (
468
+ <span className="shrink-0 tabular-nums text-[11px] text-primary/80">
469
+ {count}
470
+ </span>
471
+ )}
472
+ </div>
473
+ )}
474
+ {actions}
475
+ <CollapsibleTrigger asChild>
476
+ <button
477
+ type="button"
478
+ aria-label={open ? "Collapse" : "Expand"}
479
+ className="me-1 flex size-7 shrink-0 items-center justify-center rounded text-primary hover:bg-accent hover:text-primary focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
480
+ >
481
+ <IconChevronDown
482
+ className={cn(
483
+ "size-3.5 transition-transform motion-reduce:transition-none",
484
+ open && "rotate-180",
485
+ )}
486
+ />
487
+ </button>
488
+ </CollapsibleTrigger>
489
+ </div>
490
+ <CollapsibleContent className="clips-collapsible-content">
491
+ <div className="ms-3.5 border-s border-border/70 ps-2">
492
+ {children}
493
+ </div>
494
+ </CollapsibleContent>
495
+ </Collapsible>
496
+ );
497
+ },
498
+ );
499
+ AppSidebarNavGroup.displayName = "AppSidebarNavGroup";
500
+
501
+ // ---------------------------------------------------------------------------
502
+ // Section / Divider
503
+ // ---------------------------------------------------------------------------
504
+
505
+ export interface AppSidebarSectionProps extends Omit<
506
+ HTMLAttributes<HTMLDivElement>,
507
+ "title"
508
+ > {
509
+ title?: ReactNode;
510
+ divider?: boolean;
511
+ }
512
+
513
+ export const AppSidebarSection = forwardRef<
514
+ HTMLDivElement,
515
+ AppSidebarSectionProps
516
+ >(({ title, divider = true, className, children, ...props }, ref) => {
517
+ const { collapsed } = useAppSidebar();
518
+
519
+ return (
520
+ <div
521
+ ref={ref}
522
+ data-sidebar-section
523
+ className={cn(
524
+ divider && "border-t border-border/70",
525
+ collapsed
526
+ ? "mt-2 flex flex-col items-center gap-1 pt-2"
527
+ : "mt-3 space-y-0.5 pt-3",
528
+ className,
529
+ )}
530
+ {...props}
531
+ >
532
+ {!collapsed && title && (
533
+ <p className="px-2 pb-1 text-[11px] font-medium uppercase tracking-wide text-muted-foreground/60">
534
+ {title}
535
+ </p>
536
+ )}
537
+ {children}
538
+ </div>
539
+ );
540
+ });
541
+ AppSidebarSection.displayName = "AppSidebarSection";
542
+
543
+ // ---------------------------------------------------------------------------
544
+ // Feedback Button
545
+ // ---------------------------------------------------------------------------
546
+
547
+ export interface AppSidebarFeedbackButtonProps {
548
+ label?: string;
549
+ icon?: ComponentType<{ className?: string }> | ReactNode;
550
+ onClick?: (event: MouseEvent) => void;
551
+ className?: string;
552
+ collapsed?: boolean;
553
+ }
554
+
555
+ export function AppSidebarFeedbackButton({
556
+ label = "Report an issue",
557
+ icon,
558
+ onClick,
559
+ className,
560
+ collapsed: propCollapsed,
561
+ }: AppSidebarFeedbackButtonProps) {
562
+ let contextCollapsed = false;
563
+ try {
564
+ const context = useAppSidebar();
565
+ contextCollapsed = context.collapsed;
566
+ } catch {
567
+ // coercion-ok: context is optional when AppSidebarFeedbackButton is used standalone
568
+ }
569
+ const isCollapsed = propCollapsed ?? contextCollapsed;
570
+
571
+ const button = (
572
+ <button
573
+ type="button"
574
+ aria-label={isCollapsed ? label : undefined}
575
+ onClick={onClick}
576
+ className={cn(
577
+ isCollapsed
578
+ ? "flex size-9 items-center justify-center rounded-md bg-transparent text-primary hover:bg-accent/60 hover:text-primary"
579
+ : "flex h-auto w-full items-center justify-start gap-2 rounded bg-transparent px-2 py-1.5 text-xs font-normal text-primary hover:bg-accent/60 hover:text-primary",
580
+ className,
581
+ )}
582
+ >
583
+ {renderSidebarIcon(icon, "size-4 shrink-0 text-primary") ?? (
584
+ <IconMessageCircle className="size-4 shrink-0 text-primary" />
585
+ )}
586
+ {!isCollapsed && <span>{label}</span>}
587
+ </button>
588
+ );
589
+
590
+ if (!isCollapsed) return button;
591
+
592
+ return (
593
+ <Tooltip>
594
+ <TooltipTrigger asChild>{button}</TooltipTrigger>
595
+ <TooltipContent side="right">{label}</TooltipContent>
596
+ </Tooltip>
597
+ );
598
+ }
599
+
600
+ // ---------------------------------------------------------------------------
601
+ // Footer
602
+ // ---------------------------------------------------------------------------
603
+
604
+ export interface AppSidebarFooterProps extends HTMLAttributes<HTMLDivElement> {
605
+ feedback?: ReactNode;
606
+ orgSwitcher?: ReactNode;
607
+ footerExtras?: ReactNode;
608
+ collapsible?: boolean;
609
+ expandLabel?: string;
610
+ collapseLabel?: string;
611
+ collapsed?: boolean;
612
+ onToggleCollapsed?: () => void;
613
+ }
614
+
615
+ export const AppSidebarFooter = forwardRef<
616
+ HTMLDivElement,
617
+ AppSidebarFooterProps
618
+ >(
619
+ (
620
+ {
621
+ feedback,
622
+ orgSwitcher,
623
+ footerExtras,
624
+ collapsible = true,
625
+ expandLabel = "Expand sidebar",
626
+ collapseLabel = "Collapse sidebar",
627
+ collapsed: propCollapsed,
628
+ onToggleCollapsed,
629
+ className,
630
+ children,
631
+ ...props
632
+ },
633
+ ref,
634
+ ) => {
635
+ const context = useAppSidebar();
636
+ const collapsed = propCollapsed ?? context.collapsed;
637
+ const toggleCollapsed = onToggleCollapsed ?? context.toggleCollapsed;
638
+
639
+ if (children) {
640
+ return (
641
+ <div
642
+ ref={ref}
643
+ data-sidebar-footer
644
+ className={cn(
645
+ "shrink-0 border-t border-border p-2",
646
+ collapsed ? "space-y-1" : "space-y-1.5",
647
+ className,
648
+ )}
649
+ {...props}
650
+ >
651
+ {children}
652
+ </div>
653
+ );
654
+ }
655
+
656
+ const collapseButton = collapsible ? (
657
+ <Tooltip>
658
+ <TooltipTrigger asChild>
659
+ <button
660
+ type="button"
661
+ aria-label={collapsed ? expandLabel : collapseLabel}
662
+ onClick={toggleCollapsed}
663
+ className="flex size-9 shrink-0 items-center justify-center rounded-md bg-transparent text-primary hover:bg-accent/60 hover:text-primary focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
664
+ >
665
+ {collapsed ? (
666
+ <IconLayoutSidebarLeftExpand className="size-4 rtl:-scale-x-100" />
667
+ ) : (
668
+ <IconLayoutSidebarLeftCollapse className="size-4 rtl:-scale-x-100" />
669
+ )}
670
+ </button>
671
+ </TooltipTrigger>
672
+ <TooltipContent side="right">
673
+ {collapsed ? expandLabel : collapseLabel}
674
+ </TooltipContent>
675
+ </Tooltip>
676
+ ) : null;
677
+
678
+ const resolvedFeedback =
679
+ feedback !== undefined ? (
680
+ feedback
681
+ ) : (
682
+ <AppSidebarFeedbackButton collapsed={collapsed} />
683
+ );
684
+
685
+ return (
686
+ <div
687
+ ref={ref}
688
+ data-sidebar-footer
689
+ className={cn(
690
+ "shrink-0 border-t border-border p-2",
691
+ collapsed ? "space-y-1" : "space-y-1.5",
692
+ className,
693
+ )}
694
+ {...props}
695
+ >
696
+ {resolvedFeedback}
697
+ <div
698
+ data-sidebar-footer-utilities
699
+ className={cn(
700
+ collapsed
701
+ ? "flex flex-col items-center gap-1"
702
+ : "flex items-center gap-0.5",
703
+ )}
704
+ >
705
+ {orgSwitcher}
706
+ {footerExtras}
707
+ {collapseButton}
708
+ </div>
709
+ </div>
710
+ );
711
+ },
712
+ );
713
+ AppSidebarFooter.displayName = "AppSidebarFooter";
714
+
715
+ // ---------------------------------------------------------------------------
716
+ // AppSidebar (Main Component)
717
+ // ---------------------------------------------------------------------------
718
+
719
+ export interface AppSidebarProps extends HTMLAttributes<HTMLElement> {
720
+ // Collapse state
721
+ collapsed?: boolean;
722
+ onCollapsedChange?: (collapsed: boolean) => void;
723
+ defaultCollapsed?: boolean;
724
+ collapsible?: boolean;
725
+ storageKey?: string;
726
+ isMobile?: boolean;
727
+ /**
728
+ * When set (boolean), the sidebar owns off-canvas mobile drawer positioning.
729
+ * Leave undefined when embedding inside an external Sheet/drawer so the
730
+ * sidebar stays visible without translate offsets.
731
+ */
732
+ mobileOpen?: boolean;
733
+ onMobileOpenChange?: (open: boolean) => void;
734
+ /** Router-aware link component. Defaults to a native `<a href>`. */
735
+ linkComponent?: AppSidebarLinkComponent;
736
+
737
+ // Header
738
+ brandName?: ReactNode;
739
+ brandHref?: string;
740
+ brandIcon?: ReactNode;
741
+ brandLink?: ReactNode;
742
+ badge?: ReactNode;
743
+ headerContent?: ReactNode;
744
+
745
+ // Items
746
+ items?: AppSidebarItemDefinition[];
747
+ secondaryItems?: AppSidebarItemDefinition[];
748
+
749
+ // Footer
750
+ feedback?: ReactNode;
751
+ orgSwitcher?: ReactNode;
752
+ footerExtras?: ReactNode;
753
+ footerContent?: ReactNode;
754
+ expandLabel?: string;
755
+ collapseLabel?: string;
756
+ }
757
+
758
+ export const AppSidebar = forwardRef<HTMLElement, AppSidebarProps>(
759
+ (
760
+ {
761
+ collapsed: controlledCollapsed,
762
+ onCollapsedChange: controlledOnCollapsedChange,
763
+ defaultCollapsed = false,
764
+ collapsible = true,
765
+ storageKey,
766
+ isMobile = false,
767
+ mobileOpen,
768
+ onMobileOpenChange,
769
+ linkComponent,
770
+
771
+ brandName,
772
+ brandHref = "/",
773
+ brandIcon,
774
+ brandLink,
775
+ badge,
776
+ headerContent,
777
+
778
+ items,
779
+ secondaryItems,
780
+
781
+ feedback,
782
+ orgSwitcher,
783
+ footerExtras,
784
+ footerContent,
785
+ expandLabel = "Expand sidebar",
786
+ collapseLabel = "Collapse sidebar",
787
+
788
+ className,
789
+ children,
790
+ ...props
791
+ },
792
+ ref,
793
+ ) => {
794
+ // Persistence hook if storageKey is provided and not controlled
795
+ const persistent = usePersistentSidebarCollapsed({
796
+ storageKey: storageKey ?? "app-sidebar-default-key",
797
+ defaultCollapsed,
798
+ });
799
+
800
+ const isControlled = controlledCollapsed !== undefined;
801
+ const isUsingStorage = !isControlled && Boolean(storageKey);
802
+
803
+ const [uncontrolledCollapsed, setUncontrolledCollapsed] =
804
+ useState(defaultCollapsed);
805
+
806
+ const collapsed = isControlled
807
+ ? controlledCollapsed!
808
+ : isUsingStorage
809
+ ? persistent.collapsed
810
+ : uncontrolledCollapsed;
811
+
812
+ const setCollapsed = useCallback(
813
+ (value: boolean | ((current: boolean) => boolean)) => {
814
+ const next = typeof value === "function" ? value(collapsed) : value;
815
+ if (controlledOnCollapsedChange) {
816
+ controlledOnCollapsedChange(next);
817
+ }
818
+ if (isUsingStorage) {
819
+ persistent.setCollapsed(next);
820
+ }
821
+ if (!isControlled && !isUsingStorage) {
822
+ setUncontrolledCollapsed(next);
823
+ }
824
+ },
825
+ [
826
+ collapsed,
827
+ controlledOnCollapsedChange,
828
+ isControlled,
829
+ isUsingStorage,
830
+ persistent,
831
+ ],
832
+ );
833
+
834
+ const toggleCollapsed = useCallback(() => {
835
+ setCollapsed((prev) => !prev);
836
+ }, [setCollapsed]);
837
+
838
+ const showCollapsedSidebar = collapsed && !isMobile;
839
+
840
+ const LinkComponent = linkComponent ?? NativeSidebarLink;
841
+ // Only self-manage off-canvas transform when the caller controls mobileOpen.
842
+ // Sheet/drawer embeds leave mobileOpen undefined so content stays visible.
843
+ const ownsMobileDrawer = typeof mobileOpen === "boolean";
844
+
845
+ const contextValue = useMemo<AppSidebarContextValue>(
846
+ () => ({
847
+ collapsed: showCollapsedSidebar,
848
+ setCollapsed,
849
+ toggleCollapsed,
850
+ isMobile,
851
+ LinkComponent,
852
+ }),
853
+ [
854
+ showCollapsedSidebar,
855
+ setCollapsed,
856
+ toggleCollapsed,
857
+ isMobile,
858
+ LinkComponent,
859
+ ],
860
+ );
861
+
862
+ return (
863
+ <AppSidebarContext.Provider value={contextValue}>
864
+ <TooltipProvider delayDuration={0}>
865
+ <aside
866
+ ref={ref}
867
+ data-collapsed={showCollapsedSidebar ? "true" : "false"}
868
+ className={cn(
869
+ "flex h-full w-[260px] flex-col overflow-hidden border-e border-border bg-sidebar transition-[width,transform] duration-200 ease-out",
870
+ ownsMobileDrawer
871
+ ? "agent-layout-left-drawer fixed inset-y-0 start-0 z-50 md:static md:z-auto"
872
+ : "relative md:static",
873
+ showCollapsedSidebar && "md:w-14",
874
+ ownsMobileDrawer &&
875
+ (mobileOpen
876
+ ? "translate-x-0"
877
+ : "-translate-x-full rtl:translate-x-full md:translate-x-0"),
878
+ className,
879
+ )}
880
+ {...props}
881
+ >
882
+ {headerContent ?? (
883
+ <AppSidebarHeader
884
+ brandName={brandName}
885
+ brandHref={brandHref}
886
+ brandIcon={brandIcon}
887
+ brandLink={brandLink}
888
+ badge={badge}
889
+ />
890
+ )}
891
+
892
+ <div className="min-h-0 flex-1 overflow-y-auto">
893
+ {showCollapsedSidebar ? (
894
+ <nav className="flex flex-col items-center gap-1 px-2 py-3">
895
+ {items?.map((item) => (
896
+ <AppSidebarNavItem
897
+ key={item.id ?? item.to ?? item.href ?? item.label}
898
+ to={item.to}
899
+ href={item.href}
900
+ label={item.label}
901
+ icon={item.icon}
902
+ active={item.active}
903
+ count={item.count}
904
+ badge={item.badge}
905
+ onClick={item.onClick}
906
+ />
907
+ ))}
908
+ {children}
909
+ {secondaryItems && secondaryItems.length > 0 && (
910
+ <div className="mt-2 flex flex-col items-center gap-1 border-t border-border/70 pt-2">
911
+ {secondaryItems.map((item) => (
912
+ <AppSidebarNavItem
913
+ key={item.id ?? item.to ?? item.href ?? item.label}
914
+ to={item.to}
915
+ href={item.href}
916
+ label={item.label}
917
+ icon={item.icon}
918
+ active={item.active}
919
+ count={item.count}
920
+ badge={item.badge}
921
+ onClick={item.onClick}
922
+ />
923
+ ))}
924
+ </div>
925
+ )}
926
+ </nav>
927
+ ) : (
928
+ <nav className="space-y-0.5 px-2 py-3">
929
+ {items?.map((item) => {
930
+ if (item.children) {
931
+ return (
932
+ <AppSidebarNavGroup
933
+ key={item.id ?? item.to ?? item.href ?? item.label}
934
+ to={item.to}
935
+ href={item.href}
936
+ label={item.label}
937
+ icon={item.icon}
938
+ active={item.active}
939
+ count={item.count}
940
+ actions={item.actions}
941
+ >
942
+ {item.children}
943
+ </AppSidebarNavGroup>
944
+ );
945
+ }
946
+ return (
947
+ <AppSidebarNavItem
948
+ key={item.id ?? item.to ?? item.href ?? item.label}
949
+ to={item.to}
950
+ href={item.href}
951
+ label={item.label}
952
+ icon={item.icon}
953
+ active={item.active}
954
+ count={item.count}
955
+ badge={item.badge}
956
+ actions={item.actions}
957
+ onClick={item.onClick}
958
+ />
959
+ );
960
+ })}
961
+ {children}
962
+ {secondaryItems && secondaryItems.length > 0 && (
963
+ <div className="mt-3 space-y-0.5 border-t border-border/70 pt-3">
964
+ {secondaryItems.map((item) => (
965
+ <AppSidebarNavItem
966
+ key={item.id ?? item.to ?? item.href ?? item.label}
967
+ to={item.to}
968
+ href={item.href}
969
+ label={item.label}
970
+ icon={item.icon}
971
+ active={item.active}
972
+ count={item.count}
973
+ badge={item.badge}
974
+ actions={item.actions}
975
+ onClick={item.onClick}
976
+ />
977
+ ))}
978
+ </div>
979
+ )}
980
+ </nav>
981
+ )}
982
+ </div>
983
+
984
+ {footerContent ?? (
985
+ <AppSidebarFooter
986
+ feedback={feedback}
987
+ orgSwitcher={orgSwitcher}
988
+ footerExtras={footerExtras}
989
+ collapsible={collapsible}
990
+ expandLabel={expandLabel}
991
+ collapseLabel={collapseLabel}
992
+ />
993
+ )}
994
+ </aside>
995
+ </TooltipProvider>
996
+ </AppSidebarContext.Provider>
997
+ );
998
+ },
999
+ );
1000
+ AppSidebar.displayName = "AppSidebar";