@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
@@ -1,1689 +0,0 @@
1
- import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react";
2
- import { NavLink, useNavigate } from "react-router";
3
- import { Ellipsis, Layers, PanelLeft, PanelLeftOpen, SquarePen, ChevronDown, ChevronRight, GripVertical, Plus, Upload } from "lucide-react";
4
- import { cn } from "@iloveagents/foundry-web-primitives";
5
- import {
6
- useSidebarStore,
7
- RAIL_WIDTH,
8
- SIDEBAR_MIN_WIDTH,
9
- SIDEBAR_MAX_WIDTH,
10
- } from "../lib/sidebar-store.ts";
11
- import { useToolPanelStore } from "../lib/tool-panel-store.ts";
12
- import { useNewConversation } from "../lib/use-new-conversation.ts";
13
- import { useNavStore } from "../lib/nav-store.ts";
14
- import { useAppStore } from "../lib/app-store.ts";
15
- import type { NavGroup, NavItem, NavItemDnd } from "../lib/nav-config.ts";
16
- import { getContainerDropZoneLabel, hasAnyDropTarget, hasExternalFilesData } from "../lib/nav-dnd.ts";
17
- import { TooltipIconButton } from "./tooltip-icon-button.tsx";
18
- import { Button } from "@iloveagents/foundry-web-primitives";
19
- import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger } from "../ui/dropdown-menu.tsx";
20
- import { AppBrand } from "./app-brand.tsx";
21
-
22
- const ACTIVE_NAV_ROW_CLASS =
23
- "border border-transparent bg-sidebar-selected shadow-none";
24
- const ACTIVE_NAV_TEXT_CLASS = "font-semibold text-sidebar-foreground";
25
- const ACTIVE_NAV_ICON_CLASS = "text-primary";
26
- const ACTIVE_NAV_BADGE_CLASS =
27
- "border border-sidebar-border bg-background/60 text-sidebar-foreground";
28
- const NESTED_NAV_INDENTS = ["pl-5", "pl-7", "pl-9", "pl-11", "pl-13"] as const;
29
-
30
- // Shared icon-column geometry. Top-level item rows render their
31
- // leading icon at ``size-4`` (16px) followed by ``gap-2`` (8px)
32
- // before the label. The group-header chevron uses the SAME column
33
- // width + gap so the group label and item labels share an exact
34
- // x-coordinate — without this invariant headers visually drift right
35
- // (previously the chevron defaulted to ``size-6`` = 24px) and the
36
- // sidebar looks ragged. Verified empirically against the live DOM:
37
- // item icon x-min ≈ header chevron x-min, item label x ≈ header
38
- // label x (within 1px allowing for the item's ``border-transparent``
39
- // inset).
40
- const SIDEBAR_ITEM_ICON_CLASS = "size-4 shrink-0";
41
- const SIDEBAR_ITEM_GAP_CLASS = "gap-2";
42
- // Outer padding for the group-header wrapper. Horizontal matches
43
- // ``px-3`` on item buttons so the chevron column lines up with the
44
- // item icon column. Vertical is asymmetric: ``pt-3`` (12px) above
45
- // gives breathing room between sections, ``pb-1`` (4px) below bonds
46
- // the header to its first child — the ChatGPT / Linear / Notion
47
- // "header sits on its children" pattern. Symmetric ``py-2`` made
48
- // the header float midway between sections instead of belonging
49
- // to the group below it.
50
- const SIDEBAR_GROUP_HEADER_PADDING = "px-3 pt-4 pb-0";
51
- // Typography for the group-header LABEL text (CHATS / WORKSPACES /
52
- // ADMIN / …). Stays at ``text-xs`` (12px) — same size as before —
53
- // but bumps weight from ``font-medium`` to ``font-semibold``. The
54
- // extra weight gives section headers the structural presence they
55
- // need to feel like section dividers without spending vertical
56
- // space; combined with ``uppercase tracking-wider`` it's the
57
- // ChatGPT / Linear / Notion small-caps section-label rhythm.
58
- // Bumping size AND weight together stacked too much visual mass
59
- // against the already-wide uppercase tracking — felt shouty.
60
- const SIDEBAR_GROUP_HEADER_LABEL_CLASS =
61
- "text-xs font-semibold uppercase tracking-wider";
62
-
63
- type NavStatusDot = NonNullable<NavItem["statusDot"]>;
64
- type NavStatusIcon = NonNullable<NavItem["statusIcon"]>;
65
-
66
- function getNestedNavIndent(depth: number) {
67
- return NESTED_NAV_INDENTS[depth] ?? NESTED_NAV_INDENTS[NESTED_NAV_INDENTS.length - 1];
68
- }
69
-
70
- function statusDotColor(tone: NavStatusDot["tone"]) {
71
- switch (tone) {
72
- case "low-impact":
73
- return "var(--impact-low, currentColor)";
74
- case "medium-impact":
75
- return "var(--impact-medium, currentColor)";
76
- case "high-impact":
77
- return "var(--impact-high, currentColor)";
78
- case "pending":
79
- return "var(--impact-unknown, currentColor)";
80
- case "info":
81
- return "var(--primary, currentColor)";
82
- case "neutral":
83
- default:
84
- return "currentColor";
85
- }
86
- }
87
-
88
- function NavStatusDot({ statusDot }: { statusDot?: NavStatusDot }) {
89
- if (!statusDot) return null;
90
- // Both "pending" (impact in progress) and "info" (live / active —
91
- // e.g. the currently-resumed chat conversation) pulse so the eye is
92
- // drawn to "something is happening / you are here right now".
93
- // Other tones render static.
94
- const pulse = statusDot.tone === "pending" || statusDot.tone === "info";
95
- return (
96
- <span
97
- data-nav-status-dot
98
- data-nav-status-tone={statusDot.tone}
99
- aria-label={statusDot.label}
100
- title={statusDot.label}
101
- className={cn(
102
- "size-1.5 shrink-0 rounded-full opacity-90",
103
- pulse && "animate-pulse",
104
- )}
105
- style={{ backgroundColor: statusDotColor(statusDot.tone) }}
106
- />
107
- );
108
- }
109
-
110
- function statusIconClass(tone: NavStatusIcon["tone"]) {
111
- switch (tone) {
112
- case "warning":
113
- return "text-warning";
114
- case "danger":
115
- return "text-destructive";
116
- case "info":
117
- return "text-primary";
118
- case "neutral":
119
- default:
120
- return "text-sidebar-foreground/60";
121
- }
122
- }
123
-
124
- function NavStatusIcon({ statusIcon }: { statusIcon?: NavStatusIcon }) {
125
- if (!statusIcon) return null;
126
- const Icon = statusIcon.icon;
127
- // Wrap in a ``<span>`` so the browser tooltip (``title``) and the
128
- // screen-reader announcement (``aria-label``) attach to a real DOM
129
- // node — lucide-react's icon types don't accept ``title`` as a
130
- // prop. Kept small (size-3) so the icon doesn't compete with the
131
- // entity icon on the left of the row.
132
- return (
133
- <span
134
- role="img"
135
- aria-label={statusIcon.label}
136
- title={statusIcon.label}
137
- className="inline-flex shrink-0"
138
- >
139
- <Icon
140
- aria-hidden="true"
141
- className={cn("size-3 shrink-0", statusIconClass(statusIcon.tone))}
142
- />
143
- </span>
144
- );
145
- }
146
-
147
- // --- Generic drag-and-drop via NavItem.dnd ---
148
-
149
- /** Shared drag-and-drop props derived from NavItem.dnd (feature-provided). */
150
- function useNavItemDnd(dnd?: NavItemDnd) {
151
- const [isDragOver, setIsDragOver] = useState(false);
152
- // Tracked separately from ``isDragOver`` so consumers can render a
153
- // distinct file-drop affordance (dashed primary ring + Upload icon)
154
- // instead of the subtle entity-move ring. Without the split, a user
155
- // dragging files from the desktop sees the same hover state as a
156
- // user dragging another tree node — they can't tell the container
157
- // accepts the drop.
158
- const [isFileDragOver, setIsFileDragOver] = useState(false);
159
- const dragCounter = useRef(0);
160
-
161
- const isDraggable = dnd?.canDrag() ?? false;
162
- const isDropTarget = dnd?.canDrop() ?? false;
163
- const isFileDropTarget = dnd?.canDropFiles?.() ?? false;
164
- const dndType = dnd?.type;
165
-
166
- const hasExternalFiles = useCallback((e: React.DragEvent) => hasExternalFilesData(e.dataTransfer), []);
167
-
168
- const hasMatchingDragData = useCallback(
169
- (e: React.DragEvent) =>
170
- Boolean(
171
- (isDropTarget && dndType && e.dataTransfer.types.includes(dndType)) ||
172
- (isFileDropTarget && hasExternalFiles(e)),
173
- ),
174
- [isDropTarget, dndType, isFileDropTarget, hasExternalFiles],
175
- );
176
-
177
- const onDragStart = useCallback(
178
- (e: React.DragEvent) => {
179
- if (!dnd) return;
180
- const data = dnd.getData();
181
- if (!data) return;
182
- e.dataTransfer.setData(dnd.type, data);
183
- e.dataTransfer.effectAllowed = "move";
184
- },
185
- [dnd],
186
- );
187
-
188
- const onDragEnter = useCallback(
189
- (e: React.DragEvent) => {
190
- if (!hasMatchingDragData(e)) return;
191
- e.preventDefault();
192
- e.stopPropagation();
193
- dragCounter.current++;
194
- setIsDragOver(true);
195
- if (isFileDropTarget && hasExternalFiles(e)) {
196
- setIsFileDragOver(true);
197
- }
198
- },
199
- [hasMatchingDragData, isFileDropTarget, hasExternalFiles],
200
- );
201
-
202
- // ``dragenter`` / ``dragleave`` fire on every parent-to-child crossing
203
- // inside the drop target (entering the inner button, then a span, etc.).
204
- // The counter pattern only flips ``isDragOver`` back to false when the
205
- // cursor truly leaves the OUTER element. There's a transient between
206
- // crossings where leave fires before the matching enter — React 18
207
- // auto-batches setState calls within a single task so the user doesn't
208
- // see a flicker. If you ever observe one, switch to a relatedTarget
209
- // ``contains()`` check instead of the counter.
210
- const onDragLeave = useCallback(
211
- (e: React.DragEvent) => {
212
- if (!hasAnyDropTarget(isDropTarget, isFileDropTarget)) return;
213
- e.preventDefault();
214
- dragCounter.current--;
215
- if (dragCounter.current <= 0) {
216
- dragCounter.current = 0;
217
- setIsDragOver(false);
218
- setIsFileDragOver(false);
219
- }
220
- },
221
- [isDropTarget, isFileDropTarget],
222
- );
223
-
224
- const onDragOver = useCallback(
225
- (e: React.DragEvent) => {
226
- if (!hasMatchingDragData(e)) return;
227
- e.preventDefault();
228
- e.stopPropagation();
229
- e.dataTransfer.dropEffect = hasExternalFiles(e) ? "copy" : "move";
230
- },
231
- [hasMatchingDragData, hasExternalFiles],
232
- );
233
-
234
- // Capture-phase handlers run BEFORE the bubble-phase ``onDragEnter`` /
235
- // ``onDragOver`` on the same node. They MUST NOT call
236
- // ``e.stopPropagation()``: in React's synthetic event system, stopping
237
- // propagation in the capture phase short-circuits the dispatch — neither
238
- // the bubble-phase handler on the same node (which actually mutates
239
- // ``isDragOver`` / ``isFileDragOver``) nor any ancestor handler runs.
240
- //
241
- // We keep ``preventDefault()`` here because some browsers (Chrome) need
242
- // it on ``dragenter``/``dragover`` to mark the element as droppable —
243
- // doing it in the capture phase makes the visual cue stable while the
244
- // user is still hovering inside descendant elements (button, span,
245
- // action controls).
246
- const onDragEnterCapture = useCallback(
247
- (e: React.DragEvent) => {
248
- if (!hasMatchingDragData(e)) return;
249
- e.preventDefault();
250
- },
251
- [hasMatchingDragData],
252
- );
253
-
254
- const onDragOverCapture = useCallback(
255
- (e: React.DragEvent) => {
256
- if (!hasMatchingDragData(e)) return;
257
- e.preventDefault();
258
- e.dataTransfer.dropEffect = hasExternalFiles(e) ? "copy" : "move";
259
- },
260
- [hasMatchingDragData, hasExternalFiles],
261
- );
262
-
263
- const onDrop = useCallback(
264
- async (e: React.DragEvent) => {
265
- if (!dnd) return;
266
- const files = Array.from(e.dataTransfer.files ?? []);
267
- const hasFiles = isFileDropTarget && files.length > 0 && dnd.onFileDrop;
268
- const draggedData =
269
- isDropTarget && dndType ? e.dataTransfer.getData(dndType) : "";
270
- const hasEntityDrop = Boolean(isDropTarget && draggedData);
271
- if (!hasFiles && !hasEntityDrop) return;
272
- e.preventDefault();
273
- e.stopPropagation();
274
- dragCounter.current = 0;
275
- setIsDragOver(false);
276
- setIsFileDragOver(false);
277
- if (hasFiles) {
278
- await dnd.onFileDrop!(files);
279
- return;
280
- }
281
- await dnd.onDrop(draggedData);
282
- },
283
- [isDropTarget, isFileDropTarget, dnd, dndType],
284
- );
285
-
286
- return {
287
- isDragOver,
288
- isFileDragOver,
289
- draggableProps: isDraggable ? { draggable: true, onDragStart } : {},
290
- dropTargetProps:
291
- isDropTarget || isFileDropTarget
292
- ? {
293
- onDragEnterCapture,
294
- onDragOverCapture,
295
- onDragEnter,
296
- onDragLeave,
297
- onDragOver,
298
- onDrop,
299
- }
300
- : {},
301
- };
302
- }
303
-
304
- /** Drop zone rendered inside expanded containers so users can move items back to the container root. */
305
- function ContainerDropZone({ dnd }: { dnd: NavItemDnd }) {
306
- const [isDragOver, setIsDragOver] = useState(false);
307
- const [isFileDragOver, setIsFileDragOver] = useState(false);
308
- const dragCounter = useRef(0);
309
- const acceptsFiles = dnd.canDropFiles?.() ?? false;
310
- const acceptsEntities = dnd.canDrop();
311
-
312
- const hasExternalFiles = (e: React.DragEvent) => hasExternalFilesData(e.dataTransfer);
313
-
314
- const hasMatchingDragData = (e: React.DragEvent) =>
315
- Boolean(
316
- (acceptsEntities && e.dataTransfer.types.includes(dnd.type)) ||
317
- (acceptsFiles && hasExternalFiles(e)),
318
- );
319
-
320
- return (
321
- <div
322
- onDragEnter={(e) => {
323
- if (!hasMatchingDragData(e)) return;
324
- e.preventDefault();
325
- dragCounter.current++;
326
- setIsFileDragOver(hasExternalFiles(e));
327
- setIsDragOver(true);
328
- }}
329
- onDragLeave={() => {
330
- dragCounter.current--;
331
- if (dragCounter.current <= 0) {
332
- dragCounter.current = 0;
333
- setIsFileDragOver(false);
334
- setIsDragOver(false);
335
- }
336
- }}
337
- onDragOver={(e) => {
338
- if (!hasMatchingDragData(e)) return;
339
- e.preventDefault();
340
- e.dataTransfer.dropEffect = hasExternalFiles(e) ? "copy" : "move";
341
- }}
342
- onDrop={async (e) => {
343
- const files = Array.from(e.dataTransfer.files ?? []);
344
- const draggedData = acceptsEntities ? e.dataTransfer.getData(dnd.type) : "";
345
- const hasFiles = acceptsFiles && files.length > 0 && dnd.onFileDrop;
346
- const hasEntityDrop = Boolean(acceptsEntities && draggedData);
347
- if (!hasFiles && !hasEntityDrop) return;
348
- e.preventDefault();
349
- e.stopPropagation();
350
- dragCounter.current = 0;
351
- setIsFileDragOver(false);
352
- setIsDragOver(false);
353
- if (hasFiles) {
354
- await dnd.onFileDrop!(files);
355
- return;
356
- }
357
- await dnd.onDrop(draggedData);
358
- }}
359
- className={cn(
360
- "mx-2 rounded-md transition-all overflow-hidden",
361
- isDragOver
362
- ? cn(
363
- "h-7 flex items-center justify-center gap-1",
364
- isFileDragOver
365
- ? "bg-primary/10 border border-dashed border-primary"
366
- : "bg-primary/10 border border-dashed border-primary/30",
367
- )
368
- : "h-1",
369
- )}
370
- >
371
- {isDragOver && (
372
- <>
373
- {isFileDragOver && <Upload className="size-3 text-primary" />}
374
- <span className="text-[10px] text-primary/70">
375
- {getContainerDropZoneLabel(isFileDragOver)}
376
- </span>
377
- </>
378
- )}
379
- </div>
380
- );
381
- }
382
-
383
- function NavItemActions({ actions }: { actions: NonNullable<NavItem["actions"]> }) {
384
- return (
385
- <DropdownMenu>
386
- <DropdownMenuTrigger asChild>
387
- <Button
388
- type="button"
389
- variant="ghost"
390
- size="icon"
391
- className="size-7 rounded-md text-muted-foreground hover:bg-muted/80 hover:text-foreground"
392
- onClick={(e) => {
393
- e.preventDefault();
394
- e.stopPropagation();
395
- }}
396
- aria-label="More actions"
397
- >
398
- <Ellipsis className="size-3.5" />
399
- </Button>
400
- </DropdownMenuTrigger>
401
- <DropdownMenuContent align="end" className="w-56">
402
- {actions.map((action) => (
403
- <span key={action.label}>
404
- {action.separator && <DropdownMenuSeparator />}
405
- <DropdownMenuItem
406
- onSelect={(e) => {
407
- e.preventDefault();
408
- void action.handler();
409
- }}
410
- className={cn("gap-2", action.destructive && "text-destructive focus:text-destructive")}
411
- >
412
- <action.icon className={cn("size-3.5", action.destructive ? "text-destructive" : "text-muted-foreground")} />
413
- <span>{action.label}</span>
414
- </DropdownMenuItem>
415
- </span>
416
- ))}
417
- </DropdownMenuContent>
418
- </DropdownMenu>
419
- );
420
- }
421
-
422
- function NavRowControls({
423
- item,
424
- hasChildren,
425
- isExpanded,
426
- onToggleExpand,
427
- }: {
428
- item: NavItem;
429
- hasChildren: boolean;
430
- isExpanded: boolean;
431
- onToggleExpand?: () => void;
432
- }) {
433
- const isDisabled = item.disabled === true;
434
- const hasActions = !isDisabled && item.actions && item.actions.length > 0;
435
-
436
- return (
437
- <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">
438
- {hasActions && <NavItemActions actions={item.actions!} />}
439
- {hasChildren && (
440
- <button
441
- type="button"
442
- onClick={(e) => {
443
- e.preventDefault();
444
- e.stopPropagation();
445
- onToggleExpand?.();
446
- }}
447
- className="flex size-7 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-muted/80 hover:text-foreground"
448
- aria-label={isExpanded ? "Collapse" : "Expand"}
449
- >
450
- <ChevronRight className={cn("size-3.5 transition-transform", isExpanded && "rotate-90")} />
451
- </button>
452
- )}
453
- </div>
454
- );
455
- }
456
-
457
- /** Leaf nav item with optional drag-and-drop support via NavItem.dnd. */
458
- function NavLeafItem({
459
- item,
460
- isActive,
461
- onClick,
462
- depthPl = getNestedNavIndent(0),
463
- }: {
464
- item: NavItem;
465
- isActive: boolean;
466
- onClick: () => void;
467
- depthPl?: string;
468
- }) {
469
- const isDisabled = item.disabled === true;
470
- const hasActions = !isDisabled && item.actions && item.actions.length > 0;
471
- const { draggableProps, dropTargetProps, isDragOver, isFileDragOver } = useNavItemDnd(item.dnd);
472
-
473
- // Items with actions use the group layout (button + action controls)
474
- if (hasActions) {
475
- return (
476
- <div
477
- {...(isDisabled ? {} : dropTargetProps)}
478
- {...(isDisabled ? {} : draggableProps)}
479
- className={cn(
480
- "group relative flex items-center w-full rounded-xl border border-transparent transition-colors",
481
- isDisabled
482
- ? "opacity-40"
483
- : isActive
484
- ? ACTIVE_NAV_ROW_CLASS
485
- : "hover:bg-sidebar-accent/70",
486
- isDragOver &&
487
- !isFileDragOver &&
488
- !isDisabled &&
489
- "ring-2 ring-inset ring-sidebar-ring/50 bg-sidebar-selected",
490
- isFileDragOver &&
491
- !isDisabled &&
492
- "outline-dashed outline-2 -outline-offset-2 outline-primary bg-primary/10",
493
- item.dimmed && !isDisabled && "opacity-50",
494
- )}
495
- >
496
- <button
497
- type="button"
498
- disabled={isDisabled}
499
- onClick={isDisabled ? undefined : onClick}
500
- className={cn(
501
- "flex items-center gap-2 flex-1 min-w-0 py-1.5 text-sm text-left",
502
- depthPl,
503
- "pr-3 transition-[padding] duration-150 group-hover:pr-10 group-focus-within:pr-10",
504
- isDisabled
505
- ? "text-sidebar-foreground/45 cursor-default"
506
- : isActive
507
- ? ACTIVE_NAV_TEXT_CLASS
508
- : "text-sidebar-foreground/80",
509
- )}
510
- >
511
- <item.icon
512
- className={cn(
513
- "size-3.5 shrink-0",
514
- isActive && !isDisabled ? ACTIVE_NAV_ICON_CLASS : "text-sidebar-foreground/60",
515
- )}
516
- />
517
- <span className="truncate">{item.label}</span>
518
- {item.badge && (
519
- <span
520
- className={cn(
521
- "shrink-0 rounded px-1 py-0.5 text-[10px] font-medium",
522
- isActive ? ACTIVE_NAV_BADGE_CLASS : "bg-sidebar-accent text-sidebar-accent-foreground",
523
- )}
524
- >
525
- {item.badge}
526
- </span>
527
- )}
528
- <NavStatusIcon statusIcon={item.statusIcon} />
529
- <NavStatusDot statusDot={item.statusDot} />
530
- </button>
531
- {/* File-drop hint icon — rendered as an inline flex sibling so it
532
- naturally sits to the LEFT of the action controls instead of
533
- overlapping them at a magic-number offset. */}
534
- {isFileDragOver && !isDisabled && (
535
- <Upload
536
- aria-hidden="true"
537
- className="size-3.5 mr-2 shrink-0 text-primary pointer-events-none"
538
- />
539
- )}
540
- <NavRowControls item={item} hasChildren={false} isExpanded={false} />
541
- </div>
542
- );
543
- }
544
-
545
- return (
546
- <button
547
- type="button"
548
- disabled={isDisabled}
549
- onClick={isDisabled ? undefined : onClick}
550
- {...(isDisabled ? {} : draggableProps)}
551
- {...(isDisabled ? {} : dropTargetProps)}
552
- className={cn(
553
- "flex items-center gap-2 w-full",
554
- "rounded-lg border border-transparent pr-3 py-1.5 text-sm text-left",
555
- depthPl,
556
- isDisabled
557
- ? "opacity-40 cursor-default"
558
- : "text-sidebar-foreground/80 transition-colors hover:bg-sidebar-accent/70",
559
- !isDisabled && isActive && ACTIVE_NAV_ROW_CLASS,
560
- isDragOver &&
561
- !isFileDragOver &&
562
- !isDisabled &&
563
- "ring-2 ring-inset ring-sidebar-ring/50 bg-sidebar-selected",
564
- isFileDragOver &&
565
- !isDisabled &&
566
- "outline-dashed outline-2 -outline-offset-2 outline-primary bg-primary/10",
567
- item.dimmed && !isDisabled && "opacity-50",
568
- )}
569
- >
570
- <item.icon
571
- className={cn(
572
- "size-3.5 shrink-0",
573
- isActive && !isDisabled ? ACTIVE_NAV_ICON_CLASS : "text-sidebar-foreground/60",
574
- )}
575
- />
576
- <span className={cn("truncate", isActive && !isDisabled && ACTIVE_NAV_TEXT_CLASS)}>{item.label}</span>
577
- {item.badge && (
578
- <span
579
- className={cn(
580
- "shrink-0 rounded px-1 py-0.5 text-[10px] font-medium",
581
- isActive ? ACTIVE_NAV_BADGE_CLASS : "bg-sidebar-accent text-sidebar-accent-foreground",
582
- )}
583
- >
584
- {item.badge}
585
- </span>
586
- )}
587
- <NavStatusIcon statusIcon={item.statusIcon} />
588
- <NavStatusDot statusDot={item.statusDot} />
589
- {isFileDragOver && !isDisabled && (
590
- <Upload
591
- aria-hidden="true"
592
- className="ml-auto size-3.5 shrink-0 text-primary pointer-events-none"
593
- />
594
- )}
595
- </button>
596
- );
597
- }
598
-
599
- function SubNavItems({
600
- items,
601
- parentPath,
602
- depth = 1,
603
- }: {
604
- items: NavItem[];
605
- parentPath: string;
606
- depth?: number;
607
- }) {
608
- const navigate = useNavigate();
609
- const currentPage = useAppStore((s) => s.currentPage);
610
- const setNavContext = useAppStore((s) => s.setNavContext);
611
- const navContext = useAppStore((s) => s.navContext);
612
- const isOnParent = currentPage === parentPath;
613
- const [activeLabel, setActiveLabel] = useState(items[0]?.label ?? "");
614
- const childDepthPl = getNestedNavIndent(depth);
615
-
616
- // Children with unique paths (e.g., SPACES documents) use URL matching;
617
- // children sharing the parent path (e.g., Documents sub-filters) use label matching.
618
- const allShareParent = items.every((c) => c.to === parentPath);
619
-
620
- return (
621
- <div>
622
- {items.map((child) => {
623
- const hasNestedChildren = child.children && child.children.length > 0;
624
-
625
- // Folders with children render as collapsible nested items
626
- if (hasNestedChildren) {
627
- return <NestedFolderItem key={child.to + child.label} item={child} depth={depth} />;
628
- }
629
-
630
- // Items sharing parent path use label matching (e.g., Documents sub-filters).
631
- // Items with unique paths use URL matching. Items whose path equals the
632
- // parent path (e.g., folders) are never highlighted on the parent page.
633
- const isActive = allShareParent
634
- ? isOnParent && activeLabel === child.label
635
- : child.to !== parentPath && currentPage === child.to;
636
- return (
637
- <NavLeafItem
638
- key={child.to + child.label}
639
- item={child}
640
- isActive={isActive}
641
- depthPl={childDepthPl}
642
- onClick={() => {
643
- setActiveLabel(child.label);
644
- setNavContext({
645
- ...navContext,
646
- label: child.label,
647
- description: child.description ?? navContext.description,
648
- meta: child.meta ?? {},
649
- contextInstructions:
650
- [navContext.contextInstructions, child.contextInstructions]
651
- .filter(Boolean)
652
- .join("\n") || null,
653
- });
654
- navigate(child.to);
655
- }}
656
- />
657
- );
658
- })}
659
- </div>
660
- );
661
- }
662
-
663
- /** Check if current page is inside a nav item's subtree. */
664
- function isInSubtree(currentPage: string, item: NavItem): boolean {
665
- if (currentPage === item.to) return true;
666
- return item.children?.some((c) => isInSubtree(currentPage, c)) ?? false;
667
- }
668
-
669
- /** Nested folder with collapsible children (used inside SubNavItems). */
670
- function NestedFolderItem({ item, depth }: { item: NavItem; depth: number }) {
671
- const navigate = useNavigate();
672
- const currentPage = useAppStore((s) => s.currentPage);
673
- const setNavContext = useAppStore((s) => s.setNavContext);
674
- const navContext = useAppStore((s) => s.navContext);
675
- const isInsideSubtree = isInSubtree(currentPage, item);
676
- const isActive = currentPage === item.to;
677
- const isAncestorActive = isInsideSubtree && !isActive;
678
- const [manualOverride, setManualOverride] = useState<boolean | null>(null);
679
- const isExpanded = manualOverride ?? isInsideSubtree;
680
- const { draggableProps, dropTargetProps, isDragOver, isFileDragOver } = useNavItemDnd(item.dnd);
681
- const depthPl = getNestedNavIndent(depth);
682
-
683
- const isDisabled = item.disabled === true;
684
-
685
- return (
686
- <div {...(isDisabled ? {} : dropTargetProps)}>
687
- <div
688
- {...(isDisabled ? {} : draggableProps)}
689
- className={cn(
690
- "group relative flex items-center w-full",
691
- "rounded-xl border border-transparent transition-colors",
692
- isDisabled
693
- ? "opacity-40"
694
- : isActive
695
- ? ACTIVE_NAV_ROW_CLASS
696
- : "hover:bg-sidebar-accent/70",
697
- isDragOver &&
698
- !isFileDragOver &&
699
- !isDisabled &&
700
- "ring-2 ring-inset ring-sidebar-ring/50 bg-sidebar-selected",
701
- isFileDragOver &&
702
- !isDisabled &&
703
- "outline-dashed outline-2 -outline-offset-2 outline-primary bg-primary/10",
704
- item.dimmed && !isDisabled && "opacity-50",
705
- )}
706
- >
707
- <button
708
- type="button"
709
- disabled={isDisabled}
710
- onClick={() => {
711
- if (isDisabled) return;
712
- setManualOverride(true);
713
- setNavContext({
714
- ...navContext,
715
- label: item.label,
716
- description: item.description ?? navContext.description,
717
- meta: item.meta ?? {},
718
- contextInstructions:
719
- [navContext.contextInstructions, item.contextInstructions]
720
- .filter(Boolean)
721
- .join("\n") || null,
722
- });
723
- navigate(item.to);
724
- }}
725
- className={cn(
726
- "flex items-center gap-2 flex-1 min-w-0",
727
- depthPl,
728
- "pr-3 py-1.5 text-sm text-left transition-[padding] duration-150 group-hover:pr-16 group-focus-within:pr-16",
729
- isDisabled
730
- ? "text-sidebar-foreground/45 cursor-default"
731
- : isActive
732
- ? ACTIVE_NAV_TEXT_CLASS
733
- : isAncestorActive
734
- ? "text-sidebar-foreground font-medium"
735
- : "text-sidebar-foreground/80",
736
- )}
737
- >
738
- <item.icon
739
- className={cn(
740
- "size-3.5 shrink-0",
741
- isActive && !isDisabled
742
- ? ACTIVE_NAV_ICON_CLASS
743
- : isAncestorActive
744
- ? "text-sidebar-foreground/80"
745
- : "text-sidebar-foreground/60",
746
- )}
747
- />
748
- <span className="truncate">{item.label}</span>
749
- {item.badge && (
750
- <span
751
- className={cn(
752
- "shrink-0 rounded px-1 py-0.5 text-[10px] font-medium",
753
- isActive ? ACTIVE_NAV_BADGE_CLASS : "bg-sidebar-accent text-sidebar-accent-foreground",
754
- )}
755
- >
756
- {item.badge}
757
- </span>
758
- )}
759
- <NavStatusIcon statusIcon={item.statusIcon} />
760
- <NavStatusDot statusDot={item.statusDot} />
761
- </button>
762
- {/* File-drop hint icon — inline flex sibling so it sits to the
763
- LEFT of the action controls instead of overlapping them. */}
764
- {isFileDragOver && !isDisabled && (
765
- <Upload
766
- aria-hidden="true"
767
- className="size-3.5 mr-2 shrink-0 text-primary pointer-events-none"
768
- />
769
- )}
770
- <NavRowControls
771
- item={item}
772
- hasChildren
773
- isExpanded={isExpanded}
774
- onToggleExpand={() => setManualOverride(!isExpanded)}
775
- />
776
- </div>
777
- {isExpanded && item.children && (
778
- <div>
779
- {item.children.map((child) => {
780
- const hasNestedChildren = child.children && child.children.length > 0;
781
- if (hasNestedChildren) {
782
- return (
783
- <NestedFolderItem key={child.to + child.label} item={child} depth={depth + 1} />
784
- );
785
- }
786
- const isChildActive = currentPage === child.to;
787
- return (
788
- <NavLeafItem
789
- key={child.to + child.label}
790
- item={child}
791
- isActive={isChildActive}
792
- depthPl={getNestedNavIndent(depth + 1)}
793
- onClick={() => {
794
- setNavContext({
795
- ...navContext,
796
- label: child.label,
797
- description: child.description ?? navContext.description,
798
- meta: child.meta ?? {},
799
- contextInstructions:
800
- [navContext.contextInstructions, child.contextInstructions]
801
- .filter(Boolean)
802
- .join("\n") || null,
803
- });
804
- navigate(child.to);
805
- }}
806
- />
807
- );
808
- })}
809
- {item.dnd?.canDrop() && <ContainerDropZone dnd={item.dnd} />}
810
- </div>
811
- )}
812
- </div>
813
- );
814
- }
815
-
816
- function CollapsibleNavItem({ item }: { item: NavItem }) {
817
- const navigate = useNavigate();
818
- const currentPage = useAppStore((s) => s.currentPage);
819
- const setNavContext = useAppStore((s) => s.setNavContext);
820
- const navContext = useAppStore((s) => s.navContext);
821
- const isInsideSubtree = isInSubtree(currentPage, item);
822
- const isActive = currentPage === item.to;
823
- const isAncestorActive = isInsideSubtree && !isActive;
824
- const [manualOverride, setManualOverride] = useState<boolean | null>(null);
825
- const isExpanded = manualOverride ?? isInsideSubtree;
826
- const hasChildren = item.children && item.children.length > 0;
827
- const isDisabled = item.disabled === true;
828
- const hasActions = !isDisabled && item.actions && item.actions.length > 0;
829
- const { draggableProps, dropTargetProps, isDragOver, isFileDragOver } = useNavItemDnd(item.dnd);
830
-
831
- if (hasChildren || hasActions) {
832
- const inner = (selected: boolean, ancestor: boolean) => (
833
- <div
834
- className={cn(
835
- "group relative flex items-center w-full rounded-xl border border-transparent transition-colors",
836
- isDisabled
837
- ? "opacity-40"
838
- : selected
839
- ? ACTIVE_NAV_ROW_CLASS
840
- : "hover:bg-sidebar-accent/70",
841
- isDragOver &&
842
- !isFileDragOver &&
843
- !isDisabled &&
844
- "ring-2 ring-inset ring-sidebar-ring/50 bg-sidebar-selected",
845
- isFileDragOver &&
846
- !isDisabled &&
847
- "outline-dashed outline-2 -outline-offset-2 outline-primary bg-primary/10",
848
- item.dimmed && !isDisabled && "opacity-50",
849
- )}
850
- >
851
- <button
852
- type="button"
853
- onClick={() => {
854
- if (isDisabled) return;
855
- setNavContext({
856
- ...navContext,
857
- label: item.label,
858
- description: item.description ?? navContext.description,
859
- meta: item.meta ?? {},
860
- contextInstructions:
861
- [navContext.contextInstructions, item.contextInstructions]
862
- .filter(Boolean)
863
- .join("\n") || null,
864
- });
865
- if (hasChildren) setManualOverride(true);
866
- navigate(item.to);
867
- }}
868
- className={cn(
869
- "flex items-center gap-2 flex-1 min-w-0 px-3 py-2 text-sm text-left",
870
- "pr-3 transition-[padding] duration-150 group-hover:pr-16 group-focus-within:pr-16",
871
- isDisabled
872
- ? "text-sidebar-foreground/45 cursor-default"
873
- : selected
874
- ? ACTIVE_NAV_TEXT_CLASS
875
- : ancestor
876
- ? "text-sidebar-foreground font-medium"
877
- : "text-sidebar-foreground/85",
878
- )}
879
- disabled={isDisabled}
880
- >
881
- <item.icon
882
- className={cn(
883
- "size-4 shrink-0",
884
- selected && !isDisabled
885
- ? ACTIVE_NAV_ICON_CLASS
886
- : ancestor
887
- ? "text-sidebar-foreground/80"
888
- : "text-sidebar-foreground/60",
889
- )}
890
- />
891
- <span className="truncate">{item.label}</span>
892
- {item.badge && (
893
- <span
894
- className={cn(
895
- "shrink-0 rounded px-1 py-0.5 text-[10px] font-medium",
896
- selected ? ACTIVE_NAV_BADGE_CLASS : "bg-sidebar-accent text-sidebar-accent-foreground",
897
- )}
898
- >
899
- {item.badge}
900
- </span>
901
- )}
902
- <NavStatusIcon statusIcon={item.statusIcon} />
903
- <NavStatusDot statusDot={item.statusDot} />
904
- </button>
905
- {/* File-drop hint icon — inline flex sibling so it sits to the
906
- LEFT of the action controls instead of overlapping them. */}
907
- {isFileDragOver && !isDisabled && (
908
- <Upload
909
- aria-hidden="true"
910
- className="size-3.5 mr-2 shrink-0 text-primary pointer-events-none"
911
- />
912
- )}
913
- <NavRowControls
914
- item={item}
915
- hasChildren={Boolean(hasChildren)}
916
- isExpanded={Boolean(isExpanded)}
917
- onToggleExpand={hasChildren ? () => setManualOverride(!isExpanded) : undefined}
918
- />
919
- </div>
920
- );
921
-
922
- return (
923
- <div {...(isDisabled ? {} : dropTargetProps)} {...(isDisabled ? {} : draggableProps)}>
924
- {inner(isActive, isAncestorActive)}
925
- {hasChildren && isExpanded && (
926
- <>
927
- <SubNavItems items={item.children!} parentPath={item.to} />
928
- {item.dnd?.canDrop() && <ContainerDropZone dnd={item.dnd} />}
929
- </>
930
- )}
931
- </div>
932
- );
933
- }
934
-
935
- if (isDisabled) {
936
- return (
937
- <div
938
- className={cn(
939
- "flex items-center gap-2 w-full opacity-40",
940
- "rounded-lg px-3 py-2 text-left text-sm text-sidebar-foreground/45",
941
- )}
942
- >
943
- <item.icon className="size-4 shrink-0 text-sidebar-foreground/45" />
944
- <span className="truncate">{item.label}</span>
945
- </div>
946
- );
947
- }
948
-
949
- return (
950
- <NavLink
951
- to={item.to}
952
- {...draggableProps}
953
- {...dropTargetProps}
954
- className={({ isActive }) =>
955
- cn(
956
- "flex items-center gap-2 w-full",
957
- "rounded-xl px-3 py-2 text-sm text-left",
958
- "text-sidebar-foreground/85 transition-colors hover:bg-sidebar-accent/70",
959
- isActive && cn(ACTIVE_NAV_ROW_CLASS, ACTIVE_NAV_TEXT_CLASS),
960
- isDragOver &&
961
- !isFileDragOver &&
962
- "bg-sidebar-selected ring-2 ring-inset ring-sidebar-ring/50",
963
- isFileDragOver && "outline-dashed outline-2 -outline-offset-2 outline-primary bg-primary/10",
964
- item.dimmed && "opacity-50",
965
- )
966
- }
967
- >
968
- {({ isActive }) => (
969
- <>
970
- <item.icon
971
- className={cn(
972
- "size-4 shrink-0",
973
- isActive ? ACTIVE_NAV_ICON_CLASS : "text-sidebar-foreground/60",
974
- )}
975
- />
976
- <span className="truncate">{item.label}</span>
977
- {item.badge && (
978
- <span
979
- className={cn(
980
- "shrink-0 rounded px-1 py-0.5 text-[10px] font-medium",
981
- isActive
982
- ? ACTIVE_NAV_BADGE_CLASS
983
- : "bg-sidebar-accent text-sidebar-accent-foreground",
984
- )}
985
- >
986
- {item.badge}
987
- </span>
988
- )}
989
- <NavStatusIcon statusIcon={item.statusIcon} />
990
- <NavStatusDot statusDot={item.statusDot} />
991
- {isFileDragOver && (
992
- <Upload
993
- aria-hidden="true"
994
- className="ml-auto size-3.5 shrink-0 text-primary pointer-events-none"
995
- />
996
- )}
997
- </>
998
- )}
999
- </NavLink>
1000
- );
1001
- }
1002
-
1003
- function SidebarResizeHandle() {
1004
- const setWidth = useSidebarStore((s) => s.setWidth);
1005
- const width = useSidebarStore((s) => s.width);
1006
- const dragState = useRef<{ startX: number; startWidth: number; pointerId: number } | null>(null);
1007
-
1008
- useEffect(() => {
1009
- const onPointerMove = (event: PointerEvent) => {
1010
- if (!dragState.current) return;
1011
- const nextWidth = dragState.current.startWidth + (event.clientX - dragState.current.startX);
1012
- setWidth(nextWidth);
1013
- };
1014
-
1015
- const onPointerUp = (event: PointerEvent) => {
1016
- if (dragState.current && event.pointerId !== dragState.current.pointerId) return;
1017
- dragState.current = null;
1018
- document.body.style.cursor = "";
1019
- document.body.style.userSelect = "";
1020
- };
1021
-
1022
- window.addEventListener("pointermove", onPointerMove);
1023
- window.addEventListener("pointerup", onPointerUp);
1024
- window.addEventListener("pointercancel", onPointerUp);
1025
- return () => {
1026
- window.removeEventListener("pointermove", onPointerMove);
1027
- window.removeEventListener("pointerup", onPointerUp);
1028
- window.removeEventListener("pointercancel", onPointerUp);
1029
- };
1030
- }, [setWidth]);
1031
-
1032
- return (
1033
- <div
1034
- className="absolute inset-y-0 right-0 z-20 hidden w-3 cursor-col-resize md:block"
1035
- onPointerDown={(event) => {
1036
- event.preventDefault();
1037
- dragState.current = {
1038
- startX: event.clientX,
1039
- startWidth: width,
1040
- pointerId: event.pointerId,
1041
- };
1042
- document.body.style.cursor = "col-resize";
1043
- document.body.style.userSelect = "none";
1044
- }}
1045
- onKeyDown={(event) => {
1046
- if (event.key === "ArrowLeft") {
1047
- event.preventDefault();
1048
- setWidth(width - 16);
1049
- }
1050
- if (event.key === "ArrowRight") {
1051
- event.preventDefault();
1052
- setWidth(width + 16);
1053
- }
1054
- if (event.key === "Home") {
1055
- event.preventDefault();
1056
- setWidth(SIDEBAR_MIN_WIDTH);
1057
- }
1058
- if (event.key === "End") {
1059
- event.preventDefault();
1060
- setWidth(SIDEBAR_MAX_WIDTH);
1061
- }
1062
- }}
1063
- role="separator"
1064
- aria-orientation="vertical"
1065
- aria-label="Resize navigation sidebar"
1066
- aria-valuemin={SIDEBAR_MIN_WIDTH}
1067
- aria-valuemax={SIDEBAR_MAX_WIDTH}
1068
- aria-valuenow={Math.round(width)}
1069
- tabIndex={0}
1070
- >
1071
- <div className="absolute inset-y-6 right-0 flex w-3 items-center justify-center opacity-0 transition-opacity group-hover/sidebar:opacity-100">
1072
- <div className="flex h-14 w-1.5 items-center justify-center rounded-full bg-border/90 text-muted-foreground shadow-sm">
1073
- <GripVertical className="size-3" />
1074
- </div>
1075
- </div>
1076
- </div>
1077
- );
1078
- }
1079
-
1080
- // ThreadIndicator (the "Active thread •" badge under New Thread) was
1081
- // removed — the active conversation's selected row in the Recents
1082
- // group is now the source of truth for "you're in a chat right now".
1083
- // Kept as a comment so future spelunkers know it was intentional.
1084
-
1085
- function CollapsedNavShortcut({
1086
- to,
1087
- label,
1088
- icon: Icon,
1089
- isActive,
1090
- }: {
1091
- to: string;
1092
- label: string;
1093
- icon: typeof Layers;
1094
- isActive: boolean;
1095
- }) {
1096
- return (
1097
- <NavLink key={to} to={to}>
1098
- <TooltipIconButton
1099
- tooltip={label}
1100
- side="right"
1101
- className={cn(isActive && "border border-transparent bg-sidebar-selected text-sidebar-foreground")}
1102
- >
1103
- <Icon className={cn("size-4", isActive && "text-primary")} />
1104
- </TooltipIconButton>
1105
- </NavLink>
1106
- );
1107
- }
1108
-
1109
- // ---------------------------------------------------------------------------
1110
- // Collapsible nav-group state — persisted per-label in localStorage so the
1111
- // user's "minimised Admin section" choice survives reloads. A single JSON
1112
- // blob keyed on label means we only touch one storage key per render and
1113
- // we can subscribe via useSyncExternalStore for cheap cross-component
1114
- // updates (toggle one group, all SidebarContent instances re-render).
1115
- // ---------------------------------------------------------------------------
1116
-
1117
- // Generic package-scoped key — ``foundry-web-ui`` is consumed beyond
1118
- // the Spaces app, so we avoid consumer-specific names like
1119
- // ``spaces.nav.collapsed``. Matches the keying style used by the
1120
- // adjacent sidebar-store helpers (``sidebar``, ``sidebar-width``,
1121
- // ``theme``).
1122
- const COLLAPSE_STORAGE_KEY = "sidebar-nav-collapsed";
1123
- type CollapseMap = Record<string, boolean>;
1124
-
1125
- let _collapseCache: CollapseMap | null = null;
1126
- const _collapseListeners = new Set<() => void>();
1127
-
1128
- function readCollapseMap(): CollapseMap {
1129
- if (_collapseCache) return _collapseCache;
1130
- if (typeof window === "undefined") {
1131
- _collapseCache = {};
1132
- return _collapseCache;
1133
- }
1134
- try {
1135
- const raw = window.localStorage.getItem(COLLAPSE_STORAGE_KEY);
1136
- _collapseCache = raw ? (JSON.parse(raw) as CollapseMap) : {};
1137
- } catch {
1138
- _collapseCache = {};
1139
- }
1140
- return _collapseCache;
1141
- }
1142
-
1143
- function writeCollapseMap(next: CollapseMap): void {
1144
- _collapseCache = next;
1145
- if (typeof window !== "undefined") {
1146
- try {
1147
- window.localStorage.setItem(COLLAPSE_STORAGE_KEY, JSON.stringify(next));
1148
- } catch {
1149
- // localStorage full / disabled — toggle still works in-memory for
1150
- // this page session.
1151
- }
1152
- }
1153
- for (const fn of _collapseListeners) fn();
1154
- }
1155
-
1156
- function subscribeCollapse(fn: () => void): () => void {
1157
- _collapseListeners.add(fn);
1158
- return () => {
1159
- _collapseListeners.delete(fn);
1160
- };
1161
- }
1162
-
1163
- /**
1164
- * Read the persisted collapse state for a single group. ``defaultCollapsed``
1165
- * is the fallback when the user has never toggled this group.
1166
- */
1167
- function useGroupCollapsed(label: string, defaultCollapsed: boolean | undefined): boolean {
1168
- const map = useSyncExternalStore(subscribeCollapse, readCollapseMap, readCollapseMap);
1169
- // Use ``hasOwnProperty`` instead of the ``in`` operator so a label
1170
- // that happens to collide with a prototype key (e.g. ``toString``,
1171
- // ``__proto__``, ``constructor``) is not treated as a persisted
1172
- // entry. ``map`` is parsed JSON so its prototype is ``Object``'s.
1173
- return Object.prototype.hasOwnProperty.call(map, label)
1174
- ? map[label]
1175
- : Boolean(defaultCollapsed);
1176
- }
1177
-
1178
- function toggleGroupCollapsed(label: string, current: boolean): void {
1179
- writeCollapseMap({ ...readCollapseMap(), [label]: !current });
1180
- }
1181
-
1182
- /**
1183
- * Walk a group's items (and their children) to find the first statusDot.
1184
- * Used to "bubble up" the active-chat indicator onto a collapsed group
1185
- * header — so the user never loses sight of "your active conversation is
1186
- * inside this minimised section".
1187
- */
1188
- function findFirstStatusDot(items: readonly NavItem[]): NavStatusDot | undefined {
1189
- for (const item of items) {
1190
- if (item.statusDot) return item.statusDot;
1191
- if (item.children && item.children.length > 0) {
1192
- const nested = findFirstStatusDot(item.children);
1193
- if (nested) return nested;
1194
- }
1195
- }
1196
- return undefined;
1197
- }
1198
-
1199
- /**
1200
- * Render a single nav group: label header (with optional collapse
1201
- * chevron + bubbled-up status dot) + child items. Pulled out into its
1202
- * own component so each group's collapse state is its own hook scope
1203
- * — toggling one doesn't re-render siblings.
1204
- */
1205
- function NavGroupSection({ group }: { group: NavGroup }) {
1206
- // hideLabel + collapsible are mutually exclusive — a hidden label
1207
- // can't host a chevron. The collapsible flag is the opt-in; legacy
1208
- // groups stay uncollapsable so behaviour is unchanged.
1209
- const isCollapsible = !group.hideLabel && Boolean(group.collapsible);
1210
- const persistedCollapsed = useGroupCollapsed(group.label, group.defaultCollapsed);
1211
- const collapsed = isCollapsible && persistedCollapsed;
1212
- // Bubble: show the first child statusDot on the collapsed header so
1213
- // the user never loses sight of "the active chat is inside this
1214
- // minimised group". Only render when actually collapsed.
1215
- const bubbledStatusDot = collapsed ? findFirstStatusDot(group.items) : undefined;
1216
-
1217
- return (
1218
- <div data-nav-group-label={group.label}>
1219
- {!group.hideLabel && (
1220
- // Outer padding matches the depth-0 item button's ``px-3`` so
1221
- // the chevron / leading spacer sits in the SAME icon column as
1222
- // item icons, and the group label ends up at the exact x-coordinate
1223
- // as item labels. See ``SIDEBAR_GROUP_HEADER_PADDING`` definition
1224
- // for the alignment invariant.
1225
- <div className={SIDEBAR_GROUP_HEADER_PADDING}>
1226
- {isCollapsible ? (
1227
- // Collapsible header is a flex of three siblings: chevron
1228
- // toggle (own button), label (NavLink when ``group.to``
1229
- // is set so the user can still navigate to the group
1230
- // landing page — Workspaces does this), createActions
1231
- // menu. Three SIBLINGS, not nested, so HTML stays valid
1232
- // (no <button> inside <button> / inside Radix's button).
1233
- //
1234
- // Geometry: the chevron occupies the SAME width as item
1235
- // icons (``SIDEBAR_ITEM_ICON_CLASS``) followed by
1236
- // ``SIDEBAR_ITEM_GAP_CLASS``, so the group label and the
1237
- // item labels share an exact x-coordinate. The chevron's
1238
- // click target is larger than its visual size — extended
1239
- // via padding on the wrapper button — so users don't have
1240
- // to hit a tiny target. The clickable invisible padding
1241
- // OVERLAPS the gap rather than pushing the label right
1242
- // (negative-margin + extra padding cancel out).
1243
- <div
1244
- className={cn(
1245
- "group/header flex items-center justify-between rounded-lg transition-colors hover:bg-sidebar-accent/70",
1246
- SIDEBAR_ITEM_GAP_CLASS,
1247
- )}
1248
- >
1249
- <button
1250
- type="button"
1251
- data-nav-group-toggle
1252
- onClick={() => toggleGroupCollapsed(group.label, persistedCollapsed)}
1253
- aria-expanded={!collapsed}
1254
- aria-label={collapsed ? `Expand ${group.label}` : `Collapse ${group.label}`}
1255
- // Visual cell width = ``SIDEBAR_ITEM_ICON_CLASS`` (14px)
1256
- // so the chevron sits in the same column as item icons
1257
- // and the group label aligns with item labels. The
1258
- // chevron SVG (``size-3`` = 12px) is smaller than the
1259
- // cell so it reads as a hint, not a primary control.
1260
- // Hit area is the cell width; the row's ``group/header``
1261
- // hover state covers the rest of the strip so users get
1262
- // visual feedback even on near-misses.
1263
- className={cn(
1264
- "inline-flex items-center justify-center rounded text-sidebar-foreground/50 hover:text-sidebar-foreground",
1265
- SIDEBAR_ITEM_ICON_CLASS,
1266
- )}
1267
- >
1268
- {collapsed ? (
1269
- <ChevronRight className="size-3.5 transition-transform" />
1270
- ) : (
1271
- <ChevronDown className="size-3.5 transition-transform" />
1272
- )}
1273
- </button>
1274
- {group.to ? (
1275
- <NavLink to={group.to} end className="flex min-w-0 flex-1 items-center gap-1.5 py-1.5">
1276
- {({ isActive }) => (
1277
- <>
1278
- <span
1279
- className={cn(
1280
- "truncate",
1281
- SIDEBAR_GROUP_HEADER_LABEL_CLASS,
1282
- isActive
1283
- ? "text-sidebar-selected-foreground"
1284
- : "text-sidebar-foreground/65 group-hover/header:text-sidebar-foreground",
1285
- )}
1286
- >
1287
- {group.label}
1288
- </span>
1289
- <NavStatusDot statusDot={bubbledStatusDot} />
1290
- </>
1291
- )}
1292
- </NavLink>
1293
- ) : (
1294
- <button
1295
- type="button"
1296
- onClick={() => toggleGroupCollapsed(group.label, persistedCollapsed)}
1297
- className="flex min-w-0 flex-1 items-center gap-1.5 py-1.5 text-left"
1298
- >
1299
- <span
1300
- className={cn(
1301
- "truncate text-sidebar-foreground/65 group-hover/header:text-sidebar-foreground",
1302
- SIDEBAR_GROUP_HEADER_LABEL_CLASS,
1303
- )}
1304
- >
1305
- {group.label}
1306
- </span>
1307
- <NavStatusDot statusDot={bubbledStatusDot} />
1308
- </button>
1309
- )}
1310
- {group.createActions && group.createActions.length > 0 && (
1311
- <CreateActionsMenu group={group} />
1312
- )}
1313
- </div>
1314
- ) : group.to ? (
1315
- // Non-collapsible header: no chevron, but we still emit an
1316
- // invisible spacer of ``SIDEBAR_ITEM_ICON_CLASS`` width + the
1317
- // shared gap so the group label lands in the same column as
1318
- // collapsible headers and item labels.
1319
- <div
1320
- className={cn(
1321
- "flex items-center justify-between rounded-lg py-1.5 transition-colors hover:bg-sidebar-accent/70",
1322
- SIDEBAR_ITEM_GAP_CLASS,
1323
- )}
1324
- >
1325
- <span aria-hidden="true" className={SIDEBAR_ITEM_ICON_CLASS} />
1326
- <NavLink to={group.to} end className="min-w-0 flex-1">
1327
- {({ isActive }) => (
1328
- <span
1329
- className={cn(
1330
- "block",
1331
- SIDEBAR_GROUP_HEADER_LABEL_CLASS,
1332
- isActive
1333
- ? "text-sidebar-selected-foreground"
1334
- : "text-sidebar-foreground/65 hover:text-sidebar-foreground",
1335
- )}
1336
- >
1337
- {group.label}
1338
- </span>
1339
- )}
1340
- </NavLink>
1341
- {group.createActions && group.createActions.length > 0 && (
1342
- <CreateActionsMenu group={group} />
1343
- )}
1344
- </div>
1345
- ) : (
1346
- <div className={cn("flex items-center", SIDEBAR_ITEM_GAP_CLASS)}>
1347
- <span aria-hidden="true" className={SIDEBAR_ITEM_ICON_CLASS} />
1348
- <span
1349
- className={cn(
1350
- "text-sidebar-foreground/65",
1351
- SIDEBAR_GROUP_HEADER_LABEL_CLASS,
1352
- )}
1353
- >
1354
- {group.label}
1355
- </span>
1356
- </div>
1357
- )}
1358
- </div>
1359
- )}
1360
- {!collapsed &&
1361
- group.items.map((item) => (
1362
- <CollapsibleNavItem key={item.to + item.label} item={item} />
1363
- ))}
1364
- </div>
1365
- );
1366
- }
1367
-
1368
- /**
1369
- * The "+" dropdown shown next to a group label when the group declares
1370
- * ``createActions``. Pulled out so both the collapsible-button branch
1371
- * and the legacy plain-label branch can render it identically.
1372
- *
1373
- * The trigger button stops click propagation so opening the menu
1374
- * doesn't also toggle the surrounding collapsible-header button.
1375
- */
1376
- function CreateActionsMenu({ group }: { group: NavGroup }) {
1377
- return (
1378
- <DropdownMenu>
1379
- <DropdownMenuTrigger asChild>
1380
- <button
1381
- type="button"
1382
- onClick={(e) => e.stopPropagation()}
1383
- className="inline-flex size-6 items-center justify-center text-sidebar-foreground/65 transition-colors hover:text-sidebar-foreground"
1384
- aria-label={`Create in ${group.label}`}
1385
- >
1386
- <Plus className="size-4" />
1387
- </button>
1388
- </DropdownMenuTrigger>
1389
- <DropdownMenuContent align="end" className="w-48">
1390
- {(group.createActions ?? []).map((action) => (
1391
- <DropdownMenuItem
1392
- key={action.id}
1393
- onSelect={(e) => {
1394
- e.preventDefault();
1395
- action.handler();
1396
- }}
1397
- >
1398
- {action.label}
1399
- </DropdownMenuItem>
1400
- ))}
1401
- </DropdownMenuContent>
1402
- </DropdownMenu>
1403
- );
1404
- }
1405
-
1406
- function SidebarContent({ collapsed }: { collapsed: boolean }) {
1407
- const toggle = useSidebarStore((s) => s.toggle);
1408
- const handleNewConversation = useNewConversation();
1409
- const rawNavConfig = useNavStore((s) => s.config);
1410
- const navLoading = useNavStore((s) => s.navLoading);
1411
- // Sort by `priority` descending so feature hooks can declare slot
1412
- // intent (e.g. "Recents above Tasks") instead of relying on the
1413
- // order in which addGroup() happened to be called. Stable sort: ties
1414
- // preserve insertion order, so groups that don't set a priority keep
1415
- // the legacy append-where-you-arrive behaviour.
1416
- const navConfig = useMemo(
1417
- () =>
1418
- [...rawNavConfig]
1419
- .map((g, i) => ({ g, i, p: g.priority ?? 0 }))
1420
- .sort((a, b) => b.p - a.p || a.i - b.i)
1421
- .map(({ g }) => g),
1422
- [rawNavConfig],
1423
- );
1424
- // Show skeleton rows during the cold nav fetch — but only until the
1425
- // integrator's primary dynamic group ("Workspaces") has been published.
1426
- // Once it exists, real rows render even if a later phase (status dots) is
1427
- // still resolving, so the skeleton never flashes over a populated tree.
1428
- const hasWorkspacesGroup = navConfig.some((group) => group.label === "Workspaces");
1429
- const showNavSkeleton = navLoading && !hasWorkspacesGroup;
1430
- const currentPage = useAppStore((s) => s.currentPage);
1431
- const navContext = useAppStore((s) => s.navContext);
1432
-
1433
- const preventNativeFileDrop = useCallback((e: React.DragEvent) => {
1434
- if (!e.dataTransfer.types.includes("Files")) return;
1435
- e.preventDefault();
1436
- }, []);
1437
-
1438
- if (collapsed) {
1439
- return (
1440
- <div className="flex flex-col items-center gap-1 py-3 flex-1">
1441
- <TooltipIconButton tooltip="Expand sidebar" side="right" onClick={toggle}>
1442
- <PanelLeftOpen className="size-5" />
1443
- </TooltipIconButton>
1444
- <TooltipIconButton tooltip="New Thread" side="right" onClick={handleNewConversation}>
1445
- <SquarePen className="size-4" />
1446
- </TooltipIconButton>
1447
- <div className="mt-4 flex w-full flex-col items-stretch gap-1">
1448
- {/* `w-full` on this inner column makes children resolve to the
1449
- rail width even though the outer rail uses `items-center`
1450
- — that way the per-group `border-t` divider spans the
1451
- full rail instead of just icon-column width. */}
1452
- {/* Resolve each group's items first, drop the empties, THEN
1453
- render — so the separator is keyed off visible-index
1454
- (not array-index). Without this an empty group at idx 0
1455
- would push a `border-t` onto whatever group renders first,
1456
- giving the rail a stray divider above the very first icon. */}
1457
- {navConfig
1458
- .map((group, groupIndex) => {
1459
- // Modules with a non-flat collapsed rail (e.g. Spaces'
1460
- // "header + active workspace") supply `collapsedRail`; the
1461
- // shell stays module-agnostic.
1462
- const items = group.collapsedRail
1463
- ? group
1464
- .collapsedRail({
1465
- currentPage,
1466
- navContextMeta: navContext.meta ?? {},
1467
- })
1468
- .map((it) => ({
1469
- to: it.to,
1470
- label: it.label,
1471
- icon: it.icon,
1472
- isActive: it.isActive,
1473
- }))
1474
- : group.items.map((it) => ({
1475
- to: it.to,
1476
- label: it.label,
1477
- icon: it.icon,
1478
- isActive: currentPage === it.to,
1479
- }));
1480
- return { group, groupIndex, items };
1481
- })
1482
- .filter(({ items }) => items.length > 0)
1483
- .map(({ group, groupIndex, items }, visibleIndex) => (
1484
- // Each non-first VISIBLE group gets a thin top border —
1485
- // visually separates groups (e.g. user Workspaces vs
1486
- // Admin) in the collapsed rail without changing the
1487
- // CollapsedRailItem shape. The wrapper itself is a centred
1488
- // column so icons stay aligned with the rail.
1489
- <div
1490
- key={`${group.label || ""}-${groupIndex}`}
1491
- className={cn(
1492
- "flex flex-col items-center gap-1",
1493
- visibleIndex > 0 && "mt-2 pt-2 border-t border-sidebar-border/40",
1494
- )}
1495
- >
1496
- {items.map(({ to, label, icon, isActive }) => (
1497
- <CollapsedNavShortcut
1498
- key={to}
1499
- to={to}
1500
- label={label}
1501
- icon={icon}
1502
- isActive={isActive}
1503
- />
1504
- ))}
1505
- </div>
1506
- ))}
1507
- </div>
1508
- </div>
1509
- );
1510
- }
1511
-
1512
- return (
1513
- <div className="flex flex-col flex-1 min-h-0">
1514
- {/* Header */}
1515
- <div className="flex items-center justify-between px-3 py-3">
1516
- <NavLink to="/" className="flex items-center gap-2 hover:opacity-80 transition-opacity">
1517
- <AppBrand
1518
- iconClassName="text-primary"
1519
- labelClassName="truncate text-sm font-semibold text-sidebar-foreground"
1520
- />
1521
- </NavLink>
1522
- <TooltipIconButton tooltip="Collapse sidebar" side="right" onClick={toggle}>
1523
- <PanelLeft className="size-5" />
1524
- </TooltipIconButton>
1525
- </div>
1526
-
1527
- {/* New Thread */}
1528
- <div className="px-1 mb-0.5">
1529
- <button
1530
- type="button"
1531
- onClick={handleNewConversation}
1532
- className={cn(
1533
- "flex items-center gap-2 w-full",
1534
- "rounded-xl px-3 py-2 text-sm text-left",
1535
- "text-sidebar-foreground/85 transition-colors hover:bg-sidebar-accent/70",
1536
- )}
1537
- >
1538
- <SquarePen className="size-4 shrink-0 text-sidebar-foreground/60" />
1539
- New Thread
1540
- </button>
1541
- </div>
1542
-
1543
- {/* ThreadIndicator removed — the active conversation's row in
1544
- the Recents group is now the source of truth for "you're in a
1545
- chat right now". A separate pulsing dot was redundant noise. */}
1546
-
1547
- {/* Navigation groups */}
1548
- <nav
1549
- className="flex-1 overflow-y-auto px-1 pb-16"
1550
- onDragOverCapture={preventNativeFileDrop}
1551
- onDropCapture={preventNativeFileDrop}
1552
- >
1553
- {navConfig.map((group) => (
1554
- <NavGroupSection key={group.label} group={group} />
1555
- ))}
1556
- {showNavSkeleton && (
1557
- <div className="space-y-1 px-1 pt-2" aria-hidden="true">
1558
- {Array.from({ length: 6 }).map((_, index) => (
1559
- <div
1560
- key={index}
1561
- className="h-7 mx-1 rounded-lg animate-pulse bg-sidebar-accent/40"
1562
- />
1563
- ))}
1564
- </div>
1565
- )}
1566
- </nav>
1567
-
1568
- {/* Footer actions — registered by feature modules */}
1569
- <SidebarFooterActions />
1570
- </div>
1571
- );
1572
- }
1573
-
1574
- function SidebarFooterActions() {
1575
- const footerActions = useNavStore((s) => s.footerActions);
1576
- if (footerActions.length === 0) return null;
1577
-
1578
- return (
1579
- <div className="shrink-0 space-y-0.5 px-3 py-2">
1580
- {footerActions.map((action) => (
1581
- <button
1582
- key={action.id}
1583
- type="button"
1584
- onClick={action.onToggle}
1585
- className={cn(
1586
- "w-full flex items-center gap-2 px-2 py-1.5 rounded-md",
1587
- "text-xs text-sidebar-foreground/70 transition-colors hover:bg-sidebar-accent hover:text-sidebar-foreground",
1588
- )}
1589
- >
1590
- <span
1591
- className={cn(
1592
- "size-2 rounded-full border",
1593
- action.active
1594
- ? "border-sidebar-primary bg-sidebar-primary"
1595
- : "border-sidebar-foreground/30",
1596
- )}
1597
- />
1598
- {action.active ? (action.activeLabel ?? action.label) : action.label}
1599
- </button>
1600
- ))}
1601
- </div>
1602
- );
1603
- }
1604
-
1605
- /** Mobile drawer overlay */
1606
- function MobileDrawer() {
1607
- const isMobileOpen = useSidebarStore((s) => s.isMobileOpen);
1608
- const closeMobile = useSidebarStore((s) => s.closeMobile);
1609
- const isToolPanelOpen = useToolPanelStore((s) => s.isOpen);
1610
- const width = useSidebarStore((s) => s.width);
1611
- const drawerWidth = Math.min(width, typeof window !== "undefined" ? window.innerWidth - 24 : width);
1612
-
1613
- // Close on Escape — but only if ToolPanel is not open (it has its own Escape handler)
1614
- useEffect(() => {
1615
- if (!isMobileOpen) return;
1616
-
1617
- const handleEscape = (e: KeyboardEvent) => {
1618
- if (e.key === "Escape" && !isToolPanelOpen) {
1619
- closeMobile();
1620
- }
1621
- };
1622
-
1623
- window.addEventListener("keydown", handleEscape);
1624
- return () => window.removeEventListener("keydown", handleEscape);
1625
- }, [isMobileOpen, isToolPanelOpen, closeMobile]);
1626
-
1627
- return (
1628
- <>
1629
- {/* Backdrop */}
1630
- <div
1631
- className={cn(
1632
- "fixed inset-0 z-50 bg-black/50 transition-opacity duration-200",
1633
- isMobileOpen ? "opacity-100" : "opacity-0 pointer-events-none",
1634
- )}
1635
- onClick={closeMobile}
1636
- aria-hidden="true"
1637
- />
1638
-
1639
- {/* Drawer */}
1640
- <aside
1641
- className={cn(
1642
- "fixed top-0 left-0 z-50 h-dvh",
1643
- "flex flex-col",
1644
- "border-r border-sidebar-border/60 bg-sidebar text-sidebar-foreground shadow-xl",
1645
- "transition-transform duration-200 ease-in-out",
1646
- isMobileOpen ? "translate-x-0" : "-translate-x-full",
1647
- )}
1648
- style={{ width: drawerWidth }}
1649
- role="complementary"
1650
- aria-label="Navigation sidebar"
1651
- >
1652
- <SidebarContent collapsed={false} />
1653
- </aside>
1654
- </>
1655
- );
1656
- }
1657
-
1658
- /** Desktop inline sidebar (expanded or collapsed rail) */
1659
- function DesktopSidebar() {
1660
- const isOpen = useSidebarStore((s) => s.isOpen);
1661
- const width = useSidebarStore((s) => s.width);
1662
-
1663
- return (
1664
- <aside
1665
- className={cn(
1666
- "group/sidebar relative hidden md:flex flex-col shrink-0",
1667
- "h-full",
1668
- "border-r border-sidebar-border/60 bg-sidebar text-sidebar-foreground",
1669
- "transition-[width] duration-200 ease-in-out",
1670
- "overflow-hidden",
1671
- )}
1672
- style={{ width: isOpen ? width : RAIL_WIDTH }}
1673
- role="complementary"
1674
- aria-label="Navigation sidebar"
1675
- >
1676
- <SidebarContent collapsed={!isOpen} />
1677
- {isOpen && <SidebarResizeHandle />}
1678
- </aside>
1679
- );
1680
- }
1681
-
1682
- export function Sidebar() {
1683
- return (
1684
- <>
1685
- <MobileDrawer />
1686
- <DesktopSidebar />
1687
- </>
1688
- );
1689
- }