@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 +43 -0
- package/package.json +5 -5
- package/src/components/ag-ui-runtime-provider.tsx +120 -9
- package/src/components/sidebar.test.tsx +203 -0
- package/src/components/sidebar.tsx +274 -74
- package/src/index.ts +4 -0
- package/src/lib/nav-config.ts +21 -0
- package/src/lib/use-new-conversation.test.tsx +7 -1
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
|
|
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(
|
|
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
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
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
|
-
|
|
39
|
-
|
|
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: {
|
|
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
|
-
|
|
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
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
<
|
|
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";
|
package/src/lib/nav-config.ts
CHANGED
|
@@ -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
|
-
|
|
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 () => {
|