@iloveagents/foundry-web-shell 0.3.1 → 0.5.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.
@@ -1,162 +0,0 @@
1
- /**
2
- * Wrapper-order test.
3
- *
4
- * Asserts the contractual outer→inner ordering: BrowserRouter wraps
5
- * AuthProvider wraps [module wrappers] wraps AGUIRuntimeProvider wraps
6
- * Routes. Uses a depth-marker context — each layer increments the depth
7
- * via a Provider, and a probe component reads the depth at render time.
8
- */
9
- import { describe, expect, it, vi } from "vitest";
10
- import { render, waitFor } from "@testing-library/react";
11
- import { createContext, useContext, type ReactNode } from "react";
12
- import { useLocation } from "react-router";
13
- import { defineChatModule, type ChatModule, type ShellPage } from "../types.ts";
14
-
15
- const DepthContext = createContext<number>(-1);
16
-
17
- function makeWrapper(label: string, expectedDepth: number) {
18
- return function Wrap({ children }: { children: ReactNode }) {
19
- const incoming = useContext(DepthContext);
20
- // Each wrapper asserts its own depth via the test contract.
21
- // Using a side-effect ref so the assertion runs even if children
22
- // never resolve (e.g. routing to a no-match path).
23
- seen[label] = incoming;
24
- return (
25
- <DepthContext.Provider value={incoming + 1}>{children}</DepthContext.Provider>
26
- );
27
- };
28
- }
29
-
30
- const seen: Record<string, number> = {};
31
-
32
- // Probe inside the route confirms BrowserRouter is reachable + reports the
33
- // final inner depth.
34
- function RouteProbe() {
35
- const depth = useContext(DepthContext);
36
- const location = useLocation();
37
- seen["route-probe"] = depth;
38
- seen["route-pathname"] = location.pathname.length;
39
- return <div data-testid="route-probe">probe</div>;
40
- }
41
-
42
- // Mock the web-ui surface the shell consumes. AuthProvider +
43
- // AGUIRuntimeProvider are wrapped with the depth context so the test
44
- // can probe their nesting order. Don't spread the real module — its
45
- // stores call window.matchMedia at import time and jsdom doesn't have
46
- // that.
47
- vi.mock("@iloveagents/foundry-web-ui", () => ({
48
- AuthProvider: ({ children }: { children: ReactNode }) => {
49
- const incoming = useContext(DepthContext);
50
- seen["auth-provider"] = incoming;
51
- return (
52
- <DepthContext.Provider value={incoming + 1}>{children}</DepthContext.Provider>
53
- );
54
- },
55
- AGUIRuntimeProvider: ({ children }: { children: ReactNode }) => {
56
- const incoming = useContext(DepthContext);
57
- seen["agui-runtime"] = incoming;
58
- return (
59
- <DepthContext.Provider value={incoming + 1}>{children}</DepthContext.Provider>
60
- );
61
- },
62
- Sidebar: () => null,
63
- ChatHeader: () => null,
64
- ChatBubble: () => null,
65
- ChatContent: () => null,
66
- ToolPanelLayout: ({ children }: { children: ReactNode }) => <>{children}</>,
67
- ThemeRuntimeProvider: ({ children }: { children: ReactNode }) => <>{children}</>,
68
- ThemeScope: ({ children }: { children: ReactNode }) => <>{children}</>,
69
- ThemeDocumentMetadata: () => null,
70
- GlobalSelectionPopover: () => null,
71
- ContextPins: () => null,
72
- TooltipIconButton: ({ children }: { children: ReactNode }) => <>{children}</>,
73
- findNavItem: () => undefined,
74
- useAppStore: (selector: (s: any) => unknown) =>
75
- selector({ setCurrentPage: () => undefined, setNavContext: () => undefined }),
76
- useNavStore: (selector: (s: any) => unknown) => selector({ config: [] }),
77
- useChatBubbleStore: Object.assign(
78
- (selector: (s: any) => unknown) =>
79
- selector({
80
- isExpanded: false,
81
- showPagePanel: false,
82
- togglePagePanel: () => undefined,
83
- }),
84
- { getState: () => ({ close: () => undefined }) },
85
- ),
86
- useThemeStore: (selector: (s: any) => unknown) => selector({ mode: "system" }),
87
- }));
88
-
89
- vi.mock("@iloveagents/foundry-web-primitives", () => ({
90
- cn: (...classes: unknown[]) => classes.filter(Boolean).join(" "),
91
- }));
92
-
93
- vi.mock("@iloveagents/foundry-agent", () => ({
94
- createServiceFetch: () => fetch,
95
- }));
96
-
97
- vi.mock("@iloveagents/foundry-agent/msal", () => ({
98
- authStore: { getState: () => ({ getAccessToken: () => Promise.resolve("") }) },
99
- }));
100
-
101
- import { ShellApp } from "../shell-app.tsx";
102
- import type { AuthAdapter } from "../types.ts";
103
- // Imported AFTER vi.mock so this resolves to the mocked AuthProvider.
104
- import { AuthProvider as MockedAuthProvider } from "@iloveagents/foundry-web-ui";
105
-
106
- describe("Shell wrapper ordering contract", () => {
107
- it("nests BrowserRouter > AuthProvider > [module wrappers] > AGUIRuntimeProvider > Routes", async () => {
108
- Object.keys(seen).forEach((k) => delete seen[k]);
109
-
110
- const ModuleWrapperA = makeWrapper("module-a", 2);
111
- const ModuleWrapperB = makeWrapper("module-b", 3);
112
-
113
- const m: ChatModule = defineChatModule({
114
- name: "test",
115
- wrappers: [ModuleWrapperA, ModuleWrapperB],
116
- });
117
-
118
- const homePage: ShellPage = {
119
- path: "/",
120
- element: <RouteProbe />,
121
- };
122
-
123
- // The auth adapter under test wraps children with the mocked
124
- // AuthProvider from web-ui (which counts depth). In production
125
- // defaultAuthAdapter does the same — this just makes the contract
126
- // explicit for the test.
127
- const auth: AuthAdapter = {
128
- Provider: ({ children }: { children: ReactNode }) => (
129
- // The vi.mock factory above replaces this provider's runtime impl with one
130
- // that ignores `config`, but TypeScript only sees the real component's
131
- // signature. Cast to satisfy the prop requirement without a real config.
132
- <MockedAuthProvider config={{} as never}>{children}</MockedAuthProvider>
133
- ),
134
- };
135
-
136
- render(
137
- <ShellApp
138
- modules={[m]}
139
- pages={[homePage]}
140
- baseThemeLayers={[]}
141
- authProvider={auth}
142
- agentFetch={fetch}
143
- />,
144
- );
145
-
146
- // Wait for the route probe to render.
147
- await waitFor(() => expect(seen["route-probe"]).toBeDefined());
148
-
149
- // Layer depths (each Provider sees the incoming depth, then adds 1).
150
- // BrowserRouter does not use DepthContext, so the first layer to bump
151
- // the depth is AuthProvider (sees depth -1, advances to 0).
152
- expect(seen["auth-provider"]).toBe(-1);
153
- // ProbeWrapper (the test mock around the AuthProvider mock) ran AFTER
154
- // AuthProvider — module wrappers nest INSIDE AuthProvider.
155
- expect(seen["module-a"]).toBe(0);
156
- expect(seen["module-b"]).toBe(1);
157
- // AGUIRuntimeProvider nests INSIDE module wrappers.
158
- expect(seen["agui-runtime"]).toBe(2);
159
- // RouteProbe nests INSIDE AGUIRuntimeProvider.
160
- expect(seen["route-probe"]).toBe(3);
161
- });
162
- });
@@ -1,78 +0,0 @@
1
- import type { FC, ReactNode } from "react";
2
- import { AuthProvider } from "@iloveagents/foundry-web-ui";
3
- import type { MsalAuthConfig } from "@iloveagents/foundry-agent/msal";
4
- import type { AuthAdapter } from "./types.ts";
5
- import { runtimeConfig } from "./runtime-config.ts";
6
-
7
-
8
-
9
- interface AuthConfigErrorProps {
10
- missingKeys: string[];
11
- }
12
-
13
- function AuthConfigError({ missingKeys }: AuthConfigErrorProps) {
14
- return (
15
- <div className="flex min-h-dvh items-center justify-center bg-background px-6 text-center">
16
- <div className="max-w-md rounded-2xl border border-border bg-card px-6 py-5 shadow-sm">
17
- <h1 className="text-lg font-semibold text-foreground">Authentication unavailable</h1>
18
- <p className="mt-2 text-sm text-muted-foreground">
19
- Missing required MSAL environment variables: {missingKeys.join(", ")}.
20
- </p>
21
- </div>
22
- </div>
23
- );
24
- }
25
-
26
- /**
27
- * Read MSAL configuration. Prefers runtime config (`window.__APP_CONFIG__`,
28
- * injected by the container at startup) and falls back to build-time Vite env
29
- * (`import.meta.env`) for local dev — see {@link runtimeConfig}. Returns the
30
- * resolved config or the list of missing keys; the shell renders
31
- * <AuthConfigError /> when any key is missing.
32
- */
33
- function readMsalConfig(): MsalAuthConfig | { missing: string[] } {
34
- const clientId = runtimeConfig("VITE_MSAL_CLIENT_ID");
35
- const authority = runtimeConfig("VITE_MSAL_AUTHORITY");
36
- const apiScope = runtimeConfig("VITE_MSAL_API_SCOPE");
37
- const missing = [
38
- !clientId ? "VITE_MSAL_CLIENT_ID" : null,
39
- !authority ? "VITE_MSAL_AUTHORITY" : null,
40
- !apiScope ? "VITE_MSAL_API_SCOPE" : null,
41
- ].filter((v): v is string => v !== null);
42
- if (missing.length > 0) return { missing };
43
- return {
44
- clientId,
45
- authority,
46
- apiScope,
47
- redirectUri: window.location.origin,
48
- };
49
- }
50
-
51
- /**
52
- * Default MSAL-backed auth adapter. Reads VITE_MSAL_* from runtime config
53
- * (`window.__APP_CONFIG__`) with `import.meta.env` fallback for local dev.
54
- * Customers override via `bootstrapShell({ authProvider: { Provider: ... } })`
55
- * for tests or non-MSAL deployments.
56
- */
57
- export const defaultAuthAdapter: AuthAdapter = {
58
- Provider: ({ children }: { children: ReactNode }) => {
59
- const result = readMsalConfig();
60
- if ("missing" in result) {
61
- return <AuthConfigError missingKeys={result.missing} />;
62
- }
63
- return <AuthProvider config={result}>{children}</AuthProvider>;
64
- },
65
- };
66
-
67
- /**
68
- * Tiny wrapper that picks the default or override adapter and renders it.
69
- * Kept as a separate component so the wrapper-order test can probe it
70
- * easily.
71
- */
72
- export const ShellAuth: FC<{ adapter?: AuthAdapter; children: ReactNode }> = ({
73
- adapter,
74
- children,
75
- }) => {
76
- const Adapter = (adapter ?? defaultAuthAdapter).Provider;
77
- return <Adapter>{children}</Adapter>;
78
- };
@@ -1,49 +0,0 @@
1
- import { StrictMode } from "react";
2
- import { createRoot } from "react-dom/client";
3
- import { ShellApp } from "./shell-app.tsx";
4
- import type { BootstrapShellOptions } from "./types.ts";
5
-
6
- /**
7
- * Mount the agent UI into the DOM.
8
- *
9
- * Composition pipeline:
10
- * 1. Run each module's `fetchInterceptor` (BEFORE createRoot so window.fetch
11
- * wrappers are in place by the first React render).
12
- * 2. createRoot on `rootElement` (default: #root).
13
- * 3. Wrap `<ShellApp>` in <StrictMode> unless `strictMode === false`.
14
- */
15
- export function bootstrapShell(opts: BootstrapShellOptions = {}): void {
16
- const {
17
- modules = [],
18
- pages = [],
19
- theme = [],
20
- authProvider,
21
- strictMode = true,
22
- rootElement,
23
- } = opts;
24
-
25
- // 1. Install module fetch interceptors before React mounts.
26
- for (const m of modules) {
27
- m.fetchInterceptor?.();
28
- }
29
-
30
- // 2. Resolve mount target.
31
- const root = rootElement ?? document.getElementById("root");
32
- if (!root) {
33
- throw new Error(
34
- "[@iloveagents/foundry-web-shell] No mount target — pass `rootElement` or ensure a #root element exists.",
35
- );
36
- }
37
-
38
- const tree = (
39
- <ShellApp
40
- modules={modules}
41
- pages={pages}
42
- baseThemeLayers={theme}
43
- authProvider={authProvider}
44
- />
45
- );
46
-
47
- // 3. Mount with StrictMode by default.
48
- createRoot(root).render(strictMode ? <StrictMode>{tree}</StrictMode> : tree);
49
- }
package/src/index.ts DELETED
@@ -1,14 +0,0 @@
1
- export { bootstrapShell } from "./bootstrap-shell.tsx";
2
- export { ShellApp } from "./shell-app.tsx";
3
- export { ShellLayout } from "./shell-layout.tsx";
4
- export { defaultAuthAdapter, ShellAuth } from "./auth-default.tsx";
5
- export { defaultAgentFetch } from "./service-fetch-default.ts";
6
- export { runtimeConfig } from "./runtime-config.ts";
7
- export { defineChatModule } from "./types.ts";
8
- export type {
9
- ChatModule,
10
- ChatConversationConfig,
11
- ShellPage,
12
- AuthAdapter,
13
- BootstrapShellOptions,
14
- } from "./types.ts";
package/src/shell-app.tsx DELETED
@@ -1,386 +0,0 @@
1
- import { type ReactNode, Suspense, useCallback, useMemo, useRef } from "react";
2
- import { BrowserRouter, Routes, Route, useLocation } from "react-router";
3
- import { AGUIRuntimeProvider } from "@iloveagents/foundry-web-ui";
4
- import type {
5
- AGUIChatConversationFactoryArgs,
6
- AGUIHistoryAdapterFactory,
7
- } from "@iloveagents/foundry-web-ui";
8
- import type { ThemeLayer } from "@iloveagents/foundry-web-ui";
9
- import type { ChatConversationConfig, ChatModule, AuthAdapter, ShellPage } from "./types.ts";
10
- import { ShellAuth } from "./auth-default.tsx";
11
- import { ShellLayout } from "./shell-layout.tsx";
12
- import { defaultAgentFetch } from "./service-fetch-default.ts";
13
-
14
- interface ShellAppProps {
15
- modules: ChatModule[];
16
- pages: ShellPage[];
17
- baseThemeLayers: ThemeLayer[];
18
- authProvider?: AuthAdapter;
19
- /** Test seam — overrides defaultAgentFetch. */
20
- agentFetch?: typeof fetch;
21
- }
22
-
23
- /**
24
- * Inner shell component — pure React (no `createRoot`). Imported by
25
- * `bootstrap-shell.tsx` for production and by `__tests__/` for assertions.
26
- *
27
- * Wrapper ordering is contractual; see `wrapper-order.test.tsx`.
28
- */
29
- export function ShellApp({
30
- modules,
31
- pages,
32
- baseThemeLayers,
33
- authProvider,
34
- agentFetch,
35
- }: ShellAppProps) {
36
- // Merge module pages with customer pages — customer wins on path collision
37
- // (last-wins). Stable order: customer pages first (they appear in Routes
38
- // before module pages, and React Router matches the FIRST matching route).
39
- const mergedPages = useMemo(() => {
40
- const seen = new Set(pages.map((p) => p.path));
41
- const moduleRoutes: ShellPage[] = [];
42
- for (const m of modules) {
43
- for (const p of m.pages ?? []) {
44
- if (!seen.has(p.path)) {
45
- seen.add(p.path);
46
- moduleRoutes.push(p);
47
- }
48
- }
49
- }
50
- return [...pages, ...moduleRoutes];
51
- }, [pages, modules]);
52
-
53
- // Compose module wrappers outer→inner from each module's array.
54
- const wrappers = useMemo(() => {
55
- const out: React.ComponentType<{ children: ReactNode }>[] = [];
56
- for (const m of modules) {
57
- for (const W of m.wrappers ?? []) out.push(W);
58
- }
59
- return out;
60
- }, [modules]);
61
-
62
- // Aggregate module toolUIs.
63
- const toolUIs: ReactNode[] = [];
64
- for (const m of modules) {
65
- if (m.toolUIs) {
66
- toolUIs.push(<ToolUIGroup key={m.name}>{m.toolUIs}</ToolUIGroup>);
67
- }
68
- }
69
-
70
- const fetchFn = agentFetch ?? defaultAgentFetch;
71
-
72
- // Aggregate chatConversation configs from all modules. First-match
73
- // wins at URL-match time (rare to have more than one anyway).
74
- const chatConversationConfigs = useMemo(
75
- () => modules.flatMap((m) => (m.chatConversation ? [m.chatConversation] : [])),
76
- [modules],
77
- );
78
-
79
- return (
80
- <BrowserRouter>
81
- <ShellAuth adapter={authProvider}>
82
- <ComposedWrappers wrappers={wrappers}>
83
- <ChatConversationAwareRuntime
84
- fetchFn={fetchFn}
85
- configs={chatConversationConfigs}
86
- toolUIs={toolUIs}
87
- >
88
- <Suspense fallback={null}>
89
- <Routes>
90
- <Route element={<ShellLayout modules={modules} baseThemeLayers={baseThemeLayers} />}>
91
- {mergedPages.map((p) => (
92
- <Route key={p.path} path={p.path === "/" ? undefined : p.path} index={p.path === "/"} element={p.element} />
93
- ))}
94
- </Route>
95
- </Routes>
96
- </Suspense>
97
- </ChatConversationAwareRuntime>
98
- </ComposedWrappers>
99
- </ShellAuth>
100
- </BrowserRouter>
101
- );
102
- }
103
-
104
- /**
105
- * Reads the current pathname (only meaningful inside ``<BrowserRouter>``),
106
- * matches it against any module-declared :type:`ChatConversationConfig`,
107
- * and hands the matched config + URL-extracted id down into
108
- * :type:`AGUIRuntimeProvider` as a :type:`AGUIHistoryAdapterFactory`.
109
- *
110
- * Why a factory (not a pre-built adapter)
111
- * =======================================
112
- * The history adapter needs *both* the URL-extracted conversation id
113
- * (when present) *and* the AG-UI adapter's freshly-minted thread id
114
- * (always set — covers fresh chats). The AG-UI adapter only exists
115
- * once :type:`AGUIRuntimeProvider` mounts, so the factory is invoked
116
- * inside that mount with both pieces available. This makes "fresh
117
- * chats also persist" a built-in property of the contract, not an
118
- * afterthought.
119
- */
120
- function ChatConversationAwareRuntime({
121
- children,
122
- toolUIs,
123
- fetchFn,
124
- configs,
125
- }: {
126
- children: ReactNode;
127
- toolUIs: ReactNode[];
128
- fetchFn: typeof fetch;
129
- configs: ChatConversationConfig[];
130
- }) {
131
- const { pathname } = useLocation();
132
-
133
- // Pick the matching config (URL-pattern match) OR fall back to the
134
- // first config registered — modules without a URL match still want
135
- // their fresh-chat persistence. With ≥1 config the runtime always
136
- // gets a factory; without configs it stays in the legacy in-memory
137
- // mode.
138
- const { config, urlMatch } = useMemo(() => {
139
- for (const cfg of configs) {
140
- const m = pathname.match(cfg.pathPattern);
141
- if (m && m[1]) {
142
- return { config: cfg, urlMatch: m[1] };
143
- }
144
- }
145
- return { config: configs[0], urlMatch: undefined as string | undefined };
146
- }, [pathname, configs]);
147
-
148
- if (!config) {
149
- // No chatConversation configs registered → legacy in-memory
150
- // runtime, no history / no sticky concerns.
151
- return (
152
- <AGUIRuntimeProvider fetchFn={fetchFn}>
153
- {toolUIs}
154
- {children}
155
- </AGUIRuntimeProvider>
156
- );
157
- }
158
-
159
- // Per-config child component, KEYED by config identity. Why a
160
- // key/child split: ``config.useStickyConversationId`` is optional.
161
- // Calling it inline with the optional chain ``config?.useSticky?.()``
162
- // would be a Rules-of-Hooks violation the moment a second
163
- // ChatConversationConfig registers with different hook-shape — URL
164
- // navigation flips which cfg matches, the hook count flips, React
165
- // crashes. The key makes a config swap a full remount, which is a
166
- // legitimate way to change the hook lineage without violating the
167
- // rules. ``configIndex`` is stable per config because ``configs`` is
168
- // memoised in ShellApp.
169
- const configIndex = configs.indexOf(config);
170
- if (config.useStickyConversationId) {
171
- return (
172
- <StickyAwareRuntime
173
- key={`sticky:${configIndex}`}
174
- config={config}
175
- useSticky={config.useStickyConversationId}
176
- urlMatch={urlMatch}
177
- fetchFn={fetchFn}
178
- toolUIs={toolUIs}
179
- >
180
- {children}
181
- </StickyAwareRuntime>
182
- );
183
- }
184
- return (
185
- <UrlOnlyRuntime
186
- key={`url:${configIndex}`}
187
- config={config}
188
- urlMatch={urlMatch}
189
- fetchFn={fetchFn}
190
- toolUIs={toolUIs}
191
- >
192
- {children}
193
- </UrlOnlyRuntime>
194
- );
195
- }
196
-
197
- interface ChildRuntimeProps {
198
- config: ChatConversationConfig;
199
- urlMatch: string | undefined;
200
- fetchFn: typeof fetch;
201
- toolUIs: ReactNode[];
202
- children: ReactNode;
203
- }
204
-
205
- /**
206
- * Branch for configs that DECLARE :attr:`useStickyConversationId`.
207
- * Calls the hook unconditionally so React's hook-count invariant
208
- * holds across every render of this mount.
209
- */
210
- function StickyAwareRuntime({
211
- config,
212
- useSticky,
213
- urlMatch,
214
- fetchFn,
215
- toolUIs,
216
- children,
217
- }: ChildRuntimeProps & { useSticky: () => string | null }) {
218
- const sticky = useSticky();
219
- return (
220
- <RuntimeBody
221
- config={config}
222
- sticky={sticky}
223
- urlMatch={urlMatch}
224
- fetchFn={fetchFn}
225
- toolUIs={toolUIs}
226
- >
227
- {children}
228
- </RuntimeBody>
229
- );
230
- }
231
-
232
- /**
233
- * Branch for configs that DO NOT declare :attr:`useStickyConversationId`.
234
- * No hook is ever called for sticky state — the runtime threadId
235
- * follows URL match and the shell-minted fresh UUID only.
236
- */
237
- function UrlOnlyRuntime({ config, urlMatch, fetchFn, toolUIs, children }: ChildRuntimeProps) {
238
- return (
239
- <RuntimeBody
240
- config={config}
241
- sticky={null}
242
- urlMatch={urlMatch}
243
- fetchFn={fetchFn}
244
- toolUIs={toolUIs}
245
- >
246
- {children}
247
- </RuntimeBody>
248
- );
249
- }
250
-
251
- /**
252
- * Shared rendering body — same useRef/useMemo/useCallback shape for
253
- * both branches. The hook lineage here is stable per mount because
254
- * the parent decides which branch (and which key) to use.
255
- */
256
- function RuntimeBody({
257
- config,
258
- sticky,
259
- urlMatch,
260
- fetchFn,
261
- toolUIs,
262
- children,
263
- }: ChildRuntimeProps & { sticky: string | null }) {
264
- // Pre-mint a fresh UUID so the runtime's ``threadId`` is set from
265
- // the very first render — never goes through ``undefined → defined``,
266
- // which would otherwise force a remount of the assistant-ui runtime
267
- // (and wipe in-flight messages) the moment the user sent their first
268
- // message on a fresh chat. The backend collapse (``conversation_id ==
269
- // agui_thread_id``) ensures the id the runtime uses from the start
270
- // matches the row the lazy ``ensureConversationId`` creates — same
271
- // value, no mid-conversation switch.
272
- //
273
- // Mint a new one only when the user actually starts a new thread:
274
- // ``sticky`` transitions from a value back to ``null`` (the module
275
- // clears it on the ``/`` "New Thread" landing).
276
- const freshIdRef = useRef<string>(_genUuid());
277
- const prevStickyRef = useRef<string | null>(sticky);
278
- if (sticky === null && prevStickyRef.current !== null) {
279
- // User just navigated back to "/" (New Thread). The previous chat
280
- // is being abandoned; mint a fresh UUID for the next session.
281
- freshIdRef.current = _genUuid();
282
- }
283
- prevStickyRef.current = sticky;
284
-
285
- // Effective id seen by AGUIRuntimeProvider — always defined.
286
- //
287
- // Priority order matters and is intentional:
288
- // 1. ``urlMatch`` — when the user is on ``/chat/<id>`` the URL is
289
- // ALWAYS the authoritative conversation. This MUST come before
290
- // ``sticky`` because of a stale-render race on cross-chat
291
- // navigation:
292
- // - User is on ``/chat/A``. ``active-chat-store`` holds ``A``.
293
- // So ``sticky = A``.
294
- // - User clicks a Recents row for ``B`` →
295
- // ``react-router`` updates pathname to ``/chat/B``.
296
- // - On the synchronous render that follows, ``urlMatch`` is
297
- // ``B`` (read from ``useLocation``) but
298
- // ``useTrackActiveChatFromUrl``'s ``useEffect`` hasn't run
299
- // yet — so ``sticky`` is still ``A``.
300
- // - With ``sticky ?? urlMatch`` we'd compute
301
- // ``effectiveThreadId = A``, bind the AG-UI adapter to ``A``,
302
- // and the first message the user sends would create a NEW
303
- // row at ``A``'s old thread id while the URL says ``B``.
304
- // Symptoms: TWO active dots in the sidebar (URL highlights
305
- // row ``B``, the new row's id activates the dot on a
306
- // freshly-appeared "Untitled chat"); resume appears to
307
- // mint a new conversation; messages land on the wrong row.
308
- // Putting ``urlMatch`` first eliminates the race entirely: when
309
- // the URL says we're on chat ``B``, the runtime targets ``B``
310
- // from the very first render, regardless of stale store state.
311
- // 2. ``sticky`` — for non-chat URLs (``/spaces``, ``/tasks`` …)
312
- // ``urlMatch`` is ``undefined``. ``sticky`` keeps the popout
313
- // chat "live" while the user browses elsewhere.
314
- // 3. ``freshIdRef.current`` — brand-new chat on ``/`` with no
315
- // sticky and no URL match. Pre-minted so the runtime's
316
- // ``threadId`` is defined from the first render (no
317
- // ``undefined → defined`` remount of assistant-ui).
318
- const effectiveThreadId: string = urlMatch ?? sticky ?? freshIdRef.current;
319
-
320
- const historyAdapterFactory = useCallback<AGUIHistoryAdapterFactory>(
321
- (args: AGUIChatConversationFactoryArgs) =>
322
- config.buildHistoryAdapter({
323
- urlMatch: args.urlMatch,
324
- aguiThreadId: args.aguiThreadId,
325
- }),
326
- [config],
327
- );
328
-
329
- return (
330
- <AGUIRuntimeProvider
331
- fetchFn={fetchFn}
332
- threadId={effectiveThreadId}
333
- // ``urlMatch`` carries the URL-derived resume signal verbatim.
334
- // ``effectiveThreadId`` is always defined (sticky / urlMatch /
335
- // freshly minted UUID) so passing it in place of ``urlMatch``
336
- // would erase the "fresh vs resumed" distinction the history
337
- // adapter needs to decide whether to load() past messages.
338
- urlMatch={urlMatch}
339
- historyAdapterFactory={historyAdapterFactory}
340
- >
341
- {toolUIs}
342
- {children}
343
- </AGUIRuntimeProvider>
344
- );
345
- }
346
-
347
- function ToolUIGroup({ children }: { children: ReactNode }) {
348
- return <>{children}</>;
349
- }
350
-
351
- /**
352
- * RFC 4122 v4 UUID. Use ``crypto.randomUUID`` when available; fall
353
- * back to a math-random shim for the rare environments without it
354
- * (older Safari on non-HTTPS dev hosts). The shim is fine for our
355
- * use — these UUIDs identify a conversation row in our backend; they
356
- * don't need cryptographic strength.
357
- */
358
- function _genUuid(): string {
359
- if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {
360
- return crypto.randomUUID();
361
- }
362
- return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, (c) => {
363
- const r = (Math.random() * 16) | 0;
364
- const v = c === "x" ? r : (r & 0x3) | 0x8;
365
- return v.toString(16);
366
- });
367
- }
368
-
369
- interface ComposedWrappersProps {
370
- wrappers: React.ComponentType<{ children: ReactNode }>[];
371
- children: ReactNode;
372
- }
373
-
374
- /**
375
- * Apply the wrappers array outer→inner. wrappers[0] is the outermost; the
376
- * last entry is closest to children. Pure render-time composition; no state.
377
- */
378
- function ComposedWrappers({ wrappers, children }: ComposedWrappersProps) {
379
- let tree: ReactNode = children;
380
- // Iterate in reverse so wrappers[0] ends up outermost.
381
- for (let i = wrappers.length - 1; i >= 0; i--) {
382
- const W = wrappers[i];
383
- tree = <W>{tree}</W>;
384
- }
385
- return <>{tree}</>;
386
- }