@iloveagents/foundry-web-ui 0.25.0 → 0.27.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 (82) hide show
  1. package/dist/components/assistant-chat.d.ts +12 -1
  2. package/dist/components/assistant-chat.js +18 -13
  3. package/dist/components/chat-bubble.js +9 -6
  4. package/dist/components/chat-context.d.ts +2 -0
  5. package/dist/components/chat-context.js +70 -0
  6. package/dist/components/chat-slots.d.ts +10 -0
  7. package/dist/components/chat-slots.js +7 -0
  8. package/dist/components/composer-add-menu.js +3 -2
  9. package/dist/components/composer-input.d.ts +4 -0
  10. package/dist/components/composer-input.js +23 -0
  11. package/dist/components/context-badges.d.ts +4 -1
  12. package/dist/components/context-badges.js +7 -4
  13. package/dist/components/context-bar.d.ts +1 -0
  14. package/dist/components/context-bar.js +11 -3
  15. package/dist/components/data-table/data-table-frame.d.ts +1 -22
  16. package/dist/components/data-table/data-table-frame.js +21 -48
  17. package/dist/components/data-table/data-table-overflow.d.ts +5 -0
  18. package/dist/components/data-table/data-table-overflow.js +42 -0
  19. package/dist/components/data-table/data-table.js +88 -87
  20. package/dist/components/data-table/focus-pane-resize.d.ts +8 -0
  21. package/dist/components/data-table/focus-pane-resize.js +88 -0
  22. package/dist/components/focus-chat-pane.d.ts +8 -0
  23. package/dist/components/focus-chat-pane.js +9 -0
  24. package/dist/components/focus-dialog.d.ts +8 -0
  25. package/dist/components/focus-dialog.js +103 -0
  26. package/dist/components/focus-source-pane.d.ts +7 -0
  27. package/dist/components/focus-source-pane.js +15 -0
  28. package/dist/components/loading-indicator.js +1 -1
  29. package/dist/components/message-timestamp.js +3 -2
  30. package/dist/components/pin-state-icon.d.ts +5 -0
  31. package/dist/components/pin-state-icon.js +7 -0
  32. package/dist/components/pin-toggle.d.ts +7 -0
  33. package/dist/components/pin-toggle.js +10 -0
  34. package/dist/components/sidebar.d.ts +18 -1
  35. package/dist/components/sidebar.js +193 -100
  36. package/dist/components/tool-panel-layout.d.ts +3 -1
  37. package/dist/components/tool-panel-layout.js +5 -5
  38. package/dist/components/tool-panel.d.ts +8 -1
  39. package/dist/components/tool-panel.js +12 -8
  40. package/dist/components/user-menu.js +19 -3
  41. package/dist/index.d.ts +2 -0
  42. package/dist/index.js +2 -0
  43. package/dist/lib/app-store.d.ts +0 -8
  44. package/dist/lib/app-store.js +14 -4
  45. package/dist/lib/chat-context-summary.d.ts +11 -0
  46. package/dist/lib/chat-context-summary.js +37 -0
  47. package/dist/lib/chat-lifecycle-store.d.ts +2 -0
  48. package/dist/lib/chat-lifecycle-store.js +6 -1
  49. package/dist/lib/client-tools.js +7 -0
  50. package/dist/lib/create-ui-id.d.ts +2 -0
  51. package/dist/lib/create-ui-id.js +9 -0
  52. package/dist/lib/focus-context.d.ts +2 -0
  53. package/dist/lib/focus-context.js +78 -0
  54. package/dist/lib/nav-config.d.ts +26 -0
  55. package/dist/lib/tool-panel-store.d.ts +7 -6
  56. package/dist/lib/tool-panel-store.js +29 -3
  57. package/dist/lib/use-new-conversation.js +1 -1
  58. package/dist/workbench/chat-header.d.ts +14 -0
  59. package/dist/workbench/chat-header.js +12 -0
  60. package/dist/workbench/chat-resize-handle.d.ts +4 -0
  61. package/dist/workbench/chat-resize-handle.js +40 -0
  62. package/dist/workbench/content-pane.d.ts +14 -0
  63. package/dist/workbench/content-pane.js +14 -0
  64. package/dist/workbench/conversation-history.d.ts +11 -0
  65. package/dist/workbench/conversation-history.js +83 -0
  66. package/dist/workbench/navigation.d.ts +6 -0
  67. package/dist/workbench/navigation.js +6 -0
  68. package/dist/workbench/selection-bridge.d.ts +5 -0
  69. package/dist/workbench/selection-bridge.js +84 -0
  70. package/dist/workbench/use-chat-width.d.ts +14 -0
  71. package/dist/workbench/use-chat-width.js +56 -0
  72. package/dist/workbench/use-workbench-state.d.ts +50 -0
  73. package/dist/workbench/use-workbench-state.js +145 -0
  74. package/dist/workbench/welcome-intro.d.ts +6 -0
  75. package/dist/workbench/welcome-intro.js +10 -0
  76. package/dist/workbench/workbench-styles.d.ts +1 -0
  77. package/dist/workbench/workbench-styles.js +290 -0
  78. package/dist/workbench/workbench.d.ts +5 -0
  79. package/dist/workbench/workbench.js +32 -0
  80. package/dist/workbench.d.ts +2 -0
  81. package/dist/workbench.js +2 -0
  82. package/package.json +8 -4
@@ -1,9 +1,9 @@
1
1
  import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react";
2
+ import { Fragment, useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore, } from "react";
3
3
  import { NavLink, useLocation, useNavigate } from "react-router";
4
- import { Ellipsis, PanelLeft, PanelLeftOpen, SquarePen, ChevronDown, ChevronRight, GripVertical, Plus, Upload, } from "lucide-react";
4
+ import { Ellipsis, Layers, PanelLeft, PanelLeftOpen, SquarePen, ChevronDown, ChevronRight, GripVertical, Plus, Upload, ChevronLeft, } from "lucide-react";
5
5
  import { cn } from "@iloveagents/foundry-web-primitives";
6
- import { useSidebarStore, RAIL_WIDTH, SIDEBAR_MIN_WIDTH, SIDEBAR_MAX_WIDTH, } from "../lib/sidebar-store.js";
6
+ import { useSidebarStore, RAIL_WIDTH, SIDEBAR_WIDTH, SIDEBAR_MIN_WIDTH, SIDEBAR_MAX_WIDTH, } from "../lib/sidebar-store.js";
7
7
  import { useToolPanelStore } from "../lib/tool-panel-store.js";
8
8
  import { useNewConversation } from "../lib/use-new-conversation.js";
9
9
  import { useNavStore } from "../lib/nav-store.js";
@@ -83,12 +83,19 @@ const SIDEBAR_ITEM_GAP_CLASS = "gap-2";
83
83
  // ChatGPT / Linear / Notion "header sits on its children" pattern.
84
84
  // Symmetric ``py-2`` made the header float midway between sections
85
85
  // instead of belonging to the group below it.
86
- const SIDEBAR_GROUP_HEADER_PADDING =
87
- // ``border border-transparent`` for the same reason every item row
88
- // carries one: the border is part of the box model that decides where
89
- // the padding box starts. Without it the header chevron lands 1px left
90
- // of every row's disclosure cell and the column is visibly ragged.
91
- "border border-transparent pl-3 pr-3 pt-4 pb-0";
86
+ // Vertical rhythm: rows are tight (depth-0 ``py-1.5``, nested ``py-1``) and
87
+ // sections keep their ``pt-4`` gap, so what separates is the group, not
88
+ // every row; the header label sits a step brighter than the rows' muted
89
+ // text so the section reads above its items (user's test: rows were
90
+ // airy and headers lighter than their children).
91
+ const SIDEBAR_GROUP_HEADER_PADDING = "pt-4 pb-0";
92
+ // The header's own box: the hover surface spans the row exactly like an
93
+ // item row's does — same radius, same transparent border (part of the box
94
+ // model that decides where the padding box starts: without it the chevron
95
+ // lands 1px left of every row's disclosure cell) and the same horizontal
96
+ // inset, so header and rows highlight on one outline (user's test: the
97
+ // narrower inset box read as misaligned and cramped).
98
+ const SIDEBAR_GROUP_HEADER_BOX = "rounded-xl border border-transparent pl-3 pr-3";
92
99
  // Typography for the group-header LABEL text (CHATS / WORKSPACES /
93
100
  // ADMIN / …). Stays at ``text-xs`` (12px) — same size as before —
94
101
  // but bumps weight from ``font-medium`` to ``font-semibold``. The
@@ -99,10 +106,13 @@ const SIDEBAR_GROUP_HEADER_PADDING =
99
106
  // Bumping size AND weight together stacked too much visual mass
100
107
  // against the already-wide uppercase tracking — felt shouty.
101
108
  const SIDEBAR_GROUP_HEADER_LABEL_CLASS = "text-xs font-semibold uppercase tracking-wider";
102
- /** Body padding for a row that is NOT in a tree. Flat rows render no
103
- * disclosure cell, so reserving its column just pushes them right of the
104
- * sidebar's other flat chrome which is what made Activity look indented
105
- * under New Thread. */
109
+ /** Body padding for a flat row with nothing to indent under: a row in a
110
+ * group without a visible header (New Thread, Activity) sits with the
111
+ * sidebar's other flat chrome, and a focused group's heading sits under
112
+ * its header's chevron. Flat rows UNDER a visible header indent like
113
+ * depth-0 tree rows instead, so their icon sits under the header label
114
+ * and the group reads as a group (user's test: Build's rows sat level
115
+ * with their header). */
106
116
  const NAV_FLAT_BODY_CLASS = "pl-3";
107
117
  function getNavDepthOffsets(depth) {
108
118
  return NAV_DEPTH_OFFSETS[depth] ?? NAV_DEPTH_OFFSETS[NAV_DEPTH_OFFSETS.length - 1];
@@ -395,11 +405,11 @@ function ContainerDropZone({ dnd }) {
395
405
  return;
396
406
  }
397
407
  await dnd.onDrop(draggedData);
398
- }, className: cn("mx-2 rounded-md transition-all overflow-hidden", isDragOver
408
+ }, className: cn("relative mx-2 rounded-md overflow-hidden", isDragOver
399
409
  ? cn("h-7 flex items-center justify-center gap-1", isFileDragOver
400
410
  ? "bg-primary/10 border border-dashed border-primary"
401
411
  : "bg-primary/10 border border-dashed border-primary/30")
402
- : "h-1"), children: isDragOver && (_jsxs(_Fragment, { children: [isFileDragOver && _jsx(Upload, { className: "size-3 text-primary" }), _jsx("span", { className: "text-[10px] text-primary/70", children: getContainerDropZoneLabel(isFileDragOver) })] })) }));
412
+ : "-mt-1 h-1"), children: isDragOver && (_jsxs(_Fragment, { children: [isFileDragOver && _jsx(Upload, { className: "size-3 text-primary" }), _jsx("span", { className: "text-[10px] text-primary/70", children: getContainerDropZoneLabel(isFileDragOver) })] })) }));
403
413
  }
404
414
  /**
405
415
  * Leading disclosure cell on every nav row — the Confluence page-tree
@@ -427,7 +437,7 @@ function ContainerDropZone({ dnd }) {
427
437
  function NavTwisty({ hasChildren, childrenUnloaded = false, isExpanded, onToggle, label, isActive, depth, inTree = true, }) {
428
438
  const cell = cn(NAV_TWISTY_CLASS, getNavDepthOffsets(depth).column);
429
439
  if (!hasChildren) {
430
- return (_jsx("span", { "data-nav-twisty-cell": true, "aria-hidden": "true", className: cn(cell, "pointer-events-none"), children: inTree && !childrenUnloaded && (_jsx("span", { "data-nav-leaf-dot": true, className: cn("size-1 rounded-full", isActive ? "bg-primary/70" : "bg-sidebar-foreground/30") })) }));
440
+ return (_jsx("span", { "data-nav-twisty-cell": true, "aria-hidden": "true", className: cn(cell, "pointer-events-none"), children: inTree && !childrenUnloaded && (_jsx("span", { "data-nav-leaf-dot": true, className: cn("block size-1 shrink-0 rounded-full", isActive ? "bg-primary/70" : "bg-sidebar-foreground/30") })) }));
431
441
  }
432
442
  return (_jsx("span", { "data-nav-twisty-cell": true, className: cell, children: _jsx("button", { type: "button", "data-nav-twisty": true, "aria-expanded": isExpanded, "aria-label": isExpanded ? `Collapse ${label}` : `Expand ${label}`, onClick: (e) => {
433
443
  e.preventDefault();
@@ -468,7 +478,7 @@ function NavLeafItem({ item, isActive, onClick, depth = 0, }) {
468
478
  const activeButtonRef = useActiveNavRowScroll(isActive && !hasActions);
469
479
  // Items with actions use the group layout (button + action controls)
470
480
  if (hasActions) {
471
- return (_jsxs("div", { ref: activeRowRef, "data-active-nav-row": isActive ? "true" : undefined, "data-nav-depth": depth, ...(isDisabled ? {} : dropTargetProps), ...(isDisabled ? {} : draggableProps), className: cn("group relative flex items-center w-full rounded-xl border border-transparent transition-colors", isDisabled
481
+ return (_jsxs("div", { ref: activeRowRef, "data-active-nav-row": isActive ? "true" : undefined, "data-nav-depth": depth, style: { "--nav-depth": depth }, ...(isDisabled ? {} : dropTargetProps), ...(isDisabled ? {} : draggableProps), className: cn("group relative flex items-center w-full rounded-xl border border-transparent transition-colors", isDisabled
472
482
  ? "opacity-40"
473
483
  : isActive
474
484
  ? ACTIVE_NAV_ROW_CLASS
@@ -477,7 +487,7 @@ function NavLeafItem({ item, isActive, onClick, depth = 0, }) {
477
487
  !isDisabled &&
478
488
  "ring-2 ring-inset ring-sidebar-ring/50 bg-sidebar-selected", isFileDragOver &&
479
489
  !isDisabled &&
480
- "outline-dashed outline-2 -outline-offset-2 outline-primary bg-primary/10", item.dimmed && !isDisabled && "opacity-50"), children: [_jsx(NavTwisty, { hasChildren: false, childrenUnloaded: item.childrenUnloaded, label: item.label, isActive: isActive && !isDisabled, depth: depth }), _jsxs("button", { type: "button", disabled: isDisabled, onClick: isDisabled ? undefined : onClick, className: cn("flex items-center gap-2 flex-1 min-w-0 py-1.5 text-sm text-left", bodyPl, "pr-3 transition-[padding] duration-150", NAV_ROW_ACTIONS_CLEARANCE, isDisabled
490
+ "outline-dashed outline-2 -outline-offset-2 outline-primary bg-primary/10", item.dimmed && !isDisabled && "opacity-50"), children: [_jsx(NavTwisty, { hasChildren: false, childrenUnloaded: item.childrenUnloaded, label: item.label, isActive: isActive && !isDisabled, depth: depth }), _jsxs("button", { type: "button", disabled: isDisabled, onClick: isDisabled ? undefined : onClick, className: cn("flex items-center gap-2 flex-1 min-w-0 py-1 text-sm text-left", bodyPl, "pr-3 transition-[padding] duration-150", NAV_ROW_ACTIONS_CLEARANCE, isDisabled
481
491
  ? "text-sidebar-foreground/45 cursor-default"
482
492
  : isActive
483
493
  ? ACTIVE_NAV_TEXT_CLASS
@@ -485,7 +495,7 @@ function NavLeafItem({ item, isActive, onClick, depth = 0, }) {
485
495
  ? ACTIVE_NAV_BADGE_CLASS
486
496
  : "bg-sidebar-accent text-sidebar-accent-foreground"), children: item.badge })), _jsx(NavStatusIcon, { statusIcon: item.statusIcon }), _jsx(NavStatusDot, { statusDot: item.statusDot })] }), isFileDragOver && !isDisabled && (_jsx(Upload, { "aria-hidden": "true", className: "size-3.5 mr-2 shrink-0 text-primary pointer-events-none" })), _jsx(NavRowControls, { item: item })] }));
487
497
  }
488
- return (_jsxs("button", { ref: activeButtonRef, "data-active-nav-row": isActive ? "true" : undefined, "data-nav-depth": depth, type: "button", disabled: isDisabled, onClick: isDisabled ? undefined : onClick, ...(isDisabled ? {} : draggableProps), ...(isDisabled ? {} : dropTargetProps), className: cn("relative flex items-center gap-2 w-full", "rounded-lg border border-transparent pr-3 py-1.5 text-sm text-left", bodyPl, isDisabled
498
+ return (_jsxs("button", { ref: activeButtonRef, "data-active-nav-row": isActive ? "true" : undefined, "data-nav-depth": depth, style: { "--nav-depth": depth }, type: "button", disabled: isDisabled, onClick: isDisabled ? undefined : onClick, ...(isDisabled ? {} : draggableProps), ...(isDisabled ? {} : dropTargetProps), className: cn("relative flex items-center gap-2 w-full", "rounded-lg border border-transparent pr-3 py-1 text-sm text-left", bodyPl, isDisabled
489
499
  ? "opacity-40 cursor-default"
490
500
  : "text-sidebar-foreground/80 transition-colors hover:bg-sidebar-accent/70", !isDisabled && isActive && ACTIVE_NAV_ROW_CLASS, isDragOver &&
491
501
  !isFileDragOver &&
@@ -505,7 +515,7 @@ function SubNavItems({ items, parentPath, depth = 1, }) {
505
515
  // children sharing the parent path (e.g., Documents sub-filters) use label matching.
506
516
  const allShareParent = items.every((c) => c.to === parentPath);
507
517
  return (_jsx("div", { children: items.map((child) => {
508
- const hasNestedChildren = child.children && child.children.length > 0;
518
+ const hasNestedChildren = !child.childrenHidden && child.children && child.children.length > 0;
509
519
  // Folders with children render as collapsible nested items
510
520
  if (hasNestedChildren) {
511
521
  return _jsx(NestedFolderItem, { item: child, depth: depth }, child.to + child.label);
@@ -515,7 +525,7 @@ function SubNavItems({ items, parentPath, depth = 1, }) {
515
525
  // parent path (e.g., folders) are never highlighted on the parent page.
516
526
  const isActive = allShareParent
517
527
  ? isOnParent && activeLabel === child.label
518
- : child.to !== parentPath && currentPath === child.to;
528
+ : child.to !== parentPath && isSelectedNavItem(currentPath, child);
519
529
  return (_jsx(NavLeafItem, { item: child, isActive: isActive, depth: depth, onClick: () => {
520
530
  setActiveLabel(child.label);
521
531
  setNavContext({
@@ -537,6 +547,11 @@ function isInSubtree(currentPage, item) {
537
547
  return true;
538
548
  return item.children?.some((c) => isInSubtree(currentPage, c)) ?? false;
539
549
  }
550
+ /** A list owns the sidebar selection of descendants it deliberately does not draw.
551
+ * The route lookup still resolves the actual descendant for chat and page actions. */
552
+ function isSelectedNavItem(currentPage, item) {
553
+ return currentPage === item.to || Boolean(item.childrenHidden && isInSubtree(currentPage, item));
554
+ }
540
555
  /** Nested folder with collapsible children (used inside SubNavItems). */
541
556
  function NestedFolderItem({ item, depth }) {
542
557
  const navigate = useNavigate();
@@ -544,16 +559,15 @@ function NestedFolderItem({ item, depth }) {
544
559
  const setNavContext = useAppStore((s) => s.setNavContext);
545
560
  const navContext = useAppStore((s) => s.navContext);
546
561
  const isInsideSubtree = isInSubtree(currentPath, item);
547
- const isActive = currentPath === item.to;
562
+ const isActive = isSelectedNavItem(currentPath, item);
548
563
  const isAncestorActive = isInsideSubtree && !isActive;
549
- const [manualOverride, setManualOverride] = useState(null);
550
- const isExpanded = manualOverride ?? isInsideSubtree;
564
+ const [isExpanded, setExpanded] = useTreeExpanded(item.to, isInsideSubtree);
551
565
  const { draggableProps, dropTargetProps, isDragOver, isFileDragOver } = useNavItemDnd(item.dnd);
552
566
  const bodyPl = getNavDepthOffsets(depth).body;
553
567
  const isDisabled = item.disabled === true;
554
568
  const hasActions = Boolean(!isDisabled && item.actions && item.actions.length > 0);
555
569
  const activeRowRef = useActiveNavRowScroll(isActive);
556
- return (_jsxs("div", { ...(isDisabled ? {} : dropTargetProps), children: [_jsxs("div", { ref: activeRowRef, "data-active-nav-row": isActive ? "true" : undefined, "data-nav-depth": depth, ...(isDisabled ? {} : draggableProps), className: cn("group relative flex items-center w-full", "rounded-xl border border-transparent transition-colors", isDisabled
570
+ return (_jsxs("div", { ...(isDisabled ? {} : dropTargetProps), children: [_jsxs("div", { ref: activeRowRef, "data-active-nav-row": isActive ? "true" : undefined, "data-nav-depth": depth, style: { "--nav-depth": depth }, ...(isDisabled ? {} : draggableProps), className: cn("group relative flex items-center w-full", "rounded-xl border border-transparent transition-colors", isDisabled
557
571
  ? "opacity-40"
558
572
  : isActive
559
573
  ? ACTIVE_NAV_ROW_CLASS
@@ -562,10 +576,10 @@ function NestedFolderItem({ item, depth }) {
562
576
  !isDisabled &&
563
577
  "ring-2 ring-inset ring-sidebar-ring/50 bg-sidebar-selected", isFileDragOver &&
564
578
  !isDisabled &&
565
- "outline-dashed outline-2 -outline-offset-2 outline-primary bg-primary/10", item.dimmed && !isDisabled && "opacity-50"), children: [_jsx(NavTwisty, { hasChildren: true, isExpanded: isExpanded, onToggle: () => setManualOverride(!isExpanded), label: item.label, isActive: (isActive || isAncestorActive) && !isDisabled, depth: depth }), _jsxs("button", { type: "button", disabled: isDisabled, onClick: () => {
579
+ "outline-dashed outline-2 -outline-offset-2 outline-primary bg-primary/10", item.dimmed && !isDisabled && "opacity-50"), children: [_jsx(NavTwisty, { hasChildren: true, isExpanded: isExpanded, onToggle: () => setExpanded(!isExpanded), label: item.label, isActive: (isActive || isAncestorActive) && !isDisabled, depth: depth }), _jsxs("button", { type: "button", disabled: isDisabled, onClick: () => {
566
580
  if (isDisabled)
567
581
  return;
568
- setManualOverride(true);
582
+ setExpanded(true);
569
583
  setNavContext({
570
584
  ...navContext,
571
585
  label: item.label,
@@ -576,7 +590,7 @@ function NestedFolderItem({ item, depth }) {
576
590
  .join("\n") || null,
577
591
  });
578
592
  navigate(item.to);
579
- }, className: cn("flex items-center gap-2 flex-1 min-w-0", bodyPl, "pr-3 py-1.5 text-sm text-left transition-[padding] duration-150", hasActions && NAV_ROW_ACTIONS_CLEARANCE, isDisabled
593
+ }, className: cn("flex items-center gap-2 flex-1 min-w-0", bodyPl, "pr-3 py-1 text-sm text-left transition-[padding] duration-150", hasActions && NAV_ROW_ACTIONS_CLEARANCE, isDisabled
580
594
  ? "text-sidebar-foreground/45 cursor-default"
581
595
  : isActive
582
596
  ? ACTIVE_NAV_TEXT_CLASS
@@ -589,11 +603,11 @@ function NestedFolderItem({ item, depth }) {
589
603
  : "text-sidebar-icon") }), _jsx("span", { className: "truncate", children: item.label }), item.badge && (_jsx("span", { className: cn("shrink-0 rounded px-1 py-0.5 text-[10px] font-medium", isActive
590
604
  ? ACTIVE_NAV_BADGE_CLASS
591
605
  : "bg-sidebar-accent text-sidebar-accent-foreground"), children: item.badge })), _jsx(NavStatusIcon, { statusIcon: item.statusIcon }), _jsx(NavStatusDot, { statusDot: item.statusDot })] }), isFileDragOver && !isDisabled && (_jsx(Upload, { "aria-hidden": "true", className: "size-3.5 mr-2 shrink-0 text-primary pointer-events-none" })), _jsx(NavRowControls, { item: item })] }), isExpanded && item.children && (_jsxs("div", { children: [item.children.map((child) => {
592
- const hasNestedChildren = child.children && child.children.length > 0;
606
+ const hasNestedChildren = !child.childrenHidden && child.children && child.children.length > 0;
593
607
  if (hasNestedChildren) {
594
608
  return (_jsx(NestedFolderItem, { item: child, depth: depth + 1 }, child.to + child.label));
595
609
  }
596
- const isChildActive = currentPath === child.to;
610
+ const isChildActive = isSelectedNavItem(currentPath, child);
597
611
  return (_jsx(NavLeafItem, { item: child, isActive: isChildActive, depth: depth + 1, onClick: () => {
598
612
  setNavContext({
599
613
  ...navContext,
@@ -608,19 +622,28 @@ function NestedFolderItem({ item, depth }) {
608
622
  } }, child.to + child.label));
609
623
  }), item.dnd?.canDrop() && _jsx(ContainerDropZone, { dnd: item.dnd })] }))] }));
610
624
  }
611
- function CollapsibleNavItem({ item, inTree = true }) {
625
+ function CollapsibleNavItem({ item, inTree = true, heading = false, underHeader = false, }) {
612
626
  const navigate = useNavigate();
613
627
  const currentPath = useLocation().pathname;
614
628
  const setNavContext = useAppStore((s) => s.setNavContext);
615
629
  const navContext = useAppStore((s) => s.navContext);
616
630
  const isInsideSubtree = isInSubtree(currentPath, item);
617
- const isActive = currentPath === item.to;
631
+ const isActive = isSelectedNavItem(currentPath, item);
618
632
  const isAncestorActive = isInsideSubtree && !isActive;
619
- const [manualOverride, setManualOverride] = useState(null);
620
- const isExpanded = manualOverride ?? isInsideSubtree;
621
- const hasChildren = Boolean(item.children && item.children.length > 0);
633
+ const [treeExpanded, setExpanded] = useTreeExpanded(item.to, isInsideSubtree);
634
+ // A heading is never collapsed and carries no chevron: its children ARE
635
+ // the tree it stands for.
636
+ const isExpanded = heading ? true : treeExpanded;
637
+ const hasChildren = Boolean(!item.childrenHidden && item.children && item.children.length > 0);
622
638
  const isDisabled = item.disabled === true;
623
639
  const hasActions = Boolean(!isDisabled && item.actions && item.actions.length > 0);
640
+ const rowInTree = inTree && !heading;
641
+ const bodyClass = rowInTree
642
+ ? getNavDepthOffsets(0).body
643
+ : heading || !underHeader
644
+ ? NAV_FLAT_BODY_CLASS
645
+ : getNavDepthOffsets(0).body;
646
+ const headingClass = heading && !isDisabled && "font-semibold text-sidebar-foreground";
624
647
  const { draggableProps, dropTargetProps, isDragOver, isFileDragOver } = useNavItemDnd(item.dnd);
625
648
  const rendersRow = Boolean(hasChildren || hasActions);
626
649
  const activeRowRef = useActiveNavRowScroll(isActive && rendersRow);
@@ -635,7 +658,7 @@ function CollapsibleNavItem({ item, inTree = true }) {
635
658
  !isDisabled &&
636
659
  "ring-2 ring-inset ring-sidebar-ring/50 bg-sidebar-selected", isFileDragOver &&
637
660
  !isDisabled &&
638
- "outline-dashed outline-2 -outline-offset-2 outline-primary bg-primary/10", item.dimmed && !isDisabled && "opacity-50"), children: [_jsx(NavTwisty, { hasChildren: hasChildren, childrenUnloaded: item.childrenUnloaded, inTree: inTree, isExpanded: isExpanded, onToggle: hasChildren ? () => setManualOverride(!isExpanded) : undefined, label: item.label, isActive: (selected || ancestor) && !isDisabled, depth: 0 }), _jsxs("button", { type: "button", onClick: () => {
661
+ "outline-dashed outline-2 -outline-offset-2 outline-primary bg-primary/10", item.dimmed && !isDisabled && "opacity-50"), children: [_jsx(NavTwisty, { hasChildren: hasChildren && !heading, childrenUnloaded: item.childrenUnloaded, inTree: rowInTree, isExpanded: isExpanded, onToggle: hasChildren && !heading ? () => setExpanded(!isExpanded) : undefined, label: item.label, isActive: (selected || ancestor) && !isDisabled, depth: 0 }), _jsxs("button", { type: "button", onClick: () => {
639
662
  if (isDisabled)
640
663
  return;
641
664
  setNavContext({
@@ -648,38 +671,38 @@ function CollapsibleNavItem({ item, inTree = true }) {
648
671
  .join("\n") || null,
649
672
  });
650
673
  if (hasChildren)
651
- setManualOverride(true);
674
+ setExpanded(true);
652
675
  navigate(item.to);
653
- }, className: cn("flex items-center gap-2 flex-1 min-w-0 py-2 text-sm text-left", inTree ? getNavDepthOffsets(0).body : NAV_FLAT_BODY_CLASS, "pr-3 transition-[padding] duration-150", hasActions && NAV_ROW_ACTIONS_CLEARANCE, isDisabled
676
+ }, className: cn("flex items-center gap-2 flex-1 min-w-0 py-1.5 text-sm text-left", bodyClass, "pr-3 transition-[padding] duration-150", hasActions && NAV_ROW_ACTIONS_CLEARANCE, isDisabled
654
677
  ? "text-sidebar-foreground/45 cursor-default"
655
678
  : selected
656
679
  ? ACTIVE_NAV_TEXT_CLASS
657
680
  : ancestor
658
681
  ? "text-sidebar-foreground font-medium"
659
- : "text-sidebar-foreground/85"), disabled: isDisabled, children: [_jsx(item.icon, { className: cn("size-4 shrink-0", selected && !isDisabled
682
+ : "text-sidebar-foreground/85", headingClass), disabled: isDisabled, children: [_jsx(item.icon, { className: cn("size-4 shrink-0", selected && !isDisabled
660
683
  ? ACTIVE_NAV_ICON_CLASS
661
684
  : ancestor
662
685
  ? "text-sidebar-foreground/80"
663
686
  : "text-sidebar-icon") }), _jsx("span", { className: "truncate", children: item.label }), item.badge && (_jsx("span", { className: cn("shrink-0 rounded px-1 py-0.5 text-[10px] font-medium", selected
664
687
  ? ACTIVE_NAV_BADGE_CLASS
665
688
  : "bg-sidebar-accent text-sidebar-accent-foreground"), children: item.badge })), _jsx(NavStatusIcon, { statusIcon: item.statusIcon }), _jsx(NavStatusDot, { statusDot: item.statusDot })] }), isFileDragOver && !isDisabled && (_jsx(Upload, { "aria-hidden": "true", className: "size-3.5 mr-2 shrink-0 text-primary pointer-events-none" })), _jsx(NavRowControls, { item: item })] }));
666
- return (_jsxs("div", { ...(isDisabled ? {} : dropTargetProps), ...(isDisabled ? {} : draggableProps), children: [inner(isActive, isAncestorActive), hasChildren && isExpanded && (_jsxs(_Fragment, { children: [_jsx(SubNavItems, { items: item.children, parentPath: item.to }), item.dnd?.canDrop() && _jsx(ContainerDropZone, { dnd: item.dnd })] }))] }));
689
+ return (_jsxs("div", { ...(isDisabled ? {} : dropTargetProps), ...(isDisabled ? {} : draggableProps), children: [inner(isActive, isAncestorActive), hasChildren && isExpanded && (_jsxs(_Fragment, { children: [_jsx(SubNavItems, { items: item.children, parentPath: item.to, depth: heading ? 0 : 1 }), item.dnd?.canDrop() && _jsx(ContainerDropZone, { dnd: item.dnd })] }))] }));
667
690
  }
668
691
  if (isDisabled) {
669
- return (_jsxs("div", { "data-nav-depth": 0, className: cn("relative flex items-center gap-2 w-full opacity-40", inTree ? getNavDepthOffsets(0).body : NAV_FLAT_BODY_CLASS,
692
+ return (_jsxs("div", { "data-nav-depth": 0, className: cn("relative flex items-center gap-2 w-full opacity-40", bodyClass,
670
693
  // Transparent border for box-model parity — see the NavLink variant.
671
- "rounded-lg border border-transparent pr-3 py-2 text-left text-sm text-sidebar-foreground/45"), children: [_jsx(NavTwisty, { hasChildren: false, childrenUnloaded: item.childrenUnloaded, inTree: inTree, label: item.label, depth: 0 }), _jsx(item.icon, { className: "size-4 shrink-0 text-sidebar-foreground/45" }), _jsx("span", { className: "truncate", children: item.label })] }));
694
+ "rounded-lg border border-transparent pr-3 py-1.5 text-left text-sm text-sidebar-foreground/45"), children: [_jsx(NavTwisty, { hasChildren: false, childrenUnloaded: item.childrenUnloaded, inTree: rowInTree, label: item.label, depth: 0 }), _jsx(item.icon, { className: "size-4 shrink-0 text-sidebar-foreground/45" }), _jsx("span", { className: "truncate", children: item.label })] }));
672
695
  }
673
- return (_jsx(NavLink, { ref: activeLinkRef, "data-active-nav-row": currentPath === item.to ? "true" : undefined, "data-nav-depth": 0, to: item.to, ...draggableProps, ...dropTargetProps, className: ({ isActive }) => cn("relative flex items-center gap-2 w-full", inTree ? getNavDepthOffsets(0).body : NAV_FLAT_BODY_CLASS,
696
+ return (_jsx(NavLink, { ref: activeLinkRef, "data-active-nav-row": isActive ? "true" : undefined, "data-nav-depth": 0, to: item.to, ...draggableProps, ...dropTargetProps, className: () => cn("relative flex items-center gap-2 w-full", bodyClass,
674
697
  // The transparent border is NOT decoration: it is what makes this
675
698
  // row's box model match every other variant. An absolutely
676
699
  // positioned twisty resolves against the PADDING box, so a row
677
700
  // without the border puts its disclosure cell 1px left of its
678
701
  // siblings' and the column goes ragged.
679
- "rounded-xl border border-transparent pr-3 py-2 text-sm text-left", "text-sidebar-foreground/85 transition-colors hover:bg-sidebar-accent/70", isActive && cn(ACTIVE_NAV_ROW_CLASS, ACTIVE_NAV_TEXT_CLASS), isDragOver &&
702
+ "rounded-xl border border-transparent pr-3 py-1.5 text-sm text-left", "text-sidebar-foreground/85 transition-colors hover:bg-sidebar-accent/70", headingClass, isActive && cn(ACTIVE_NAV_ROW_CLASS, ACTIVE_NAV_TEXT_CLASS), isDragOver &&
680
703
  !isFileDragOver &&
681
704
  "bg-sidebar-selected ring-2 ring-inset ring-sidebar-ring/50", isFileDragOver &&
682
- "outline-dashed outline-2 -outline-offset-2 outline-primary bg-primary/10", item.dimmed && "opacity-50"), children: ({ isActive }) => (_jsxs(_Fragment, { children: [_jsx(NavTwisty, { hasChildren: false, childrenUnloaded: item.childrenUnloaded, inTree: inTree, label: item.label, isActive: isActive, depth: 0 }), _jsx(item.icon, { className: cn("size-4 shrink-0", isActive ? ACTIVE_NAV_ICON_CLASS : "text-sidebar-icon") }), _jsx("span", { className: "truncate", children: item.label }), item.badge && (_jsx("span", { className: cn("shrink-0 rounded px-1 py-0.5 text-[10px] font-medium", isActive
705
+ "outline-dashed outline-2 -outline-offset-2 outline-primary bg-primary/10", item.dimmed && "opacity-50"), children: () => (_jsxs(_Fragment, { children: [_jsx(NavTwisty, { hasChildren: false, childrenUnloaded: item.childrenUnloaded, inTree: rowInTree, label: item.label, isActive: isActive, depth: 0 }), _jsx(item.icon, { className: cn("size-4 shrink-0", isActive ? ACTIVE_NAV_ICON_CLASS : "text-sidebar-icon") }), _jsx("span", { className: "truncate", children: item.label }), item.badge && (_jsx("span", { className: cn("shrink-0 rounded px-1 py-0.5 text-[10px] font-medium", isActive
683
706
  ? ACTIVE_NAV_BADGE_CLASS
684
707
  : "bg-sidebar-accent text-sidebar-accent-foreground"), children: item.badge })), _jsx(NavStatusIcon, { statusIcon: item.statusIcon }), _jsx(NavStatusDot, { statusDot: item.statusDot }), isFileDragOver && (_jsx(Upload, { "aria-hidden": "true", className: "ml-auto size-3.5 shrink-0 text-primary pointer-events-none" }))] })) }));
685
708
  }
@@ -710,7 +733,7 @@ function SidebarResizeHandle() {
710
733
  window.removeEventListener("pointercancel", onPointerUp);
711
734
  };
712
735
  }, [setWidth]);
713
- return (_jsx("div", { className: "absolute inset-y-0 right-0 z-20 hidden w-3 cursor-col-resize md:block", onPointerDown: (event) => {
736
+ return (_jsx("div", { "data-panel-resize": true, title: "Drag to resize navigation. Double-click to reset.", onDoubleClick: () => setWidth(SIDEBAR_WIDTH), className: "absolute inset-y-0 right-0 z-20 hidden w-3 cursor-col-resize md:block", onPointerDown: (event) => {
714
737
  event.preventDefault();
715
738
  dragState.current = {
716
739
  startX: event.clientX,
@@ -722,11 +745,11 @@ function SidebarResizeHandle() {
722
745
  }, onKeyDown: (event) => {
723
746
  if (event.key === "ArrowLeft") {
724
747
  event.preventDefault();
725
- setWidth(width - 16);
748
+ setWidth(width - (event.shiftKey ? 40 : 16));
726
749
  }
727
750
  if (event.key === "ArrowRight") {
728
751
  event.preventDefault();
729
- setWidth(width + 16);
752
+ setWidth(width + (event.shiftKey ? 40 : 16));
730
753
  }
731
754
  if (event.key === "Home") {
732
755
  event.preventDefault();
@@ -808,6 +831,17 @@ function useGroupCollapsed(label, defaultCollapsed) {
808
831
  // entry. ``map`` is parsed JSON so its prototype is ``Object``'s.
809
832
  return Object.prototype.hasOwnProperty.call(map, label) ? map[label] : Boolean(defaultCollapsed);
810
833
  }
834
+ /** Keep opened branches stable while routes and asynchronously loaded children change. */
835
+ function useTreeExpanded(to, inside) {
836
+ const key = `tree:${to}`;
837
+ const collapsed = useGroupCollapsed(key, !inside);
838
+ useEffect(() => {
839
+ if (inside && !Object.prototype.hasOwnProperty.call(readCollapseMap(), key)) {
840
+ writeCollapseMap({ ...readCollapseMap(), [key]: false });
841
+ }
842
+ }, [inside, key]);
843
+ return [!collapsed, (expanded) => writeCollapseMap({ ...readCollapseMap(), [key]: !expanded })];
844
+ }
811
845
  function toggleGroupCollapsed(label, current) {
812
846
  writeCollapseMap({ ...readCollapseMap(), [label]: !current });
813
847
  }
@@ -843,11 +877,30 @@ function NavGroupSection({ group }) {
843
877
  // that does not exist and reads as a bullet. The disclosure cell still
844
878
  // renders either way, so flat rows keep the same label column as tree
845
879
  // rows and the sidebar stays on one grid.
846
- const groupHasBranches = group.items.some((item) => Boolean(item.children && item.children.length > 0));
880
+ const groupHasBranches = group.items.some((item) => Boolean(!item.childrenHidden && item.children && item.children.length > 0));
847
881
  // hideLabel + collapsible are mutually exclusive — a hidden label
848
882
  // can't host a chevron. The collapsible flag is the opt-in; legacy
849
883
  // groups stay uncollapsable so behaviour is unchanged.
850
- const isCollapsible = !group.hideLabel && Boolean(group.collapsible);
884
+ // Focus see ``NavGroup.focusOn``: the group draws one item as a heading
885
+ // under a header that leads back. Needs a visible label to lead back
886
+ // from and a ``to`` to lead back to; otherwise it draws as usual.
887
+ const focusedItem = group.focusOn !== undefined && !group.hideLabel && group.to
888
+ ? group.items.find((item) => item.to === group.focusOn)
889
+ : undefined;
890
+ const focused = focusedItem !== undefined;
891
+ // The new shape fades in over the old one's place: keyed on the focus,
892
+ // and only after a CHANGE in either direction — not on first paint
893
+ // (user's test: the hard swap felt abrupt). The previous key is read
894
+ // during render and written after it, so a focus that returns to its
895
+ // initial value still counts as a change.
896
+ const focusKey = focused ? focusedItem.to : "";
897
+ const previousFocusKey = useRef(focusKey);
898
+ const animateRows = previousFocusKey.current !== focusKey;
899
+ useEffect(() => {
900
+ previousFocusKey.current = focusKey;
901
+ });
902
+ // Focus takes precedence over collapsing: a focused header is the way back.
903
+ const isCollapsible = !group.hideLabel && Boolean(group.collapsible) && !focused;
851
904
  const persistedCollapsed = useGroupCollapsed(group.label, group.defaultCollapsed);
852
905
  const collapsed = isCollapsible && persistedCollapsed;
853
906
  // Bubble: show the first child statusDot on the collapsed header so
@@ -877,7 +930,7 @@ function NavGroupSection({ group }) {
877
930
  // to hit a tiny target. The clickable invisible padding
878
931
  // OVERLAPS the gap rather than pushing the label right
879
932
  // (negative-margin + extra padding cancel out).
880
- _jsxs("div", { className: cn("group/header flex items-center justify-between rounded-lg transition-colors hover:bg-sidebar-accent/70", SIDEBAR_ITEM_GAP_CLASS), children: [_jsx("button", { type: "button", "data-nav-group-toggle": true, onClick: () => toggleGroupCollapsed(group.label, persistedCollapsed), "aria-expanded": !collapsed, "aria-label": collapsed ? `Expand ${group.label}` : `Collapse ${group.label}`,
933
+ _jsxs("div", { "data-nav-group-header": true, className: cn("group/header flex items-center justify-between transition-colors hover:bg-sidebar-accent/70", SIDEBAR_GROUP_HEADER_BOX, SIDEBAR_ITEM_GAP_CLASS), children: [_jsx("button", { type: "button", "data-nav-group-toggle": true, onClick: () => toggleGroupCollapsed(group.label, persistedCollapsed), "aria-expanded": !collapsed, "aria-label": collapsed ? `Expand ${group.label}` : `Collapse ${group.label}`,
881
934
  // Visual cell width = ``SIDEBAR_ITEM_ICON_CLASS`` (14px)
882
935
  // so the chevron sits in the same column as item icons
883
936
  // and the group label aligns with item labels. The
@@ -888,20 +941,25 @@ function NavGroupSection({ group }) {
888
941
  // visual feedback even on near-misses.
889
942
  className: cn("inline-flex items-center justify-center rounded text-sidebar-foreground/50 hover:text-sidebar-foreground", SIDEBAR_ITEM_ICON_CLASS), children: collapsed ? (_jsx(ChevronRight, { className: "size-3.5 transition-transform" })) : (_jsx(ChevronDown, { className: "size-3.5 transition-transform" })) }), group.to ? (_jsx(NavLink, { to: group.to, end: true, className: "flex min-w-0 flex-1 items-center gap-1.5 py-1.5", children: ({ isActive }) => (_jsxs(_Fragment, { children: [_jsx("span", { className: cn("truncate", SIDEBAR_GROUP_HEADER_LABEL_CLASS, isActive
890
943
  ? "text-sidebar-selected-foreground"
891
- : "text-sidebar-foreground/65 group-hover/header:text-sidebar-foreground"), children: group.label }), _jsx(NavStatusDot, { statusDot: bubbledStatusDot })] })) })) : (_jsxs("button", { type: "button", onClick: () => toggleGroupCollapsed(group.label, persistedCollapsed), className: "flex min-w-0 flex-1 items-center gap-1.5 py-1.5 text-left", children: [_jsx("span", { className: cn("truncate text-sidebar-foreground/65 group-hover/header:text-sidebar-foreground", SIDEBAR_GROUP_HEADER_LABEL_CLASS), children: group.label }), _jsx(NavStatusDot, { statusDot: bubbledStatusDot })] })), group.createActions && group.createActions.length > 0 && (_jsx(CreateActionsMenu, { group: group }))] })) : group.to ? (
944
+ : "text-sidebar-foreground/75 group-hover/header:text-sidebar-foreground"), children: group.label }), _jsx(NavStatusDot, { statusDot: bubbledStatusDot })] })) })) : (_jsxs("button", { type: "button", onClick: () => toggleGroupCollapsed(group.label, persistedCollapsed), className: "flex min-w-0 flex-1 items-center gap-1.5 py-1.5 text-left", children: [_jsx("span", { className: cn("truncate text-sidebar-foreground/75 group-hover/header:text-sidebar-foreground", SIDEBAR_GROUP_HEADER_LABEL_CLASS), children: group.label }), _jsx(NavStatusDot, { statusDot: bubbledStatusDot })] })), group.createActions && group.createActions.length > 0 && (_jsx(CreateActionsMenu, { group: group }))] })) : focused ? (
945
+ // The focused header is the way back: the collapse chevron's
946
+ // cell holds a back chevron, the label links to the group's
947
+ // page, and the metrics are the collapsible header's — so the
948
+ // header stays put, in the same type, when the group focuses.
949
+ _jsxs("div", { "data-nav-group-header": true, className: cn("flex items-center justify-between py-1.5 transition-colors hover:bg-sidebar-accent/70", SIDEBAR_GROUP_HEADER_BOX, SIDEBAR_ITEM_GAP_CLASS), children: [_jsxs(NavLink, { to: group.to, end: true, "data-nav-group-back": true, title: group.description, className: cn("flex min-w-0 flex-1 items-center", SIDEBAR_ITEM_GAP_CLASS), children: [_jsx("span", { "aria-hidden": "true", className: cn(SIDEBAR_ITEM_ICON_CLASS, "flex items-center justify-center"), children: _jsx(ChevronLeft, { className: "size-3.5 text-sidebar-foreground/75" }) }), _jsx("span", { className: cn("truncate", SIDEBAR_GROUP_HEADER_LABEL_CLASS, "text-sidebar-foreground/75 hover:text-sidebar-foreground"), children: group.label })] }), group.createActions && group.createActions.length > 0 && (_jsx(CreateActionsMenu, { group: group }))] })) : group.to ? (
892
950
  // Non-collapsible header: no chevron, but we still emit an
893
951
  // invisible spacer of ``SIDEBAR_ITEM_ICON_CLASS`` width + the
894
952
  // shared gap so the group label lands in the same column as
895
953
  // collapsible headers and item labels.
896
- _jsxs("div", { className: cn("flex items-center justify-between rounded-lg py-1.5 transition-colors hover:bg-sidebar-accent/70", SIDEBAR_ITEM_GAP_CLASS), children: [_jsx("span", { "aria-hidden": "true", className: SIDEBAR_ITEM_ICON_CLASS }), _jsx(NavLink, { to: group.to, end: true, className: "min-w-0 flex-1", children: ({ isActive }) => (_jsx("span", { className: cn("block", SIDEBAR_GROUP_HEADER_LABEL_CLASS, isActive
954
+ _jsxs("div", { "data-nav-group-header": true, className: cn("flex items-center justify-between py-1.5 transition-colors hover:bg-sidebar-accent/70", SIDEBAR_GROUP_HEADER_BOX, SIDEBAR_ITEM_GAP_CLASS), children: [_jsx("span", { "aria-hidden": "true", className: SIDEBAR_ITEM_ICON_CLASS }), _jsx(NavLink, { to: group.to, end: true, className: "min-w-0 flex-1", children: ({ isActive }) => (_jsx("span", { className: cn("block", SIDEBAR_GROUP_HEADER_LABEL_CLASS, isActive
897
955
  ? "text-sidebar-selected-foreground"
898
- : "text-sidebar-foreground/65 hover:text-sidebar-foreground"), children: group.label })) }), group.createActions && group.createActions.length > 0 && (_jsx(CreateActionsMenu, { group: group }))] })) : (_jsxs("div", { className: cn("flex items-center", SIDEBAR_ITEM_GAP_CLASS), children: [_jsx("span", { "aria-hidden": "true", className: SIDEBAR_ITEM_ICON_CLASS }), _jsx("span", { className: cn("text-sidebar-foreground/65", SIDEBAR_GROUP_HEADER_LABEL_CLASS), children: group.label })] })) })), !collapsed &&
899
- group.items.map((item) => (_jsx(CollapsibleNavItem, { item: item,
956
+ : "text-sidebar-foreground/75 hover:text-sidebar-foreground"), children: group.label })) }), group.createActions && group.createActions.length > 0 && (_jsx(CreateActionsMenu, { group: group }))] })) : (_jsxs("div", { "data-nav-group-header": true, className: cn("flex items-center", SIDEBAR_GROUP_HEADER_BOX, SIDEBAR_ITEM_GAP_CLASS), children: [_jsx("span", { "aria-hidden": "true", className: SIDEBAR_ITEM_ICON_CLASS }), _jsx("span", { className: cn("text-sidebar-foreground/75", SIDEBAR_GROUP_HEADER_LABEL_CLASS), children: group.label })] })) })), !collapsed && (_jsx("div", { className: cn(animateRows &&
957
+ "motion-safe:animate-in motion-safe:fade-in-0 motion-safe:slide-in-from-top-1 motion-safe:duration-200"), children: (focused ? [focusedItem] : group.items).map((item) => (_jsx(CollapsibleNavItem, { item: item,
900
958
  // A leaf dot distinguishes "nothing under this" from a sibling
901
959
  // that branches. Decided per GROUP: Workspaces has branches, so
902
960
  // its leaves are marked; Chats has none, so a dot on every row
903
961
  // would mark a distinction that does not exist.
904
- inTree: groupHasBranches }, item.to + item.label)))] }));
962
+ inTree: group.tree ?? groupHasBranches, heading: focused, underHeader: !group.hideLabel }, item.to + item.label))) }, focusKey))] }));
905
963
  }
906
964
  /**
907
965
  * The "+" dropdown shown next to a group label when the group declares
@@ -912,14 +970,15 @@ function NavGroupSection({ group }) {
912
970
  * doesn't also toggle the surrounding collapsible-header button.
913
971
  */
914
972
  function CreateActionsMenu({ group }) {
915
- return (_jsxs(DropdownMenu, { children: [_jsx(DropdownMenuTrigger, { asChild: true, children: _jsx("button", { type: "button", onClick: (e) => e.stopPropagation(), className: "inline-flex size-6 items-center justify-center text-sidebar-foreground/65 transition-colors hover:text-sidebar-foreground", "aria-label": `Create in ${group.label}`, children: _jsx(Plus, { className: "size-4" }) }) }), _jsx(DropdownMenuContent, { align: "end", className: "w-48", children: (group.createActions ?? []).map((action) => (_jsx(DropdownMenuItem, { onSelect: (e) => {
973
+ return (_jsxs(DropdownMenu, { children: [_jsx(DropdownMenuTrigger, { asChild: true, children: _jsx("button", { type: "button", onClick: (e) => e.stopPropagation(), className: "inline-flex size-6 items-center justify-center text-sidebar-foreground/75 transition-colors hover:text-sidebar-foreground", "aria-label": `Create in ${group.label}`, children: _jsx(Plus, { className: "size-4" }) }) }), _jsx(DropdownMenuContent, { align: "end", className: "w-48", children: (group.createActions ?? []).map((action) => (_jsx(DropdownMenuItem, { onSelect: (e) => {
916
974
  e.preventDefault();
917
975
  action.handler();
918
976
  }, children: action.label }, action.id))) })] }));
919
977
  }
920
- function SidebarContent({ collapsed }) {
978
+ function SidebarContent({ collapsed, groups, onNewConversation, hideFooterActions, hideNewConversation, collapsedGroupsOnly, newConversationAfterGroup, newConversationLabel = "New Thread", }) {
921
979
  const toggle = useSidebarStore((s) => s.toggle);
922
- const handleNewConversation = useNewConversation();
980
+ const defaultNewConversation = useNewConversation();
981
+ const handleNewConversation = onNewConversation ?? defaultNewConversation;
923
982
  const rawNavConfig = useNavStore((s) => s.config);
924
983
  const navLoading = useNavStore((s) => s.navLoading);
925
984
  // Sort by `priority` descending so feature hooks can declare slot
@@ -927,15 +986,15 @@ function SidebarContent({ collapsed }) {
927
986
  // order in which addGroup() happened to be called. Stable sort: ties
928
987
  // preserve insertion order, so groups that don't set a priority keep
929
988
  // the legacy append-where-you-arrive behaviour.
930
- const navConfig = useMemo(() => [...rawNavConfig]
989
+ const navConfig = useMemo(() => [...(groups ?? rawNavConfig)]
931
990
  .map((g, i) => ({ g, i, p: g.priority ?? 0 }))
932
991
  .sort((a, b) => b.p - a.p || a.i - b.i)
933
- .map(({ g }) => g), [rawNavConfig]);
992
+ .map(({ g }) => g), [rawNavConfig, groups]);
934
993
  // Show skeleton rows during the cold nav fetch — but only until the
935
994
  // integrator's primary dynamic group ("Workspaces") has been published.
936
995
  // Once it exists, real rows render even if a later phase (status dots) is
937
996
  // still resolving, so the skeleton never flashes over a populated tree.
938
- const hasWorkspacesGroup = navConfig.some((group) => group.label === "Workspaces");
997
+ const hasWorkspacesGroup = navConfig.some((group) => group.label === "Workspaces" || group.meta?.type === "spaces-workspaces");
939
998
  const showNavSkeleton = navLoading && !hasWorkspacesGroup;
940
999
  const currentPage = useLocation().pathname;
941
1000
  const navContext = useAppStore((s) => s.navContext);
@@ -946,41 +1005,68 @@ function SidebarContent({ collapsed }) {
946
1005
  e.preventDefault();
947
1006
  }, []);
948
1007
  if (collapsed) {
949
- return (_jsxs("div", { className: "flex flex-col items-center gap-1 py-3 flex-1", children: [_jsx(TooltipIconButton, { tooltip: "Expand sidebar", side: "right", onClick: toggle, children: _jsx(PanelLeftOpen, { className: "size-5" }) }), _jsx(TooltipIconButton, { tooltip: "New Thread", side: "right", onClick: handleNewConversation, children: _jsx(SquarePen, { className: "size-4 text-sidebar-icon" }) }), _jsx("div", { className: "mt-4 flex w-full flex-col items-stretch gap-1", children: navConfig
950
- .map((group, groupIndex) => {
951
- // Modules with a non-flat collapsed rail (e.g. Spaces'
952
- // "header + active workspace") supply `collapsedRail`; the
953
- // shell stays module-agnostic.
954
- const items = group.collapsedRail
955
- ? group
956
- .collapsedRail({
1008
+ return (_jsxs("div", { className: "flex flex-col items-center gap-1 py-3 flex-1", children: [_jsx(TooltipIconButton, { tooltip: "Expand sidebar", side: "right", onClick: toggle, children: _jsx(PanelLeftOpen, { className: "size-5" }) }), !hideNewConversation && !collapsedGroupsOnly && (_jsx(TooltipIconButton, { tooltip: newConversationLabel, side: "right", onClick: handleNewConversation, children: _jsx(SquarePen, { className: "size-4 text-sidebar-icon" }) })), _jsx("div", { className: "mt-4 flex w-full flex-col items-stretch gap-1", children: collapsedGroupsOnly
1009
+ ? navConfig
1010
+ .filter((group) => !group.meta?.hideInCollapsedRail &&
1011
+ (group.to || group.items.length > 0) &&
1012
+ group.collapsedRail?.({ currentPage, navContextMeta: navContext.meta ?? {} })
1013
+ .length !== 0)
1014
+ .map((group) => {
1015
+ const rail = group.collapsedRail?.({
957
1016
  currentPage,
958
1017
  navContextMeta: navContext.meta ?? {},
959
- })
960
- .map((it) => ({
961
- to: it.to,
962
- label: it.label,
963
- icon: it.icon,
964
- isActive: it.isActive,
965
- }))
966
- : group.items.map((it) => ({
967
- to: it.to,
968
- label: it.label,
969
- icon: it.icon,
970
- isActive: currentPage === it.to,
971
- }));
972
- return { group, groupIndex, items };
973
- })
974
- .filter(({ items }) => items.length > 0)
975
- .map(({ group, groupIndex, items }, visibleIndex) => (
976
- // Each non-first VISIBLE group gets a thin top border —
977
- // visually separates groups (e.g. user Workspaces vs
978
- // Admin) in the collapsed rail without changing the
979
- // CollapsedRailItem shape. The wrapper itself is a centred
980
- // column so icons stay aligned with the rail.
981
- _jsx("div", { className: cn("flex flex-col items-center gap-1", visibleIndex > 0 && "mt-2 pt-2 border-t border-sidebar-border/40"), children: items.map(({ to, label, icon, isActive }) => (_jsx(CollapsedNavShortcut, { to: to, label: label, icon: icon, isActive: isActive }, to))) }, `${group.label || ""}-${groupIndex}`))) })] }));
1018
+ });
1019
+ const active = currentPage === group.to ||
1020
+ group.items.some((item) => isInSubtree(currentPage, item)) ||
1021
+ Boolean(rail?.some((item) => item.isActive));
1022
+ const Icon = group.icon ??
1023
+ group.collapsedRail?.({ currentPage, navContextMeta: navContext.meta ?? {} })[0]
1024
+ ?.icon ??
1025
+ group.items[0]?.icon ??
1026
+ Layers;
1027
+ return group.to ? (_jsx(CollapsedNavShortcut, { to: group.to, label: group.label, icon: Icon, isActive: active }, group.label)) : (_jsx(TooltipIconButton, { tooltip: group.label, side: "right", "aria-label": `Open ${group.label}`, "aria-current": active ? "page" : undefined, className: active ? ACTIVE_NAV_ROW_CLASS : undefined, onClick: () => {
1028
+ toggleGroupCollapsed(group.label, true);
1029
+ toggle();
1030
+ }, children: _jsx(Icon, { className: "size-4" }) }, group.label));
1031
+ })
1032
+ : navConfig
1033
+ .map((group, groupIndex) => {
1034
+ // Modules with a non-flat collapsed rail (e.g. Spaces'
1035
+ // "header + active workspace") supply `collapsedRail`; the
1036
+ // shell stays module-agnostic.
1037
+ const items = group.collapsedRail
1038
+ ? group
1039
+ .collapsedRail({
1040
+ currentPage,
1041
+ navContextMeta: navContext.meta ?? {},
1042
+ })
1043
+ .map((it) => ({
1044
+ to: it.to,
1045
+ label: it.label,
1046
+ icon: it.icon,
1047
+ isActive: it.isActive,
1048
+ }))
1049
+ : group.items.map((it) => ({
1050
+ to: it.to,
1051
+ label: it.label,
1052
+ icon: it.icon,
1053
+ isActive: currentPage === it.to,
1054
+ }));
1055
+ return { group, groupIndex, items };
1056
+ })
1057
+ .filter(({ items }) => items.length > 0)
1058
+ .map(({ group, groupIndex, items }, visibleIndex) => (
1059
+ // Each non-first VISIBLE group gets a thin top border —
1060
+ // visually separates groups (e.g. user Workspaces vs
1061
+ // Admin) in the collapsed rail without changing the
1062
+ // CollapsedRailItem shape. The wrapper itself is a centred
1063
+ // column so icons stay aligned with the rail.
1064
+ _jsx("div", { className: cn("flex flex-col items-center gap-1", visibleIndex > 0 && "mt-2 pt-2 border-t border-sidebar-border/40"), children: items.map(({ to, label, icon, isActive }) => (_jsx(CollapsedNavShortcut, { to: to, label: label, icon: icon, isActive: isActive }, to))) }, `${group.label || ""}-${groupIndex}`))) })] }));
982
1065
  }
983
- return (_jsxs("div", { className: "flex flex-col flex-1 min-h-0", children: [_jsxs("div", { className: "flex items-center justify-between px-3 py-3", children: [_jsx(NavLink, { to: "/", className: "flex items-center gap-2 hover:opacity-80 transition-opacity", children: _jsx(AppBrand, { iconClassName: "text-primary", labelClassName: "truncate text-sm font-semibold text-sidebar-foreground" }) }), _jsx(TooltipIconButton, { tooltip: "Collapse sidebar", side: "right", onClick: toggle, children: _jsx(PanelLeft, { className: "size-5" }) })] }), _jsx("div", { className: "px-1 mb-0.5", children: _jsxs("button", { type: "button", onClick: handleNewConversation, className: cn("flex items-center gap-2 w-full", "rounded-xl px-3 py-2 text-sm text-left", "text-sidebar-foreground/85 transition-colors hover:bg-sidebar-accent/70"), children: [_jsx(SquarePen, { className: "size-4 shrink-0 text-sidebar-icon" }), "New Thread"] }) }), _jsxs("nav", { className: "flex-1 overflow-y-auto px-1 pb-16", onDragOverCapture: preventNativeFileDrop, onDropCapture: preventNativeFileDrop, children: [navConfig.map((group) => (_jsx(NavGroupSection, { group: group }, group.label))), showNavSkeleton && (_jsx("div", { className: "space-y-1 px-1 pt-2", "aria-hidden": "true", children: Array.from({ length: 6 }).map((_, index) => (_jsx("div", { className: "h-7 mx-1 rounded-lg animate-pulse bg-sidebar-accent/40" }, index))) }))] }), _jsx(SidebarFooterActions, {})] }));
1066
+ const newConversationAction = (_jsx("div", { className: "px-1 mb-0.5", children: _jsxs("button", { type: "button", onClick: handleNewConversation, className: cn("flex items-center gap-2 w-full", "rounded-xl px-3 py-2 text-sm text-left", "text-sidebar-foreground/85 transition-colors hover:bg-sidebar-accent/70"), children: [_jsx(SquarePen, { className: "size-4 shrink-0 text-sidebar-icon" }), newConversationLabel] }) }));
1067
+ return (_jsxs("div", { className: "flex flex-col flex-1 min-h-0", children: [_jsxs("div", { "data-navigation-header": true, className: "flex items-center justify-between px-3 py-3", children: [_jsx(NavLink, { to: "/", className: "flex items-center gap-2 hover:opacity-80 transition-opacity", children: _jsx(AppBrand, { iconClassName: "text-primary", labelClassName: "truncate text-sm font-semibold text-sidebar-foreground" }) }), _jsx(TooltipIconButton, { tooltip: "Collapse sidebar", side: "right", onClick: toggle, children: _jsx(PanelLeft, { className: "size-5" }) })] }), !hideNewConversation && !newConversationAfterGroup && newConversationAction, _jsxs("nav", { className: "flex-1 overflow-y-auto px-1 pb-16", onDragOverCapture: preventNativeFileDrop, onDropCapture: preventNativeFileDrop, children: [navConfig.map((group) => (_jsxs(Fragment, { children: [_jsx(NavGroupSection, { group: group }), !hideNewConversation &&
1068
+ group.label === newConversationAfterGroup &&
1069
+ newConversationAction] }, group.label))), showNavSkeleton && (_jsx("div", { className: "space-y-1 px-1 pt-2", "aria-hidden": "true", children: Array.from({ length: 6 }).map((_, index) => (_jsx("div", { className: "h-7 mx-1 rounded-lg animate-pulse bg-sidebar-accent/40" }, index))) }))] }), !hideFooterActions && _jsx(SidebarFooterActions, {})] }));
984
1070
  }
985
1071
  function SidebarFooterActions() {
986
1072
  const footerActions = useNavStore((s) => s.footerActions);
@@ -991,9 +1077,16 @@ function SidebarFooterActions() {
991
1077
  : "border-sidebar-foreground/30") }), action.active ? (action.activeLabel ?? action.label) : action.label] }, action.id))) }));
992
1078
  }
993
1079
  /** Mobile drawer overlay */
994
- function MobileDrawer() {
1080
+ function MobileDrawer({ footer, ...view }) {
995
1081
  const isMobileOpen = useSidebarStore((s) => s.isMobileOpen);
996
1082
  const closeMobile = useSidebarStore((s) => s.closeMobile);
1083
+ const locationKey = useLocation().key;
1084
+ const previousLocationKey = useRef(locationKey);
1085
+ useEffect(() => {
1086
+ if (previousLocationKey.current !== locationKey)
1087
+ closeMobile();
1088
+ previousLocationKey.current = locationKey;
1089
+ }, [locationKey, closeMobile]);
997
1090
  const isToolPanelOpen = useToolPanelStore((s) => s.isOpen);
998
1091
  const width = useSidebarStore((s) => s.width);
999
1092
  const drawerWidth = Math.min(width, typeof window !== "undefined" ? window.innerWidth - 24 : width);
@@ -1009,14 +1102,14 @@ function MobileDrawer() {
1009
1102
  window.addEventListener("keydown", handleEscape);
1010
1103
  return () => window.removeEventListener("keydown", handleEscape);
1011
1104
  }, [isMobileOpen, isToolPanelOpen, closeMobile]);
1012
- return (_jsxs(_Fragment, { children: [_jsx("div", { className: cn("fixed inset-0 z-50 bg-black/50 transition-opacity duration-200", isMobileOpen ? "opacity-100" : "opacity-0 pointer-events-none"), onClick: closeMobile, "aria-hidden": "true" }), _jsx("aside", { "data-sidebar-surface": "mobile", className: cn("fixed top-0 left-0 z-50 h-dvh", "flex flex-col", "border-r border-sidebar-border/60 bg-sidebar text-sidebar-foreground shadow-xl", "transition-transform duration-200 ease-in-out", isMobileOpen ? "translate-x-0" : "-translate-x-full"), style: { width: drawerWidth }, role: "complementary", "aria-label": "Navigation sidebar", children: _jsx(SidebarContent, { collapsed: false }) })] }));
1105
+ return (_jsxs(_Fragment, { children: [_jsx("div", { className: cn("fixed inset-0 z-50 bg-black/50 transition-opacity duration-200", isMobileOpen ? "opacity-100" : "opacity-0 pointer-events-none"), onClick: closeMobile, "aria-hidden": "true" }), _jsxs("aside", { "data-sidebar-surface": "mobile", className: cn("fixed top-0 left-0 z-50 h-dvh", "flex flex-col", "border-r border-sidebar-border/60 bg-sidebar text-sidebar-foreground shadow-xl", "transition-transform duration-200 ease-in-out", isMobileOpen ? "translate-x-0" : "-translate-x-full"), style: { width: drawerWidth }, role: "complementary", "aria-label": "Navigation sidebar", children: [_jsx(SidebarContent, { collapsed: false, ...view }), footer] })] }));
1013
1106
  }
1014
1107
  /** Desktop inline sidebar (expanded or collapsed rail) */
1015
- function DesktopSidebar() {
1108
+ function DesktopSidebar({ footer, collapsedFooter, ...view }) {
1016
1109
  const isOpen = useSidebarStore((s) => s.isOpen);
1017
1110
  const width = useSidebarStore((s) => s.width);
1018
- return (_jsxs("aside", { "data-sidebar-surface": "desktop", className: cn("group/sidebar relative hidden md:flex flex-col shrink-0", "h-full", "border-r border-sidebar-border/60 bg-sidebar text-sidebar-foreground", "transition-[width] duration-200 ease-in-out", "overflow-hidden"), style: { width: isOpen ? width : RAIL_WIDTH }, role: "complementary", "aria-label": "Navigation sidebar", children: [_jsx(SidebarContent, { collapsed: !isOpen }), isOpen && _jsx(SidebarResizeHandle, {})] }));
1111
+ return (_jsxs("aside", { "data-sidebar-surface": "desktop", "data-sidebar-collapsed": !isOpen || undefined, className: cn("group/sidebar relative hidden md:flex flex-col shrink-0", "h-full", "border-r border-sidebar-border/60 bg-sidebar text-sidebar-foreground", "transition-[width] duration-200 ease-in-out", "overflow-hidden"), style: { width: isOpen ? width : RAIL_WIDTH }, role: "complementary", "aria-label": "Navigation sidebar", children: [_jsx(SidebarContent, { collapsed: !isOpen, ...view }), isOpen ? footer : collapsedFooter, isOpen && _jsx(SidebarResizeHandle, {})] }));
1019
1112
  }
1020
- export function Sidebar() {
1021
- return (_jsxs(_Fragment, { children: [_jsx(MobileDrawer, {}), _jsx(DesktopSidebar, {})] }));
1113
+ export function Sidebar({ footer, collapsedFooter, ...view } = {}) {
1114
+ return (_jsxs(_Fragment, { children: [_jsx(MobileDrawer, { footer: footer, ...view }), _jsx(DesktopSidebar, { footer: footer, collapsedFooter: collapsedFooter, ...view })] }));
1022
1115
  }