@iloveagents/foundry-web-ui 0.3.1 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (223) hide show
  1. package/README.md +13 -4
  2. package/dist/components/ag-ui-runtime-provider.d.ts +64 -0
  3. package/dist/components/ag-ui-runtime-provider.js +71 -0
  4. package/dist/components/app-brand.d.ts +6 -0
  5. package/dist/components/app-brand.js +13 -0
  6. package/dist/components/assistant-chat.d.ts +26 -0
  7. package/dist/components/assistant-chat.js +93 -0
  8. package/dist/components/chat-attachments.d.ts +25 -0
  9. package/dist/components/chat-attachments.js +20 -0
  10. package/dist/components/chat-bubble.d.ts +1 -0
  11. package/dist/components/chat-bubble.js +103 -0
  12. package/dist/components/chat-header.d.ts +2 -0
  13. package/dist/components/chat-header.js +16 -0
  14. package/dist/components/client-tool-executor.d.ts +12 -0
  15. package/dist/components/client-tool-executor.js +108 -0
  16. package/dist/components/collection-empty-state.d.ts +9 -0
  17. package/dist/components/collection-empty-state.js +6 -0
  18. package/dist/components/collection-filter-bar.d.ts +19 -0
  19. package/dist/components/collection-filter-bar.js +28 -0
  20. package/dist/components/collection-search-input.d.ts +6 -0
  21. package/dist/components/collection-search-input.js +8 -0
  22. package/dist/components/collection-skeleton.d.ts +6 -0
  23. package/dist/components/collection-skeleton.js +10 -0
  24. package/dist/components/collection-sort-menu.d.ts +10 -0
  25. package/dist/components/collection-sort-menu.js +9 -0
  26. package/dist/components/collection-surface.d.ts +8 -0
  27. package/dist/components/collection-surface.js +5 -0
  28. package/dist/components/collection-toolbar.d.ts +9 -0
  29. package/dist/components/collection-toolbar.js +5 -0
  30. package/dist/components/collection-view-toggle.d.ts +7 -0
  31. package/dist/components/collection-view-toggle.js +17 -0
  32. package/dist/components/composer-submit-bridge.d.ts +1 -0
  33. package/dist/components/composer-submit-bridge.js +15 -0
  34. package/dist/components/confirm-dialog.d.ts +11 -0
  35. package/dist/components/confirm-dialog.js +6 -0
  36. package/dist/components/confirmation-card.d.ts +14 -0
  37. package/dist/components/confirmation-card.js +12 -0
  38. package/dist/components/context-badges.d.ts +16 -0
  39. package/dist/components/context-badges.js +56 -0
  40. package/dist/components/context-bar.d.ts +41 -0
  41. package/dist/components/context-bar.js +123 -0
  42. package/dist/components/form-dialog.d.ts +12 -0
  43. package/dist/components/form-dialog.js +13 -0
  44. package/dist/components/global-selection-popover.d.ts +7 -0
  45. package/dist/components/global-selection-popover.js +167 -0
  46. package/dist/components/infinite-scroll-sentinel.d.ts +6 -0
  47. package/dist/components/infinite-scroll-sentinel.js +25 -0
  48. package/dist/components/json-viewer.d.ts +10 -0
  49. package/dist/components/json-viewer.js +80 -0
  50. package/dist/components/loading-indicator.d.ts +4 -0
  51. package/dist/components/loading-indicator.js +22 -0
  52. package/dist/components/markdown-text.d.ts +13 -0
  53. package/dist/components/markdown-text.js +221 -0
  54. package/dist/components/name-dialog.d.ts +16 -0
  55. package/dist/components/name-dialog.js +23 -0
  56. package/dist/components/selection-popover.d.ts +12 -0
  57. package/dist/components/selection-popover.js +111 -0
  58. package/dist/components/show-document-tool-ui.d.ts +1 -0
  59. package/dist/components/show-document-tool-ui.js +53 -0
  60. package/dist/components/sidebar.d.ts +4 -0
  61. package/dist/components/sidebar.js +904 -0
  62. package/dist/components/surface-card.d.ts +9 -0
  63. package/dist/components/surface-card.js +33 -0
  64. package/dist/components/theme-runtime-provider.d.ts +15 -0
  65. package/dist/components/theme-runtime-provider.js +48 -0
  66. package/dist/components/theme-toggle.d.ts +2 -0
  67. package/dist/components/theme-toggle.js +19 -0
  68. package/dist/components/tool-call-card.d.ts +27 -0
  69. package/dist/components/tool-call-card.js +13 -0
  70. package/dist/components/tool-fallback.d.ts +26 -0
  71. package/dist/components/tool-fallback.js +129 -0
  72. package/dist/components/tool-panel-layout.d.ts +10 -0
  73. package/dist/components/tool-panel-layout.js +12 -0
  74. package/dist/components/tool-panel.d.ts +1 -0
  75. package/dist/components/tool-panel.js +89 -0
  76. package/dist/components/tooltip-icon-button.d.ts +7 -0
  77. package/dist/components/tooltip-icon-button.js +9 -0
  78. package/dist/components/user-menu.d.ts +2 -0
  79. package/dist/components/user-menu.js +14 -0
  80. package/dist/index.d.ts +68 -0
  81. package/dist/index.js +83 -0
  82. package/dist/lib/ag-ui-adapter.d.ts +29 -0
  83. package/dist/lib/ag-ui-adapter.js +373 -0
  84. package/dist/lib/app-store.d.ts +115 -0
  85. package/dist/lib/app-store.js +199 -0
  86. package/dist/lib/attachment-adapter.d.ts +20 -0
  87. package/dist/lib/attachment-adapter.js +99 -0
  88. package/dist/lib/auth-provider.d.ts +16 -0
  89. package/dist/lib/auth-provider.js +88 -0
  90. package/dist/lib/chat-bubble-store.d.ts +30 -0
  91. package/dist/lib/chat-bubble-store.js +23 -0
  92. package/dist/lib/chat-lifecycle-store.d.ts +10 -0
  93. package/{src/lib/chat-lifecycle-store.ts → dist/lib/chat-lifecycle-store.js} +3 -13
  94. package/dist/lib/client-tools.d.ts +11 -0
  95. package/dist/lib/client-tools.js +274 -0
  96. package/dist/lib/composer-submit-store.d.ts +12 -0
  97. package/dist/lib/composer-submit-store.js +17 -0
  98. package/dist/lib/dev-store.d.ts +38 -0
  99. package/dist/lib/dev-store.js +32 -0
  100. package/dist/lib/nav-config.d.ts +159 -0
  101. package/dist/lib/nav-config.js +162 -0
  102. package/dist/lib/nav-dnd.d.ts +8 -0
  103. package/dist/lib/nav-dnd.js +9 -0
  104. package/dist/lib/nav-store.d.ts +45 -0
  105. package/dist/lib/nav-store.js +49 -0
  106. package/dist/lib/selection-context.d.ts +21 -0
  107. package/dist/lib/selection-context.js +34 -0
  108. package/dist/lib/sidebar-store.d.ts +23 -0
  109. package/dist/lib/sidebar-store.js +98 -0
  110. package/dist/lib/theme-runtime.d.ts +130 -0
  111. package/dist/lib/theme-runtime.js +479 -0
  112. package/dist/lib/theme-store.d.ts +15 -0
  113. package/dist/lib/theme-store.js +53 -0
  114. package/dist/lib/tool-panel-store.d.ts +48 -0
  115. package/dist/lib/tool-panel-store.js +69 -0
  116. package/dist/lib/use-new-conversation.d.ts +8 -0
  117. package/dist/lib/use-new-conversation.js +62 -0
  118. package/dist/lib/use-page-tools.d.ts +27 -0
  119. package/{src/lib/use-page-tools.ts → dist/lib/use-page-tools.js} +11 -13
  120. package/dist/styles.css +142 -0
  121. package/dist/ui/collapsible.d.ts +5 -0
  122. package/{src/ui/collapsible.tsx → dist/ui/collapsible.js} +0 -2
  123. package/dist/ui/dialog.d.ts +15 -0
  124. package/dist/ui/dialog.js +25 -0
  125. package/dist/ui/dropdown-menu.d.ts +7 -0
  126. package/dist/ui/dropdown-menu.js +19 -0
  127. package/dist/ui/popover.d.ts +7 -0
  128. package/dist/ui/popover.js +10 -0
  129. package/dist/ui/select.d.ts +4 -0
  130. package/dist/ui/select.js +9 -0
  131. package/dist/ui/tooltip.d.ts +7 -0
  132. package/dist/ui/tooltip.js +10 -0
  133. package/package.json +31 -12
  134. package/AGENTS.md +0 -59
  135. package/CHANGELOG.md +0 -211
  136. package/CLAUDE.md +0 -1
  137. package/src/__tests__/no-spaces-imports.test.ts +0 -59
  138. package/src/__tests__/open-core-guards.test.ts +0 -307
  139. package/src/__tests__/theme-runtime.test.ts +0 -81
  140. package/src/__tests__/tool-fallback.test.tsx +0 -109
  141. package/src/components/__tests__/context-bar.test.ts +0 -112
  142. package/src/components/ag-ui-runtime-provider.tsx +0 -170
  143. package/src/components/app-brand.tsx +0 -38
  144. package/src/components/assistant-chat.tsx +0 -461
  145. package/src/components/chat-attachments.tsx +0 -68
  146. package/src/components/chat-bubble.tsx +0 -373
  147. package/src/components/chat-header.tsx +0 -44
  148. package/src/components/client-tool-executor.tsx +0 -230
  149. package/src/components/collection-empty-state.tsx +0 -37
  150. package/src/components/collection-filter-bar.tsx +0 -168
  151. package/src/components/collection-search-input.tsx +0 -41
  152. package/src/components/collection-skeleton.tsx +0 -55
  153. package/src/components/collection-sort-menu.tsx +0 -62
  154. package/src/components/collection-surface.tsx +0 -46
  155. package/src/components/collection-toolbar.tsx +0 -33
  156. package/src/components/collection-view-toggle.tsx +0 -61
  157. package/src/components/composer-submit-bridge.tsx +0 -19
  158. package/src/components/confirm-dialog.tsx +0 -49
  159. package/src/components/confirmation-card.tsx +0 -32
  160. package/src/components/context-badges.tsx +0 -136
  161. package/src/components/context-bar.tsx +0 -238
  162. package/src/components/form-dialog.tsx +0 -56
  163. package/src/components/global-selection-popover.tsx +0 -266
  164. package/src/components/infinite-scroll-sentinel.tsx +0 -48
  165. package/src/components/json-viewer.tsx +0 -232
  166. package/src/components/loading-indicator.tsx +0 -40
  167. package/src/components/markdown-text-citations.test.tsx +0 -108
  168. package/src/components/markdown-text.tsx +0 -508
  169. package/src/components/name-dialog.tsx +0 -87
  170. package/src/components/selection-popover.tsx +0 -156
  171. package/src/components/show-document-tool-ui.tsx +0 -90
  172. package/src/components/sidebar.test.tsx +0 -611
  173. package/src/components/sidebar.tsx +0 -1689
  174. package/src/components/surface-card.tsx +0 -45
  175. package/src/components/theme-runtime-provider.tsx +0 -93
  176. package/src/components/theme-toggle.tsx +0 -27
  177. package/src/components/tool-call-card.tsx +0 -126
  178. package/src/components/tool-fallback.tsx +0 -169
  179. package/src/components/tool-panel-layout.tsx +0 -36
  180. package/src/components/tool-panel.tsx +0 -233
  181. package/src/components/tooltip-icon-button.tsx +0 -28
  182. package/src/components/user-menu.tsx +0 -60
  183. package/src/index.ts +0 -185
  184. package/src/lib/__tests__/ag-ui-adapter.test.ts +0 -279
  185. package/src/lib/__tests__/app-store.test.ts +0 -405
  186. package/src/lib/__tests__/attachment-adapter.test.ts +0 -48
  187. package/src/lib/__tests__/chat-bubble-store.test.ts +0 -67
  188. package/src/lib/__tests__/client-tools.test.ts +0 -124
  189. package/src/lib/__tests__/dev-store.test.ts +0 -78
  190. package/src/lib/__tests__/nav-config.test.ts +0 -135
  191. package/src/lib/__tests__/nav-dnd.test.ts +0 -24
  192. package/src/lib/__tests__/nav-store.test.ts +0 -59
  193. package/src/lib/__tests__/selection-context.test.ts +0 -114
  194. package/src/lib/__tests__/sidebar-store.test.ts +0 -144
  195. package/src/lib/__tests__/theme-store.test.ts +0 -83
  196. package/src/lib/__tests__/tool-panel-store.test.ts +0 -61
  197. package/src/lib/__tests__/use-page-context.test.ts +0 -58
  198. package/src/lib/ag-ui-adapter.ts +0 -426
  199. package/src/lib/app-store.ts +0 -361
  200. package/src/lib/attachment-adapter.ts +0 -92
  201. package/src/lib/auth-provider.tsx +0 -144
  202. package/src/lib/chat-bubble-store.ts +0 -46
  203. package/src/lib/client-tools.ts +0 -293
  204. package/src/lib/composer-submit-store.ts +0 -32
  205. package/src/lib/dev-store.ts +0 -69
  206. package/src/lib/nav-config.ts +0 -329
  207. package/src/lib/nav-dnd.ts +0 -14
  208. package/src/lib/nav-store.ts +0 -89
  209. package/src/lib/selection-context.ts +0 -65
  210. package/src/lib/sidebar-store.ts +0 -113
  211. package/src/lib/theme-runtime.ts +0 -660
  212. package/src/lib/theme-store.ts +0 -67
  213. package/src/lib/tool-panel-store.ts +0 -121
  214. package/src/lib/use-new-conversation.test.tsx +0 -168
  215. package/src/lib/use-new-conversation.ts +0 -64
  216. package/src/ui/dialog.tsx +0 -109
  217. package/src/ui/dropdown-menu.tsx +0 -75
  218. package/src/ui/popover.tsx +0 -34
  219. package/src/ui/select.tsx +0 -15
  220. package/src/ui/tooltip.tsx +0 -30
  221. package/src/vite-env.d.ts +0 -1
  222. package/tsconfig.json +0 -16
  223. package/vitest.config.ts +0 -8
@@ -0,0 +1,904 @@
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";
3
+ import { NavLink, useLocation, useNavigate } from "react-router";
4
+ import { Ellipsis, PanelLeft, PanelLeftOpen, SquarePen, ChevronDown, ChevronRight, GripVertical, Plus, Upload } from "lucide-react";
5
+ import { cn } from "@iloveagents/foundry-web-primitives";
6
+ import { useSidebarStore, RAIL_WIDTH, SIDEBAR_MIN_WIDTH, SIDEBAR_MAX_WIDTH, } from "../lib/sidebar-store.js";
7
+ import { useToolPanelStore } from "../lib/tool-panel-store.js";
8
+ import { useNewConversation } from "../lib/use-new-conversation.js";
9
+ import { useNavStore } from "../lib/nav-store.js";
10
+ import { useAppStore } from "../lib/app-store.js";
11
+ import { getContainerDropZoneLabel, hasAnyDropTarget, hasExternalFilesData } from "../lib/nav-dnd.js";
12
+ import { TooltipIconButton } from "./tooltip-icon-button.js";
13
+ import { Button } from "@iloveagents/foundry-web-primitives";
14
+ import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger } from "../ui/dropdown-menu.js";
15
+ import { AppBrand } from "./app-brand.js";
16
+ const ACTIVE_NAV_ROW_CLASS = "border border-transparent bg-sidebar-selected shadow-none";
17
+ const ACTIVE_NAV_TEXT_CLASS = "font-semibold text-sidebar-foreground";
18
+ const ACTIVE_NAV_ICON_CLASS = "text-primary";
19
+ const ACTIVE_NAV_BADGE_CLASS = "border border-sidebar-border bg-background/60 text-sidebar-foreground";
20
+ const NESTED_NAV_INDENTS = ["pl-5", "pl-7", "pl-9", "pl-11", "pl-13"];
21
+ // Shared icon-column geometry. Top-level item rows render their
22
+ // leading icon at ``size-4`` (16px) followed by ``gap-2`` (8px)
23
+ // before the label. The group-header chevron uses the SAME column
24
+ // width + gap so the group label and item labels share an exact
25
+ // x-coordinate — without this invariant headers visually drift right
26
+ // (previously the chevron defaulted to ``size-6`` = 24px) and the
27
+ // sidebar looks ragged. Verified empirically against the live DOM:
28
+ // item icon x-min ≈ header chevron x-min, item label x ≈ header
29
+ // label x (within 1px allowing for the item's ``border-transparent``
30
+ // inset).
31
+ const SIDEBAR_ITEM_ICON_CLASS = "size-4 shrink-0";
32
+ const SIDEBAR_ITEM_GAP_CLASS = "gap-2";
33
+ // Outer padding for the group-header wrapper. Horizontal matches
34
+ // ``px-3`` on item buttons so the chevron column lines up with the
35
+ // item icon column. Vertical is asymmetric: ``pt-3`` (12px) above
36
+ // gives breathing room between sections, ``pb-1`` (4px) below bonds
37
+ // the header to its first child — the ChatGPT / Linear / Notion
38
+ // "header sits on its children" pattern. Symmetric ``py-2`` made
39
+ // the header float midway between sections instead of belonging
40
+ // to the group below it.
41
+ const SIDEBAR_GROUP_HEADER_PADDING = "px-3 pt-4 pb-0";
42
+ // Typography for the group-header LABEL text (CHATS / WORKSPACES /
43
+ // ADMIN / …). Stays at ``text-xs`` (12px) — same size as before —
44
+ // but bumps weight from ``font-medium`` to ``font-semibold``. The
45
+ // extra weight gives section headers the structural presence they
46
+ // need to feel like section dividers without spending vertical
47
+ // space; combined with ``uppercase tracking-wider`` it's the
48
+ // ChatGPT / Linear / Notion small-caps section-label rhythm.
49
+ // Bumping size AND weight together stacked too much visual mass
50
+ // against the already-wide uppercase tracking — felt shouty.
51
+ const SIDEBAR_GROUP_HEADER_LABEL_CLASS = "text-xs font-semibold uppercase tracking-wider";
52
+ function getNestedNavIndent(depth) {
53
+ return NESTED_NAV_INDENTS[depth] ?? NESTED_NAV_INDENTS[NESTED_NAV_INDENTS.length - 1];
54
+ }
55
+ function scrollNavRowIntoView(nav, row) {
56
+ const navRect = nav.getBoundingClientRect();
57
+ const rowRect = row.getBoundingClientRect();
58
+ if (rowRect.top < navRect.top || rowRect.bottom > navRect.bottom) {
59
+ const rowCenter = rowRect.top - navRect.top + rowRect.height / 2;
60
+ nav.scrollTop += rowCenter - nav.clientHeight / 2;
61
+ }
62
+ }
63
+ export const _scrollNavRowIntoViewForTest = scrollNavRowIntoView;
64
+ function useActiveNavRowScroll(isActive) {
65
+ const ref = useRef(null);
66
+ useEffect(() => {
67
+ if (!isActive)
68
+ return;
69
+ const scroll = () => {
70
+ const element = ref.current;
71
+ if (!element)
72
+ return;
73
+ const rect = element.getBoundingClientRect();
74
+ const viewportWidth = window.innerWidth || document.documentElement.clientWidth;
75
+ const isOnScreen = rect.width > 0 &&
76
+ rect.height > 0 &&
77
+ rect.right > 0 &&
78
+ rect.left < viewportWidth;
79
+ if (!isOnScreen)
80
+ return;
81
+ const nav = element.closest("nav");
82
+ if (!nav)
83
+ return;
84
+ scrollNavRowIntoView(nav, element);
85
+ };
86
+ const frame = window.requestAnimationFrame(scroll);
87
+ const retry = window.setTimeout(scroll, 120);
88
+ const finalRetry = window.setTimeout(scroll, 400);
89
+ return () => {
90
+ window.cancelAnimationFrame(frame);
91
+ window.clearTimeout(retry);
92
+ window.clearTimeout(finalRetry);
93
+ };
94
+ }, [isActive]);
95
+ return ref;
96
+ }
97
+ function useScrollActiveNavRow(currentPage, navConfig, collapsed) {
98
+ useEffect(() => {
99
+ if (collapsed)
100
+ return;
101
+ const scroll = () => {
102
+ const viewportWidth = window.innerWidth || document.documentElement.clientWidth;
103
+ const navs = Array.from(document.querySelectorAll("nav")).filter((nav) => {
104
+ const rect = nav.getBoundingClientRect();
105
+ return rect.width > 0 && rect.right > 0 && rect.left < viewportWidth;
106
+ });
107
+ for (const nav of navs) {
108
+ const row = nav.querySelector('[data-active-nav-row="true"]');
109
+ if (!row)
110
+ continue;
111
+ scrollNavRowIntoView(nav, row);
112
+ break;
113
+ }
114
+ };
115
+ const frame = window.requestAnimationFrame(scroll);
116
+ const retry = window.setTimeout(scroll, 120);
117
+ const finalRetry = window.setTimeout(scroll, 400);
118
+ return () => {
119
+ window.cancelAnimationFrame(frame);
120
+ window.clearTimeout(retry);
121
+ window.clearTimeout(finalRetry);
122
+ };
123
+ }, [collapsed, currentPage, navConfig]);
124
+ }
125
+ function statusDotColor(tone) {
126
+ switch (tone) {
127
+ case "low-impact":
128
+ return "var(--impact-low, currentColor)";
129
+ case "medium-impact":
130
+ return "var(--impact-medium, currentColor)";
131
+ case "high-impact":
132
+ return "var(--impact-high, currentColor)";
133
+ case "pending":
134
+ return "var(--impact-unknown, currentColor)";
135
+ case "info":
136
+ return "var(--primary, currentColor)";
137
+ case "neutral":
138
+ default:
139
+ return "currentColor";
140
+ }
141
+ }
142
+ function NavStatusDot({ statusDot }) {
143
+ if (!statusDot)
144
+ return null;
145
+ // Both "pending" (impact in progress) and "info" (live / active —
146
+ // e.g. the currently-resumed chat conversation) pulse so the eye is
147
+ // drawn to "something is happening / you are here right now".
148
+ // Other tones render static.
149
+ const pulse = statusDot.tone === "pending" || statusDot.tone === "info";
150
+ return (_jsx("span", { "data-nav-status-dot": true, "data-nav-status-tone": statusDot.tone, "aria-label": statusDot.label, title: statusDot.label, className: cn("size-1.5 shrink-0 rounded-full opacity-90", pulse && "animate-pulse"), style: { backgroundColor: statusDotColor(statusDot.tone) } }));
151
+ }
152
+ function statusIconClass(tone) {
153
+ switch (tone) {
154
+ case "warning":
155
+ return "text-warning";
156
+ case "danger":
157
+ return "text-destructive";
158
+ case "info":
159
+ return "text-primary";
160
+ case "neutral":
161
+ default:
162
+ return "text-sidebar-foreground/60";
163
+ }
164
+ }
165
+ function NavStatusIcon({ statusIcon }) {
166
+ if (!statusIcon)
167
+ return null;
168
+ const Icon = statusIcon.icon;
169
+ // Wrap in a ``<span>`` so the browser tooltip (``title``) and the
170
+ // screen-reader announcement (``aria-label``) attach to a real DOM
171
+ // node — lucide-react's icon types don't accept ``title`` as a
172
+ // prop. Kept small (size-3) so the icon doesn't compete with the
173
+ // entity icon on the left of the row.
174
+ return (_jsx("span", { role: "img", "aria-label": statusIcon.label, title: statusIcon.label, className: "inline-flex shrink-0", children: _jsx(Icon, { "aria-hidden": "true", className: cn("size-3 shrink-0", statusIconClass(statusIcon.tone)) }) }));
175
+ }
176
+ // --- Generic drag-and-drop via NavItem.dnd ---
177
+ /** Shared drag-and-drop props derived from NavItem.dnd (feature-provided). */
178
+ function useNavItemDnd(dnd) {
179
+ const [isDragOver, setIsDragOver] = useState(false);
180
+ // Tracked separately from ``isDragOver`` so consumers can render a
181
+ // distinct file-drop affordance (dashed primary ring + Upload icon)
182
+ // instead of the subtle entity-move ring. Without the split, a user
183
+ // dragging files from the desktop sees the same hover state as a
184
+ // user dragging another tree node — they can't tell the container
185
+ // accepts the drop.
186
+ const [isFileDragOver, setIsFileDragOver] = useState(false);
187
+ const dragCounter = useRef(0);
188
+ const isDraggable = dnd?.canDrag() ?? false;
189
+ const isDropTarget = dnd?.canDrop() ?? false;
190
+ const isFileDropTarget = dnd?.canDropFiles?.() ?? false;
191
+ const dndType = dnd?.type;
192
+ const hasExternalFiles = useCallback((e) => hasExternalFilesData(e.dataTransfer), []);
193
+ const hasMatchingDragData = useCallback((e) => Boolean((isDropTarget && dndType && e.dataTransfer.types.includes(dndType)) ||
194
+ (isFileDropTarget && hasExternalFiles(e))), [isDropTarget, dndType, isFileDropTarget, hasExternalFiles]);
195
+ const onDragStart = useCallback((e) => {
196
+ if (!dnd)
197
+ return;
198
+ const data = dnd.getData();
199
+ if (!data)
200
+ return;
201
+ e.dataTransfer.setData(dnd.type, data);
202
+ e.dataTransfer.effectAllowed = "move";
203
+ }, [dnd]);
204
+ const onDragEnter = useCallback((e) => {
205
+ if (!hasMatchingDragData(e))
206
+ return;
207
+ e.preventDefault();
208
+ e.stopPropagation();
209
+ dragCounter.current++;
210
+ setIsDragOver(true);
211
+ if (isFileDropTarget && hasExternalFiles(e)) {
212
+ setIsFileDragOver(true);
213
+ }
214
+ }, [hasMatchingDragData, isFileDropTarget, hasExternalFiles]);
215
+ // ``dragenter`` / ``dragleave`` fire on every parent-to-child crossing
216
+ // inside the drop target (entering the inner button, then a span, etc.).
217
+ // The counter pattern only flips ``isDragOver`` back to false when the
218
+ // cursor truly leaves the OUTER element. There's a transient between
219
+ // crossings where leave fires before the matching enter — React 18
220
+ // auto-batches setState calls within a single task so the user doesn't
221
+ // see a flicker. If you ever observe one, switch to a relatedTarget
222
+ // ``contains()`` check instead of the counter.
223
+ const onDragLeave = useCallback((e) => {
224
+ if (!hasAnyDropTarget(isDropTarget, isFileDropTarget))
225
+ return;
226
+ e.preventDefault();
227
+ dragCounter.current--;
228
+ if (dragCounter.current <= 0) {
229
+ dragCounter.current = 0;
230
+ setIsDragOver(false);
231
+ setIsFileDragOver(false);
232
+ }
233
+ }, [isDropTarget, isFileDropTarget]);
234
+ const onDragOver = useCallback((e) => {
235
+ if (!hasMatchingDragData(e))
236
+ return;
237
+ e.preventDefault();
238
+ e.stopPropagation();
239
+ e.dataTransfer.dropEffect = hasExternalFiles(e) ? "copy" : "move";
240
+ }, [hasMatchingDragData, hasExternalFiles]);
241
+ // Capture-phase handlers run BEFORE the bubble-phase ``onDragEnter`` /
242
+ // ``onDragOver`` on the same node. They MUST NOT call
243
+ // ``e.stopPropagation()``: in React's synthetic event system, stopping
244
+ // propagation in the capture phase short-circuits the dispatch — neither
245
+ // the bubble-phase handler on the same node (which actually mutates
246
+ // ``isDragOver`` / ``isFileDragOver``) nor any ancestor handler runs.
247
+ //
248
+ // We keep ``preventDefault()`` here because some browsers (Chrome) need
249
+ // it on ``dragenter``/``dragover`` to mark the element as droppable —
250
+ // doing it in the capture phase makes the visual cue stable while the
251
+ // user is still hovering inside descendant elements (button, span,
252
+ // action controls).
253
+ const onDragEnterCapture = useCallback((e) => {
254
+ if (!hasMatchingDragData(e))
255
+ return;
256
+ e.preventDefault();
257
+ }, [hasMatchingDragData]);
258
+ const onDragOverCapture = useCallback((e) => {
259
+ if (!hasMatchingDragData(e))
260
+ return;
261
+ e.preventDefault();
262
+ e.dataTransfer.dropEffect = hasExternalFiles(e) ? "copy" : "move";
263
+ }, [hasMatchingDragData, hasExternalFiles]);
264
+ const onDrop = useCallback(async (e) => {
265
+ if (!dnd)
266
+ return;
267
+ const files = Array.from(e.dataTransfer.files ?? []);
268
+ const hasFiles = isFileDropTarget && files.length > 0 && dnd.onFileDrop;
269
+ const draggedData = isDropTarget && dndType ? e.dataTransfer.getData(dndType) : "";
270
+ const hasEntityDrop = Boolean(isDropTarget && draggedData);
271
+ if (!hasFiles && !hasEntityDrop)
272
+ return;
273
+ e.preventDefault();
274
+ e.stopPropagation();
275
+ dragCounter.current = 0;
276
+ setIsDragOver(false);
277
+ setIsFileDragOver(false);
278
+ if (hasFiles) {
279
+ await dnd.onFileDrop(files);
280
+ return;
281
+ }
282
+ await dnd.onDrop(draggedData);
283
+ }, [isDropTarget, isFileDropTarget, dnd, dndType]);
284
+ return {
285
+ isDragOver,
286
+ isFileDragOver,
287
+ draggableProps: isDraggable ? { draggable: true, onDragStart } : {},
288
+ dropTargetProps: isDropTarget || isFileDropTarget
289
+ ? {
290
+ onDragEnterCapture,
291
+ onDragOverCapture,
292
+ onDragEnter,
293
+ onDragLeave,
294
+ onDragOver,
295
+ onDrop,
296
+ }
297
+ : {},
298
+ };
299
+ }
300
+ /** Drop zone rendered inside expanded containers so users can move items back to the container root. */
301
+ function ContainerDropZone({ dnd }) {
302
+ const [isDragOver, setIsDragOver] = useState(false);
303
+ const [isFileDragOver, setIsFileDragOver] = useState(false);
304
+ const dragCounter = useRef(0);
305
+ const acceptsFiles = dnd.canDropFiles?.() ?? false;
306
+ const acceptsEntities = dnd.canDrop();
307
+ const hasExternalFiles = (e) => hasExternalFilesData(e.dataTransfer);
308
+ const hasMatchingDragData = (e) => Boolean((acceptsEntities && e.dataTransfer.types.includes(dnd.type)) ||
309
+ (acceptsFiles && hasExternalFiles(e)));
310
+ return (_jsx("div", { onDragEnter: (e) => {
311
+ if (!hasMatchingDragData(e))
312
+ return;
313
+ e.preventDefault();
314
+ dragCounter.current++;
315
+ setIsFileDragOver(hasExternalFiles(e));
316
+ setIsDragOver(true);
317
+ }, onDragLeave: () => {
318
+ dragCounter.current--;
319
+ if (dragCounter.current <= 0) {
320
+ dragCounter.current = 0;
321
+ setIsFileDragOver(false);
322
+ setIsDragOver(false);
323
+ }
324
+ }, onDragOver: (e) => {
325
+ if (!hasMatchingDragData(e))
326
+ return;
327
+ e.preventDefault();
328
+ e.dataTransfer.dropEffect = hasExternalFiles(e) ? "copy" : "move";
329
+ }, onDrop: async (e) => {
330
+ const files = Array.from(e.dataTransfer.files ?? []);
331
+ const draggedData = acceptsEntities ? e.dataTransfer.getData(dnd.type) : "";
332
+ const hasFiles = acceptsFiles && files.length > 0 && dnd.onFileDrop;
333
+ const hasEntityDrop = Boolean(acceptsEntities && draggedData);
334
+ if (!hasFiles && !hasEntityDrop)
335
+ return;
336
+ e.preventDefault();
337
+ e.stopPropagation();
338
+ dragCounter.current = 0;
339
+ setIsFileDragOver(false);
340
+ setIsDragOver(false);
341
+ if (hasFiles) {
342
+ await dnd.onFileDrop(files);
343
+ return;
344
+ }
345
+ await dnd.onDrop(draggedData);
346
+ }, className: cn("mx-2 rounded-md transition-all overflow-hidden", isDragOver
347
+ ? cn("h-7 flex items-center justify-center gap-1", isFileDragOver
348
+ ? "bg-primary/10 border border-dashed border-primary"
349
+ : "bg-primary/10 border border-dashed border-primary/30")
350
+ : "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) })] })) }));
351
+ }
352
+ function NavItemActions({ actions }) {
353
+ return (_jsxs(DropdownMenu, { children: [_jsx(DropdownMenuTrigger, { asChild: true, children: _jsx(Button, { type: "button", variant: "ghost", size: "icon", className: "size-7 rounded-md text-muted-foreground hover:bg-muted/80 hover:text-foreground", onClick: (e) => {
354
+ e.preventDefault();
355
+ e.stopPropagation();
356
+ }, "aria-label": "More actions", children: _jsx(Ellipsis, { className: "size-3.5" }) }) }), _jsx(DropdownMenuContent, { align: "end", className: "w-56", children: actions.map((action) => (_jsxs("span", { children: [action.separator && _jsx(DropdownMenuSeparator, {}), _jsxs(DropdownMenuItem, { onSelect: (e) => {
357
+ e.preventDefault();
358
+ void action.handler();
359
+ }, className: cn("gap-2", action.destructive && "text-destructive focus:text-destructive"), children: [_jsx(action.icon, { className: cn("size-3.5", action.destructive ? "text-destructive" : "text-muted-foreground") }), _jsx("span", { children: action.label })] })] }, action.label))) })] }));
360
+ }
361
+ function NavRowControls({ item, hasChildren, isExpanded, onToggleExpand, }) {
362
+ const isDisabled = item.disabled === true;
363
+ const hasActions = !isDisabled && item.actions && item.actions.length > 0;
364
+ return (_jsxs("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", children: [hasActions && _jsx(NavItemActions, { actions: item.actions }), hasChildren && (_jsx("button", { type: "button", onClick: (e) => {
365
+ e.preventDefault();
366
+ e.stopPropagation();
367
+ onToggleExpand?.();
368
+ }, className: "flex size-7 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-muted/80 hover:text-foreground", "aria-label": isExpanded ? "Collapse" : "Expand", children: _jsx(ChevronRight, { className: cn("size-3.5 transition-transform", isExpanded && "rotate-90") }) }))] }));
369
+ }
370
+ /** Leaf nav item with optional drag-and-drop support via NavItem.dnd. */
371
+ function NavLeafItem({ item, isActive, onClick, depthPl = getNestedNavIndent(0), }) {
372
+ const isDisabled = item.disabled === true;
373
+ const hasActions = Boolean(!isDisabled && item.actions && item.actions.length > 0);
374
+ const { draggableProps, dropTargetProps, isDragOver, isFileDragOver } = useNavItemDnd(item.dnd);
375
+ const activeRowRef = useActiveNavRowScroll(isActive && hasActions);
376
+ const activeButtonRef = useActiveNavRowScroll(isActive && !hasActions);
377
+ // Items with actions use the group layout (button + action controls)
378
+ if (hasActions) {
379
+ return (_jsxs("div", { ref: activeRowRef, "data-active-nav-row": isActive ? "true" : undefined, ...(isDisabled ? {} : dropTargetProps), ...(isDisabled ? {} : draggableProps), className: cn("group relative flex items-center w-full rounded-xl border border-transparent transition-colors", isDisabled
380
+ ? "opacity-40"
381
+ : isActive
382
+ ? ACTIVE_NAV_ROW_CLASS
383
+ : "hover:bg-sidebar-accent/70", isDragOver &&
384
+ !isFileDragOver &&
385
+ !isDisabled &&
386
+ "ring-2 ring-inset ring-sidebar-ring/50 bg-sidebar-selected", isFileDragOver &&
387
+ !isDisabled &&
388
+ "outline-dashed outline-2 -outline-offset-2 outline-primary bg-primary/10", item.dimmed && !isDisabled && "opacity-50"), children: [_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", depthPl, "pr-3 transition-[padding] duration-150 group-hover:pr-10 group-focus-within:pr-10", isDisabled
389
+ ? "text-sidebar-foreground/45 cursor-default"
390
+ : isActive
391
+ ? ACTIVE_NAV_TEXT_CLASS
392
+ : "text-sidebar-foreground/80"), children: [_jsx(item.icon, { className: cn("size-3.5 shrink-0", isActive && !isDisabled ? ACTIVE_NAV_ICON_CLASS : "text-sidebar-foreground/60") }), _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 ? ACTIVE_NAV_BADGE_CLASS : "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, hasChildren: false, isExpanded: false })] }));
393
+ }
394
+ return (_jsxs("button", { ref: activeButtonRef, "data-active-nav-row": isActive ? "true" : undefined, type: "button", disabled: isDisabled, onClick: isDisabled ? undefined : onClick, ...(isDisabled ? {} : draggableProps), ...(isDisabled ? {} : dropTargetProps), className: cn("flex items-center gap-2 w-full", "rounded-lg border border-transparent pr-3 py-1.5 text-sm text-left", depthPl, isDisabled
395
+ ? "opacity-40 cursor-default"
396
+ : "text-sidebar-foreground/80 transition-colors hover:bg-sidebar-accent/70", !isDisabled && isActive && ACTIVE_NAV_ROW_CLASS, isDragOver &&
397
+ !isFileDragOver &&
398
+ !isDisabled &&
399
+ "ring-2 ring-inset ring-sidebar-ring/50 bg-sidebar-selected", isFileDragOver &&
400
+ !isDisabled &&
401
+ "outline-dashed outline-2 -outline-offset-2 outline-primary bg-primary/10", item.dimmed && !isDisabled && "opacity-50"), children: [_jsx(item.icon, { className: cn("size-3.5 shrink-0", isActive && !isDisabled ? ACTIVE_NAV_ICON_CLASS : "text-sidebar-foreground/60") }), _jsx("span", { className: cn("truncate", isActive && !isDisabled && ACTIVE_NAV_TEXT_CLASS), children: item.label }), item.badge && (_jsx("span", { className: cn("shrink-0 rounded px-1 py-0.5 text-[10px] font-medium", isActive ? ACTIVE_NAV_BADGE_CLASS : "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: "ml-auto size-3.5 shrink-0 text-primary pointer-events-none" }))] }));
402
+ }
403
+ function SubNavItems({ items, parentPath, depth = 1, }) {
404
+ const navigate = useNavigate();
405
+ const currentPath = useLocation().pathname;
406
+ const setNavContext = useAppStore((s) => s.setNavContext);
407
+ const navContext = useAppStore((s) => s.navContext);
408
+ const isOnParent = currentPath === parentPath;
409
+ const [activeLabel, setActiveLabel] = useState(items[0]?.label ?? "");
410
+ const childDepthPl = getNestedNavIndent(depth);
411
+ // Children with unique paths (e.g., SPACES documents) use URL matching;
412
+ // children sharing the parent path (e.g., Documents sub-filters) use label matching.
413
+ const allShareParent = items.every((c) => c.to === parentPath);
414
+ return (_jsx("div", { children: items.map((child) => {
415
+ const hasNestedChildren = child.children && child.children.length > 0;
416
+ // Folders with children render as collapsible nested items
417
+ if (hasNestedChildren) {
418
+ return _jsx(NestedFolderItem, { item: child, depth: depth }, child.to + child.label);
419
+ }
420
+ // Items sharing parent path use label matching (e.g., Documents sub-filters).
421
+ // Items with unique paths use URL matching. Items whose path equals the
422
+ // parent path (e.g., folders) are never highlighted on the parent page.
423
+ const isActive = allShareParent
424
+ ? isOnParent && activeLabel === child.label
425
+ : child.to !== parentPath && currentPath === child.to;
426
+ return (_jsx(NavLeafItem, { item: child, isActive: isActive, depthPl: childDepthPl, onClick: () => {
427
+ setActiveLabel(child.label);
428
+ setNavContext({
429
+ ...navContext,
430
+ label: child.label,
431
+ description: child.description ?? navContext.description,
432
+ meta: child.meta ?? {},
433
+ contextInstructions: [navContext.contextInstructions, child.contextInstructions]
434
+ .filter(Boolean)
435
+ .join("\n") || null,
436
+ });
437
+ navigate(child.to);
438
+ } }, child.to + child.label));
439
+ }) }));
440
+ }
441
+ /** Check if current page is inside a nav item's subtree. */
442
+ function isInSubtree(currentPage, item) {
443
+ if (currentPage === item.to)
444
+ return true;
445
+ return item.children?.some((c) => isInSubtree(currentPage, c)) ?? false;
446
+ }
447
+ /** Nested folder with collapsible children (used inside SubNavItems). */
448
+ function NestedFolderItem({ item, depth }) {
449
+ const navigate = useNavigate();
450
+ const currentPath = useLocation().pathname;
451
+ const setNavContext = useAppStore((s) => s.setNavContext);
452
+ const navContext = useAppStore((s) => s.navContext);
453
+ const isInsideSubtree = isInSubtree(currentPath, item);
454
+ const isActive = currentPath === item.to;
455
+ const isAncestorActive = isInsideSubtree && !isActive;
456
+ const [manualOverride, setManualOverride] = useState(null);
457
+ const isExpanded = manualOverride ?? isInsideSubtree;
458
+ const { draggableProps, dropTargetProps, isDragOver, isFileDragOver } = useNavItemDnd(item.dnd);
459
+ const depthPl = getNestedNavIndent(depth);
460
+ const isDisabled = item.disabled === true;
461
+ const activeRowRef = useActiveNavRowScroll(isActive);
462
+ return (_jsxs("div", { ...(isDisabled ? {} : dropTargetProps), children: [_jsxs("div", { ref: activeRowRef, "data-active-nav-row": isActive ? "true" : undefined, ...(isDisabled ? {} : draggableProps), className: cn("group relative flex items-center w-full", "rounded-xl border border-transparent transition-colors", isDisabled
463
+ ? "opacity-40"
464
+ : isActive
465
+ ? ACTIVE_NAV_ROW_CLASS
466
+ : "hover:bg-sidebar-accent/70", isDragOver &&
467
+ !isFileDragOver &&
468
+ !isDisabled &&
469
+ "ring-2 ring-inset ring-sidebar-ring/50 bg-sidebar-selected", isFileDragOver &&
470
+ !isDisabled &&
471
+ "outline-dashed outline-2 -outline-offset-2 outline-primary bg-primary/10", item.dimmed && !isDisabled && "opacity-50"), children: [_jsxs("button", { type: "button", disabled: isDisabled, onClick: () => {
472
+ if (isDisabled)
473
+ return;
474
+ setManualOverride(true);
475
+ setNavContext({
476
+ ...navContext,
477
+ label: item.label,
478
+ description: item.description ?? navContext.description,
479
+ meta: item.meta ?? {},
480
+ contextInstructions: [navContext.contextInstructions, item.contextInstructions]
481
+ .filter(Boolean)
482
+ .join("\n") || null,
483
+ });
484
+ navigate(item.to);
485
+ }, className: cn("flex items-center gap-2 flex-1 min-w-0", depthPl, "pr-3 py-1.5 text-sm text-left transition-[padding] duration-150 group-hover:pr-16 group-focus-within:pr-16", isDisabled
486
+ ? "text-sidebar-foreground/45 cursor-default"
487
+ : isActive
488
+ ? ACTIVE_NAV_TEXT_CLASS
489
+ : isAncestorActive
490
+ ? "text-sidebar-foreground font-medium"
491
+ : "text-sidebar-foreground/80"), children: [_jsx(item.icon, { className: cn("size-3.5 shrink-0", isActive && !isDisabled
492
+ ? ACTIVE_NAV_ICON_CLASS
493
+ : isAncestorActive
494
+ ? "text-sidebar-foreground/80"
495
+ : "text-sidebar-foreground/60") }), _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 ? ACTIVE_NAV_BADGE_CLASS : "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, hasChildren: true, isExpanded: isExpanded, onToggleExpand: () => setManualOverride(!isExpanded) })] }), isExpanded && item.children && (_jsxs("div", { children: [item.children.map((child) => {
496
+ const hasNestedChildren = child.children && child.children.length > 0;
497
+ if (hasNestedChildren) {
498
+ return (_jsx(NestedFolderItem, { item: child, depth: depth + 1 }, child.to + child.label));
499
+ }
500
+ const isChildActive = currentPath === child.to;
501
+ return (_jsx(NavLeafItem, { item: child, isActive: isChildActive, depthPl: getNestedNavIndent(depth + 1), onClick: () => {
502
+ setNavContext({
503
+ ...navContext,
504
+ label: child.label,
505
+ description: child.description ?? navContext.description,
506
+ meta: child.meta ?? {},
507
+ contextInstructions: [navContext.contextInstructions, child.contextInstructions]
508
+ .filter(Boolean)
509
+ .join("\n") || null,
510
+ });
511
+ navigate(child.to);
512
+ } }, child.to + child.label));
513
+ }), item.dnd?.canDrop() && _jsx(ContainerDropZone, { dnd: item.dnd })] }))] }));
514
+ }
515
+ function CollapsibleNavItem({ item }) {
516
+ const navigate = useNavigate();
517
+ const currentPath = useLocation().pathname;
518
+ const setNavContext = useAppStore((s) => s.setNavContext);
519
+ const navContext = useAppStore((s) => s.navContext);
520
+ const isInsideSubtree = isInSubtree(currentPath, item);
521
+ const isActive = currentPath === item.to;
522
+ const isAncestorActive = isInsideSubtree && !isActive;
523
+ const [manualOverride, setManualOverride] = useState(null);
524
+ const isExpanded = manualOverride ?? isInsideSubtree;
525
+ const hasChildren = Boolean(item.children && item.children.length > 0);
526
+ const isDisabled = item.disabled === true;
527
+ const hasActions = Boolean(!isDisabled && item.actions && item.actions.length > 0);
528
+ const { draggableProps, dropTargetProps, isDragOver, isFileDragOver } = useNavItemDnd(item.dnd);
529
+ const rendersRow = Boolean(hasChildren || hasActions);
530
+ const activeRowRef = useActiveNavRowScroll(isActive && rendersRow);
531
+ const activeLinkRef = useActiveNavRowScroll(isActive && !rendersRow);
532
+ if (rendersRow) {
533
+ const inner = (selected, ancestor) => (_jsxs("div", { ref: activeRowRef, "data-active-nav-row": selected ? "true" : undefined, className: cn("group relative flex items-center w-full rounded-xl border border-transparent transition-colors", isDisabled
534
+ ? "opacity-40"
535
+ : selected
536
+ ? ACTIVE_NAV_ROW_CLASS
537
+ : "hover:bg-sidebar-accent/70", isDragOver &&
538
+ !isFileDragOver &&
539
+ !isDisabled &&
540
+ "ring-2 ring-inset ring-sidebar-ring/50 bg-sidebar-selected", isFileDragOver &&
541
+ !isDisabled &&
542
+ "outline-dashed outline-2 -outline-offset-2 outline-primary bg-primary/10", item.dimmed && !isDisabled && "opacity-50"), children: [_jsxs("button", { type: "button", onClick: () => {
543
+ if (isDisabled)
544
+ return;
545
+ setNavContext({
546
+ ...navContext,
547
+ label: item.label,
548
+ description: item.description ?? navContext.description,
549
+ meta: item.meta ?? {},
550
+ contextInstructions: [navContext.contextInstructions, item.contextInstructions]
551
+ .filter(Boolean)
552
+ .join("\n") || null,
553
+ });
554
+ if (hasChildren)
555
+ setManualOverride(true);
556
+ navigate(item.to);
557
+ }, className: cn("flex items-center gap-2 flex-1 min-w-0 px-3 py-2 text-sm text-left", "pr-3 transition-[padding] duration-150 group-hover:pr-16 group-focus-within:pr-16", isDisabled
558
+ ? "text-sidebar-foreground/45 cursor-default"
559
+ : selected
560
+ ? ACTIVE_NAV_TEXT_CLASS
561
+ : ancestor
562
+ ? "text-sidebar-foreground font-medium"
563
+ : "text-sidebar-foreground/85"), disabled: isDisabled, children: [_jsx(item.icon, { className: cn("size-4 shrink-0", selected && !isDisabled
564
+ ? ACTIVE_NAV_ICON_CLASS
565
+ : ancestor
566
+ ? "text-sidebar-foreground/80"
567
+ : "text-sidebar-foreground/60") }), _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 ? ACTIVE_NAV_BADGE_CLASS : "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, hasChildren: Boolean(hasChildren), isExpanded: Boolean(isExpanded), onToggleExpand: hasChildren ? () => setManualOverride(!isExpanded) : undefined })] }));
568
+ 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 })] }))] }));
569
+ }
570
+ if (isDisabled) {
571
+ return (_jsxs("div", { className: cn("flex items-center gap-2 w-full opacity-40", "rounded-lg px-3 py-2 text-left text-sm text-sidebar-foreground/45"), children: [_jsx(item.icon, { className: "size-4 shrink-0 text-sidebar-foreground/45" }), _jsx("span", { className: "truncate", children: item.label })] }));
572
+ }
573
+ return (_jsx(NavLink, { ref: activeLinkRef, "data-active-nav-row": currentPath === item.to ? "true" : undefined, to: item.to, ...draggableProps, ...dropTargetProps, className: ({ isActive }) => 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", isActive && cn(ACTIVE_NAV_ROW_CLASS, ACTIVE_NAV_TEXT_CLASS), isDragOver &&
574
+ !isFileDragOver &&
575
+ "bg-sidebar-selected ring-2 ring-inset ring-sidebar-ring/50", isFileDragOver && "outline-dashed outline-2 -outline-offset-2 outline-primary bg-primary/10", item.dimmed && "opacity-50"), children: ({ isActive }) => (_jsxs(_Fragment, { children: [_jsx(item.icon, { className: cn("size-4 shrink-0", isActive ? ACTIVE_NAV_ICON_CLASS : "text-sidebar-foreground/60") }), _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
576
+ ? ACTIVE_NAV_BADGE_CLASS
577
+ : "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" }))] })) }));
578
+ }
579
+ function SidebarResizeHandle() {
580
+ const setWidth = useSidebarStore((s) => s.setWidth);
581
+ const width = useSidebarStore((s) => s.width);
582
+ const dragState = useRef(null);
583
+ useEffect(() => {
584
+ const onPointerMove = (event) => {
585
+ if (!dragState.current)
586
+ return;
587
+ const nextWidth = dragState.current.startWidth + (event.clientX - dragState.current.startX);
588
+ setWidth(nextWidth);
589
+ };
590
+ const onPointerUp = (event) => {
591
+ if (dragState.current && event.pointerId !== dragState.current.pointerId)
592
+ return;
593
+ dragState.current = null;
594
+ document.body.style.cursor = "";
595
+ document.body.style.userSelect = "";
596
+ };
597
+ window.addEventListener("pointermove", onPointerMove);
598
+ window.addEventListener("pointerup", onPointerUp);
599
+ window.addEventListener("pointercancel", onPointerUp);
600
+ return () => {
601
+ window.removeEventListener("pointermove", onPointerMove);
602
+ window.removeEventListener("pointerup", onPointerUp);
603
+ window.removeEventListener("pointercancel", onPointerUp);
604
+ };
605
+ }, [setWidth]);
606
+ return (_jsx("div", { className: "absolute inset-y-0 right-0 z-20 hidden w-3 cursor-col-resize md:block", onPointerDown: (event) => {
607
+ event.preventDefault();
608
+ dragState.current = {
609
+ startX: event.clientX,
610
+ startWidth: width,
611
+ pointerId: event.pointerId,
612
+ };
613
+ document.body.style.cursor = "col-resize";
614
+ document.body.style.userSelect = "none";
615
+ }, onKeyDown: (event) => {
616
+ if (event.key === "ArrowLeft") {
617
+ event.preventDefault();
618
+ setWidth(width - 16);
619
+ }
620
+ if (event.key === "ArrowRight") {
621
+ event.preventDefault();
622
+ setWidth(width + 16);
623
+ }
624
+ if (event.key === "Home") {
625
+ event.preventDefault();
626
+ setWidth(SIDEBAR_MIN_WIDTH);
627
+ }
628
+ if (event.key === "End") {
629
+ event.preventDefault();
630
+ setWidth(SIDEBAR_MAX_WIDTH);
631
+ }
632
+ }, role: "separator", "aria-orientation": "vertical", "aria-label": "Resize navigation sidebar", "aria-valuemin": SIDEBAR_MIN_WIDTH, "aria-valuemax": SIDEBAR_MAX_WIDTH, "aria-valuenow": Math.round(width), tabIndex: 0, children: _jsx("div", { className: "absolute inset-y-6 right-0 flex w-3 items-center justify-center opacity-0 transition-opacity group-hover/sidebar:opacity-100", children: _jsx("div", { className: "flex h-14 w-1.5 items-center justify-center rounded-full bg-border/90 text-muted-foreground shadow-sm", children: _jsx(GripVertical, { className: "size-3" }) }) }) }));
633
+ }
634
+ // ThreadIndicator (the "Active thread •" badge under New Thread) was
635
+ // removed — the active conversation's selected row in the Recents
636
+ // group is now the source of truth for "you're in a chat right now".
637
+ // Kept as a comment so future spelunkers know it was intentional.
638
+ function CollapsedNavShortcut({ to, label, icon: Icon, isActive, }) {
639
+ return (_jsx(NavLink, { to: to, children: _jsx(TooltipIconButton, { tooltip: label, side: "right", className: cn(isActive && "border border-transparent bg-sidebar-selected text-sidebar-foreground"), children: _jsx(Icon, { className: cn("size-4", isActive && "text-primary") }) }) }, to));
640
+ }
641
+ // ---------------------------------------------------------------------------
642
+ // Collapsible nav-group state — persisted per-label in localStorage so the
643
+ // user's "minimised Admin section" choice survives reloads. A single JSON
644
+ // blob keyed on label means we only touch one storage key per render and
645
+ // we can subscribe via useSyncExternalStore for cheap cross-component
646
+ // updates (toggle one group, all SidebarContent instances re-render).
647
+ // ---------------------------------------------------------------------------
648
+ // Generic package-scoped key — ``foundry-web-ui`` is consumed beyond
649
+ // the Spaces app, so we avoid consumer-specific names like
650
+ // ``spaces.nav.collapsed``. Matches the keying style used by the
651
+ // adjacent sidebar-store helpers (``sidebar``, ``sidebar-width``,
652
+ // ``theme``).
653
+ const COLLAPSE_STORAGE_KEY = "sidebar-nav-collapsed";
654
+ let _collapseCache = null;
655
+ const _collapseListeners = new Set();
656
+ function readCollapseMap() {
657
+ if (_collapseCache)
658
+ return _collapseCache;
659
+ if (typeof window === "undefined") {
660
+ _collapseCache = {};
661
+ return _collapseCache;
662
+ }
663
+ try {
664
+ const raw = window.localStorage.getItem(COLLAPSE_STORAGE_KEY);
665
+ _collapseCache = raw ? JSON.parse(raw) : {};
666
+ }
667
+ catch {
668
+ _collapseCache = {};
669
+ }
670
+ return _collapseCache;
671
+ }
672
+ function writeCollapseMap(next) {
673
+ _collapseCache = next;
674
+ if (typeof window !== "undefined") {
675
+ try {
676
+ window.localStorage.setItem(COLLAPSE_STORAGE_KEY, JSON.stringify(next));
677
+ }
678
+ catch {
679
+ // localStorage full / disabled — toggle still works in-memory for
680
+ // this page session.
681
+ }
682
+ }
683
+ for (const fn of _collapseListeners)
684
+ fn();
685
+ }
686
+ function subscribeCollapse(fn) {
687
+ _collapseListeners.add(fn);
688
+ return () => {
689
+ _collapseListeners.delete(fn);
690
+ };
691
+ }
692
+ /**
693
+ * Read the persisted collapse state for a single group. ``defaultCollapsed``
694
+ * is the fallback when the user has never toggled this group.
695
+ */
696
+ function useGroupCollapsed(label, defaultCollapsed) {
697
+ const map = useSyncExternalStore(subscribeCollapse, readCollapseMap, readCollapseMap);
698
+ // Use ``hasOwnProperty`` instead of the ``in`` operator so a label
699
+ // that happens to collide with a prototype key (e.g. ``toString``,
700
+ // ``__proto__``, ``constructor``) is not treated as a persisted
701
+ // entry. ``map`` is parsed JSON so its prototype is ``Object``'s.
702
+ return Object.prototype.hasOwnProperty.call(map, label)
703
+ ? map[label]
704
+ : Boolean(defaultCollapsed);
705
+ }
706
+ function toggleGroupCollapsed(label, current) {
707
+ writeCollapseMap({ ...readCollapseMap(), [label]: !current });
708
+ }
709
+ /**
710
+ * Walk a group's items (and their children) to find the first statusDot.
711
+ * Used to "bubble up" the active-chat indicator onto a collapsed group
712
+ * header — so the user never loses sight of "your active conversation is
713
+ * inside this minimised section".
714
+ */
715
+ function findFirstStatusDot(items) {
716
+ for (const item of items) {
717
+ if (item.statusDot)
718
+ return item.statusDot;
719
+ if (item.children && item.children.length > 0) {
720
+ const nested = findFirstStatusDot(item.children);
721
+ if (nested)
722
+ return nested;
723
+ }
724
+ }
725
+ return undefined;
726
+ }
727
+ /**
728
+ * Render a single nav group: label header (with optional collapse
729
+ * chevron + bubbled-up status dot) + child items. Pulled out into its
730
+ * own component so each group's collapse state is its own hook scope
731
+ * — toggling one doesn't re-render siblings.
732
+ */
733
+ function NavGroupSection({ group }) {
734
+ // hideLabel + collapsible are mutually exclusive — a hidden label
735
+ // can't host a chevron. The collapsible flag is the opt-in; legacy
736
+ // groups stay uncollapsable so behaviour is unchanged.
737
+ const isCollapsible = !group.hideLabel && Boolean(group.collapsible);
738
+ const persistedCollapsed = useGroupCollapsed(group.label, group.defaultCollapsed);
739
+ const collapsed = isCollapsible && persistedCollapsed;
740
+ // Bubble: show the first child statusDot on the collapsed header so
741
+ // the user never loses sight of "the active chat is inside this
742
+ // minimised group". Only render when actually collapsed.
743
+ const bubbledStatusDot = collapsed ? findFirstStatusDot(group.items) : undefined;
744
+ return (_jsxs("div", { "data-nav-group-label": group.label, children: [!group.hideLabel && (
745
+ // Outer padding matches the depth-0 item button's ``px-3`` so
746
+ // the chevron / leading spacer sits in the SAME icon column as
747
+ // item icons, and the group label ends up at the exact x-coordinate
748
+ // as item labels. See ``SIDEBAR_GROUP_HEADER_PADDING`` definition
749
+ // for the alignment invariant.
750
+ _jsx("div", { className: SIDEBAR_GROUP_HEADER_PADDING, children: isCollapsible ? (
751
+ // Collapsible header is a flex of three siblings: chevron
752
+ // toggle (own button), label (NavLink when ``group.to``
753
+ // is set so the user can still navigate to the group
754
+ // landing page — Workspaces does this), createActions
755
+ // menu. Three SIBLINGS, not nested, so HTML stays valid
756
+ // (no <button> inside <button> / inside Radix's button).
757
+ //
758
+ // Geometry: the chevron occupies the SAME width as item
759
+ // icons (``SIDEBAR_ITEM_ICON_CLASS``) followed by
760
+ // ``SIDEBAR_ITEM_GAP_CLASS``, so the group label and the
761
+ // item labels share an exact x-coordinate. The chevron's
762
+ // click target is larger than its visual size — extended
763
+ // via padding on the wrapper button — so users don't have
764
+ // to hit a tiny target. The clickable invisible padding
765
+ // OVERLAPS the gap rather than pushing the label right
766
+ // (negative-margin + extra padding cancel out).
767
+ _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}`,
768
+ // Visual cell width = ``SIDEBAR_ITEM_ICON_CLASS`` (14px)
769
+ // so the chevron sits in the same column as item icons
770
+ // and the group label aligns with item labels. The
771
+ // chevron SVG (``size-3`` = 12px) is smaller than the
772
+ // cell so it reads as a hint, not a primary control.
773
+ // Hit area is the cell width; the row's ``group/header``
774
+ // hover state covers the rest of the strip so users get
775
+ // visual feedback even on near-misses.
776
+ 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
777
+ ? "text-sidebar-selected-foreground"
778
+ : "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 ? (
779
+ // Non-collapsible header: no chevron, but we still emit an
780
+ // invisible spacer of ``SIDEBAR_ITEM_ICON_CLASS`` width + the
781
+ // shared gap so the group label lands in the same column as
782
+ // collapsible headers and item labels.
783
+ _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
784
+ ? "text-sidebar-selected-foreground"
785
+ : "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 &&
786
+ group.items.map((item) => (_jsx(CollapsibleNavItem, { item: item }, item.to + item.label)))] }));
787
+ }
788
+ /**
789
+ * The "+" dropdown shown next to a group label when the group declares
790
+ * ``createActions``. Pulled out so both the collapsible-button branch
791
+ * and the legacy plain-label branch can render it identically.
792
+ *
793
+ * The trigger button stops click propagation so opening the menu
794
+ * doesn't also toggle the surrounding collapsible-header button.
795
+ */
796
+ function CreateActionsMenu({ group }) {
797
+ 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) => {
798
+ e.preventDefault();
799
+ action.handler();
800
+ }, children: action.label }, action.id))) })] }));
801
+ }
802
+ function SidebarContent({ collapsed }) {
803
+ const toggle = useSidebarStore((s) => s.toggle);
804
+ const handleNewConversation = useNewConversation();
805
+ const rawNavConfig = useNavStore((s) => s.config);
806
+ const navLoading = useNavStore((s) => s.navLoading);
807
+ // Sort by `priority` descending so feature hooks can declare slot
808
+ // intent (e.g. "Recents above Tasks") instead of relying on the
809
+ // order in which addGroup() happened to be called. Stable sort: ties
810
+ // preserve insertion order, so groups that don't set a priority keep
811
+ // the legacy append-where-you-arrive behaviour.
812
+ const navConfig = useMemo(() => [...rawNavConfig]
813
+ .map((g, i) => ({ g, i, p: g.priority ?? 0 }))
814
+ .sort((a, b) => b.p - a.p || a.i - b.i)
815
+ .map(({ g }) => g), [rawNavConfig]);
816
+ // Show skeleton rows during the cold nav fetch — but only until the
817
+ // integrator's primary dynamic group ("Workspaces") has been published.
818
+ // Once it exists, real rows render even if a later phase (status dots) is
819
+ // still resolving, so the skeleton never flashes over a populated tree.
820
+ const hasWorkspacesGroup = navConfig.some((group) => group.label === "Workspaces");
821
+ const showNavSkeleton = navLoading && !hasWorkspacesGroup;
822
+ const currentPage = useLocation().pathname;
823
+ const navContext = useAppStore((s) => s.navContext);
824
+ useScrollActiveNavRow(currentPage, navConfig, collapsed);
825
+ const preventNativeFileDrop = useCallback((e) => {
826
+ if (!e.dataTransfer.types.includes("Files"))
827
+ return;
828
+ e.preventDefault();
829
+ }, []);
830
+ if (collapsed) {
831
+ 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" }) }), _jsx("div", { className: "mt-4 flex w-full flex-col items-stretch gap-1", children: navConfig
832
+ .map((group, groupIndex) => {
833
+ // Modules with a non-flat collapsed rail (e.g. Spaces'
834
+ // "header + active workspace") supply `collapsedRail`; the
835
+ // shell stays module-agnostic.
836
+ const items = group.collapsedRail
837
+ ? group
838
+ .collapsedRail({
839
+ currentPage,
840
+ navContextMeta: navContext.meta ?? {},
841
+ })
842
+ .map((it) => ({
843
+ to: it.to,
844
+ label: it.label,
845
+ icon: it.icon,
846
+ isActive: it.isActive,
847
+ }))
848
+ : group.items.map((it) => ({
849
+ to: it.to,
850
+ label: it.label,
851
+ icon: it.icon,
852
+ isActive: currentPage === it.to,
853
+ }));
854
+ return { group, groupIndex, items };
855
+ })
856
+ .filter(({ items }) => items.length > 0)
857
+ .map(({ group, groupIndex, items }, visibleIndex) => (
858
+ // Each non-first VISIBLE group gets a thin top border —
859
+ // visually separates groups (e.g. user Workspaces vs
860
+ // Admin) in the collapsed rail without changing the
861
+ // CollapsedRailItem shape. The wrapper itself is a centred
862
+ // column so icons stay aligned with the rail.
863
+ _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}`))) })] }));
864
+ }
865
+ 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-foreground/60" }), "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, {})] }));
866
+ }
867
+ function SidebarFooterActions() {
868
+ const footerActions = useNavStore((s) => s.footerActions);
869
+ if (footerActions.length === 0)
870
+ return null;
871
+ return (_jsx("div", { className: "shrink-0 space-y-0.5 px-3 py-2", children: footerActions.map((action) => (_jsxs("button", { type: "button", onClick: action.onToggle, className: cn("w-full flex items-center gap-2 px-2 py-1.5 rounded-md", "text-xs text-sidebar-foreground/70 transition-colors hover:bg-sidebar-accent hover:text-sidebar-foreground"), children: [_jsx("span", { className: cn("size-2 rounded-full border", action.active
872
+ ? "border-sidebar-primary bg-sidebar-primary"
873
+ : "border-sidebar-foreground/30") }), action.active ? (action.activeLabel ?? action.label) : action.label] }, action.id))) }));
874
+ }
875
+ /** Mobile drawer overlay */
876
+ function MobileDrawer() {
877
+ const isMobileOpen = useSidebarStore((s) => s.isMobileOpen);
878
+ const closeMobile = useSidebarStore((s) => s.closeMobile);
879
+ const isToolPanelOpen = useToolPanelStore((s) => s.isOpen);
880
+ const width = useSidebarStore((s) => s.width);
881
+ const drawerWidth = Math.min(width, typeof window !== "undefined" ? window.innerWidth - 24 : width);
882
+ // Close on Escape — but only if ToolPanel is not open (it has its own Escape handler)
883
+ useEffect(() => {
884
+ if (!isMobileOpen)
885
+ return;
886
+ const handleEscape = (e) => {
887
+ if (e.key === "Escape" && !isToolPanelOpen) {
888
+ closeMobile();
889
+ }
890
+ };
891
+ window.addEventListener("keydown", handleEscape);
892
+ return () => window.removeEventListener("keydown", handleEscape);
893
+ }, [isMobileOpen, isToolPanelOpen, closeMobile]);
894
+ 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", { 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 }) })] }));
895
+ }
896
+ /** Desktop inline sidebar (expanded or collapsed rail) */
897
+ function DesktopSidebar() {
898
+ const isOpen = useSidebarStore((s) => s.isOpen);
899
+ const width = useSidebarStore((s) => s.width);
900
+ return (_jsxs("aside", { 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, {})] }));
901
+ }
902
+ export function Sidebar() {
903
+ return (_jsxs(_Fragment, { children: [_jsx(MobileDrawer, {}), _jsx(DesktopSidebar, {})] }));
904
+ }