@iloveagents/foundry-web-shell 0.3.0 → 0.4.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/README.md +24 -0
- package/dist/auth-default.d.ts +18 -0
- package/dist/auth-default.js +55 -0
- package/dist/bootstrap-shell.d.ts +11 -0
- package/dist/bootstrap-shell.js +28 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.js +7 -0
- package/dist/runtime-config.d.ts +22 -0
- package/{src/runtime-config.ts → dist/runtime-config.js} +7 -8
- package/dist/service-fetch-default.d.ts +17 -0
- package/{src/service-fetch-default.ts → dist/service-fetch-default.js} +5 -7
- package/dist/shell-app.d.ts +18 -0
- package/dist/shell-app.js +227 -0
- package/dist/shell-layout.d.ts +19 -0
- package/dist/shell-layout.js +82 -0
- package/dist/types.d.ts +174 -0
- package/dist/types.js +4 -0
- package/package.json +26 -10
- package/AGENTS.md +0 -82
- package/CHANGELOG.md +0 -175
- package/CLAUDE.md +0 -1
- package/src/__tests__/module-bootstrap.test.tsx +0 -142
- package/src/__tests__/wrapper-order.test.tsx +0 -162
- package/src/auth-default.tsx +0 -78
- package/src/bootstrap-shell.tsx +0 -49
- package/src/index.ts +0 -14
- package/src/shell-app.tsx +0 -386
- package/src/shell-layout.tsx +0 -171
- package/src/types.ts +0 -171
- package/tsconfig.json +0 -17
- package/vitest.config.ts +0 -9
|
@@ -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
|
-
});
|
package/src/auth-default.tsx
DELETED
|
@@ -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
|
-
};
|
package/src/bootstrap-shell.tsx
DELETED
|
@@ -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
|
-
}
|