@iloveagents/foundry-web-ui 0.1.0 → 0.1.2
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/CHANGELOG.md +36 -0
- package/package.json +3 -3
- package/src/components/__tests__/context-bar.test.ts +112 -0
- package/src/components/app-brand.tsx +1 -1
- package/src/components/assistant-chat.tsx +60 -22
- package/src/components/chat-bubble.tsx +48 -14
- package/src/components/composer-submit-bridge.tsx +19 -0
- package/src/components/context-badges.tsx +25 -13
- package/src/components/context-bar.tsx +38 -2
- package/src/components/global-selection-popover.tsx +165 -34
- package/src/components/markdown-text-citations.test.tsx +108 -0
- package/src/components/markdown-text.tsx +155 -26
- package/src/components/selection-popover.tsx +3 -3
- package/src/components/sidebar.test.tsx +144 -0
- package/src/components/sidebar.tsx +207 -41
- package/src/components/theme-runtime-provider.tsx +1 -1
- package/src/components/tool-panel.tsx +3 -3
- package/src/components/user-menu.tsx +1 -1
- package/src/index.ts +11 -1
- package/src/lib/__tests__/ag-ui-adapter.test.ts +94 -3
- package/src/lib/__tests__/app-store.test.ts +75 -0
- package/src/lib/__tests__/selection-context.test.ts +114 -0
- package/src/lib/ag-ui-adapter.ts +64 -14
- package/src/lib/app-store.ts +68 -1
- package/src/lib/composer-submit-store.ts +32 -0
- package/src/lib/nav-config.ts +5 -0
- package/src/lib/selection-context.ts +65 -0
- package/src/lib/theme-runtime.ts +2 -2
- package/src/lib/use-new-conversation.test.tsx +38 -0
- package/src/lib/use-new-conversation.ts +24 -3
- package/src/ui/dropdown-menu.tsx +5 -1
|
@@ -179,4 +179,148 @@ describe("Sidebar active state", () => {
|
|
|
179
179
|
expect(viewsButton?.className).toContain("pl-7");
|
|
180
180
|
expect(documentsButton?.className).not.toContain("pl-5");
|
|
181
181
|
});
|
|
182
|
+
|
|
183
|
+
it("collapsed rail wraps each nav group and adds a top border between groups", async () => {
|
|
184
|
+
const { Sidebar } = await import("./sidebar.tsx");
|
|
185
|
+
|
|
186
|
+
const navConfig: NavGroup[] = [
|
|
187
|
+
{
|
|
188
|
+
label: "Pages",
|
|
189
|
+
hideLabel: true,
|
|
190
|
+
items: [{ to: "/", label: "Chat", icon: MessageSquare }],
|
|
191
|
+
},
|
|
192
|
+
{
|
|
193
|
+
label: "Workspaces",
|
|
194
|
+
items: [{ to: "/spaces/demo", label: "Demo Workspace", icon: FolderOpen }],
|
|
195
|
+
},
|
|
196
|
+
{
|
|
197
|
+
label: "Admin",
|
|
198
|
+
items: [{ to: "/spaces/admin/workspaces", label: "Workspaces", icon: FolderOpen }],
|
|
199
|
+
},
|
|
200
|
+
];
|
|
201
|
+
|
|
202
|
+
useNavStore.getState().setConfig(navConfig);
|
|
203
|
+
useAppStore.setState({
|
|
204
|
+
currentPage: "/",
|
|
205
|
+
threadActive: false,
|
|
206
|
+
contextItems: [],
|
|
207
|
+
sentContext: {},
|
|
208
|
+
highlights: [],
|
|
209
|
+
selectedItemId: null,
|
|
210
|
+
content: {},
|
|
211
|
+
});
|
|
212
|
+
// Collapse the sidebar so the rail renders.
|
|
213
|
+
useSidebarStore.setState({ isOpen: false, isMobileOpen: false, width: SIDEBAR_WIDTH });
|
|
214
|
+
|
|
215
|
+
await act(async () => {
|
|
216
|
+
root.render(
|
|
217
|
+
<MemoryRouter initialEntries={["/"]}>
|
|
218
|
+
<TooltipProvider>
|
|
219
|
+
<Sidebar />
|
|
220
|
+
</TooltipProvider>
|
|
221
|
+
</MemoryRouter>,
|
|
222
|
+
);
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
// Sidebar renders twice (mobile drawer + desktop rail) but only the
|
|
226
|
+
// desktop rail uses ``collapsed={true}`` — the mobile drawer always
|
|
227
|
+
// renders ``collapsed={false}``, so it doesn't emit collapsed-rail
|
|
228
|
+
// group wrappers. Grouping the matched divs by owning <aside> handles
|
|
229
|
+
// the (unlikely) case where multiple instances do contribute.
|
|
230
|
+
// Filter out the outer rail container itself (which has these classes
|
|
231
|
+
// but additionally carries ``py-3``).
|
|
232
|
+
const wrappersByAside = new Map<Element, HTMLDivElement[]>();
|
|
233
|
+
container
|
|
234
|
+
.querySelectorAll<HTMLDivElement>("div.flex.flex-col.items-center.gap-1")
|
|
235
|
+
.forEach((el) => {
|
|
236
|
+
if (el.className.includes("py-3")) return;
|
|
237
|
+
const aside = el.closest("aside");
|
|
238
|
+
if (!aside) return;
|
|
239
|
+
const list = wrappersByAside.get(aside) ?? [];
|
|
240
|
+
list.push(el);
|
|
241
|
+
wrappersByAside.set(aside, list);
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
// Each rendered sidebar instance must have one wrapper per nav group —
|
|
245
|
+
// first plain, second + third with the separator border.
|
|
246
|
+
const perAside = Array.from(wrappersByAside.values());
|
|
247
|
+
expect(perAside.length).toBeGreaterThan(0);
|
|
248
|
+
for (const wrappers of perAside) {
|
|
249
|
+
expect(wrappers.length).toBe(3);
|
|
250
|
+
expect(wrappers[0].className).not.toContain("border-t");
|
|
251
|
+
expect(wrappers[1].className).toContain("border-t");
|
|
252
|
+
expect(wrappers[1].className).toContain("mt-2");
|
|
253
|
+
expect(wrappers[1].className).toContain("pt-2");
|
|
254
|
+
expect(wrappers[2].className).toContain("border-t");
|
|
255
|
+
}
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
it("collapsed rail keeps the first VISIBLE group plain when an earlier group renders nothing", async () => {
|
|
259
|
+
const { Sidebar } = await import("./sidebar.tsx");
|
|
260
|
+
|
|
261
|
+
// The first group supplies a `collapsedRail` that returns no items —
|
|
262
|
+
// simulates an early-load state where a module hasn't populated yet.
|
|
263
|
+
// The rendering must skip it entirely AND must not push a border-top
|
|
264
|
+
// onto whatever group renders first.
|
|
265
|
+
const navConfig: NavGroup[] = [
|
|
266
|
+
{
|
|
267
|
+
label: "EmptyFirst",
|
|
268
|
+
items: [{ to: "/empty", label: "Empty", icon: MessageSquare }],
|
|
269
|
+
collapsedRail: () => [],
|
|
270
|
+
},
|
|
271
|
+
{
|
|
272
|
+
label: "Workspaces",
|
|
273
|
+
items: [{ to: "/spaces/demo", label: "Demo", icon: FolderOpen }],
|
|
274
|
+
},
|
|
275
|
+
{
|
|
276
|
+
label: "Admin",
|
|
277
|
+
items: [{ to: "/spaces/admin", label: "Admin", icon: FolderOpen }],
|
|
278
|
+
},
|
|
279
|
+
];
|
|
280
|
+
|
|
281
|
+
useNavStore.getState().setConfig(navConfig);
|
|
282
|
+
useAppStore.setState({
|
|
283
|
+
currentPage: "/",
|
|
284
|
+
threadActive: false,
|
|
285
|
+
contextItems: [],
|
|
286
|
+
sentContext: {},
|
|
287
|
+
highlights: [],
|
|
288
|
+
selectedItemId: null,
|
|
289
|
+
content: {},
|
|
290
|
+
});
|
|
291
|
+
useSidebarStore.setState({ isOpen: false, isMobileOpen: false, width: SIDEBAR_WIDTH });
|
|
292
|
+
|
|
293
|
+
await act(async () => {
|
|
294
|
+
root.render(
|
|
295
|
+
<MemoryRouter initialEntries={["/"]}>
|
|
296
|
+
<TooltipProvider>
|
|
297
|
+
<Sidebar />
|
|
298
|
+
</TooltipProvider>
|
|
299
|
+
</MemoryRouter>,
|
|
300
|
+
);
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
const wrappersByAside = new Map<Element, HTMLDivElement[]>();
|
|
304
|
+
container
|
|
305
|
+
.querySelectorAll<HTMLDivElement>("div.flex.flex-col.items-center.gap-1")
|
|
306
|
+
.forEach((el) => {
|
|
307
|
+
if (el.className.includes("py-3")) return;
|
|
308
|
+
const aside = el.closest("aside");
|
|
309
|
+
if (!aside) return;
|
|
310
|
+
const list = wrappersByAside.get(aside) ?? [];
|
|
311
|
+
list.push(el);
|
|
312
|
+
wrappersByAside.set(aside, list);
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
const perAside = Array.from(wrappersByAside.values());
|
|
316
|
+
expect(perAside.length).toBeGreaterThan(0);
|
|
317
|
+
for (const wrappers of perAside) {
|
|
318
|
+
// Empty group skipped → 2 visible wrappers
|
|
319
|
+
expect(wrappers.length).toBe(2);
|
|
320
|
+
// First VISIBLE wrapper has no separator (despite being array-idx 1)
|
|
321
|
+
expect(wrappers[0].className).not.toContain("border-t");
|
|
322
|
+
// Second visible wrapper carries the divider
|
|
323
|
+
expect(wrappers[1].className).toContain("border-t");
|
|
324
|
+
}
|
|
325
|
+
});
|
|
182
326
|
});
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { useCallback, useEffect, useRef, useState } from "react";
|
|
2
2
|
import { NavLink, useNavigate } from "react-router";
|
|
3
|
-
import { Ellipsis, Layers, PanelLeft, PanelLeftOpen, SquarePen, ChevronRight, GripVertical, Plus } from "lucide-react";
|
|
3
|
+
import { Ellipsis, Layers, PanelLeft, PanelLeftOpen, SquarePen, ChevronRight, GripVertical, Plus, Upload } from "lucide-react";
|
|
4
4
|
import { cn } from "@iloveagents/foundry-web-primitives";
|
|
5
5
|
import {
|
|
6
6
|
useSidebarStore,
|
|
@@ -27,15 +27,57 @@ const ACTIVE_NAV_BADGE_CLASS =
|
|
|
27
27
|
"border border-sidebar-border bg-background/60 text-sidebar-foreground";
|
|
28
28
|
const NESTED_NAV_INDENTS = ["pl-5", "pl-7", "pl-9", "pl-11", "pl-13"] as const;
|
|
29
29
|
|
|
30
|
+
type NavStatusDot = NonNullable<NavItem["statusDot"]>;
|
|
31
|
+
|
|
30
32
|
function getNestedNavIndent(depth: number) {
|
|
31
33
|
return NESTED_NAV_INDENTS[depth] ?? NESTED_NAV_INDENTS[NESTED_NAV_INDENTS.length - 1];
|
|
32
34
|
}
|
|
33
35
|
|
|
36
|
+
function statusDotColor(tone: NavStatusDot["tone"]) {
|
|
37
|
+
switch (tone) {
|
|
38
|
+
case "low-impact":
|
|
39
|
+
return "var(--impact-low, currentColor)";
|
|
40
|
+
case "medium-impact":
|
|
41
|
+
return "var(--impact-medium, currentColor)";
|
|
42
|
+
case "high-impact":
|
|
43
|
+
return "var(--impact-high, currentColor)";
|
|
44
|
+
case "pending":
|
|
45
|
+
return "var(--impact-unknown, currentColor)";
|
|
46
|
+
case "info":
|
|
47
|
+
return "var(--primary, currentColor)";
|
|
48
|
+
case "neutral":
|
|
49
|
+
default:
|
|
50
|
+
return "currentColor";
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function NavStatusDot({ statusDot }: { statusDot?: NavStatusDot }) {
|
|
55
|
+
if (!statusDot) return null;
|
|
56
|
+
return (
|
|
57
|
+
<span
|
|
58
|
+
aria-label={statusDot.label}
|
|
59
|
+
title={statusDot.label}
|
|
60
|
+
className={cn(
|
|
61
|
+
"size-1.5 shrink-0 rounded-full opacity-90",
|
|
62
|
+
statusDot.tone === "pending" && "animate-pulse",
|
|
63
|
+
)}
|
|
64
|
+
style={{ backgroundColor: statusDotColor(statusDot.tone) }}
|
|
65
|
+
/>
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
|
|
34
69
|
// --- Generic drag-and-drop via NavItem.dnd ---
|
|
35
70
|
|
|
36
71
|
/** Shared drag-and-drop props derived from NavItem.dnd (feature-provided). */
|
|
37
72
|
function useNavItemDnd(dnd?: NavItemDnd) {
|
|
38
73
|
const [isDragOver, setIsDragOver] = useState(false);
|
|
74
|
+
// Tracked separately from ``isDragOver`` so consumers can render a
|
|
75
|
+
// distinct file-drop affordance (dashed primary ring + Upload icon)
|
|
76
|
+
// instead of the subtle entity-move ring. Without the split, a user
|
|
77
|
+
// dragging files from the desktop sees the same hover state as a
|
|
78
|
+
// user dragging another tree node — they can't tell the container
|
|
79
|
+
// accepts the drop.
|
|
80
|
+
const [isFileDragOver, setIsFileDragOver] = useState(false);
|
|
39
81
|
const dragCounter = useRef(0);
|
|
40
82
|
|
|
41
83
|
const isDraggable = dnd?.canDrag() ?? false;
|
|
@@ -72,10 +114,21 @@ function useNavItemDnd(dnd?: NavItemDnd) {
|
|
|
72
114
|
e.stopPropagation();
|
|
73
115
|
dragCounter.current++;
|
|
74
116
|
setIsDragOver(true);
|
|
117
|
+
if (isFileDropTarget && hasExternalFiles(e)) {
|
|
118
|
+
setIsFileDragOver(true);
|
|
119
|
+
}
|
|
75
120
|
},
|
|
76
|
-
[hasMatchingDragData],
|
|
121
|
+
[hasMatchingDragData, isFileDropTarget, hasExternalFiles],
|
|
77
122
|
);
|
|
78
123
|
|
|
124
|
+
// ``dragenter`` / ``dragleave`` fire on every parent-to-child crossing
|
|
125
|
+
// inside the drop target (entering the inner button, then a span, etc.).
|
|
126
|
+
// The counter pattern only flips ``isDragOver`` back to false when the
|
|
127
|
+
// cursor truly leaves the OUTER element. There's a transient between
|
|
128
|
+
// crossings where leave fires before the matching enter — React 18
|
|
129
|
+
// auto-batches setState calls within a single task so the user doesn't
|
|
130
|
+
// see a flicker. If you ever observe one, switch to a relatedTarget
|
|
131
|
+
// ``contains()`` check instead of the counter.
|
|
79
132
|
const onDragLeave = useCallback(
|
|
80
133
|
(e: React.DragEvent) => {
|
|
81
134
|
if (!hasAnyDropTarget(isDropTarget, isFileDropTarget)) return;
|
|
@@ -84,6 +137,7 @@ function useNavItemDnd(dnd?: NavItemDnd) {
|
|
|
84
137
|
if (dragCounter.current <= 0) {
|
|
85
138
|
dragCounter.current = 0;
|
|
86
139
|
setIsDragOver(false);
|
|
140
|
+
setIsFileDragOver(false);
|
|
87
141
|
}
|
|
88
142
|
},
|
|
89
143
|
[isDropTarget, isFileDropTarget],
|
|
@@ -99,11 +153,22 @@ function useNavItemDnd(dnd?: NavItemDnd) {
|
|
|
99
153
|
[hasMatchingDragData, hasExternalFiles],
|
|
100
154
|
);
|
|
101
155
|
|
|
156
|
+
// Capture-phase handlers run BEFORE the bubble-phase ``onDragEnter`` /
|
|
157
|
+
// ``onDragOver`` on the same node. They MUST NOT call
|
|
158
|
+
// ``e.stopPropagation()``: in React's synthetic event system, stopping
|
|
159
|
+
// propagation in the capture phase short-circuits the dispatch — neither
|
|
160
|
+
// the bubble-phase handler on the same node (which actually mutates
|
|
161
|
+
// ``isDragOver`` / ``isFileDragOver``) nor any ancestor handler runs.
|
|
162
|
+
//
|
|
163
|
+
// We keep ``preventDefault()`` here because some browsers (Chrome) need
|
|
164
|
+
// it on ``dragenter``/``dragover`` to mark the element as droppable —
|
|
165
|
+
// doing it in the capture phase makes the visual cue stable while the
|
|
166
|
+
// user is still hovering inside descendant elements (button, span,
|
|
167
|
+
// action controls).
|
|
102
168
|
const onDragEnterCapture = useCallback(
|
|
103
169
|
(e: React.DragEvent) => {
|
|
104
170
|
if (!hasMatchingDragData(e)) return;
|
|
105
171
|
e.preventDefault();
|
|
106
|
-
e.stopPropagation();
|
|
107
172
|
},
|
|
108
173
|
[hasMatchingDragData],
|
|
109
174
|
);
|
|
@@ -112,7 +177,6 @@ function useNavItemDnd(dnd?: NavItemDnd) {
|
|
|
112
177
|
(e: React.DragEvent) => {
|
|
113
178
|
if (!hasMatchingDragData(e)) return;
|
|
114
179
|
e.preventDefault();
|
|
115
|
-
e.stopPropagation();
|
|
116
180
|
e.dataTransfer.dropEffect = hasExternalFiles(e) ? "copy" : "move";
|
|
117
181
|
},
|
|
118
182
|
[hasMatchingDragData, hasExternalFiles],
|
|
@@ -131,6 +195,7 @@ function useNavItemDnd(dnd?: NavItemDnd) {
|
|
|
131
195
|
e.stopPropagation();
|
|
132
196
|
dragCounter.current = 0;
|
|
133
197
|
setIsDragOver(false);
|
|
198
|
+
setIsFileDragOver(false);
|
|
134
199
|
if (hasFiles) {
|
|
135
200
|
await dnd.onFileDrop!(files);
|
|
136
201
|
return;
|
|
@@ -142,6 +207,7 @@ function useNavItemDnd(dnd?: NavItemDnd) {
|
|
|
142
207
|
|
|
143
208
|
return {
|
|
144
209
|
isDragOver,
|
|
210
|
+
isFileDragOver,
|
|
145
211
|
draggableProps: isDraggable ? { draggable: true, onDragStart } : {},
|
|
146
212
|
dropTargetProps:
|
|
147
213
|
isDropTarget || isFileDropTarget
|
|
@@ -215,14 +281,22 @@ function ContainerDropZone({ dnd }: { dnd: NavItemDnd }) {
|
|
|
215
281
|
className={cn(
|
|
216
282
|
"mx-2 rounded-md transition-all overflow-hidden",
|
|
217
283
|
isDragOver
|
|
218
|
-
?
|
|
284
|
+
? cn(
|
|
285
|
+
"h-7 flex items-center justify-center gap-1",
|
|
286
|
+
isFileDragOver
|
|
287
|
+
? "bg-primary/10 border border-dashed border-primary"
|
|
288
|
+
: "bg-primary/10 border border-dashed border-primary/30",
|
|
289
|
+
)
|
|
219
290
|
: "h-1",
|
|
220
291
|
)}
|
|
221
292
|
>
|
|
222
293
|
{isDragOver && (
|
|
223
|
-
|
|
224
|
-
{
|
|
225
|
-
|
|
294
|
+
<>
|
|
295
|
+
{isFileDragOver && <Upload className="size-3 text-primary" />}
|
|
296
|
+
<span className="text-[10px] text-primary/70">
|
|
297
|
+
{getContainerDropZoneLabel(isFileDragOver)}
|
|
298
|
+
</span>
|
|
299
|
+
</>
|
|
226
300
|
)}
|
|
227
301
|
</div>
|
|
228
302
|
);
|
|
@@ -316,7 +390,7 @@ function NavLeafItem({
|
|
|
316
390
|
}) {
|
|
317
391
|
const isDisabled = item.disabled === true;
|
|
318
392
|
const hasActions = !isDisabled && item.actions && item.actions.length > 0;
|
|
319
|
-
const { draggableProps, dropTargetProps, isDragOver } = useNavItemDnd(item.dnd);
|
|
393
|
+
const { draggableProps, dropTargetProps, isDragOver, isFileDragOver } = useNavItemDnd(item.dnd);
|
|
320
394
|
|
|
321
395
|
// Items with actions use the group layout (button + action controls)
|
|
322
396
|
if (hasActions) {
|
|
@@ -332,8 +406,12 @@ function NavLeafItem({
|
|
|
332
406
|
? ACTIVE_NAV_ROW_CLASS
|
|
333
407
|
: "hover:bg-sidebar-accent/70",
|
|
334
408
|
isDragOver &&
|
|
409
|
+
!isFileDragOver &&
|
|
335
410
|
!isDisabled &&
|
|
336
411
|
"ring-2 ring-inset ring-sidebar-ring/50 bg-sidebar-selected",
|
|
412
|
+
isFileDragOver &&
|
|
413
|
+
!isDisabled &&
|
|
414
|
+
"outline-dashed outline-2 -outline-offset-2 outline-primary bg-primary/10",
|
|
337
415
|
item.dimmed && !isDisabled && "opacity-50",
|
|
338
416
|
)}
|
|
339
417
|
>
|
|
@@ -369,7 +447,17 @@ function NavLeafItem({
|
|
|
369
447
|
{item.badge}
|
|
370
448
|
</span>
|
|
371
449
|
)}
|
|
450
|
+
<NavStatusDot statusDot={item.statusDot} />
|
|
372
451
|
</button>
|
|
452
|
+
{/* File-drop hint icon — rendered as an inline flex sibling so it
|
|
453
|
+
naturally sits to the LEFT of the action controls instead of
|
|
454
|
+
overlapping them at a magic-number offset. */}
|
|
455
|
+
{isFileDragOver && !isDisabled && (
|
|
456
|
+
<Upload
|
|
457
|
+
aria-hidden="true"
|
|
458
|
+
className="size-3.5 mr-2 shrink-0 text-primary pointer-events-none"
|
|
459
|
+
/>
|
|
460
|
+
)}
|
|
373
461
|
<NavRowControls item={item} hasChildren={false} isExpanded={false} />
|
|
374
462
|
</div>
|
|
375
463
|
);
|
|
@@ -391,8 +479,12 @@ function NavLeafItem({
|
|
|
391
479
|
: "text-sidebar-foreground/80 transition-colors hover:bg-sidebar-accent/70",
|
|
392
480
|
!isDisabled && isActive && ACTIVE_NAV_ROW_CLASS,
|
|
393
481
|
isDragOver &&
|
|
482
|
+
!isFileDragOver &&
|
|
394
483
|
!isDisabled &&
|
|
395
484
|
"ring-2 ring-inset ring-sidebar-ring/50 bg-sidebar-selected",
|
|
485
|
+
isFileDragOver &&
|
|
486
|
+
!isDisabled &&
|
|
487
|
+
"outline-dashed outline-2 -outline-offset-2 outline-primary bg-primary/10",
|
|
396
488
|
item.dimmed && !isDisabled && "opacity-50",
|
|
397
489
|
)}
|
|
398
490
|
>
|
|
@@ -413,6 +505,13 @@ function NavLeafItem({
|
|
|
413
505
|
{item.badge}
|
|
414
506
|
</span>
|
|
415
507
|
)}
|
|
508
|
+
<NavStatusDot statusDot={item.statusDot} />
|
|
509
|
+
{isFileDragOver && !isDisabled && (
|
|
510
|
+
<Upload
|
|
511
|
+
aria-hidden="true"
|
|
512
|
+
className="ml-auto size-3.5 shrink-0 text-primary pointer-events-none"
|
|
513
|
+
/>
|
|
514
|
+
)}
|
|
416
515
|
</button>
|
|
417
516
|
);
|
|
418
517
|
}
|
|
@@ -498,7 +597,7 @@ function NestedFolderItem({ item, depth }: { item: NavItem; depth: number }) {
|
|
|
498
597
|
const isAncestorActive = isInsideSubtree && !isActive;
|
|
499
598
|
const [manualOverride, setManualOverride] = useState<boolean | null>(null);
|
|
500
599
|
const isExpanded = manualOverride ?? isInsideSubtree;
|
|
501
|
-
const { draggableProps, dropTargetProps, isDragOver } = useNavItemDnd(item.dnd);
|
|
600
|
+
const { draggableProps, dropTargetProps, isDragOver, isFileDragOver } = useNavItemDnd(item.dnd);
|
|
502
601
|
const depthPl = getNestedNavIndent(depth);
|
|
503
602
|
|
|
504
603
|
const isDisabled = item.disabled === true;
|
|
@@ -516,8 +615,12 @@ function NestedFolderItem({ item, depth }: { item: NavItem; depth: number }) {
|
|
|
516
615
|
? ACTIVE_NAV_ROW_CLASS
|
|
517
616
|
: "hover:bg-sidebar-accent/70",
|
|
518
617
|
isDragOver &&
|
|
618
|
+
!isFileDragOver &&
|
|
519
619
|
!isDisabled &&
|
|
520
620
|
"ring-2 ring-inset ring-sidebar-ring/50 bg-sidebar-selected",
|
|
621
|
+
isFileDragOver &&
|
|
622
|
+
!isDisabled &&
|
|
623
|
+
"outline-dashed outline-2 -outline-offset-2 outline-primary bg-primary/10",
|
|
521
624
|
item.dimmed && !isDisabled && "opacity-50",
|
|
522
625
|
)}
|
|
523
626
|
>
|
|
@@ -573,7 +676,16 @@ function NestedFolderItem({ item, depth }: { item: NavItem; depth: number }) {
|
|
|
573
676
|
{item.badge}
|
|
574
677
|
</span>
|
|
575
678
|
)}
|
|
679
|
+
<NavStatusDot statusDot={item.statusDot} />
|
|
576
680
|
</button>
|
|
681
|
+
{/* File-drop hint icon — inline flex sibling so it sits to the
|
|
682
|
+
LEFT of the action controls instead of overlapping them. */}
|
|
683
|
+
{isFileDragOver && !isDisabled && (
|
|
684
|
+
<Upload
|
|
685
|
+
aria-hidden="true"
|
|
686
|
+
className="size-3.5 mr-2 shrink-0 text-primary pointer-events-none"
|
|
687
|
+
/>
|
|
688
|
+
)}
|
|
577
689
|
<NavRowControls
|
|
578
690
|
item={item}
|
|
579
691
|
hasChildren
|
|
@@ -633,7 +745,7 @@ function CollapsibleNavItem({ item }: { item: NavItem }) {
|
|
|
633
745
|
const hasChildren = item.children && item.children.length > 0;
|
|
634
746
|
const isDisabled = item.disabled === true;
|
|
635
747
|
const hasActions = !isDisabled && item.actions && item.actions.length > 0;
|
|
636
|
-
const { draggableProps, dropTargetProps, isDragOver } = useNavItemDnd(item.dnd);
|
|
748
|
+
const { draggableProps, dropTargetProps, isDragOver, isFileDragOver } = useNavItemDnd(item.dnd);
|
|
637
749
|
|
|
638
750
|
if (hasChildren || hasActions) {
|
|
639
751
|
const inner = (selected: boolean, ancestor: boolean) => (
|
|
@@ -646,8 +758,12 @@ function CollapsibleNavItem({ item }: { item: NavItem }) {
|
|
|
646
758
|
? ACTIVE_NAV_ROW_CLASS
|
|
647
759
|
: "hover:bg-sidebar-accent/70",
|
|
648
760
|
isDragOver &&
|
|
761
|
+
!isFileDragOver &&
|
|
649
762
|
!isDisabled &&
|
|
650
763
|
"ring-2 ring-inset ring-sidebar-ring/50 bg-sidebar-selected",
|
|
764
|
+
isFileDragOver &&
|
|
765
|
+
!isDisabled &&
|
|
766
|
+
"outline-dashed outline-2 -outline-offset-2 outline-primary bg-primary/10",
|
|
651
767
|
item.dimmed && !isDisabled && "opacity-50",
|
|
652
768
|
)}
|
|
653
769
|
>
|
|
@@ -702,7 +818,16 @@ function CollapsibleNavItem({ item }: { item: NavItem }) {
|
|
|
702
818
|
{item.badge}
|
|
703
819
|
</span>
|
|
704
820
|
)}
|
|
821
|
+
<NavStatusDot statusDot={item.statusDot} />
|
|
705
822
|
</button>
|
|
823
|
+
{/* File-drop hint icon — inline flex sibling so it sits to the
|
|
824
|
+
LEFT of the action controls instead of overlapping them. */}
|
|
825
|
+
{isFileDragOver && !isDisabled && (
|
|
826
|
+
<Upload
|
|
827
|
+
aria-hidden="true"
|
|
828
|
+
className="size-3.5 mr-2 shrink-0 text-primary pointer-events-none"
|
|
829
|
+
/>
|
|
830
|
+
)}
|
|
706
831
|
<NavRowControls
|
|
707
832
|
item={item}
|
|
708
833
|
hasChildren={Boolean(hasChildren)}
|
|
@@ -750,7 +875,10 @@ function CollapsibleNavItem({ item }: { item: NavItem }) {
|
|
|
750
875
|
"rounded-xl px-3 py-2 text-sm text-left",
|
|
751
876
|
"text-sidebar-foreground/85 transition-colors hover:bg-sidebar-accent/70",
|
|
752
877
|
isActive && cn(ACTIVE_NAV_ROW_CLASS, ACTIVE_NAV_TEXT_CLASS),
|
|
753
|
-
isDragOver &&
|
|
878
|
+
isDragOver &&
|
|
879
|
+
!isFileDragOver &&
|
|
880
|
+
"bg-sidebar-selected ring-2 ring-inset ring-sidebar-ring/50",
|
|
881
|
+
isFileDragOver && "outline-dashed outline-2 -outline-offset-2 outline-primary bg-primary/10",
|
|
754
882
|
item.dimmed && "opacity-50",
|
|
755
883
|
)
|
|
756
884
|
}
|
|
@@ -776,6 +904,13 @@ function CollapsibleNavItem({ item }: { item: NavItem }) {
|
|
|
776
904
|
{item.badge}
|
|
777
905
|
</span>
|
|
778
906
|
)}
|
|
907
|
+
<NavStatusDot statusDot={item.statusDot} />
|
|
908
|
+
{isFileDragOver && (
|
|
909
|
+
<Upload
|
|
910
|
+
aria-hidden="true"
|
|
911
|
+
className="ml-auto size-3.5 shrink-0 text-primary pointer-events-none"
|
|
912
|
+
/>
|
|
913
|
+
)}
|
|
779
914
|
</>
|
|
780
915
|
)}
|
|
781
916
|
</NavLink>
|
|
@@ -916,35 +1051,66 @@ function SidebarContent({ collapsed }: { collapsed: boolean }) {
|
|
|
916
1051
|
<TooltipIconButton tooltip="New Thread" side="right" onClick={handleNewConversation}>
|
|
917
1052
|
<SquarePen className="size-4" />
|
|
918
1053
|
</TooltipIconButton>
|
|
919
|
-
<div className="mt-4 flex flex-col items-
|
|
920
|
-
{
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
1054
|
+
<div className="mt-4 flex w-full flex-col items-stretch gap-1">
|
|
1055
|
+
{/* `w-full` on this inner column makes children resolve to the
|
|
1056
|
+
rail width even though the outer rail uses `items-center`
|
|
1057
|
+
— that way the per-group `border-t` divider spans the
|
|
1058
|
+
full rail instead of just icon-column width. */}
|
|
1059
|
+
{/* Resolve each group's items first, drop the empties, THEN
|
|
1060
|
+
render — so the separator is keyed off visible-index
|
|
1061
|
+
(not array-index). Without this an empty group at idx 0
|
|
1062
|
+
would push a `border-t` onto whatever group renders first,
|
|
1063
|
+
giving the rail a stray divider above the very first icon. */}
|
|
1064
|
+
{navConfig
|
|
1065
|
+
.map((group, groupIndex) => {
|
|
1066
|
+
// Modules with a non-flat collapsed rail (e.g. Spaces'
|
|
1067
|
+
// "header + active workspace") supply `collapsedRail`; the
|
|
1068
|
+
// shell stays module-agnostic.
|
|
1069
|
+
const items = group.collapsedRail
|
|
1070
|
+
? group
|
|
1071
|
+
.collapsedRail({
|
|
1072
|
+
currentPage,
|
|
1073
|
+
navContextMeta: navContext.meta ?? {},
|
|
1074
|
+
})
|
|
1075
|
+
.map((it) => ({
|
|
1076
|
+
to: it.to,
|
|
1077
|
+
label: it.label,
|
|
1078
|
+
icon: it.icon,
|
|
1079
|
+
isActive: it.isActive,
|
|
1080
|
+
}))
|
|
1081
|
+
: group.items.map((it) => ({
|
|
1082
|
+
to: it.to,
|
|
1083
|
+
label: it.label,
|
|
1084
|
+
icon: it.icon,
|
|
1085
|
+
isActive: currentPage === it.to,
|
|
1086
|
+
}));
|
|
1087
|
+
return { group, groupIndex, items };
|
|
1088
|
+
})
|
|
1089
|
+
.filter(({ items }) => items.length > 0)
|
|
1090
|
+
.map(({ group, groupIndex, items }, visibleIndex) => (
|
|
1091
|
+
// Each non-first VISIBLE group gets a thin top border —
|
|
1092
|
+
// visually separates groups (e.g. user Workspaces vs
|
|
1093
|
+
// Admin) in the collapsed rail without changing the
|
|
1094
|
+
// CollapsedRailItem shape. The wrapper itself is a centred
|
|
1095
|
+
// column so icons stay aligned with the rail.
|
|
1096
|
+
<div
|
|
1097
|
+
key={`${group.label || ""}-${groupIndex}`}
|
|
1098
|
+
className={cn(
|
|
1099
|
+
"flex flex-col items-center gap-1",
|
|
1100
|
+
visibleIndex > 0 && "mt-2 pt-2 border-t border-sidebar-border/40",
|
|
1101
|
+
)}
|
|
1102
|
+
>
|
|
1103
|
+
{items.map(({ to, label, icon, isActive }) => (
|
|
1104
|
+
<CollapsedNavShortcut
|
|
1105
|
+
key={to}
|
|
1106
|
+
to={to}
|
|
1107
|
+
label={label}
|
|
1108
|
+
icon={icon}
|
|
1109
|
+
isActive={isActive}
|
|
1110
|
+
/>
|
|
1111
|
+
))}
|
|
1112
|
+
</div>
|
|
1113
|
+
))}
|
|
948
1114
|
</div>
|
|
949
1115
|
</div>
|
|
950
1116
|
);
|
|
@@ -86,7 +86,7 @@ export function ThemeDocumentMetadata() {
|
|
|
86
86
|
|
|
87
87
|
useEffect(() => {
|
|
88
88
|
if (typeof document === "undefined") return;
|
|
89
|
-
document.title = runtime.branding.appTitle || runtime.branding.appName || "
|
|
89
|
+
document.title = runtime.branding.appTitle || runtime.branding.appName || "eomi";
|
|
90
90
|
}, [runtime.branding.appName, runtime.branding.appTitle]);
|
|
91
91
|
|
|
92
92
|
return null;
|
|
@@ -86,10 +86,10 @@ export function ToolPanel() {
|
|
|
86
86
|
}, [entityId, entityPin, content, addContextItem, removeContextItem]);
|
|
87
87
|
|
|
88
88
|
const handleNavigateToEntity = useCallback(() => {
|
|
89
|
-
if (!entityId) return;
|
|
90
|
-
navigate(`/spaces/${entityId}`);
|
|
89
|
+
if (!entityId || !content) return;
|
|
90
|
+
navigate(content.type === "page" ? content.content : `/spaces/${entityId}`);
|
|
91
91
|
closePanel();
|
|
92
|
-
}, [entityId, navigate, closePanel]);
|
|
92
|
+
}, [entityId, content, navigate, closePanel]);
|
|
93
93
|
|
|
94
94
|
const handleCopyAll = useCallback(() => {
|
|
95
95
|
if (!content) return;
|
|
@@ -51,7 +51,7 @@ export const UserMenu: FC = () => {
|
|
|
51
51
|
</DropdownMenuLabel>
|
|
52
52
|
<DropdownMenuSeparator />
|
|
53
53
|
<DropdownMenuItem onClick={signOut} className="text-destructive cursor-pointer">
|
|
54
|
-
<LogOut className="size-4
|
|
54
|
+
<LogOut className="size-4" />
|
|
55
55
|
Sign out
|
|
56
56
|
</DropdownMenuItem>
|
|
57
57
|
</DropdownMenuContent>
|
package/src/index.ts
CHANGED
|
@@ -5,7 +5,8 @@ export { ToolPanelLayout } from "./components/tool-panel-layout.tsx";
|
|
|
5
5
|
export { MarkdownText, markdownComponents } from "./components/markdown-text.tsx";
|
|
6
6
|
export { ClientToolExecutor } from "./components/client-tool-executor.tsx";
|
|
7
7
|
export { AGUIRuntimeProvider, useAGUIAdapter } from "./components/ag-ui-runtime-provider.tsx";
|
|
8
|
-
export { ChatContent } from "./components/assistant-chat.tsx";
|
|
8
|
+
export { ChatContent, DEFAULT_STARTER_SUGGESTIONS } from "./components/assistant-chat.tsx";
|
|
9
|
+
export type { ChatContentProps } from "./components/assistant-chat.tsx";
|
|
9
10
|
export { ChatBubble } from "./components/chat-bubble.tsx";
|
|
10
11
|
export { ChatHeader } from "./components/chat-header.tsx";
|
|
11
12
|
export {
|
|
@@ -111,6 +112,15 @@ export type {
|
|
|
111
112
|
} from "./lib/tool-panel-store.ts";
|
|
112
113
|
export { useSidebarStore, SIDEBAR_WIDTH, RAIL_WIDTH } from "./lib/sidebar-store.ts";
|
|
113
114
|
export { useChatBubbleStore } from "./lib/chat-bubble-store.ts";
|
|
115
|
+
export { submitComposerText } from "./lib/composer-submit-store.ts";
|
|
116
|
+
export {
|
|
117
|
+
registerSelectionContextResolver,
|
|
118
|
+
resolveSelectionContext,
|
|
119
|
+
type SelectionContextInput,
|
|
120
|
+
type SelectionContextItem,
|
|
121
|
+
type SelectionContextResolver,
|
|
122
|
+
type SelectionContextResult,
|
|
123
|
+
} from "./lib/selection-context.ts";
|
|
114
124
|
export { useThemeStore } from "./lib/theme-store.ts";
|
|
115
125
|
export type { ThemeMode } from "./lib/theme-store.ts";
|
|
116
126
|
export {
|