@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 +44 -0
- package/package.json +5 -5
- package/src/components/ag-ui-runtime-provider.tsx +120 -9
- package/src/components/sidebar.test.tsx +286 -1
- package/src/components/sidebar.tsx +317 -74
- package/src/index.ts +4 -0
- package/src/lib/nav-config.ts +32 -0
- package/src/lib/use-new-conversation.test.tsx +7 -1
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.
|
|
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(
|
|
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 (
|
|
@@ -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
|
-
|
|
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
|
-
|
|
998
|
-
|
|
999
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
<
|
|
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";
|
package/src/lib/nav-config.ts
CHANGED
|
@@ -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
|
-
|
|
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 () => {
|