@iloveagents/foundry-web-ui 0.1.4 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,49 @@
1
1
  # @iloveagents/foundry-web-ui
2
2
 
3
+ ## 0.2.0
4
+
5
+ ### Minor Changes
6
+
7
+ - UI: collapsible nav groups in `sidebar.tsx` with persisted state via
8
+ `useSyncExternalStore` + localStorage, plus stable group ordering via
9
+ new `priority` field in `nav-config.ts`.
10
+
11
+ `ag-ui-runtime-provider.tsx` accepts an external `threadId` and
12
+ `historyAdapterFactory` so the consuming app can wire its own
13
+ persistence (e.g. `ThreadHistoryAdapter` → REST `/api/conversations`).
14
+
15
+ ### Patch Changes
16
+
17
+ - Move `@iloveagents/foundry-agent`, `-web-primitives` from
18
+ `peerDependencies` to `dependencies`. They always ship together as a
19
+ coordinated fixed group from this monorepo — they were never
20
+ independently-versioned peers. Declaring them as peerDeps caused
21
+ changesets' `shouldBumpMajor` cascade to promote the entire group to
22
+ a major version on every minor changeset. See the root `foundry-agent`
23
+ CHANGELOG entry for details.
24
+
25
+ - Updated dependencies
26
+ - @iloveagents/foundry-agent@0.2.0
27
+ - @iloveagents/foundry-web-primitives@0.2.0
28
+
29
+ ### NOTE: 1.0.1 was accidental
30
+
31
+ Version 1.0.1 was published briefly on 2026-05-27 due to the peerDep
32
+ cascade bug described above (1.0.0 was reserved but not published —
33
+ 1.0.1 was the retry). It has been unpublished. Both 1.0.0 and 1.0.1
34
+ are now permanently reserved on npm and CANNOT be re-published. Do
35
+ not depend on 1.0.x.
36
+
37
+ ## 0.1.5
38
+
39
+ ### Patch Changes
40
+
41
+ - f9144ca: Add `NavItem.statusIcon` slot for sidebar warning / status hints.
42
+
43
+ Renders a small tone-coloured icon next to the existing `statusDot`. Use for "this item needs attention" annotations (failed background jobs, permission drift, etc.) where a coloured dot isn't legible enough. Tones map to `warning` / `danger` / `info` / `neutral`. The `label` lands on both `aria-label` and the wrapping span's `title` attribute so screen-reader announcement + hover tooltip work without consumer wiring.
44
+ - @iloveagents/foundry-agent@0.1.5
45
+ - @iloveagents/foundry-web-primitives@0.1.5
46
+
3
47
  ## 0.1.4
4
48
 
5
49
  ### Patch Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@iloveagents/foundry-web-ui",
3
- "version": "0.1.4",
3
+ "version": "0.2.0",
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.4",
27
- "@iloveagents/foundry-web-primitives": "0.1.4"
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.0",
50
+ "@iloveagents/foundry-web-primitives": "0.2.0"
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 (
@@ -2,7 +2,7 @@ import { act } from "react";
2
2
  import { createRoot, type Root } from "react-dom/client";
3
3
  import { MemoryRouter } from "react-router";
4
4
  import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
5
- import { FolderOpen, MessageSquare } from "lucide-react";
5
+ import { AlertTriangle, FolderOpen, MessageSquare } from "lucide-react";
6
6
  import { useNavStore } from "../lib/nav-store.ts";
7
7
  import { useAppStore } from "../lib/app-store.ts";
8
8
  import { SIDEBAR_WIDTH, useSidebarStore } from "../lib/sidebar-store.ts";
@@ -323,4 +323,289 @@ describe("Sidebar active state", () => {
323
323
  expect(wrappers[1].className).toContain("border-t");
324
324
  }
325
325
  });
326
+
327
+ it("renders NavItem.statusIcon with tone class + tooltip label", async () => {
328
+ // The statusIcon slot is consumer-driven (e.g. ``spaces-web-ui``
329
+ // stamps a warning triangle on workspaces with failed indexing).
330
+ // This test pins three things the consumer relies on:
331
+ // 1. The supplied LucideIcon component is rendered.
332
+ // 2. The tone maps to the right Tailwind class (``warning`` →
333
+ // ``text-warning``).
334
+ // 3. The ``label`` lands on both ``aria-label`` and the
335
+ // ``title`` attribute so the icon gets a hover tooltip and
336
+ // a screen-reader announcement without the consumer having
337
+ // to wire anything else.
338
+ const { Sidebar } = await import("./sidebar.tsx");
339
+
340
+ const navConfig: NavGroup[] = [
341
+ {
342
+ label: "Workspaces",
343
+ items: [
344
+ {
345
+ to: "/spaces/wsA",
346
+ label: "Workspace With Failures",
347
+ icon: FolderOpen,
348
+ statusIcon: {
349
+ icon: AlertTriangle,
350
+ tone: "warning",
351
+ label: "3 documents failed to index",
352
+ },
353
+ },
354
+ {
355
+ to: "/spaces/wsB",
356
+ label: "Healthy Workspace",
357
+ icon: FolderOpen,
358
+ },
359
+ ],
360
+ },
361
+ ];
362
+ useNavStore.getState().setConfig(navConfig);
363
+ useAppStore.setState({
364
+ currentPage: "/spaces/wsA",
365
+ threadActive: false,
366
+ contextItems: [],
367
+ sentContext: {},
368
+ highlights: [],
369
+ selectedItemId: null,
370
+ content: {},
371
+ });
372
+
373
+ await act(async () => {
374
+ root.render(
375
+ <MemoryRouter initialEntries={["/spaces/wsA"]}>
376
+ <TooltipProvider>
377
+ <Sidebar />
378
+ </TooltipProvider>
379
+ </MemoryRouter>,
380
+ );
381
+ });
382
+
383
+ // The warning row carries the icon wrapped in a span. The label
384
+ // lands on both ``aria-label`` (screen reader) and ``title``
385
+ // (hover tooltip) on the wrapping span; the inner svg carries
386
+ // the tone class.
387
+ const warningWrappers = container.querySelectorAll<HTMLElement>(
388
+ 'span[aria-label="3 documents failed to index"]',
389
+ );
390
+ expect(warningWrappers.length).toBeGreaterThan(0);
391
+ const wrapper = warningWrappers[0];
392
+ expect(wrapper.getAttribute("title")).toBe("3 documents failed to index");
393
+ expect(wrapper.querySelector("svg.text-warning")).not.toBeNull();
394
+
395
+ // No statusIcon on the healthy workspace ⇒ its leaf row
396
+ // contains no warning svg. Workspaces are rendered as NavLink
397
+ // anchors (button is for folders), so scope to ``a, button``
398
+ // and reject ancestor containers that happen to enclose BOTH
399
+ // workspaces.
400
+ const healthyRowLeaves = Array.from(
401
+ container.querySelectorAll<HTMLElement>("a, button"),
402
+ ).filter((el) => el.textContent?.includes("Healthy Workspace"));
403
+ expect(healthyRowLeaves.length).toBeGreaterThan(0);
404
+ for (const leaf of healthyRowLeaves) {
405
+ expect(leaf.querySelectorAll("svg.text-warning").length).toBe(0);
406
+ }
407
+ });
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
+ });
326
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";
@@ -28,6 +28,7 @@ const ACTIVE_NAV_BADGE_CLASS =
28
28
  const NESTED_NAV_INDENTS = ["pl-5", "pl-7", "pl-9", "pl-11", "pl-13"] as const;
29
29
 
30
30
  type NavStatusDot = NonNullable<NavItem["statusDot"]>;
31
+ type NavStatusIcon = NonNullable<NavItem["statusIcon"]>;
31
32
 
32
33
  function getNestedNavIndent(depth: number) {
33
34
  return NESTED_NAV_INDENTS[depth] ?? NESTED_NAV_INDENTS[NESTED_NAV_INDENTS.length - 1];
@@ -53,19 +54,63 @@ function statusDotColor(tone: NavStatusDot["tone"]) {
53
54
 
54
55
  function NavStatusDot({ statusDot }: { statusDot?: NavStatusDot }) {
55
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";
56
62
  return (
57
63
  <span
64
+ data-nav-status-dot
65
+ data-nav-status-tone={statusDot.tone}
58
66
  aria-label={statusDot.label}
59
67
  title={statusDot.label}
60
68
  className={cn(
61
69
  "size-1.5 shrink-0 rounded-full opacity-90",
62
- statusDot.tone === "pending" && "animate-pulse",
70
+ pulse && "animate-pulse",
63
71
  )}
64
72
  style={{ backgroundColor: statusDotColor(statusDot.tone) }}
65
73
  />
66
74
  );
67
75
  }
68
76
 
77
+ function statusIconClass(tone: NavStatusIcon["tone"]) {
78
+ switch (tone) {
79
+ case "warning":
80
+ return "text-warning";
81
+ case "danger":
82
+ return "text-destructive";
83
+ case "info":
84
+ return "text-primary";
85
+ case "neutral":
86
+ default:
87
+ return "text-sidebar-foreground/60";
88
+ }
89
+ }
90
+
91
+ function NavStatusIcon({ statusIcon }: { statusIcon?: NavStatusIcon }) {
92
+ if (!statusIcon) return null;
93
+ const Icon = statusIcon.icon;
94
+ // Wrap in a ``<span>`` so the browser tooltip (``title``) and the
95
+ // screen-reader announcement (``aria-label``) attach to a real DOM
96
+ // node — lucide-react's icon types don't accept ``title`` as a
97
+ // prop. Kept small (size-3) so the icon doesn't compete with the
98
+ // entity icon on the left of the row.
99
+ return (
100
+ <span
101
+ role="img"
102
+ aria-label={statusIcon.label}
103
+ title={statusIcon.label}
104
+ className="inline-flex shrink-0"
105
+ >
106
+ <Icon
107
+ aria-hidden="true"
108
+ className={cn("size-3 shrink-0", statusIconClass(statusIcon.tone))}
109
+ />
110
+ </span>
111
+ );
112
+ }
113
+
69
114
  // --- Generic drag-and-drop via NavItem.dnd ---
70
115
 
71
116
  /** Shared drag-and-drop props derived from NavItem.dnd (feature-provided). */
@@ -447,6 +492,7 @@ function NavLeafItem({
447
492
  {item.badge}
448
493
  </span>
449
494
  )}
495
+ <NavStatusIcon statusIcon={item.statusIcon} />
450
496
  <NavStatusDot statusDot={item.statusDot} />
451
497
  </button>
452
498
  {/* File-drop hint icon — rendered as an inline flex sibling so it
@@ -505,6 +551,7 @@ function NavLeafItem({
505
551
  {item.badge}
506
552
  </span>
507
553
  )}
554
+ <NavStatusIcon statusIcon={item.statusIcon} />
508
555
  <NavStatusDot statusDot={item.statusDot} />
509
556
  {isFileDragOver && !isDisabled && (
510
557
  <Upload
@@ -676,6 +723,7 @@ function NestedFolderItem({ item, depth }: { item: NavItem; depth: number }) {
676
723
  {item.badge}
677
724
  </span>
678
725
  )}
726
+ <NavStatusIcon statusIcon={item.statusIcon} />
679
727
  <NavStatusDot statusDot={item.statusDot} />
680
728
  </button>
681
729
  {/* File-drop hint icon — inline flex sibling so it sits to the
@@ -818,6 +866,7 @@ function CollapsibleNavItem({ item }: { item: NavItem }) {
818
866
  {item.badge}
819
867
  </span>
820
868
  )}
869
+ <NavStatusIcon statusIcon={item.statusIcon} />
821
870
  <NavStatusDot statusDot={item.statusDot} />
822
871
  </button>
823
872
  {/* File-drop hint icon — inline flex sibling so it sits to the
@@ -904,6 +953,7 @@ function CollapsibleNavItem({ item }: { item: NavItem }) {
904
953
  {item.badge}
905
954
  </span>
906
955
  )}
956
+ <NavStatusIcon statusIcon={item.statusIcon} />
907
957
  <NavStatusDot statusDot={item.statusDot} />
908
958
  {isFileDragOver && (
909
959
  <Upload
@@ -994,17 +1044,10 @@ function SidebarResizeHandle() {
994
1044
  );
995
1045
  }
996
1046
 
997
- function ThreadIndicator() {
998
- const threadActive = useAppStore((s) => s.threadActive);
999
- if (!threadActive) return null;
1000
-
1001
- return (
1002
- <div className="px-4 pb-2 flex items-center gap-2">
1003
- <span className="text-xs text-muted-foreground">Active thread</span>
1004
- <div className="size-1.5 rounded-full bg-primary animate-pulse" />
1005
- </div>
1006
- );
1007
- }
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.
1008
1051
 
1009
1052
  function CollapsedNavShortcut({
1010
1053
  to,
@@ -1030,10 +1073,264 @@ function CollapsedNavShortcut({
1030
1073
  );
1031
1074
  }
1032
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
+
1033
1317
  function SidebarContent({ collapsed }: { collapsed: boolean }) {
1034
1318
  const toggle = useSidebarStore((s) => s.toggle);
1035
1319
  const handleNewConversation = useNewConversation();
1036
- 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
+ );
1037
1334
  const currentPage = useAppStore((s) => s.currentPage);
1038
1335
  const navContext = useAppStore((s) => s.navContext);
1039
1336
 
@@ -1147,7 +1444,9 @@ function SidebarContent({ collapsed }: { collapsed: boolean }) {
1147
1444
  </button>
1148
1445
  </div>
1149
1446
 
1150
- <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. */}
1151
1450
 
1152
1451
  {/* Navigation groups */}
1153
1452
  <nav
@@ -1156,63 +1455,7 @@ function SidebarContent({ collapsed }: { collapsed: boolean }) {
1156
1455
  onDropCapture={preventNativeFileDrop}
1157
1456
  >
1158
1457
  {navConfig.map((group) => (
1159
- <div key={group.label}>
1160
- {!group.hideLabel && (
1161
- <div className="px-3 py-2">
1162
- {group.to ? (
1163
- <div className="flex items-center justify-between rounded-lg py-1.5 transition-colors hover:bg-sidebar-accent/70">
1164
- <NavLink to={group.to} end className="min-w-0 flex-1">
1165
- {({ isActive }) => (
1166
- <span
1167
- className={cn(
1168
- "block text-xs font-medium uppercase tracking-wider",
1169
- isActive
1170
- ? "text-sidebar-selected-foreground"
1171
- : "text-sidebar-foreground/65 hover:text-sidebar-foreground",
1172
- )}
1173
- >
1174
- {group.label}
1175
- </span>
1176
- )}
1177
- </NavLink>
1178
- {group.createActions && group.createActions.length > 0 && (
1179
- <DropdownMenu>
1180
- <DropdownMenuTrigger asChild>
1181
- <button
1182
- type="button"
1183
- className="inline-flex size-6 items-center justify-center text-sidebar-foreground/65 transition-colors hover:text-sidebar-foreground"
1184
- aria-label={`Create in ${group.label}`}
1185
- >
1186
- <Plus className="size-4" />
1187
- </button>
1188
- </DropdownMenuTrigger>
1189
- <DropdownMenuContent align="end" className="w-48">
1190
- {group.createActions.map((action) => (
1191
- <DropdownMenuItem
1192
- key={action.id}
1193
- onSelect={(e) => {
1194
- e.preventDefault();
1195
- action.handler();
1196
- }}
1197
- >
1198
- {action.label}
1199
- </DropdownMenuItem>
1200
- ))}
1201
- </DropdownMenuContent>
1202
- </DropdownMenu>
1203
- )}
1204
- </div>
1205
- ) : (
1206
- <span className="text-xs font-medium uppercase tracking-wider text-sidebar-foreground/65">
1207
- {group.label}
1208
- </span>
1209
- )}
1210
- </div>
1211
- )}
1212
- {group.items.map((item) => (
1213
- <CollapsibleNavItem key={item.to + item.label} item={item} />
1214
- ))}
1215
- </div>
1458
+ <NavGroupSection key={group.label} group={group} />
1216
1459
  ))}
1217
1460
  </nav>
1218
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";
@@ -60,6 +60,17 @@ export interface NavItem {
60
60
  tone?: "neutral" | "info" | "low-impact" | "medium-impact" | "high-impact" | "pending";
61
61
  label?: string;
62
62
  };
63
+ /**
64
+ * Optional warning / status icon shown after the label. Use for
65
+ * "something needs attention" annotations (e.g. failed indexing,
66
+ * permission drift) where a coloured dot isn't legible enough.
67
+ * Pass any LucideIcon; the tone drives the colour.
68
+ */
69
+ statusIcon?: {
70
+ icon: LucideIcon;
71
+ tone?: "neutral" | "info" | "warning" | "danger";
72
+ label?: string;
73
+ };
63
74
  /** Page description sent to the agent for context */
64
75
  description?: string;
65
76
  /** Arbitrary metadata sent to the agent (e.g., workspace IDs, entity types) */
@@ -102,6 +113,27 @@ export interface CollapsedRailContext {
102
113
  export interface NavGroup {
103
114
  label: string;
104
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;
105
137
  /** Optional link for the group header label */
106
138
  to?: string;
107
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 () => {