@iloveagents/foundry-web-ui 0.3.0 → 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.
- package/README.md +14 -5
- package/dist/components/ag-ui-runtime-provider.d.ts +64 -0
- package/dist/components/ag-ui-runtime-provider.js +71 -0
- package/dist/components/app-brand.d.ts +6 -0
- package/dist/components/app-brand.js +13 -0
- package/dist/components/assistant-chat.d.ts +26 -0
- package/dist/components/assistant-chat.js +93 -0
- package/dist/components/chat-attachments.d.ts +25 -0
- package/dist/components/chat-attachments.js +20 -0
- package/dist/components/chat-bubble.d.ts +1 -0
- package/dist/components/chat-bubble.js +103 -0
- package/dist/components/chat-header.d.ts +2 -0
- package/dist/components/chat-header.js +16 -0
- package/dist/components/client-tool-executor.d.ts +12 -0
- package/dist/components/client-tool-executor.js +108 -0
- package/dist/components/collection-empty-state.d.ts +9 -0
- package/dist/components/collection-empty-state.js +6 -0
- package/dist/components/collection-filter-bar.d.ts +19 -0
- package/dist/components/collection-filter-bar.js +28 -0
- package/dist/components/collection-search-input.d.ts +6 -0
- package/dist/components/collection-search-input.js +8 -0
- package/dist/components/collection-skeleton.d.ts +6 -0
- package/dist/components/collection-skeleton.js +10 -0
- package/dist/components/collection-sort-menu.d.ts +10 -0
- package/dist/components/collection-sort-menu.js +9 -0
- package/dist/components/collection-surface.d.ts +8 -0
- package/dist/components/collection-surface.js +5 -0
- package/dist/components/collection-toolbar.d.ts +9 -0
- package/dist/components/collection-toolbar.js +5 -0
- package/dist/components/collection-view-toggle.d.ts +7 -0
- package/dist/components/collection-view-toggle.js +17 -0
- package/dist/components/composer-submit-bridge.d.ts +1 -0
- package/dist/components/composer-submit-bridge.js +15 -0
- package/dist/components/confirm-dialog.d.ts +11 -0
- package/dist/components/confirm-dialog.js +6 -0
- package/dist/components/confirmation-card.d.ts +14 -0
- package/dist/components/confirmation-card.js +12 -0
- package/dist/components/context-badges.d.ts +16 -0
- package/dist/components/context-badges.js +56 -0
- package/dist/components/context-bar.d.ts +41 -0
- package/dist/components/context-bar.js +123 -0
- package/dist/components/form-dialog.d.ts +12 -0
- package/dist/components/form-dialog.js +13 -0
- package/dist/components/global-selection-popover.d.ts +7 -0
- package/dist/components/global-selection-popover.js +167 -0
- package/dist/components/infinite-scroll-sentinel.d.ts +6 -0
- package/dist/components/infinite-scroll-sentinel.js +25 -0
- package/dist/components/json-viewer.d.ts +10 -0
- package/dist/components/json-viewer.js +80 -0
- package/dist/components/loading-indicator.d.ts +4 -0
- package/dist/components/loading-indicator.js +22 -0
- package/dist/components/markdown-text.d.ts +13 -0
- package/dist/components/markdown-text.js +221 -0
- package/dist/components/name-dialog.d.ts +16 -0
- package/dist/components/name-dialog.js +23 -0
- package/dist/components/selection-popover.d.ts +12 -0
- package/dist/components/selection-popover.js +111 -0
- package/dist/components/show-document-tool-ui.d.ts +1 -0
- package/dist/components/show-document-tool-ui.js +53 -0
- package/dist/components/sidebar.d.ts +4 -0
- package/dist/components/sidebar.js +904 -0
- package/dist/components/surface-card.d.ts +9 -0
- package/dist/components/surface-card.js +33 -0
- package/dist/components/theme-runtime-provider.d.ts +15 -0
- package/dist/components/theme-runtime-provider.js +48 -0
- package/dist/components/theme-toggle.d.ts +2 -0
- package/dist/components/theme-toggle.js +19 -0
- package/dist/components/tool-call-card.d.ts +27 -0
- package/dist/components/tool-call-card.js +13 -0
- package/dist/components/tool-fallback.d.ts +26 -0
- package/dist/components/tool-fallback.js +129 -0
- package/dist/components/tool-panel-layout.d.ts +10 -0
- package/dist/components/tool-panel-layout.js +12 -0
- package/dist/components/tool-panel.d.ts +1 -0
- package/dist/components/tool-panel.js +89 -0
- package/dist/components/tooltip-icon-button.d.ts +7 -0
- package/dist/components/tooltip-icon-button.js +9 -0
- package/dist/components/user-menu.d.ts +2 -0
- package/dist/components/user-menu.js +14 -0
- package/dist/index.d.ts +68 -0
- package/dist/index.js +83 -0
- package/dist/lib/ag-ui-adapter.d.ts +29 -0
- package/dist/lib/ag-ui-adapter.js +373 -0
- package/dist/lib/app-store.d.ts +115 -0
- package/dist/lib/app-store.js +199 -0
- package/dist/lib/attachment-adapter.d.ts +20 -0
- package/dist/lib/attachment-adapter.js +99 -0
- package/dist/lib/auth-provider.d.ts +16 -0
- package/dist/lib/auth-provider.js +88 -0
- package/dist/lib/chat-bubble-store.d.ts +30 -0
- package/dist/lib/chat-bubble-store.js +23 -0
- package/dist/lib/chat-lifecycle-store.d.ts +10 -0
- package/{src/lib/chat-lifecycle-store.ts → dist/lib/chat-lifecycle-store.js} +3 -13
- package/dist/lib/client-tools.d.ts +11 -0
- package/dist/lib/client-tools.js +274 -0
- package/dist/lib/composer-submit-store.d.ts +12 -0
- package/dist/lib/composer-submit-store.js +17 -0
- package/dist/lib/dev-store.d.ts +38 -0
- package/dist/lib/dev-store.js +32 -0
- package/dist/lib/nav-config.d.ts +159 -0
- package/dist/lib/nav-config.js +162 -0
- package/dist/lib/nav-dnd.d.ts +8 -0
- package/dist/lib/nav-dnd.js +9 -0
- package/dist/lib/nav-store.d.ts +45 -0
- package/dist/lib/nav-store.js +49 -0
- package/dist/lib/selection-context.d.ts +21 -0
- package/dist/lib/selection-context.js +34 -0
- package/dist/lib/sidebar-store.d.ts +23 -0
- package/dist/lib/sidebar-store.js +98 -0
- package/dist/lib/theme-runtime.d.ts +130 -0
- package/dist/lib/theme-runtime.js +479 -0
- package/dist/lib/theme-store.d.ts +15 -0
- package/dist/lib/theme-store.js +53 -0
- package/dist/lib/tool-panel-store.d.ts +48 -0
- package/dist/lib/tool-panel-store.js +69 -0
- package/dist/lib/use-new-conversation.d.ts +8 -0
- package/dist/lib/use-new-conversation.js +62 -0
- package/dist/lib/use-page-tools.d.ts +27 -0
- package/{src/lib/use-page-tools.ts → dist/lib/use-page-tools.js} +11 -13
- package/dist/styles.css +142 -0
- package/dist/ui/collapsible.d.ts +5 -0
- package/{src/ui/collapsible.tsx → dist/ui/collapsible.js} +0 -2
- package/dist/ui/dialog.d.ts +15 -0
- package/dist/ui/dialog.js +25 -0
- package/dist/ui/dropdown-menu.d.ts +7 -0
- package/dist/ui/dropdown-menu.js +19 -0
- package/dist/ui/popover.d.ts +7 -0
- package/dist/ui/popover.js +10 -0
- package/dist/ui/select.d.ts +4 -0
- package/dist/ui/select.js +9 -0
- package/dist/ui/tooltip.d.ts +7 -0
- package/dist/ui/tooltip.js +10 -0
- package/package.json +31 -12
- package/AGENTS.md +0 -59
- package/CHANGELOG.md +0 -203
- package/CLAUDE.md +0 -1
- package/src/__tests__/no-spaces-imports.test.ts +0 -59
- package/src/__tests__/open-core-guards.test.ts +0 -307
- package/src/__tests__/theme-runtime.test.ts +0 -81
- package/src/__tests__/tool-fallback.test.tsx +0 -109
- package/src/components/__tests__/context-bar.test.ts +0 -112
- package/src/components/ag-ui-runtime-provider.tsx +0 -170
- package/src/components/app-brand.tsx +0 -38
- package/src/components/assistant-chat.tsx +0 -461
- package/src/components/chat-attachments.tsx +0 -68
- package/src/components/chat-bubble.tsx +0 -373
- package/src/components/chat-header.tsx +0 -44
- package/src/components/client-tool-executor.tsx +0 -230
- package/src/components/collection-empty-state.tsx +0 -37
- package/src/components/collection-filter-bar.tsx +0 -168
- package/src/components/collection-search-input.tsx +0 -41
- package/src/components/collection-skeleton.tsx +0 -55
- package/src/components/collection-sort-menu.tsx +0 -62
- package/src/components/collection-surface.tsx +0 -46
- package/src/components/collection-toolbar.tsx +0 -33
- package/src/components/collection-view-toggle.tsx +0 -61
- package/src/components/composer-submit-bridge.tsx +0 -19
- package/src/components/confirm-dialog.tsx +0 -49
- package/src/components/confirmation-card.tsx +0 -32
- package/src/components/context-badges.tsx +0 -136
- package/src/components/context-bar.tsx +0 -238
- package/src/components/form-dialog.tsx +0 -56
- package/src/components/global-selection-popover.tsx +0 -266
- package/src/components/infinite-scroll-sentinel.tsx +0 -48
- package/src/components/json-viewer.tsx +0 -232
- package/src/components/loading-indicator.tsx +0 -40
- package/src/components/markdown-text-citations.test.tsx +0 -108
- package/src/components/markdown-text.tsx +0 -508
- package/src/components/name-dialog.tsx +0 -87
- package/src/components/selection-popover.tsx +0 -156
- package/src/components/show-document-tool-ui.tsx +0 -90
- package/src/components/sidebar.test.tsx +0 -611
- package/src/components/sidebar.tsx +0 -1689
- package/src/components/surface-card.tsx +0 -45
- package/src/components/theme-runtime-provider.tsx +0 -93
- package/src/components/theme-toggle.tsx +0 -27
- package/src/components/tool-call-card.tsx +0 -126
- package/src/components/tool-fallback.tsx +0 -169
- package/src/components/tool-panel-layout.tsx +0 -36
- package/src/components/tool-panel.tsx +0 -233
- package/src/components/tooltip-icon-button.tsx +0 -28
- package/src/components/user-menu.tsx +0 -60
- package/src/index.ts +0 -185
- package/src/lib/__tests__/ag-ui-adapter.test.ts +0 -273
- package/src/lib/__tests__/app-store.test.ts +0 -405
- package/src/lib/__tests__/attachment-adapter.test.ts +0 -48
- package/src/lib/__tests__/chat-bubble-store.test.ts +0 -67
- package/src/lib/__tests__/client-tools.test.ts +0 -124
- package/src/lib/__tests__/dev-store.test.ts +0 -78
- package/src/lib/__tests__/nav-config.test.ts +0 -135
- package/src/lib/__tests__/nav-dnd.test.ts +0 -24
- package/src/lib/__tests__/nav-store.test.ts +0 -59
- package/src/lib/__tests__/selection-context.test.ts +0 -114
- package/src/lib/__tests__/sidebar-store.test.ts +0 -144
- package/src/lib/__tests__/theme-store.test.ts +0 -83
- package/src/lib/__tests__/tool-panel-store.test.ts +0 -61
- package/src/lib/__tests__/use-page-context.test.ts +0 -58
- package/src/lib/ag-ui-adapter.ts +0 -412
- package/src/lib/app-store.ts +0 -361
- package/src/lib/attachment-adapter.ts +0 -92
- package/src/lib/auth-provider.tsx +0 -144
- package/src/lib/chat-bubble-store.ts +0 -46
- package/src/lib/client-tools.ts +0 -293
- package/src/lib/composer-submit-store.ts +0 -32
- package/src/lib/dev-store.ts +0 -69
- package/src/lib/nav-config.ts +0 -329
- package/src/lib/nav-dnd.ts +0 -14
- package/src/lib/nav-store.ts +0 -89
- package/src/lib/selection-context.ts +0 -65
- package/src/lib/sidebar-store.ts +0 -113
- package/src/lib/theme-runtime.ts +0 -660
- package/src/lib/theme-store.ts +0 -67
- package/src/lib/tool-panel-store.ts +0 -121
- package/src/lib/use-new-conversation.test.tsx +0 -168
- package/src/lib/use-new-conversation.ts +0 -64
- package/src/ui/dialog.tsx +0 -109
- package/src/ui/dropdown-menu.tsx +0 -75
- package/src/ui/popover.tsx +0 -34
- package/src/ui/select.tsx +0 -15
- package/src/ui/tooltip.tsx +0 -30
- package/src/vite-env.d.ts +0 -1
- package/tsconfig.json +0 -16
- 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
|
-
}
|