@iloveagents/foundry-web-shell 0.1.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/AGENTS.md ADDED
@@ -0,0 +1,82 @@
1
+ Browser bootstrap for the LastSpace agent UI starter. Published as `@iloveagents/foundry-web-shell`. Composes `@iloveagents/foundry-web-primitives`, `@iloveagents/foundry-agent` (and `@iloveagents/foundry-agent/msal`), and `@iloveagents/foundry-web-ui` into a generic mounting point — customers and modules contribute pages, theme layers, layout extras, tool UIs, and per-request fetch interceptors via the `ChatModule` protocol.
2
+
3
+ # Architecture
4
+
5
+ Source-shipping (no build step) — same convention as the other `@lastspace/*` packages.
6
+
7
+ ```
8
+ src/
9
+ index.ts ← public barrel (bootstrapShell, ChatModule, defineChatModule, types)
10
+ bootstrap-shell.tsx ← entry point: installs fetch interceptors, calls createRoot
11
+ shell-app.tsx ← <BrowserRouter> → AuthProvider → [wrappers outer→inner] → AGUIRuntimeProvider → {toolUIs} → Suspense → Routes
12
+ shell-layout.tsx ← generic layout (Sidebar + ChatHeader + ToolPanelLayout + ChatBubble + GlobalSelectionPopover + module layoutExtras)
13
+ types.ts ← ChatModule, ShellPage, AuthAdapter, BootstrapShellOptions
14
+ auth-default.tsx ← default authProvider (wraps @iloveagents/foundry-web-ui's AuthProvider; reads MsalAuthConfig from import.meta.env)
15
+ service-fetch-default.ts ← default fetchFn (createServiceFetch + tokenFetch wiring)
16
+ ```
17
+
18
+ # Public API
19
+
20
+ ```ts
21
+ import { bootstrapShell, defineChatModule, type ChatModule } from "@iloveagents/foundry-web-shell";
22
+ ```
23
+
24
+ `bootstrapShell({ modules, pages, theme, authProvider, strictMode })` mounts the SPA. Each `ChatModule` may contribute:
25
+
26
+ - `useInit?: () => void` — React hook body called inside the layout (rules-of-hooks apply).
27
+ - `toolUIs?: ReactNode` — rendered inside `<AGUIRuntimeProvider>`.
28
+ - `layoutExtras?: ReactNode` — rendered inside the layout (banners, dialogs, popovers).
29
+ - `wrappers?: ComponentType<{ children }>[]` — applied outer→inner from array.
30
+ - `useThemeLayers?: () => ThemeLayer[]` — hook returning theme layers; merged on top of static `theme` prop.
31
+ - `pages?: { path; element }[]` — appended to Routes; customer `pages` win on path collision. (Sidebar nav items are mutated via `useNavStore.getState().setConfig(...)` inside `useInit` — see Spaces' `useSpacesNavSync` for the pattern.)
32
+ - `fetchInterceptor?: () => void` — installed before `createRoot` (use to install global fetch wrappers).
33
+
34
+ # Wrapper ordering
35
+
36
+ ```
37
+ <StrictMode>
38
+ <BrowserRouter>
39
+ <AuthProvider>
40
+ {wrappers outer→inner from modules}
41
+ <AGUIRuntimeProvider>
42
+ {toolUIs}
43
+ <Suspense>
44
+ <Routes>
45
+ <Route element={<ShellLayout />}>
46
+ {pages — customer wins on path collision}
47
+ </Route>
48
+ </Routes>
49
+ </Suspense>
50
+ </AGUIRuntimeProvider>
51
+ {/wrappers}
52
+ </AuthProvider>
53
+ </BrowserRouter>
54
+ </StrictMode>
55
+ ```
56
+
57
+ Asserted by `src/__tests__/wrapper-order.test.tsx`.
58
+
59
+ # Import boundary (enforced by guard test)
60
+
61
+ `@iloveagents/foundry-web-shell` MAY import: `@iloveagents/foundry-web-primitives`, `@iloveagents/foundry-web-ui`, `@iloveagents/foundry-agent` (+ subpaths). MUST NOT import `@lastspace/spaces-web-ui` — modules are runtime parameters, never compile-time deps. Enforced by `OPEN_CORE_GRAPH` in `packages/web-ui/src/__tests__/open-core-guards.test.ts`.
62
+
63
+ `packages/web-shell/src/` MUST stay ≤ 15 files (file-count ceiling, hardFail).
64
+
65
+ # Theme
66
+
67
+ `bootstrapShell({ theme })` provides static base layers. Modules' `useThemeLayers()` hooks return additional layers stacked on top in module-array order. Final layers feed `<ThemeRuntimeProvider layers={...}>`.
68
+
69
+ # Auth
70
+
71
+ The default `authProvider` reads `VITE_MSAL_CLIENT_ID`, `VITE_MSAL_AUTHORITY`, `VITE_MSAL_API_SCOPE` from `import.meta.env`. Missing keys render the `AuthConfigError` UI. Customers can override with `authProvider={{ Provider: MyAuthProvider }}` for testing or non-MSAL deployments.
72
+
73
+ # Commands
74
+
75
+ ```bash
76
+ pnpm --filter @iloveagents/foundry-web-shell test:unit # vitest run
77
+ pnpm --filter @iloveagents/foundry-web-shell typecheck # tsc --noEmit
78
+ ```
79
+
80
+ # Authoring a module
81
+
82
+ See [`docs/AUTHORING_A_MODULE.md`](../../docs/AUTHORING_A_MODULE.md).
package/CLAUDE.md ADDED
@@ -0,0 +1 @@
1
+ @AGENTS.md
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 iLoveAgents, a brand of Leitwolf GmbH
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/package.json ADDED
@@ -0,0 +1,37 @@
1
+ {
2
+ "name": "@iloveagents/foundry-web-shell",
3
+ "version": "0.1.0",
4
+ "license": "SEE LICENSE IN LICENSE",
5
+ "type": "module",
6
+ "types": "./src/index.ts",
7
+ "exports": {
8
+ ".": "./src/index.ts"
9
+ },
10
+ "publishConfig": {
11
+ "access": "public"
12
+ },
13
+ "peerDependencies": {
14
+ "react": "^19.0.0",
15
+ "react-dom": "^19.0.0",
16
+ "react-router": "^7.0.0",
17
+ "@assistant-ui/react": "^0.12.25",
18
+ "lucide-react": ">=0.400.0",
19
+ "zustand": "^5.0.0",
20
+ "@iloveagents/foundry-agent": "0.1.0",
21
+ "@iloveagents/foundry-web-primitives": "0.1.0",
22
+ "@iloveagents/foundry-web-ui": "0.1.0"
23
+ },
24
+ "devDependencies": {
25
+ "typescript": "~5.9.3",
26
+ "@types/react": "^19.2.2",
27
+ "@types/react-dom": "^19.2.2",
28
+ "vite": "^7.2.2",
29
+ "vitest": "^4.1.4",
30
+ "jsdom": "^28.1.0",
31
+ "@testing-library/react": "^16.0.0"
32
+ },
33
+ "scripts": {
34
+ "test:unit": "vitest run",
35
+ "typecheck": "tsc --noEmit"
36
+ }
37
+ }
@@ -0,0 +1,142 @@
1
+ /**
2
+ * Module-bootstrap test.
3
+ *
4
+ * Asserts that every ChatModule extension point fires when ShellApp is
5
+ * rendered. Bypasses bootstrap-shell.tsx (which calls createRoot) and
6
+ * renders ShellApp directly so we can probe React's tree.
7
+ */
8
+ import { describe, expect, it, vi } from "vitest";
9
+ import { render, screen, waitFor } from "@testing-library/react";
10
+ import type { ReactNode } from "react";
11
+ import { defineChatModule, type ChatModule, type ShellPage } from "../types.ts";
12
+
13
+ // Mock the web-ui surface the shell consumes. Don't spread the real
14
+ // module — its top-level zustand stores call window.matchMedia at import
15
+ // time, which jsdom doesn't provide. We only need the symbols ShellApp
16
+ // + ShellLayout import directly.
17
+ vi.mock("@iloveagents/foundry-web-ui", () => ({
18
+ AuthProvider: ({ children }: { children: ReactNode }) => <>{children}</>,
19
+ AGUIRuntimeProvider: ({ children }: { children: ReactNode }) => <>{children}</>,
20
+ Sidebar: () => <div data-testid="sidebar" />,
21
+ ChatHeader: () => <div data-testid="chat-header" />,
22
+ ChatBubble: () => <div data-testid="chat-bubble" />,
23
+ ChatContent: () => <div data-testid="chat-content" />,
24
+ ToolPanelLayout: ({ children }: { children: ReactNode }) => <>{children}</>,
25
+ ThemeRuntimeProvider: ({ children }: { children: ReactNode }) => <>{children}</>,
26
+ ThemeScope: ({ children }: { children: ReactNode }) => <>{children}</>,
27
+ ThemeDocumentMetadata: () => null,
28
+ GlobalSelectionPopover: () => null,
29
+ ContextPins: () => null,
30
+ TooltipIconButton: ({ children }: { children: ReactNode }) => <>{children}</>,
31
+ findNavItem: () => undefined,
32
+ useAppStore: (selector: (s: any) => unknown) =>
33
+ selector({ setCurrentPage: () => undefined, setNavContext: () => undefined }),
34
+ useNavStore: (selector: (s: any) => unknown) => selector({ config: [] }),
35
+ useChatBubbleStore: Object.assign(
36
+ (selector: (s: any) => unknown) =>
37
+ selector({
38
+ isExpanded: false,
39
+ showPagePanel: false,
40
+ togglePagePanel: () => undefined,
41
+ }),
42
+ { getState: () => ({ close: () => undefined }) },
43
+ ),
44
+ useThemeStore: (selector: (s: any) => unknown) => selector({ mode: "system" }),
45
+ }));
46
+
47
+ vi.mock("@iloveagents/foundry-web-primitives", () => ({
48
+ cn: (...classes: unknown[]) => classes.filter(Boolean).join(" "),
49
+ }));
50
+
51
+ vi.mock("@iloveagents/foundry-agent", () => ({
52
+ createServiceFetch: () => fetch,
53
+ }));
54
+
55
+ vi.mock("@iloveagents/foundry-agent/msal", () => ({
56
+ authStore: { getState: () => ({ getAccessToken: () => Promise.resolve("") }) },
57
+ }));
58
+
59
+ import { ShellApp } from "../shell-app.tsx";
60
+ import type { AuthAdapter } from "../types.ts";
61
+
62
+ const noopAuth: AuthAdapter = {
63
+ Provider: ({ children }) => <>{children}</>,
64
+ };
65
+
66
+ describe("ChatModule bootstrap composition", () => {
67
+ it("calls every extension point when ShellApp renders", async () => {
68
+ const useInit = vi.fn();
69
+ const useThemeLayers = vi.fn(() => []);
70
+ const wrapperRendered = vi.fn();
71
+
72
+ function ProbeWrapper({ children }: { children: ReactNode }) {
73
+ wrapperRendered();
74
+ return <div data-testid="probe-wrapper">{children}</div>;
75
+ }
76
+
77
+ const homePage: ShellPage = {
78
+ path: "/",
79
+ element: <div data-testid="home-page">home</div>,
80
+ };
81
+
82
+ const moduleHome: ShellPage = {
83
+ path: "/",
84
+ element: <div data-testid="module-home">module home</div>,
85
+ };
86
+
87
+ const modulePage: ShellPage = {
88
+ path: "elsewhere",
89
+ element: <div data-testid="module-page">module elsewhere</div>,
90
+ };
91
+
92
+ const m: ChatModule = defineChatModule({
93
+ name: "test",
94
+ useInit,
95
+ useThemeLayers,
96
+ toolUIs: <div data-testid="tool-ui-marker" />,
97
+ layoutExtras: <div data-testid="layout-extra-marker" />,
98
+ wrappers: [ProbeWrapper],
99
+ pages: [moduleHome, modulePage],
100
+ });
101
+
102
+ render(
103
+ <ShellApp
104
+ modules={[m]}
105
+ pages={[homePage]}
106
+ baseThemeLayers={[]}
107
+ authProvider={noopAuth}
108
+ agentFetch={fetch}
109
+ />,
110
+ );
111
+
112
+ // useInit hook fired.
113
+ await waitFor(() => expect(useInit).toHaveBeenCalled());
114
+
115
+ // useThemeLayers hook fired.
116
+ expect(useThemeLayers).toHaveBeenCalled();
117
+
118
+ // Module wrapper rendered.
119
+ expect(screen.getByTestId("probe-wrapper")).toBeTruthy();
120
+ expect(wrapperRendered).toHaveBeenCalled();
121
+
122
+ // Tool UI rendered.
123
+ expect(screen.getByTestId("tool-ui-marker")).toBeTruthy();
124
+
125
+ // Layout extra rendered.
126
+ expect(screen.getByTestId("layout-extra-marker")).toBeTruthy();
127
+
128
+ // Customer page wins on path collision (/ resolves to home-page, not module-home).
129
+ expect(screen.getByTestId("home-page")).toBeTruthy();
130
+ expect(screen.queryByTestId("module-home")).toBeNull();
131
+ });
132
+
133
+ it("invokes fetchInterceptor (verified by bootstrap-shell test)", () => {
134
+ // bootstrap-shell.tsx wraps installSpacesFetchInterceptor BEFORE createRoot.
135
+ // The contract is that ShellApp itself does not invoke it — bootstrap-shell does.
136
+ // This test just guards the type marker (`defineChatModule` is identity-typed).
137
+ const interceptor = vi.fn();
138
+ const m = defineChatModule({ name: "x", fetchInterceptor: interceptor });
139
+ expect(m.fetchInterceptor).toBe(interceptor);
140
+ expect(interceptor).not.toHaveBeenCalled();
141
+ });
142
+ });
@@ -0,0 +1,162 @@
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
+ });
@@ -0,0 +1,75 @@
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
+
6
+
7
+
8
+ interface AuthConfigErrorProps {
9
+ missingKeys: string[];
10
+ }
11
+
12
+ function AuthConfigError({ missingKeys }: AuthConfigErrorProps) {
13
+ return (
14
+ <div className="flex min-h-dvh items-center justify-center bg-background px-6 text-center">
15
+ <div className="max-w-md rounded-2xl border border-border bg-card px-6 py-5 shadow-sm">
16
+ <h1 className="text-lg font-semibold text-foreground">Authentication unavailable</h1>
17
+ <p className="mt-2 text-sm text-muted-foreground">
18
+ Missing required MSAL environment variables: {missingKeys.join(", ")}.
19
+ </p>
20
+ </div>
21
+ </div>
22
+ );
23
+ }
24
+
25
+ /**
26
+ * Read MSAL configuration from Vite env. Returns the resolved config or the
27
+ * list of missing keys. The shell renders <AuthConfigError /> when any key
28
+ * is missing.
29
+ */
30
+ function readMsalConfigFromEnv(): MsalAuthConfig | { missing: string[] } {
31
+ const env = import.meta.env;
32
+ const clientId = env.VITE_MSAL_CLIENT_ID;
33
+ const authority = env.VITE_MSAL_AUTHORITY;
34
+ const apiScope = env.VITE_MSAL_API_SCOPE;
35
+ const missing = [
36
+ !clientId ? "VITE_MSAL_CLIENT_ID" : null,
37
+ !authority ? "VITE_MSAL_AUTHORITY" : null,
38
+ !apiScope ? "VITE_MSAL_API_SCOPE" : null,
39
+ ].filter((v): v is string => v !== null);
40
+ if (missing.length > 0) return { missing };
41
+ return {
42
+ clientId: clientId!,
43
+ authority: authority!,
44
+ apiScope: apiScope!,
45
+ redirectUri: window.location.origin,
46
+ };
47
+ }
48
+
49
+ /**
50
+ * Default MSAL-backed auth adapter. Reads VITE_MSAL_* from import.meta.env.
51
+ * Customers override via `bootstrapShell({ authProvider: { Provider: ... } })`
52
+ * for tests or non-MSAL deployments.
53
+ */
54
+ export const defaultAuthAdapter: AuthAdapter = {
55
+ Provider: ({ children }: { children: ReactNode }) => {
56
+ const result = readMsalConfigFromEnv();
57
+ if ("missing" in result) {
58
+ return <AuthConfigError missingKeys={result.missing} />;
59
+ }
60
+ return <AuthProvider config={result}>{children}</AuthProvider>;
61
+ },
62
+ };
63
+
64
+ /**
65
+ * Tiny wrapper that picks the default or override adapter and renders it.
66
+ * Kept as a separate component so the wrapper-order test can probe it
67
+ * easily.
68
+ */
69
+ export const ShellAuth: FC<{ adapter?: AuthAdapter; children: ReactNode }> = ({
70
+ adapter,
71
+ children,
72
+ }) => {
73
+ const Adapter = (adapter ?? defaultAuthAdapter).Provider;
74
+ return <Adapter>{children}</Adapter>;
75
+ };
@@ -0,0 +1,49 @@
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 ADDED
@@ -0,0 +1,12 @@
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 { defineChatModule } from "./types.ts";
7
+ export type {
8
+ ChatModule,
9
+ ShellPage,
10
+ AuthAdapter,
11
+ BootstrapShellOptions,
12
+ } from "./types.ts";
@@ -0,0 +1,12 @@
1
+ import { createServiceFetch } from "@iloveagents/foundry-agent";
2
+ import { authStore } from "@iloveagents/foundry-agent/msal";
3
+
4
+ /**
5
+ * Default agent fetch client wired to the MSAL token store and the host's
6
+ * Vite env. Customers can build their own via `createServiceFetch` from
7
+ * `@iloveagents/foundry-agent` if they need different acquireToken / baseUrl behavior.
8
+ */
9
+ export const defaultAgentFetch: typeof fetch = createServiceFetch({
10
+ acquireToken: () => authStore.getState().getAccessToken("api"),
11
+ baseUrl: import.meta.env.VITE_API_BASE_URL,
12
+ });
@@ -0,0 +1,111 @@
1
+ import { type ReactNode, Suspense, useMemo } from "react";
2
+ import { BrowserRouter, Routes, Route } from "react-router";
3
+ import { AGUIRuntimeProvider } from "@iloveagents/foundry-web-ui";
4
+ import type { ThemeLayer } from "@iloveagents/foundry-web-ui";
5
+ import type { ChatModule, AuthAdapter, ShellPage } from "./types.ts";
6
+ import { ShellAuth } from "./auth-default.tsx";
7
+ import { ShellLayout } from "./shell-layout.tsx";
8
+ import { defaultAgentFetch } from "./service-fetch-default.ts";
9
+
10
+ interface ShellAppProps {
11
+ modules: ChatModule[];
12
+ pages: ShellPage[];
13
+ baseThemeLayers: ThemeLayer[];
14
+ authProvider?: AuthAdapter;
15
+ /** Test seam — overrides defaultAgentFetch. */
16
+ agentFetch?: typeof fetch;
17
+ }
18
+
19
+ /**
20
+ * Inner shell component — pure React (no `createRoot`). Imported by
21
+ * `bootstrap-shell.tsx` for production and by `__tests__/` for assertions.
22
+ *
23
+ * Wrapper ordering is contractual; see `wrapper-order.test.tsx`.
24
+ */
25
+ export function ShellApp({
26
+ modules,
27
+ pages,
28
+ baseThemeLayers,
29
+ authProvider,
30
+ agentFetch,
31
+ }: ShellAppProps) {
32
+ // Merge module pages with customer pages — customer wins on path collision
33
+ // (last-wins). Stable order: customer pages first (they appear in Routes
34
+ // before module pages, and React Router matches the FIRST matching route).
35
+ const mergedPages = useMemo(() => {
36
+ const seen = new Set(pages.map((p) => p.path));
37
+ const moduleRoutes: ShellPage[] = [];
38
+ for (const m of modules) {
39
+ for (const p of m.pages ?? []) {
40
+ if (!seen.has(p.path)) {
41
+ seen.add(p.path);
42
+ moduleRoutes.push(p);
43
+ }
44
+ }
45
+ }
46
+ return [...pages, ...moduleRoutes];
47
+ }, [pages, modules]);
48
+
49
+ // Compose module wrappers outer→inner from each module's array.
50
+ const wrappers = useMemo(() => {
51
+ const out: React.ComponentType<{ children: ReactNode }>[] = [];
52
+ for (const m of modules) {
53
+ for (const W of m.wrappers ?? []) out.push(W);
54
+ }
55
+ return out;
56
+ }, [modules]);
57
+
58
+ // Aggregate module toolUIs.
59
+ const toolUIs: ReactNode[] = [];
60
+ for (const m of modules) {
61
+ if (m.toolUIs) {
62
+ toolUIs.push(<ToolUIGroup key={m.name}>{m.toolUIs}</ToolUIGroup>);
63
+ }
64
+ }
65
+
66
+ const fetchFn = agentFetch ?? defaultAgentFetch;
67
+
68
+ return (
69
+ <BrowserRouter>
70
+ <ShellAuth adapter={authProvider}>
71
+ <ComposedWrappers wrappers={wrappers}>
72
+ <AGUIRuntimeProvider fetchFn={fetchFn}>
73
+ {toolUIs}
74
+ <Suspense fallback={null}>
75
+ <Routes>
76
+ <Route element={<ShellLayout modules={modules} baseThemeLayers={baseThemeLayers} />}>
77
+ {mergedPages.map((p) => (
78
+ <Route key={p.path} path={p.path === "/" ? undefined : p.path} index={p.path === "/"} element={p.element} />
79
+ ))}
80
+ </Route>
81
+ </Routes>
82
+ </Suspense>
83
+ </AGUIRuntimeProvider>
84
+ </ComposedWrappers>
85
+ </ShellAuth>
86
+ </BrowserRouter>
87
+ );
88
+ }
89
+
90
+ function ToolUIGroup({ children }: { children: ReactNode }) {
91
+ return <>{children}</>;
92
+ }
93
+
94
+ interface ComposedWrappersProps {
95
+ wrappers: React.ComponentType<{ children: ReactNode }>[];
96
+ children: ReactNode;
97
+ }
98
+
99
+ /**
100
+ * Apply the wrappers array outer→inner. wrappers[0] is the outermost; the
101
+ * last entry is closest to children. Pure render-time composition; no state.
102
+ */
103
+ function ComposedWrappers({ wrappers, children }: ComposedWrappersProps) {
104
+ let tree: ReactNode = children;
105
+ // Iterate in reverse so wrappers[0] ends up outermost.
106
+ for (let i = wrappers.length - 1; i >= 0; i--) {
107
+ const W = wrappers[i];
108
+ tree = <W>{tree}</W>;
109
+ }
110
+ return <>{tree}</>;
111
+ }
@@ -0,0 +1,171 @@
1
+ import { type ReactNode, useEffect } from "react";
2
+ import { Outlet, useLocation } from "react-router";
3
+ import {
4
+ Sidebar,
5
+ ToolPanelLayout,
6
+ ChatHeader,
7
+ ChatBubble,
8
+ ThemeDocumentMetadata,
9
+ ThemeRuntimeProvider,
10
+ ThemeScope,
11
+ GlobalSelectionPopover,
12
+ ChatContent,
13
+ ContextPins,
14
+ TooltipIconButton,
15
+ useAppStore,
16
+ useNavStore,
17
+ findNavItem,
18
+ useChatBubbleStore,
19
+ useThemeStore,
20
+ type ThemeLayer,
21
+ } from "@iloveagents/foundry-web-ui";
22
+ import { cn } from "@iloveagents/foundry-web-primitives";
23
+ import { X } from "lucide-react";
24
+ import type { ChatModule } from "./types.ts";
25
+
26
+ interface ShellLayoutProps {
27
+ modules: ChatModule[];
28
+ baseThemeLayers: ThemeLayer[];
29
+ }
30
+
31
+ /**
32
+ * Generic app layout — rendered as the outlet for the customer's routes.
33
+ *
34
+ * Each module contributes:
35
+ * - `useInit`: hook body run here so React's rules-of-hooks apply.
36
+ * - `useThemeLayers`: theme layers stacked above `baseThemeLayers`.
37
+ * - `layoutExtras`: rendered as a sibling group inside <ThemeScope>.
38
+ *
39
+ * The shell knows nothing about Spaces — `useSpacesInit`, dialogs, banners
40
+ * all flow through the module protocol.
41
+ */
42
+ export function ShellLayout({ modules, baseThemeLayers }: ShellLayoutProps) {
43
+ // Module init hooks — called in registration order. Rules-of-hooks
44
+ // require a stable count, so module additions/removals across renders
45
+ // are forbidden (ChatModule[] is meant to be a static prop).
46
+ for (const m of modules) {
47
+ m.useInit?.();
48
+ }
49
+
50
+ // Module theme-layer hooks — flatten and merge above the static base.
51
+ const moduleLayers: ThemeLayer[] = [];
52
+ for (const m of modules) {
53
+ if (m.useThemeLayers) {
54
+ for (const layer of m.useThemeLayers()) {
55
+ moduleLayers.push(layer);
56
+ }
57
+ }
58
+ }
59
+ const themeLayers = [...baseThemeLayers, ...moduleLayers];
60
+
61
+ const themeMode = useThemeStore((s) => s.mode);
62
+ const { pathname } = useLocation();
63
+ const isExpanded = useChatBubbleStore((s) => s.isExpanded);
64
+ const showPagePanel = useChatBubbleStore((s) => s.showPagePanel);
65
+
66
+ // Collapse expanded chat when navigating to root (already full-screen there).
67
+ useEffect(() => {
68
+ if (pathname === "/" && isExpanded) {
69
+ useChatBubbleStore.getState().close();
70
+ }
71
+ }, [pathname, isExpanded]);
72
+
73
+ // Sync route + navigation context to app store.
74
+ const setCurrentPage = useAppStore((s) => s.setCurrentPage);
75
+ const setNavContext = useAppStore((s) => s.setNavContext);
76
+ const navConfig = useNavStore((s) => s.config);
77
+
78
+ useEffect(() => {
79
+ setCurrentPage(pathname);
80
+ const found = findNavItem(navConfig, pathname);
81
+ const mergedInstructions = [found?.groupInstructions, found?.item.contextInstructions]
82
+ .filter(Boolean)
83
+ .join("\n");
84
+
85
+ setNavContext({
86
+ group: found?.group ?? null,
87
+ groupDescription: found?.groupDescription ?? null,
88
+ groupMeta: found?.groupMeta ?? {},
89
+ label: found?.item.label ?? pathname,
90
+ description: found?.item.description ?? null,
91
+ meta: found?.item.meta ?? {},
92
+ contextInstructions: mergedInstructions || null,
93
+ });
94
+ }, [pathname, navConfig, setCurrentPage, setNavContext]);
95
+
96
+ // Page content (Outlet) — always rendered to keep page tools and context alive.
97
+ const page = <Outlet />;
98
+
99
+ // Aggregate module layoutExtras into a single fragment.
100
+ const layoutExtras: ReactNode[] = [];
101
+ for (const m of modules) {
102
+ if (m.layoutExtras) {
103
+ layoutExtras.push(<ModuleExtra key={m.name}>{m.layoutExtras}</ModuleExtra>);
104
+ }
105
+ }
106
+
107
+ return (
108
+ <ThemeRuntimeProvider layers={themeLayers} mode={themeMode}>
109
+ <ThemeScope className="flex h-dvh flex-col overflow-hidden bg-background text-foreground">
110
+ <ThemeDocumentMetadata />
111
+ {layoutExtras}
112
+
113
+ <div className="flex min-h-0 flex-1 overflow-hidden">
114
+ <Sidebar />
115
+
116
+ {isExpanded ? (
117
+ <ToolPanelLayout>
118
+ <div className="flex h-full min-w-0 flex-1 overflow-hidden">
119
+ <div className="flex min-w-0 flex-1 flex-col overflow-hidden">
120
+ <ChatHeader />
121
+ <ChatContent />
122
+ </div>
123
+
124
+ {showPagePanel && (
125
+ <div
126
+ className={cn(
127
+ "hidden h-full shrink-0 border-l border-border bg-background xl:flex xl:flex-col",
128
+ )}
129
+ style={{ width: 600 }}
130
+ >
131
+ <PagePanelActions />
132
+ <div className="flex-1 flex flex-col min-h-0 overflow-hidden">{page}</div>
133
+ </div>
134
+ )}
135
+ </div>
136
+ </ToolPanelLayout>
137
+ ) : (
138
+ <ToolPanelLayout>
139
+ <div className="flex flex-1 flex-col overflow-hidden">
140
+ <ChatHeader />
141
+ {page}
142
+ </div>
143
+ </ToolPanelLayout>
144
+ )}
145
+ </div>
146
+
147
+ <ChatBubble />
148
+ <GlobalSelectionPopover />
149
+ </ThemeScope>
150
+ </ThemeRuntimeProvider>
151
+ );
152
+ }
153
+
154
+ function ModuleExtra({ children }: { children: ReactNode }) {
155
+ return <>{children}</>;
156
+ }
157
+
158
+ function PagePanelActions() {
159
+ const togglePagePanel = useChatBubbleStore((s) => s.togglePagePanel);
160
+
161
+ return (
162
+ <div className="shrink-0 flex items-center justify-end px-4 py-3 border-b border-border">
163
+ <div className="flex items-center gap-0.5">
164
+ <ContextPins compact flyoutDirection="down" />
165
+ <TooltipIconButton tooltip="Close panel" size="icon" onClick={togglePagePanel}>
166
+ <X className="size-4" />
167
+ </TooltipIconButton>
168
+ </div>
169
+ </div>
170
+ );
171
+ }
package/src/types.ts ADDED
@@ -0,0 +1,70 @@
1
+ import type { ComponentType, ReactNode } from "react";
2
+ import type { ThemeLayer } from "@iloveagents/foundry-web-ui";
3
+
4
+ /** Route entry contributed by a module or the host app. */
5
+ export interface ShellPage {
6
+ path: string;
7
+ element: ReactNode;
8
+ }
9
+
10
+ /**
11
+ * A ChatModule is the unit of composition for `bootstrapShell`. Each field is
12
+ * optional — modules contribute what they need:
13
+ *
14
+ * - `useInit`: React hook body called once per layout render (rules-of-hooks
15
+ * apply). Use for cross-store sync, registry registration, etc.
16
+ * Modules wanting to mutate `useNavStore` config (sidebar nav items)
17
+ * do it inside `useInit` — see Spaces' `useSpacesNavSync()` for the
18
+ * canonical pattern.
19
+ * - `toolUIs`: ReactNode rendered inside `<AGUIRuntimeProvider>`. Use to
20
+ * register `makeAssistantToolUI` instances.
21
+ * - `layoutExtras`: ReactNode rendered inside the shell layout (banners,
22
+ * global dialogs, popovers).
23
+ * - `wrappers`: ComponentType<{children}>[] applied outer→inner from the
24
+ * array. Wrap providers like SpacesQueryProvider here.
25
+ * - `useThemeLayers`: hook returning ThemeLayer[]; merged on top of the
26
+ * static `theme` prop in module-array order.
27
+ * - `pages`: ShellPage[] appended to Routes; customer-supplied `pages` win
28
+ * on path collision.
29
+ * - `fetchInterceptor`: zero-arg installer called once before `createRoot`.
30
+ * Use to install `window.fetch` wrappers.
31
+ */
32
+ export interface ChatModule {
33
+ name: string;
34
+ useInit?: () => void;
35
+ toolUIs?: ReactNode;
36
+ layoutExtras?: ReactNode;
37
+ wrappers?: ComponentType<{ children: ReactNode }>[];
38
+ useThemeLayers?: () => ThemeLayer[];
39
+ pages?: ShellPage[];
40
+ fetchInterceptor?: () => void;
41
+ }
42
+
43
+ /**
44
+ * Adapter for plugging in an auth Provider. The Provider renders children
45
+ * once authenticated and is responsible for resolving its own config
46
+ * (the default reads MSAL settings from `import.meta.env`). Tests and
47
+ * non-MSAL deployments override with a different Provider.
48
+ */
49
+ export interface AuthAdapter {
50
+ Provider: ComponentType<{ children: ReactNode }>;
51
+ }
52
+
53
+ export interface BootstrapShellOptions {
54
+ modules?: ChatModule[];
55
+ /** Customer-supplied pages — win over module pages on `path` collision. */
56
+ pages?: ShellPage[];
57
+ /** Static base theme layers; modules' useThemeLayers stack on top. */
58
+ theme?: ThemeLayer[];
59
+ /** Override default MSAL-backed auth provider (e.g. for tests). */
60
+ authProvider?: AuthAdapter;
61
+ /** Wrap the tree in <StrictMode>. Default: true. */
62
+ strictMode?: boolean;
63
+ /** DOM element to mount into. Default: document.getElementById("root"). */
64
+ rootElement?: HTMLElement;
65
+ }
66
+
67
+ /** Pass-through helper that adds nothing at runtime — pure type marker. */
68
+ export function defineChatModule(m: ChatModule): ChatModule {
69
+ return m;
70
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,17 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2020",
4
+ "module": "ESNext",
5
+ "moduleResolution": "bundler",
6
+ "strict": true,
7
+ "jsx": "react-jsx",
8
+ "skipLibCheck": true,
9
+ "resolveJsonModule": true,
10
+ "esModuleInterop": true,
11
+ "allowImportingTsExtensions": true,
12
+ "noEmit": true,
13
+ "types": ["vite/client"]
14
+ },
15
+ "include": ["src/**/*"],
16
+ "exclude": ["node_modules"]
17
+ }
@@ -0,0 +1,9 @@
1
+ import { defineConfig } from "vitest/config";
2
+
3
+ export default defineConfig({
4
+ test: {
5
+ environment: "jsdom",
6
+ globals: false,
7
+ include: ["src/**/__tests__/**/*.test.{ts,tsx}"],
8
+ },
9
+ });