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