@iloveagents/foundry-web-ui 0.1.5 → 0.2.1

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 CHANGED
@@ -1,5 +1,48 @@
1
1
  # @iloveagents/foundry-web-ui
2
2
 
3
+ ## 0.2.1
4
+
5
+ ### Patch Changes
6
+
7
+ - No source changes. Bumped in lock-step with the fixed group so the
8
+ whole group can publish at a version number that's free across all
9
+ four packages. (`ui@0.2.0` already published cleanly; this is a
10
+ follow-the-group patch bump.)
11
+
12
+ ## 0.2.0
13
+
14
+ ### Minor Changes
15
+
16
+ - UI: collapsible nav groups in `sidebar.tsx` with persisted state via
17
+ `useSyncExternalStore` + localStorage, plus stable group ordering via
18
+ new `priority` field in `nav-config.ts`.
19
+
20
+ `ag-ui-runtime-provider.tsx` accepts an external `threadId` and
21
+ `historyAdapterFactory` so the consuming app can wire its own
22
+ persistence (e.g. `ThreadHistoryAdapter` → REST `/api/conversations`).
23
+
24
+ ### Patch Changes
25
+
26
+ - Move `@iloveagents/foundry-agent`, `-web-primitives` from
27
+ `peerDependencies` to `dependencies`. They always ship together as a
28
+ coordinated fixed group from this monorepo — they were never
29
+ independently-versioned peers. Declaring them as peerDeps caused
30
+ changesets' `shouldBumpMajor` cascade to promote the entire group to
31
+ a major version on every minor changeset. See the root `foundry-agent`
32
+ CHANGELOG entry for details.
33
+
34
+ - Updated dependencies
35
+ - @iloveagents/foundry-agent@0.2.0
36
+ - @iloveagents/foundry-web-primitives@0.2.0
37
+
38
+ ### NOTE: 1.0.1 was accidental
39
+
40
+ Version 1.0.1 was published briefly on 2026-05-27 due to the peerDep
41
+ cascade bug described above (1.0.0 was reserved but not published —
42
+ 1.0.1 was the retry). It has been unpublished. Both 1.0.0 and 1.0.1
43
+ are now permanently reserved on npm and CANNOT be re-published. Do
44
+ not depend on 1.0.x.
45
+
3
46
  ## 0.1.5
4
47
 
5
48
  ### Patch Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@iloveagents/foundry-web-ui",
3
- "version": "0.1.5",
3
+ "version": "0.2.1",
4
4
  "license": "SEE LICENSE IN LICENSE",
5
5
  "type": "module",
6
6
  "types": "./src/index.ts",
@@ -22,9 +22,7 @@
22
22
  "zustand": "^5.0.0",
23
23
  "lucide-react": ">=0.400.0",
24
24
  "@azure/msal-browser": "^5.0.0",
25
- "@azure/msal-react": "^5.0.0",
26
- "@iloveagents/foundry-agent": "0.1.5",
27
- "@iloveagents/foundry-web-primitives": "0.1.5"
25
+ "@azure/msal-react": "^5.0.0"
28
26
  },
29
27
  "peerDependenciesMeta": {
30
28
  "@azure/msal-browser": {
@@ -47,7 +45,9 @@
47
45
  "tailwind-merge": "^3.5.0",
48
46
  "react-markdown": "^10.0.0",
49
47
  "remark-gfm": "^4.0.0",
50
- "shiki": "^4.0.0"
48
+ "shiki": "^4.0.0",
49
+ "@iloveagents/foundry-agent": "0.2.1",
50
+ "@iloveagents/foundry-web-primitives": "0.2.1"
51
51
  },
52
52
  "devDependencies": {
53
53
  "typescript": "~5.9.3",
@@ -1,15 +1,70 @@
1
1
  import { createContext, useContext, useState, useCallback, useMemo } from "react";
2
2
  import { AssistantRuntimeProvider, useLocalRuntime } from "@assistant-ui/react";
3
+ import type { ThreadHistoryAdapter } from "@assistant-ui/react";
3
4
  import { TooltipProvider } from "../ui/tooltip.tsx";
4
5
  import { AGUIAdapterSDK } from "../lib/ag-ui-adapter.ts";
5
6
  import { FileAttachmentAdapter } from "../lib/attachment-adapter.ts";
6
7
  import { ShowDocumentToolUI } from "./show-document-tool-ui.tsx";
7
8
  import { ClientToolExecutor } from "./client-tool-executor.tsx";
8
9
 
10
+ /**
11
+ * Per-mount inputs the host shell passes when it wants a chat
12
+ * conversation history wired to the runtime. Mirrors
13
+ * :type:`ChatConversationFactoryArgs` in ``foundry-web-shell`` —
14
+ * duplicated here (rather than imported) so the web-ui package stays
15
+ * independent of the shell package's dependency graph.
16
+ */
17
+ export interface AGUIChatConversationFactoryArgs {
18
+ /** Conversation id captured from the URL (resumed chat) or undefined (fresh). */
19
+ urlMatch?: string;
20
+ /** Stable AG-UI adapter thread id — present in both cases. */
21
+ aguiThreadId: string;
22
+ }
23
+
24
+ /**
25
+ * Factory the consumer passes to build a :type:`ThreadHistoryAdapter`
26
+ * once the AG-UI adapter has minted its thread id. Called inside the
27
+ * keyed runtime mount; assistant-ui invokes ``load()``/``append()``
28
+ * via the returned adapter.
29
+ */
30
+ export type AGUIHistoryAdapterFactory = (
31
+ args: AGUIChatConversationFactoryArgs,
32
+ ) => ThreadHistoryAdapter;
33
+
9
34
  interface AGUIRuntimeProviderProps {
10
35
  children: React.ReactNode;
11
36
  /** Custom fetch for agent requests. Handles URL rewriting + auth in production. */
12
37
  fetchFn?: typeof fetch;
38
+ /**
39
+ * Optional stable thread id for the AG-UI adapter. When set, the
40
+ * agent's POST body carries this id so server-side persistence
41
+ * (Foundry thread, conversation row) targets the same conversation
42
+ * across reloads / resumes. Changing the id remounts the inner
43
+ * runtime — past messages from a different thread don't bleed
44
+ * into the new one.
45
+ */
46
+ threadId?: string;
47
+ /**
48
+ * Conversation id captured from the current URL when the user
49
+ * resumed an existing chat (e.g. ``/chat/<id>``), or ``undefined``
50
+ * for a brand-new chat. Distinct from ``threadId``: the host shell
51
+ * MAY set ``threadId`` from a non-URL source (a sticky active
52
+ * conversation, a freshly-minted UUID) so ``threadId`` alone can't
53
+ * tell a history adapter "we're resuming". ``urlMatch`` carries
54
+ * that signal verbatim into ``historyAdapterFactory`` so the
55
+ * adapter can decide whether to load history (resume) or
56
+ * initialise empty (fresh) without inferring from ``threadId``.
57
+ */
58
+ urlMatch?: string;
59
+ /**
60
+ * Factory that builds the assistant-ui ``ThreadHistoryAdapter`` for
61
+ * this mount. Called once after the AG-UI adapter exists so it can
62
+ * receive both the URL match (when resuming) and the adapter's own
63
+ * thread id (always set — covers fresh chats). Pass alongside
64
+ * ``threadId`` for resumed chats; pass without ``threadId`` to get
65
+ * a fresh-chat runtime that still persists.
66
+ */
67
+ historyAdapterFactory?: AGUIHistoryAdapterFactory;
13
68
  }
14
69
 
15
70
  interface AGUIContextValue {
@@ -27,22 +82,78 @@ export function useAGUIAdapter() {
27
82
  return context;
28
83
  }
29
84
 
30
- export function AGUIRuntimeProvider({ children, fetchFn }: AGUIRuntimeProviderProps) {
85
+ export function AGUIRuntimeProvider(props: AGUIRuntimeProviderProps) {
86
+ // Splitting render across an outer wrapper + a keyed inner is the
87
+ // canonical React idiom for "rebuild this stateful subtree when X
88
+ // changes". assistant-ui's runtime is created inside useLocalRuntime
89
+ // and tracks its messages internally; swapping threadId or history
90
+ // mid-life would leave stale state. Keying the inner subtree on
91
+ // threadId (+ the manual reset counter) guarantees a clean runtime
92
+ // per conversation, matching the ChatGPT-style "click → fresh chat
93
+ // backed by past messages" UX.
31
94
  const [resetKey, setResetKey] = useState(0);
32
- // eslint-disable-next-line react-hooks/exhaustive-deps -- resetKey triggers new adapter with fresh threadId
33
- const adapter = useMemo(
34
- () => new AGUIAdapterSDK("/api/agent", fetchFn ? { fetchFn } : undefined),
35
- [resetKey, fetchFn],
95
+ const resetThread = useCallback(() => setResetKey((p) => p + 1), []);
96
+
97
+ const innerKey = `${props.threadId ?? "fresh"}:${resetKey}`;
98
+
99
+ return (
100
+ <AGUIRuntimeInner key={innerKey} {...props} resetThread={resetThread} />
36
101
  );
102
+ }
37
103
 
38
- const resetThread = useCallback(() => {
39
- setResetKey((prev) => prev + 1);
40
- }, []);
104
+ function AGUIRuntimeInner({
105
+ children,
106
+ fetchFn,
107
+ threadId,
108
+ urlMatch,
109
+ historyAdapterFactory,
110
+ resetThread,
111
+ }: AGUIRuntimeProviderProps & { resetThread: () => void }) {
112
+ const adapter = useMemo(
113
+ () =>
114
+ new AGUIAdapterSDK(
115
+ "/api/agent",
116
+ // Only pass options if at least one is set, mirroring the prior
117
+ // single-arg semantics for the "fresh chat, no overrides" case.
118
+ fetchFn || threadId ? { fetchFn, threadId } : undefined,
119
+ ),
120
+ [fetchFn, threadId],
121
+ );
41
122
 
42
123
  const attachmentAdapter = useMemo(() => new FileAttachmentAdapter(), []);
43
124
 
125
+ // Build the history adapter ONCE per inner mount, after the AG-UI
126
+ // adapter exists so we know its thread id. The keyed-remount in the
127
+ // outer wrapper ensures threadId changes trigger a fresh adapter +
128
+ // fresh history adapter together, so resumed chats hydrate from the
129
+ // right conversation and fresh chats persist under their assistant-
130
+ // ui-minted UUID.
131
+ const historyAdapter = useMemo<ThreadHistoryAdapter | undefined>(() => {
132
+ if (!historyAdapterFactory) return undefined;
133
+ return historyAdapterFactory({
134
+ // ``urlMatch`` is the URL-derived resume signal (undefined for
135
+ // fresh chats). ``aguiThreadId`` is always defined — the AG-UI
136
+ // adapter mints it on construction. The factory uses ``urlMatch``
137
+ // to decide "load history" vs "initialise empty", and
138
+ // ``aguiThreadId`` to identify the row to write into.
139
+ urlMatch,
140
+ aguiThreadId: adapter.threadId,
141
+ });
142
+ // adapter is stable for the life of this inner mount (keyed remount
143
+ // covers the threadId change case), so it's OK to depend on the
144
+ // factory + urlMatch here.
145
+ // eslint-disable-next-line react-hooks/exhaustive-deps
146
+ }, [historyAdapterFactory, urlMatch]);
147
+
44
148
  const runtime = useLocalRuntime(adapter, {
45
- adapters: { attachments: attachmentAdapter },
149
+ adapters: {
150
+ attachments: attachmentAdapter,
151
+ // history is optional — when undefined, the runtime acts as a
152
+ // pure in-memory thread (the original behaviour). When provided,
153
+ // assistant-ui calls .load() on mount to seed past messages and
154
+ // .append() after every turn.
155
+ ...(historyAdapter ? { history: historyAdapter } : {}),
156
+ },
46
157
  });
47
158
 
48
159
  return (
@@ -406,3 +406,206 @@ describe("Sidebar active state", () => {
406
406
  }
407
407
  });
408
408
  });
409
+
410
+ describe("Sidebar group priority + collapse + status bubble-up", () => {
411
+ let container: HTMLDivElement;
412
+ let root: Root;
413
+ let originalActEnvironment: boolean | undefined;
414
+ let originalInnerWidth: number;
415
+ let originalMatchMedia: typeof window.matchMedia | undefined;
416
+
417
+ beforeEach(() => {
418
+ originalActEnvironment = globalThis.IS_REACT_ACT_ENVIRONMENT;
419
+ originalInnerWidth = window.innerWidth;
420
+ originalMatchMedia = window.matchMedia;
421
+ globalThis.IS_REACT_ACT_ENVIRONMENT = true;
422
+ localStorage.clear();
423
+ window.innerWidth = 1280;
424
+ Object.defineProperty(window, "matchMedia", {
425
+ configurable: true,
426
+ writable: true,
427
+ value:
428
+ originalMatchMedia ||
429
+ ((query: string) =>
430
+ ({
431
+ matches: false,
432
+ media: query,
433
+ onchange: null,
434
+ addListener: () => {},
435
+ removeListener: () => {},
436
+ addEventListener: () => {},
437
+ removeEventListener: () => {},
438
+ dispatchEvent: () => false,
439
+ })),
440
+ });
441
+ container = document.createElement("div");
442
+ document.body.appendChild(container);
443
+ act(() => {
444
+ root = createRoot(container);
445
+ });
446
+ useSidebarStore.setState({ collapsed: false, width: SIDEBAR_WIDTH });
447
+ useNavStore.setState({ groups: [], config: [] } as Parameters<
448
+ typeof useNavStore.setState
449
+ >[0]);
450
+ });
451
+
452
+ afterEach(() => {
453
+ act(() => {
454
+ root.unmount();
455
+ });
456
+ container.remove();
457
+ globalThis.IS_REACT_ACT_ENVIRONMENT = originalActEnvironment;
458
+ window.innerWidth = originalInnerWidth;
459
+ if (originalMatchMedia) {
460
+ Object.defineProperty(window, "matchMedia", {
461
+ configurable: true,
462
+ writable: true,
463
+ value: originalMatchMedia,
464
+ });
465
+ } else {
466
+ // @ts-expect-error deleting configurable test override
467
+ delete window.matchMedia;
468
+ }
469
+ });
470
+
471
+ function indexOfLabel(label: string): number {
472
+ const headers = Array.from(
473
+ container.querySelectorAll<HTMLElement>("[data-nav-group-label]"),
474
+ );
475
+ return headers.findIndex(
476
+ (el) => el.getAttribute("data-nav-group-label") === label,
477
+ );
478
+ }
479
+
480
+ it("sorts groups by NavGroup.priority desc (higher priority first)", async () => {
481
+ const { Sidebar } = await import("./sidebar.tsx");
482
+ const navConfig: NavGroup[] = [
483
+ {
484
+ label: "Low",
485
+ priority: 10,
486
+ items: [{ to: "/low", label: "Low item", icon: FolderOpen }],
487
+ },
488
+ {
489
+ label: "High",
490
+ priority: 100,
491
+ items: [{ to: "/high", label: "High item", icon: FolderOpen }],
492
+ },
493
+ {
494
+ label: "Mid",
495
+ priority: 50,
496
+ items: [{ to: "/mid", label: "Mid item", icon: FolderOpen }],
497
+ },
498
+ ];
499
+ useNavStore.getState().setConfig(navConfig);
500
+
501
+ await act(async () => {
502
+ root.render(
503
+ <MemoryRouter initialEntries={["/"]}>
504
+ <TooltipProvider>
505
+ <Sidebar />
506
+ </TooltipProvider>
507
+ </MemoryRouter>,
508
+ );
509
+ });
510
+
511
+ const idxHigh = indexOfLabel("High");
512
+ const idxMid = indexOfLabel("Mid");
513
+ const idxLow = indexOfLabel("Low");
514
+ expect(idxHigh).toBeGreaterThanOrEqual(0);
515
+ expect(idxMid).toBeGreaterThan(idxHigh);
516
+ expect(idxLow).toBeGreaterThan(idxMid);
517
+ });
518
+
519
+ it("collapsing a group hides its children and persists to localStorage", async () => {
520
+ const { Sidebar } = await import("./sidebar.tsx");
521
+ const navConfig: NavGroup[] = [
522
+ {
523
+ label: "Recents",
524
+ collapsible: true,
525
+ items: [
526
+ { to: "/chat/1", label: "First chat", icon: MessageSquare },
527
+ { to: "/chat/2", label: "Second chat", icon: MessageSquare },
528
+ ],
529
+ },
530
+ ];
531
+ useNavStore.getState().setConfig(navConfig);
532
+
533
+ await act(async () => {
534
+ root.render(
535
+ <MemoryRouter initialEntries={["/"]}>
536
+ <TooltipProvider>
537
+ <Sidebar />
538
+ </TooltipProvider>
539
+ </MemoryRouter>,
540
+ );
541
+ });
542
+
543
+ // Children visible initially (count varies — the sidebar may
544
+ // also render an icon-only tooltip mirror in jsdom; we only care
545
+ // that "First chat" appears at least once now and disappears
546
+ // after collapse).
547
+ const childTextBefore = container.textContent ?? "";
548
+ expect(childTextBefore).toContain("First chat");
549
+ expect(childTextBefore).toContain("Second chat");
550
+
551
+ // Click the group header toggle button.
552
+ const headerToggle = container.querySelector<HTMLElement>(
553
+ '[data-nav-group-label="Recents"] [data-nav-group-toggle]',
554
+ );
555
+ expect(headerToggle).toBeTruthy();
556
+ await act(async () => {
557
+ headerToggle!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
558
+ });
559
+
560
+ // Children gone after collapse.
561
+ const childTextAfter = container.textContent ?? "";
562
+ expect(childTextAfter).not.toContain("First chat");
563
+ expect(childTextAfter).not.toContain("Second chat");
564
+
565
+ // Persisted to localStorage under the generic package-scoped key.
566
+ const persisted = JSON.parse(
567
+ localStorage.getItem("sidebar-nav-collapsed") ?? "{}",
568
+ );
569
+ expect(persisted.Recents).toBe(true);
570
+ });
571
+
572
+ it("child statusDot bubbles up to the collapsed group header", async () => {
573
+ const { Sidebar } = await import("./sidebar.tsx");
574
+ const navConfig: NavGroup[] = [
575
+ {
576
+ label: "Recents",
577
+ collapsible: true,
578
+ defaultCollapsed: true,
579
+ items: [
580
+ {
581
+ to: "/chat/active",
582
+ label: "Active chat",
583
+ icon: MessageSquare,
584
+ statusDot: { tone: "primary", srLabel: "active" },
585
+ },
586
+ { to: "/chat/idle", label: "Idle chat", icon: MessageSquare },
587
+ ],
588
+ },
589
+ ];
590
+ useNavStore.getState().setConfig(navConfig);
591
+
592
+ await act(async () => {
593
+ root.render(
594
+ <MemoryRouter initialEntries={["/"]}>
595
+ <TooltipProvider>
596
+ <Sidebar />
597
+ </TooltipProvider>
598
+ </MemoryRouter>,
599
+ );
600
+ });
601
+
602
+ const header = container.querySelector<HTMLElement>(
603
+ '[data-nav-group-label="Recents"]',
604
+ );
605
+ expect(header).toBeTruthy();
606
+ // The bubbled-up dot is rendered with the same status-dot marker
607
+ // as a child status dot; scope the lookup to the header.
608
+ const bubble = header!.querySelector<HTMLElement>("[data-nav-status-dot]");
609
+ expect(bubble).toBeTruthy();
610
+ });
611
+ });
@@ -1,6 +1,6 @@
1
- import { useCallback, useEffect, useRef, useState } from "react";
1
+ import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react";
2
2
  import { NavLink, useNavigate } from "react-router";
3
- import { Ellipsis, Layers, PanelLeft, PanelLeftOpen, SquarePen, ChevronRight, GripVertical, Plus, Upload } from "lucide-react";
3
+ import { Ellipsis, Layers, PanelLeft, PanelLeftOpen, SquarePen, ChevronDown, ChevronRight, GripVertical, Plus, Upload } from "lucide-react";
4
4
  import { cn } from "@iloveagents/foundry-web-primitives";
5
5
  import {
6
6
  useSidebarStore,
@@ -12,7 +12,7 @@ import { useToolPanelStore } from "../lib/tool-panel-store.ts";
12
12
  import { useNewConversation } from "../lib/use-new-conversation.ts";
13
13
  import { useNavStore } from "../lib/nav-store.ts";
14
14
  import { useAppStore } from "../lib/app-store.ts";
15
- import type { NavItem, NavItemDnd } from "../lib/nav-config.ts";
15
+ import type { NavGroup, NavItem, NavItemDnd } from "../lib/nav-config.ts";
16
16
  import { getContainerDropZoneLabel, hasAnyDropTarget, hasExternalFilesData } from "../lib/nav-dnd.ts";
17
17
  import { TooltipIconButton } from "./tooltip-icon-button.tsx";
18
18
  import { Button } from "@iloveagents/foundry-web-primitives";
@@ -54,13 +54,20 @@ function statusDotColor(tone: NavStatusDot["tone"]) {
54
54
 
55
55
  function NavStatusDot({ statusDot }: { statusDot?: NavStatusDot }) {
56
56
  if (!statusDot) return null;
57
+ // Both "pending" (impact in progress) and "info" (live / active —
58
+ // e.g. the currently-resumed chat conversation) pulse so the eye is
59
+ // drawn to "something is happening / you are here right now".
60
+ // Other tones render static.
61
+ const pulse = statusDot.tone === "pending" || statusDot.tone === "info";
57
62
  return (
58
63
  <span
64
+ data-nav-status-dot
65
+ data-nav-status-tone={statusDot.tone}
59
66
  aria-label={statusDot.label}
60
67
  title={statusDot.label}
61
68
  className={cn(
62
69
  "size-1.5 shrink-0 rounded-full opacity-90",
63
- statusDot.tone === "pending" && "animate-pulse",
70
+ pulse && "animate-pulse",
64
71
  )}
65
72
  style={{ backgroundColor: statusDotColor(statusDot.tone) }}
66
73
  />
@@ -1037,17 +1044,10 @@ function SidebarResizeHandle() {
1037
1044
  );
1038
1045
  }
1039
1046
 
1040
- function ThreadIndicator() {
1041
- const threadActive = useAppStore((s) => s.threadActive);
1042
- if (!threadActive) return null;
1043
-
1044
- return (
1045
- <div className="px-4 pb-2 flex items-center gap-2">
1046
- <span className="text-xs text-muted-foreground">Active thread</span>
1047
- <div className="size-1.5 rounded-full bg-primary animate-pulse" />
1048
- </div>
1049
- );
1050
- }
1047
+ // ThreadIndicator (the "Active thread •" badge under New Thread) was
1048
+ // removed the active conversation's selected row in the Recents
1049
+ // group is now the source of truth for "you're in a chat right now".
1050
+ // Kept as a comment so future spelunkers know it was intentional.
1051
1051
 
1052
1052
  function CollapsedNavShortcut({
1053
1053
  to,
@@ -1073,10 +1073,264 @@ function CollapsedNavShortcut({
1073
1073
  );
1074
1074
  }
1075
1075
 
1076
+ // ---------------------------------------------------------------------------
1077
+ // Collapsible nav-group state — persisted per-label in localStorage so the
1078
+ // user's "minimised Admin section" choice survives reloads. A single JSON
1079
+ // blob keyed on label means we only touch one storage key per render and
1080
+ // we can subscribe via useSyncExternalStore for cheap cross-component
1081
+ // updates (toggle one group, all SidebarContent instances re-render).
1082
+ // ---------------------------------------------------------------------------
1083
+
1084
+ // Generic package-scoped key — ``foundry-web-ui`` is consumed beyond
1085
+ // the Spaces app, so we avoid consumer-specific names like
1086
+ // ``spaces.nav.collapsed``. Matches the keying style used by the
1087
+ // adjacent sidebar-store helpers (``sidebar``, ``sidebar-width``,
1088
+ // ``theme``).
1089
+ const COLLAPSE_STORAGE_KEY = "sidebar-nav-collapsed";
1090
+ type CollapseMap = Record<string, boolean>;
1091
+
1092
+ let _collapseCache: CollapseMap | null = null;
1093
+ const _collapseListeners = new Set<() => void>();
1094
+
1095
+ function readCollapseMap(): CollapseMap {
1096
+ if (_collapseCache) return _collapseCache;
1097
+ if (typeof window === "undefined") {
1098
+ _collapseCache = {};
1099
+ return _collapseCache;
1100
+ }
1101
+ try {
1102
+ const raw = window.localStorage.getItem(COLLAPSE_STORAGE_KEY);
1103
+ _collapseCache = raw ? (JSON.parse(raw) as CollapseMap) : {};
1104
+ } catch {
1105
+ _collapseCache = {};
1106
+ }
1107
+ return _collapseCache;
1108
+ }
1109
+
1110
+ function writeCollapseMap(next: CollapseMap): void {
1111
+ _collapseCache = next;
1112
+ if (typeof window !== "undefined") {
1113
+ try {
1114
+ window.localStorage.setItem(COLLAPSE_STORAGE_KEY, JSON.stringify(next));
1115
+ } catch {
1116
+ // localStorage full / disabled — toggle still works in-memory for
1117
+ // this page session.
1118
+ }
1119
+ }
1120
+ for (const fn of _collapseListeners) fn();
1121
+ }
1122
+
1123
+ function subscribeCollapse(fn: () => void): () => void {
1124
+ _collapseListeners.add(fn);
1125
+ return () => {
1126
+ _collapseListeners.delete(fn);
1127
+ };
1128
+ }
1129
+
1130
+ /**
1131
+ * Read the persisted collapse state for a single group. ``defaultCollapsed``
1132
+ * is the fallback when the user has never toggled this group.
1133
+ */
1134
+ function useGroupCollapsed(label: string, defaultCollapsed: boolean | undefined): boolean {
1135
+ const map = useSyncExternalStore(subscribeCollapse, readCollapseMap, readCollapseMap);
1136
+ // Use ``hasOwnProperty`` instead of the ``in`` operator so a label
1137
+ // that happens to collide with a prototype key (e.g. ``toString``,
1138
+ // ``__proto__``, ``constructor``) is not treated as a persisted
1139
+ // entry. ``map`` is parsed JSON so its prototype is ``Object``'s.
1140
+ return Object.prototype.hasOwnProperty.call(map, label)
1141
+ ? map[label]
1142
+ : Boolean(defaultCollapsed);
1143
+ }
1144
+
1145
+ function toggleGroupCollapsed(label: string, current: boolean): void {
1146
+ writeCollapseMap({ ...readCollapseMap(), [label]: !current });
1147
+ }
1148
+
1149
+ /**
1150
+ * Walk a group's items (and their children) to find the first statusDot.
1151
+ * Used to "bubble up" the active-chat indicator onto a collapsed group
1152
+ * header — so the user never loses sight of "your active conversation is
1153
+ * inside this minimised section".
1154
+ */
1155
+ function findFirstStatusDot(items: readonly NavItem[]): NavStatusDot | undefined {
1156
+ for (const item of items) {
1157
+ if (item.statusDot) return item.statusDot;
1158
+ if (item.children && item.children.length > 0) {
1159
+ const nested = findFirstStatusDot(item.children);
1160
+ if (nested) return nested;
1161
+ }
1162
+ }
1163
+ return undefined;
1164
+ }
1165
+
1166
+ /**
1167
+ * Render a single nav group: label header (with optional collapse
1168
+ * chevron + bubbled-up status dot) + child items. Pulled out into its
1169
+ * own component so each group's collapse state is its own hook scope
1170
+ * — toggling one doesn't re-render siblings.
1171
+ */
1172
+ function NavGroupSection({ group }: { group: NavGroup }) {
1173
+ // hideLabel + collapsible are mutually exclusive — a hidden label
1174
+ // can't host a chevron. The collapsible flag is the opt-in; legacy
1175
+ // groups stay uncollapsable so behaviour is unchanged.
1176
+ const isCollapsible = !group.hideLabel && Boolean(group.collapsible);
1177
+ const persistedCollapsed = useGroupCollapsed(group.label, group.defaultCollapsed);
1178
+ const collapsed = isCollapsible && persistedCollapsed;
1179
+ // Bubble: show the first child statusDot on the collapsed header so
1180
+ // the user never loses sight of "the active chat is inside this
1181
+ // minimised group". Only render when actually collapsed.
1182
+ const bubbledStatusDot = collapsed ? findFirstStatusDot(group.items) : undefined;
1183
+
1184
+ return (
1185
+ <div data-nav-group-label={group.label}>
1186
+ {!group.hideLabel && (
1187
+ <div className="px-3 py-2">
1188
+ {isCollapsible ? (
1189
+ // Collapsible header is a flex of three siblings: chevron
1190
+ // toggle (own button), label (NavLink when ``group.to``
1191
+ // is set so the user can still navigate to the group
1192
+ // landing page — Workspaces does this), createActions
1193
+ // menu. Three SIBLINGS, not nested, so HTML stays valid
1194
+ // (no <button> inside <button> / inside Radix's button).
1195
+ <div className="group/header flex items-center justify-between rounded-lg transition-colors hover:bg-sidebar-accent/70">
1196
+ <button
1197
+ type="button"
1198
+ data-nav-group-toggle
1199
+ onClick={() => toggleGroupCollapsed(group.label, persistedCollapsed)}
1200
+ aria-expanded={!collapsed}
1201
+ aria-label={collapsed ? `Expand ${group.label}` : `Collapse ${group.label}`}
1202
+ className="inline-flex size-6 shrink-0 items-center justify-center rounded text-sidebar-foreground/50 hover:text-sidebar-foreground"
1203
+ >
1204
+ {collapsed ? (
1205
+ <ChevronRight className="size-3 transition-transform" />
1206
+ ) : (
1207
+ <ChevronDown className="size-3 transition-transform" />
1208
+ )}
1209
+ </button>
1210
+ {group.to ? (
1211
+ <NavLink to={group.to} end className="flex min-w-0 flex-1 items-center gap-1.5 py-1.5">
1212
+ {({ isActive }) => (
1213
+ <>
1214
+ <span
1215
+ className={cn(
1216
+ "truncate text-xs font-medium uppercase tracking-wider",
1217
+ isActive
1218
+ ? "text-sidebar-selected-foreground"
1219
+ : "text-sidebar-foreground/65 group-hover/header:text-sidebar-foreground",
1220
+ )}
1221
+ >
1222
+ {group.label}
1223
+ </span>
1224
+ <NavStatusDot statusDot={bubbledStatusDot} />
1225
+ </>
1226
+ )}
1227
+ </NavLink>
1228
+ ) : (
1229
+ <button
1230
+ type="button"
1231
+ onClick={() => toggleGroupCollapsed(group.label, persistedCollapsed)}
1232
+ className="flex min-w-0 flex-1 items-center gap-1.5 py-1.5 text-left"
1233
+ >
1234
+ <span className="truncate text-xs font-medium uppercase tracking-wider text-sidebar-foreground/65 group-hover/header:text-sidebar-foreground">
1235
+ {group.label}
1236
+ </span>
1237
+ <NavStatusDot statusDot={bubbledStatusDot} />
1238
+ </button>
1239
+ )}
1240
+ {group.createActions && group.createActions.length > 0 && (
1241
+ <CreateActionsMenu group={group} />
1242
+ )}
1243
+ </div>
1244
+ ) : group.to ? (
1245
+ <div className="flex items-center justify-between rounded-lg py-1.5 transition-colors hover:bg-sidebar-accent/70">
1246
+ <NavLink to={group.to} end className="min-w-0 flex-1">
1247
+ {({ isActive }) => (
1248
+ <span
1249
+ className={cn(
1250
+ "block text-xs font-medium uppercase tracking-wider",
1251
+ isActive
1252
+ ? "text-sidebar-selected-foreground"
1253
+ : "text-sidebar-foreground/65 hover:text-sidebar-foreground",
1254
+ )}
1255
+ >
1256
+ {group.label}
1257
+ </span>
1258
+ )}
1259
+ </NavLink>
1260
+ {group.createActions && group.createActions.length > 0 && (
1261
+ <CreateActionsMenu group={group} />
1262
+ )}
1263
+ </div>
1264
+ ) : (
1265
+ <span className="text-xs font-medium uppercase tracking-wider text-sidebar-foreground/65">
1266
+ {group.label}
1267
+ </span>
1268
+ )}
1269
+ </div>
1270
+ )}
1271
+ {!collapsed &&
1272
+ group.items.map((item) => (
1273
+ <CollapsibleNavItem key={item.to + item.label} item={item} />
1274
+ ))}
1275
+ </div>
1276
+ );
1277
+ }
1278
+
1279
+ /**
1280
+ * The "+" dropdown shown next to a group label when the group declares
1281
+ * ``createActions``. Pulled out so both the collapsible-button branch
1282
+ * and the legacy plain-label branch can render it identically.
1283
+ *
1284
+ * The trigger button stops click propagation so opening the menu
1285
+ * doesn't also toggle the surrounding collapsible-header button.
1286
+ */
1287
+ function CreateActionsMenu({ group }: { group: NavGroup }) {
1288
+ return (
1289
+ <DropdownMenu>
1290
+ <DropdownMenuTrigger asChild>
1291
+ <button
1292
+ type="button"
1293
+ onClick={(e) => e.stopPropagation()}
1294
+ className="inline-flex size-6 items-center justify-center text-sidebar-foreground/65 transition-colors hover:text-sidebar-foreground"
1295
+ aria-label={`Create in ${group.label}`}
1296
+ >
1297
+ <Plus className="size-4" />
1298
+ </button>
1299
+ </DropdownMenuTrigger>
1300
+ <DropdownMenuContent align="end" className="w-48">
1301
+ {(group.createActions ?? []).map((action) => (
1302
+ <DropdownMenuItem
1303
+ key={action.id}
1304
+ onSelect={(e) => {
1305
+ e.preventDefault();
1306
+ action.handler();
1307
+ }}
1308
+ >
1309
+ {action.label}
1310
+ </DropdownMenuItem>
1311
+ ))}
1312
+ </DropdownMenuContent>
1313
+ </DropdownMenu>
1314
+ );
1315
+ }
1316
+
1076
1317
  function SidebarContent({ collapsed }: { collapsed: boolean }) {
1077
1318
  const toggle = useSidebarStore((s) => s.toggle);
1078
1319
  const handleNewConversation = useNewConversation();
1079
- const navConfig = useNavStore((s) => s.config);
1320
+ const rawNavConfig = useNavStore((s) => s.config);
1321
+ // Sort by `priority` descending so feature hooks can declare slot
1322
+ // intent (e.g. "Recents above Tasks") instead of relying on the
1323
+ // order in which addGroup() happened to be called. Stable sort: ties
1324
+ // preserve insertion order, so groups that don't set a priority keep
1325
+ // the legacy append-where-you-arrive behaviour.
1326
+ const navConfig = useMemo(
1327
+ () =>
1328
+ [...rawNavConfig]
1329
+ .map((g, i) => ({ g, i, p: g.priority ?? 0 }))
1330
+ .sort((a, b) => b.p - a.p || a.i - b.i)
1331
+ .map(({ g }) => g),
1332
+ [rawNavConfig],
1333
+ );
1080
1334
  const currentPage = useAppStore((s) => s.currentPage);
1081
1335
  const navContext = useAppStore((s) => s.navContext);
1082
1336
 
@@ -1190,7 +1444,9 @@ function SidebarContent({ collapsed }: { collapsed: boolean }) {
1190
1444
  </button>
1191
1445
  </div>
1192
1446
 
1193
- <ThreadIndicator />
1447
+ {/* ThreadIndicator removed — the active conversation's row in
1448
+ the Recents group is now the source of truth for "you're in a
1449
+ chat right now". A separate pulsing dot was redundant noise. */}
1194
1450
 
1195
1451
  {/* Navigation groups */}
1196
1452
  <nav
@@ -1199,63 +1455,7 @@ function SidebarContent({ collapsed }: { collapsed: boolean }) {
1199
1455
  onDropCapture={preventNativeFileDrop}
1200
1456
  >
1201
1457
  {navConfig.map((group) => (
1202
- <div key={group.label}>
1203
- {!group.hideLabel && (
1204
- <div className="px-3 py-2">
1205
- {group.to ? (
1206
- <div className="flex items-center justify-between rounded-lg py-1.5 transition-colors hover:bg-sidebar-accent/70">
1207
- <NavLink to={group.to} end className="min-w-0 flex-1">
1208
- {({ isActive }) => (
1209
- <span
1210
- className={cn(
1211
- "block text-xs font-medium uppercase tracking-wider",
1212
- isActive
1213
- ? "text-sidebar-selected-foreground"
1214
- : "text-sidebar-foreground/65 hover:text-sidebar-foreground",
1215
- )}
1216
- >
1217
- {group.label}
1218
- </span>
1219
- )}
1220
- </NavLink>
1221
- {group.createActions && group.createActions.length > 0 && (
1222
- <DropdownMenu>
1223
- <DropdownMenuTrigger asChild>
1224
- <button
1225
- type="button"
1226
- className="inline-flex size-6 items-center justify-center text-sidebar-foreground/65 transition-colors hover:text-sidebar-foreground"
1227
- aria-label={`Create in ${group.label}`}
1228
- >
1229
- <Plus className="size-4" />
1230
- </button>
1231
- </DropdownMenuTrigger>
1232
- <DropdownMenuContent align="end" className="w-48">
1233
- {group.createActions.map((action) => (
1234
- <DropdownMenuItem
1235
- key={action.id}
1236
- onSelect={(e) => {
1237
- e.preventDefault();
1238
- action.handler();
1239
- }}
1240
- >
1241
- {action.label}
1242
- </DropdownMenuItem>
1243
- ))}
1244
- </DropdownMenuContent>
1245
- </DropdownMenu>
1246
- )}
1247
- </div>
1248
- ) : (
1249
- <span className="text-xs font-medium uppercase tracking-wider text-sidebar-foreground/65">
1250
- {group.label}
1251
- </span>
1252
- )}
1253
- </div>
1254
- )}
1255
- {group.items.map((item) => (
1256
- <CollapsibleNavItem key={item.to + item.label} item={item} />
1257
- ))}
1258
- </div>
1458
+ <NavGroupSection key={group.label} group={group} />
1259
1459
  ))}
1260
1460
  </nav>
1261
1461
 
package/src/index.ts CHANGED
@@ -5,6 +5,10 @@ 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 type {
9
+ AGUIChatConversationFactoryArgs,
10
+ AGUIHistoryAdapterFactory,
11
+ } from "./components/ag-ui-runtime-provider.tsx";
8
12
  export { ChatContent, DEFAULT_STARTER_SUGGESTIONS } from "./components/assistant-chat.tsx";
9
13
  export type { ChatContentProps } from "./components/assistant-chat.tsx";
10
14
  export { ChatBubble } from "./components/chat-bubble.tsx";
@@ -113,6 +113,27 @@ export interface CollapsedRailContext {
113
113
  export interface NavGroup {
114
114
  label: string;
115
115
  items: NavItem[];
116
+ /**
117
+ * Render order weight. Higher = higher in the sidebar. Default 0.
118
+ * Ties keep insertion order (stable sort), so existing call sites
119
+ * that don't set a priority preserve the legacy "addGroup appends"
120
+ * behaviour. Use this to pin a group to a specific slot regardless
121
+ * of which feature hook finishes first.
122
+ */
123
+ priority?: number;
124
+ /**
125
+ * When true, the group header gets a click-to-toggle chevron and
126
+ * the children hide when collapsed. State persists per group label
127
+ * in localStorage so the user's preference survives reloads. Mutually
128
+ * exclusive with ``hideLabel`` — a hidden label can't host a chevron.
129
+ *
130
+ * If any child carries a :attr:`NavItem.statusDot`, it bubbles up to
131
+ * the collapsed header so the user can still see "the active chat is
132
+ * inside this collapsed group" without auto-expanding.
133
+ */
134
+ collapsible?: boolean;
135
+ /** Initial collapse state when no user preference is stored. */
136
+ defaultCollapsed?: boolean;
116
137
  /** Optional link for the group header label */
117
138
  to?: string;
118
139
  /** Hide the group header while preserving group semantics. */
@@ -131,7 +131,13 @@ describe("useNewConversation", () => {
131
131
  expect(useToolPanelStore.getState().isOpen).toBe(false);
132
132
  expect(useSidebarStore.getState().isMobileOpen).toBe(false);
133
133
  expect(citationStore.getState().results).toEqual([]);
134
- expect(citationStore.getState().handler).toBeNull();
134
+ // Handler is module-level state registered once at app boot by
135
+ // feature modules (e.g. SPACES' ``registerSpacesCitationHandler``)
136
+ // and MUST survive per-conversation resets — wiping it here would
137
+ // make every ``[n]`` marker inert for the rest of the session,
138
+ // since the registration guard suppresses re-binding. See
139
+ // ``packages/agent/src/store/citation-store.ts``.
140
+ expect(citationStore.getState().handler).not.toBeNull();
135
141
  });
136
142
 
137
143
  it("can reset the thread without leaving the current page", async () => {