@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.
@@ -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
- ? "h-7 bg-primary/10 border border-dashed border-primary/30 flex items-center justify-center"
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
- <span className="text-[10px] text-primary/70">
224
- {getContainerDropZoneLabel(isFileDragOver)}
225
- </span>
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 && "bg-sidebar-selected ring-2 ring-inset ring-sidebar-ring/50",
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-center gap-1">
920
- {navConfig.flatMap((group) => {
921
- // Modules with a non-flat collapsed rail (e.g. Spaces' "header + active workspace")
922
- // supply `collapsedRail`; the shell stays module-agnostic.
923
- if (group.collapsedRail) {
924
- const items = group.collapsedRail({
925
- currentPage,
926
- navContextMeta: navContext.meta ?? {},
927
- });
928
- return items.map(({ to, label, icon, isActive }) => (
929
- <CollapsedNavShortcut
930
- key={to}
931
- to={to}
932
- label={label}
933
- icon={icon}
934
- isActive={isActive}
935
- />
936
- ));
937
- }
938
- return group.items.map(({ to, label, icon }) => (
939
- <CollapsedNavShortcut
940
- key={to}
941
- to={to}
942
- label={label}
943
- icon={icon}
944
- isActive={currentPage === to}
945
- />
946
- ));
947
- })}
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 || "LastSpace.ai";
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 mr-2" />
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 {