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