@iloveagents/foundry-web-shell 0.3.1 → 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 ADDED
@@ -0,0 +1,24 @@
1
+ # @iloveagents/foundry-web-shell
2
+
3
+ Browser bootstrap and layout package for Foundry UI apps.
4
+
5
+ `web-shell` mounts the SPA with `bootstrapShell`, wires routes/modules, applies
6
+ theme layers, chooses auth, and hosts `@iloveagents/foundry-web-ui`.
7
+
8
+ ## Usage
9
+
10
+ ```tsx
11
+ import { bootstrapShell } from "@iloveagents/foundry-web-shell";
12
+
13
+ bootstrapShell({
14
+ pages,
15
+ modules,
16
+ theme,
17
+ });
18
+ ```
19
+
20
+ The default auth adapter is MSAL-backed and reads `VITE_MSAL_CLIENT_ID`,
21
+ `VITE_MSAL_AUTHORITY`, and `VITE_MSAL_API_SCOPE`. Starter/demo apps can pass a
22
+ custom `authProvider` and `agentFetch` for local unauthenticated development.
23
+
24
+ This package publishes built ESM JavaScript and `.d.ts` declarations.
@@ -0,0 +1,18 @@
1
+ import type { FC, ReactNode } from "react";
2
+ import type { AuthAdapter } from "./types.js";
3
+ /**
4
+ * Default MSAL-backed auth adapter. Reads VITE_MSAL_* from runtime config
5
+ * (`window.__APP_CONFIG__`) with `import.meta.env` fallback for local dev.
6
+ * Customers override via `bootstrapShell({ authProvider: { Provider: ... } })`
7
+ * for tests or non-MSAL deployments.
8
+ */
9
+ export declare const defaultAuthAdapter: AuthAdapter;
10
+ /**
11
+ * Tiny wrapper that picks the default or override adapter and renders it.
12
+ * Kept as a separate component so the wrapper-order test can probe it
13
+ * easily.
14
+ */
15
+ export declare const ShellAuth: FC<{
16
+ adapter?: AuthAdapter;
17
+ children: ReactNode;
18
+ }>;
@@ -0,0 +1,55 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { AuthProvider } from "@iloveagents/foundry-web-ui";
3
+ import { runtimeConfig } from "./runtime-config.js";
4
+ function AuthConfigError({ missingKeys }) {
5
+ return (_jsx("div", { className: "flex min-h-dvh items-center justify-center bg-background px-6 text-center", children: _jsxs("div", { className: "max-w-md rounded-2xl border border-border bg-card px-6 py-5 shadow-sm", children: [_jsx("h1", { className: "text-lg font-semibold text-foreground", children: "Authentication unavailable" }), _jsxs("p", { className: "mt-2 text-sm text-muted-foreground", children: ["Missing required MSAL environment variables: ", missingKeys.join(", "), "."] })] }) }));
6
+ }
7
+ /**
8
+ * Read MSAL configuration. Prefers runtime config (`window.__APP_CONFIG__`,
9
+ * injected by the container at startup) and falls back to build-time Vite env
10
+ * (`import.meta.env`) for local dev — see {@link runtimeConfig}. Returns the
11
+ * resolved config or the list of missing keys; the shell renders
12
+ * <AuthConfigError /> when any key is missing.
13
+ */
14
+ function readMsalConfig() {
15
+ const clientId = runtimeConfig("VITE_MSAL_CLIENT_ID");
16
+ const authority = runtimeConfig("VITE_MSAL_AUTHORITY");
17
+ const apiScope = runtimeConfig("VITE_MSAL_API_SCOPE");
18
+ const missing = [
19
+ !clientId ? "VITE_MSAL_CLIENT_ID" : null,
20
+ !authority ? "VITE_MSAL_AUTHORITY" : null,
21
+ !apiScope ? "VITE_MSAL_API_SCOPE" : null,
22
+ ].filter((v) => v !== null);
23
+ if (missing.length > 0)
24
+ return { missing };
25
+ return {
26
+ clientId,
27
+ authority,
28
+ apiScope,
29
+ redirectUri: window.location.origin,
30
+ };
31
+ }
32
+ /**
33
+ * Default MSAL-backed auth adapter. Reads VITE_MSAL_* from runtime config
34
+ * (`window.__APP_CONFIG__`) with `import.meta.env` fallback for local dev.
35
+ * Customers override via `bootstrapShell({ authProvider: { Provider: ... } })`
36
+ * for tests or non-MSAL deployments.
37
+ */
38
+ export const defaultAuthAdapter = {
39
+ Provider: ({ children }) => {
40
+ const result = readMsalConfig();
41
+ if ("missing" in result) {
42
+ return _jsx(AuthConfigError, { missingKeys: result.missing });
43
+ }
44
+ return _jsx(AuthProvider, { config: result, children: children });
45
+ },
46
+ };
47
+ /**
48
+ * Tiny wrapper that picks the default or override adapter and renders it.
49
+ * Kept as a separate component so the wrapper-order test can probe it
50
+ * easily.
51
+ */
52
+ export const ShellAuth = ({ adapter, children, }) => {
53
+ const Adapter = (adapter ?? defaultAuthAdapter).Provider;
54
+ return _jsx(Adapter, { children: children });
55
+ };
@@ -0,0 +1,11 @@
1
+ import type { BootstrapShellOptions } from "./types.js";
2
+ /**
3
+ * Mount the agent UI into the DOM.
4
+ *
5
+ * Composition pipeline:
6
+ * 1. Run each module's `fetchInterceptor` (BEFORE createRoot so window.fetch
7
+ * wrappers are in place by the first React render).
8
+ * 2. createRoot on `rootElement` (default: #root).
9
+ * 3. Wrap `<ShellApp>` in <StrictMode> unless `strictMode === false`.
10
+ */
11
+ export declare function bootstrapShell(opts?: BootstrapShellOptions): void;
@@ -0,0 +1,28 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { StrictMode } from "react";
3
+ import { createRoot } from "react-dom/client";
4
+ import { ShellApp } from "./shell-app.js";
5
+ /**
6
+ * Mount the agent UI into the DOM.
7
+ *
8
+ * Composition pipeline:
9
+ * 1. Run each module's `fetchInterceptor` (BEFORE createRoot so window.fetch
10
+ * wrappers are in place by the first React render).
11
+ * 2. createRoot on `rootElement` (default: #root).
12
+ * 3. Wrap `<ShellApp>` in <StrictMode> unless `strictMode === false`.
13
+ */
14
+ export function bootstrapShell(opts = {}) {
15
+ const { modules = [], pages = [], theme = [], authProvider, agentFetch, strictMode = true, rootElement, } = opts;
16
+ // 1. Install module fetch interceptors before React mounts.
17
+ for (const m of modules) {
18
+ m.fetchInterceptor?.();
19
+ }
20
+ // 2. Resolve mount target.
21
+ const root = rootElement ?? document.getElementById("root");
22
+ if (!root) {
23
+ throw new Error("[@iloveagents/foundry-web-shell] No mount target — pass `rootElement` or ensure a #root element exists.");
24
+ }
25
+ const tree = (_jsx(ShellApp, { modules: modules, pages: pages, baseThemeLayers: theme, authProvider: authProvider, agentFetch: agentFetch }));
26
+ // 3. Mount with StrictMode by default.
27
+ createRoot(root).render(strictMode ? _jsx(StrictMode, { children: tree }) : tree);
28
+ }
@@ -0,0 +1,8 @@
1
+ export { bootstrapShell } from "./bootstrap-shell.js";
2
+ export { ShellApp } from "./shell-app.js";
3
+ export { ShellLayout } from "./shell-layout.js";
4
+ export { defaultAuthAdapter, ShellAuth } from "./auth-default.js";
5
+ export { defaultAgentFetch } from "./service-fetch-default.js";
6
+ export { runtimeConfig } from "./runtime-config.js";
7
+ export { defineChatModule } from "./types.js";
8
+ export type { ChatModule, ChatConversationConfig, ShellPage, AuthAdapter, BootstrapShellOptions, } from "./types.js";
package/dist/index.js ADDED
@@ -0,0 +1,7 @@
1
+ export { bootstrapShell } from "./bootstrap-shell.js";
2
+ export { ShellApp } from "./shell-app.js";
3
+ export { ShellLayout } from "./shell-layout.js";
4
+ export { defaultAuthAdapter, ShellAuth } from "./auth-default.js";
5
+ export { defaultAgentFetch } from "./service-fetch-default.js";
6
+ export { runtimeConfig } from "./runtime-config.js";
7
+ export { defineChatModule } from "./types.js";
@@ -0,0 +1,22 @@
1
+ /**
2
+ * Runtime configuration reader.
3
+ *
4
+ * The web app ships as a SINGLE prebuilt container image that must boot
5
+ * correctly for any customer / environment without a rebuild. Per-env values
6
+ * (API base URL, MSAL client/authority/scopes) therefore arrive at RUNTIME —
7
+ * the container entrypoint writes a small `/config.js` from env vars that sets
8
+ * `window.__APP_CONFIG__`, loaded before the app bundle.
9
+ *
10
+ * `runtimeConfig(key)` resolves, in order:
11
+ * 1. `window.__APP_CONFIG__[key]` — runtime config injected by the container
12
+ * 2. `import.meta.env[key]` — build-time Vite env (local `pnpm dev`)
13
+ * 3. `""` — absent
14
+ *
15
+ * Keeping `import.meta.env` as the fallback means local dev is unchanged: there
16
+ * is no `/config.js` in dev, so `window.__APP_CONFIG__` is undefined and the
17
+ * existing `VITE_*` values from `.env` are used exactly as before.
18
+ *
19
+ * The window cast is inline (no `declare global`) so this can be duplicated in
20
+ * sibling packages without conflicting global augmentations.
21
+ */
22
+ export declare function runtimeConfig(key: string): string;
@@ -19,12 +19,11 @@
19
19
  * The window cast is inline (no `declare global`) so this can be duplicated in
20
20
  * sibling packages without conflicting global augmentations.
21
21
  */
22
- export function runtimeConfig(key: string): string {
23
- const fromWindow =
24
- typeof window !== "undefined"
25
- ? (window as { __APP_CONFIG__?: Record<string, string | undefined> })
26
- .__APP_CONFIG__?.[key]
27
- : undefined;
28
- const fromEnv = (import.meta.env as Record<string, string | undefined>)[key];
29
- return fromWindow ?? fromEnv ?? "";
22
+ export function runtimeConfig(key) {
23
+ const fromWindow = typeof window !== "undefined"
24
+ ? window
25
+ .__APP_CONFIG__?.[key]
26
+ : undefined;
27
+ const fromEnv = import.meta.env[key];
28
+ return fromWindow ?? fromEnv ?? "";
30
29
  }
@@ -0,0 +1,17 @@
1
+ /**
2
+ * Default agent fetch client wired to the MSAL token store and the host's
3
+ * Vite env. Customers can build their own via `createServiceFetch` from
4
+ * `@iloveagents/foundry-agent` if they need different acquireToken / baseUrl behavior.
5
+ *
6
+ * Forward the optional ``{ forceRefresh }`` argument that the fetch
7
+ * interceptor passes on the 401 retry path. Without it, the retry would
8
+ * still ask MSAL for a cached token and the long-lived-tab 401 loop never
9
+ * recovers.
10
+ *
11
+ * ``recoverFromHardAuthFailure`` kicks in when even the force-refresh
12
+ * retry comes back with 401 — at that point the silent path is
13
+ * exhausted and only a fresh ``loginRedirect`` will produce a token
14
+ * the resource server accepts (server-side policy / audience / claims
15
+ * have drifted under the cached identity).
16
+ */
17
+ export declare const defaultAgentFetch: typeof fetch;
@@ -1,7 +1,6 @@
1
1
  import { createServiceFetch } from "@iloveagents/foundry-agent";
2
2
  import { authStore } from "@iloveagents/foundry-agent/msal";
3
- import { runtimeConfig } from "./runtime-config.ts";
4
-
3
+ import { runtimeConfig } from "./runtime-config.js";
5
4
  /**
6
5
  * Default agent fetch client wired to the MSAL token store and the host's
7
6
  * Vite env. Customers can build their own via `createServiceFetch` from
@@ -18,9 +17,8 @@ import { runtimeConfig } from "./runtime-config.ts";
18
17
  * the resource server accepts (server-side policy / audience / claims
19
18
  * have drifted under the cached identity).
20
19
  */
21
- export const defaultAgentFetch: typeof fetch = createServiceFetch({
22
- acquireToken: (options) => authStore.getState().getAccessToken("api", options),
23
- recoverFromHardAuthFailure: (reason) =>
24
- authStore.getState().recoverFromHardAuthFailure(reason),
25
- baseUrl: runtimeConfig("VITE_API_BASE_URL"),
20
+ export const defaultAgentFetch = createServiceFetch({
21
+ acquireToken: (options) => authStore.getState().getAccessToken("api", options),
22
+ recoverFromHardAuthFailure: (reason) => authStore.getState().recoverFromHardAuthFailure(reason),
23
+ baseUrl: runtimeConfig("VITE_API_BASE_URL"),
26
24
  });
@@ -0,0 +1,18 @@
1
+ import type { ThemeLayer } from "@iloveagents/foundry-web-ui";
2
+ import type { ChatModule, AuthAdapter, ShellPage } from "./types.js";
3
+ interface ShellAppProps {
4
+ modules: ChatModule[];
5
+ pages: ShellPage[];
6
+ baseThemeLayers: ThemeLayer[];
7
+ authProvider?: AuthAdapter;
8
+ /** Test seam — overrides defaultAgentFetch. */
9
+ agentFetch?: typeof fetch;
10
+ }
11
+ /**
12
+ * Inner shell component — pure React (no `createRoot`). Imported by
13
+ * `bootstrap-shell.tsx` for production and by `__tests__/` for assertions.
14
+ *
15
+ * Wrapper ordering is contractual; see `wrapper-order.test.tsx`.
16
+ */
17
+ export declare function ShellApp({ modules, pages, baseThemeLayers, authProvider, agentFetch, }: ShellAppProps): import("react/jsx-runtime").JSX.Element;
18
+ export {};
@@ -0,0 +1,227 @@
1
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
+ import { Suspense, useCallback, useMemo, useRef } from "react";
3
+ import { BrowserRouter, Routes, Route, useLocation } from "react-router";
4
+ import { AGUIRuntimeProvider } from "@iloveagents/foundry-web-ui";
5
+ import { ShellAuth } from "./auth-default.js";
6
+ import { ShellLayout } from "./shell-layout.js";
7
+ import { defaultAgentFetch } from "./service-fetch-default.js";
8
+ /**
9
+ * Inner shell component — pure React (no `createRoot`). Imported by
10
+ * `bootstrap-shell.tsx` for production and by `__tests__/` for assertions.
11
+ *
12
+ * Wrapper ordering is contractual; see `wrapper-order.test.tsx`.
13
+ */
14
+ export function ShellApp({ modules, pages, baseThemeLayers, authProvider, agentFetch, }) {
15
+ // Merge module pages with customer pages — customer wins on path collision
16
+ // (last-wins). Stable order: customer pages first (they appear in Routes
17
+ // before module pages, and React Router matches the FIRST matching route).
18
+ const mergedPages = useMemo(() => {
19
+ const seen = new Set(pages.map((p) => p.path));
20
+ const moduleRoutes = [];
21
+ for (const m of modules) {
22
+ for (const p of m.pages ?? []) {
23
+ if (!seen.has(p.path)) {
24
+ seen.add(p.path);
25
+ moduleRoutes.push(p);
26
+ }
27
+ }
28
+ }
29
+ return [...pages, ...moduleRoutes];
30
+ }, [pages, modules]);
31
+ // Compose module wrappers outer→inner from each module's array.
32
+ const wrappers = useMemo(() => {
33
+ const out = [];
34
+ for (const m of modules) {
35
+ for (const W of m.wrappers ?? [])
36
+ out.push(W);
37
+ }
38
+ return out;
39
+ }, [modules]);
40
+ // Aggregate module toolUIs.
41
+ const toolUIs = [];
42
+ for (const m of modules) {
43
+ if (m.toolUIs) {
44
+ toolUIs.push(_jsx(ToolUIGroup, { children: m.toolUIs }, m.name));
45
+ }
46
+ }
47
+ const fetchFn = agentFetch ?? defaultAgentFetch;
48
+ // Aggregate chatConversation configs from all modules. First-match
49
+ // wins at URL-match time (rare to have more than one anyway).
50
+ const chatConversationConfigs = useMemo(() => modules.flatMap((m) => (m.chatConversation ? [m.chatConversation] : [])), [modules]);
51
+ return (_jsx(BrowserRouter, { children: _jsx(ShellAuth, { adapter: authProvider, children: _jsx(ComposedWrappers, { wrappers: wrappers, children: _jsx(ChatConversationAwareRuntime, { fetchFn: fetchFn, configs: chatConversationConfigs, toolUIs: toolUIs, children: _jsx(Suspense, { fallback: null, children: _jsx(Routes, { children: _jsx(Route, { element: _jsx(ShellLayout, { modules: modules, baseThemeLayers: baseThemeLayers }), children: mergedPages.map((p) => (_jsx(Route, { path: p.path === "/" ? undefined : p.path, index: p.path === "/", element: p.element }, p.path))) }) }) }) }) }) }) }));
52
+ }
53
+ /**
54
+ * Reads the current pathname (only meaningful inside ``<BrowserRouter>``),
55
+ * matches it against any module-declared :type:`ChatConversationConfig`,
56
+ * and hands the matched config + URL-extracted id down into
57
+ * :type:`AGUIRuntimeProvider` as a :type:`AGUIHistoryAdapterFactory`.
58
+ *
59
+ * Why a factory (not a pre-built adapter)
60
+ * =======================================
61
+ * The history adapter needs *both* the URL-extracted conversation id
62
+ * (when present) *and* the AG-UI adapter's freshly-minted thread id
63
+ * (always set — covers fresh chats). The AG-UI adapter only exists
64
+ * once :type:`AGUIRuntimeProvider` mounts, so the factory is invoked
65
+ * inside that mount with both pieces available. This makes "fresh
66
+ * chats also persist" a built-in property of the contract, not an
67
+ * afterthought.
68
+ */
69
+ function ChatConversationAwareRuntime({ children, toolUIs, fetchFn, configs, }) {
70
+ const { pathname } = useLocation();
71
+ // Pick the matching config (URL-pattern match) OR fall back to the
72
+ // first config registered — modules without a URL match still want
73
+ // their fresh-chat persistence. With ≥1 config the runtime always
74
+ // gets a factory; without configs it stays in the legacy in-memory
75
+ // mode.
76
+ const { config, urlMatch } = useMemo(() => {
77
+ for (const cfg of configs) {
78
+ const m = pathname.match(cfg.pathPattern);
79
+ if (m && m[1]) {
80
+ return { config: cfg, urlMatch: m[1] };
81
+ }
82
+ }
83
+ return { config: configs[0], urlMatch: undefined };
84
+ }, [pathname, configs]);
85
+ if (!config) {
86
+ // No chatConversation configs registered → legacy in-memory
87
+ // runtime, no history / no sticky concerns.
88
+ return (_jsxs(AGUIRuntimeProvider, { fetchFn: fetchFn, children: [toolUIs, children] }));
89
+ }
90
+ // Per-config child component, KEYED by config identity. Why a
91
+ // key/child split: ``config.useStickyConversationId`` is optional.
92
+ // Calling it inline with the optional chain ``config?.useSticky?.()``
93
+ // would be a Rules-of-Hooks violation the moment a second
94
+ // ChatConversationConfig registers with different hook-shape — URL
95
+ // navigation flips which cfg matches, the hook count flips, React
96
+ // crashes. The key makes a config swap a full remount, which is a
97
+ // legitimate way to change the hook lineage without violating the
98
+ // rules. ``configIndex`` is stable per config because ``configs`` is
99
+ // memoised in ShellApp.
100
+ const configIndex = configs.indexOf(config);
101
+ if (config.useStickyConversationId) {
102
+ return (_jsx(StickyAwareRuntime, { config: config, useSticky: config.useStickyConversationId, urlMatch: urlMatch, fetchFn: fetchFn, toolUIs: toolUIs, children: children }, `sticky:${configIndex}`));
103
+ }
104
+ return (_jsx(UrlOnlyRuntime, { config: config, urlMatch: urlMatch, fetchFn: fetchFn, toolUIs: toolUIs, children: children }, `url:${configIndex}`));
105
+ }
106
+ /**
107
+ * Branch for configs that DECLARE :attr:`useStickyConversationId`.
108
+ * Calls the hook unconditionally so React's hook-count invariant
109
+ * holds across every render of this mount.
110
+ */
111
+ function StickyAwareRuntime({ config, useSticky, urlMatch, fetchFn, toolUIs, children, }) {
112
+ const sticky = useSticky();
113
+ return (_jsx(RuntimeBody, { config: config, sticky: sticky, urlMatch: urlMatch, fetchFn: fetchFn, toolUIs: toolUIs, children: children }));
114
+ }
115
+ /**
116
+ * Branch for configs that DO NOT declare :attr:`useStickyConversationId`.
117
+ * No hook is ever called for sticky state — the runtime threadId
118
+ * follows URL match and the shell-minted fresh UUID only.
119
+ */
120
+ function UrlOnlyRuntime({ config, urlMatch, fetchFn, toolUIs, children }) {
121
+ return (_jsx(RuntimeBody, { config: config, sticky: null, urlMatch: urlMatch, fetchFn: fetchFn, toolUIs: toolUIs, children: children }));
122
+ }
123
+ /**
124
+ * Shared rendering body — same useRef/useMemo/useCallback shape for
125
+ * both branches. The hook lineage here is stable per mount because
126
+ * the parent decides which branch (and which key) to use.
127
+ */
128
+ function RuntimeBody({ config, sticky, urlMatch, fetchFn, toolUIs, children, }) {
129
+ // Pre-mint a fresh UUID so the runtime's ``threadId`` is set from
130
+ // the very first render — never goes through ``undefined → defined``,
131
+ // which would otherwise force a remount of the assistant-ui runtime
132
+ // (and wipe in-flight messages) the moment the user sent their first
133
+ // message on a fresh chat. The backend collapse (``conversation_id ==
134
+ // agui_thread_id``) ensures the id the runtime uses from the start
135
+ // matches the row the lazy ``ensureConversationId`` creates — same
136
+ // value, no mid-conversation switch.
137
+ //
138
+ // Mint a new one only when the user actually starts a new thread:
139
+ // ``sticky`` transitions from a value back to ``null`` (the module
140
+ // clears it on the ``/`` "New Thread" landing).
141
+ const freshIdRef = useRef(_genUuid());
142
+ const prevStickyRef = useRef(sticky);
143
+ if (sticky === null && prevStickyRef.current !== null) {
144
+ // User just navigated back to "/" (New Thread). The previous chat
145
+ // is being abandoned; mint a fresh UUID for the next session.
146
+ freshIdRef.current = _genUuid();
147
+ }
148
+ prevStickyRef.current = sticky;
149
+ // Effective id seen by AGUIRuntimeProvider — always defined.
150
+ //
151
+ // Priority order matters and is intentional:
152
+ // 1. ``urlMatch`` — when the user is on ``/chat/<id>`` the URL is
153
+ // ALWAYS the authoritative conversation. This MUST come before
154
+ // ``sticky`` because of a stale-render race on cross-chat
155
+ // navigation:
156
+ // - User is on ``/chat/A``. ``active-chat-store`` holds ``A``.
157
+ // So ``sticky = A``.
158
+ // - User clicks a Recents row for ``B`` →
159
+ // ``react-router`` updates pathname to ``/chat/B``.
160
+ // - On the synchronous render that follows, ``urlMatch`` is
161
+ // ``B`` (read from ``useLocation``) but
162
+ // ``useTrackActiveChatFromUrl``'s ``useEffect`` hasn't run
163
+ // yet — so ``sticky`` is still ``A``.
164
+ // - With ``sticky ?? urlMatch`` we'd compute
165
+ // ``effectiveThreadId = A``, bind the AG-UI adapter to ``A``,
166
+ // and the first message the user sends would create a NEW
167
+ // row at ``A``'s old thread id while the URL says ``B``.
168
+ // Symptoms: TWO active dots in the sidebar (URL highlights
169
+ // row ``B``, the new row's id activates the dot on a
170
+ // freshly-appeared "Untitled chat"); resume appears to
171
+ // mint a new conversation; messages land on the wrong row.
172
+ // Putting ``urlMatch`` first eliminates the race entirely: when
173
+ // the URL says we're on chat ``B``, the runtime targets ``B``
174
+ // from the very first render, regardless of stale store state.
175
+ // 2. ``sticky`` — for non-chat URLs (``/spaces``, ``/tasks`` …)
176
+ // ``urlMatch`` is ``undefined``. ``sticky`` keeps the popout
177
+ // chat "live" while the user browses elsewhere.
178
+ // 3. ``freshIdRef.current`` — brand-new chat on ``/`` with no
179
+ // sticky and no URL match. Pre-minted so the runtime's
180
+ // ``threadId`` is defined from the first render (no
181
+ // ``undefined → defined`` remount of assistant-ui).
182
+ const effectiveThreadId = urlMatch ?? sticky ?? freshIdRef.current;
183
+ const historyAdapterFactory = useCallback((args) => config.buildHistoryAdapter({
184
+ urlMatch: args.urlMatch,
185
+ aguiThreadId: args.aguiThreadId,
186
+ }), [config]);
187
+ return (_jsxs(AGUIRuntimeProvider, { fetchFn: fetchFn, threadId: effectiveThreadId,
188
+ // ``urlMatch`` carries the URL-derived resume signal verbatim.
189
+ // ``effectiveThreadId`` is always defined (sticky / urlMatch /
190
+ // freshly minted UUID) so passing it in place of ``urlMatch``
191
+ // would erase the "fresh vs resumed" distinction the history
192
+ // adapter needs to decide whether to load() past messages.
193
+ urlMatch: urlMatch, historyAdapterFactory: historyAdapterFactory, children: [toolUIs, children] }));
194
+ }
195
+ function ToolUIGroup({ children }) {
196
+ return _jsx(_Fragment, { children: children });
197
+ }
198
+ /**
199
+ * RFC 4122 v4 UUID. Use ``crypto.randomUUID`` when available; fall
200
+ * back to a math-random shim for the rare environments without it
201
+ * (older Safari on non-HTTPS dev hosts). The shim is fine for our
202
+ * use — these UUIDs identify a conversation row in our backend; they
203
+ * don't need cryptographic strength.
204
+ */
205
+ function _genUuid() {
206
+ if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {
207
+ return crypto.randomUUID();
208
+ }
209
+ return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, (c) => {
210
+ const r = (Math.random() * 16) | 0;
211
+ const v = c === "x" ? r : (r & 0x3) | 0x8;
212
+ return v.toString(16);
213
+ });
214
+ }
215
+ /**
216
+ * Apply the wrappers array outer→inner. wrappers[0] is the outermost; the
217
+ * last entry is closest to children. Pure render-time composition; no state.
218
+ */
219
+ function ComposedWrappers({ wrappers, children }) {
220
+ let tree = children;
221
+ // Iterate in reverse so wrappers[0] ends up outermost.
222
+ for (let i = wrappers.length - 1; i >= 0; i--) {
223
+ const W = wrappers[i];
224
+ tree = _jsx(W, { children: tree });
225
+ }
226
+ return _jsx(_Fragment, { children: tree });
227
+ }
@@ -0,0 +1,19 @@
1
+ import { type ThemeLayer } from "@iloveagents/foundry-web-ui";
2
+ import type { ChatModule } from "./types.js";
3
+ interface ShellLayoutProps {
4
+ modules: ChatModule[];
5
+ baseThemeLayers: ThemeLayer[];
6
+ }
7
+ /**
8
+ * Generic app layout — rendered as the outlet for the customer's routes.
9
+ *
10
+ * Each module contributes:
11
+ * - `useInit`: hook body run here so React's rules-of-hooks apply.
12
+ * - `useThemeLayers`: theme layers stacked above `baseThemeLayers`.
13
+ * - `layoutExtras`: rendered as a sibling group inside <ThemeScope>.
14
+ *
15
+ * The shell knows nothing about Spaces — `useSpacesInit`, dialogs, banners
16
+ * all flow through the module protocol.
17
+ */
18
+ export declare function ShellLayout({ modules, baseThemeLayers }: ShellLayoutProps): import("react/jsx-runtime").JSX.Element;
19
+ export {};
@@ -0,0 +1,82 @@
1
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
+ import { useEffect } from "react";
3
+ import { Outlet, useLocation } from "react-router";
4
+ import { Sidebar, ToolPanelLayout, ChatHeader, ChatBubble, ThemeDocumentMetadata, ThemeRuntimeProvider, ThemeScope, GlobalSelectionPopover, ChatContent, ContextPins, TooltipIconButton, useAppStore, useNavStore, findNavItem, useChatBubbleStore, useThemeStore, } from "@iloveagents/foundry-web-ui";
5
+ import { cn } from "@iloveagents/foundry-web-primitives";
6
+ import { X } from "lucide-react";
7
+ /**
8
+ * Generic app layout — rendered as the outlet for the customer's routes.
9
+ *
10
+ * Each module contributes:
11
+ * - `useInit`: hook body run here so React's rules-of-hooks apply.
12
+ * - `useThemeLayers`: theme layers stacked above `baseThemeLayers`.
13
+ * - `layoutExtras`: rendered as a sibling group inside <ThemeScope>.
14
+ *
15
+ * The shell knows nothing about Spaces — `useSpacesInit`, dialogs, banners
16
+ * all flow through the module protocol.
17
+ */
18
+ export function ShellLayout({ modules, baseThemeLayers }) {
19
+ // Module init hooks — called in registration order. Rules-of-hooks
20
+ // require a stable count, so module additions/removals across renders
21
+ // are forbidden (ChatModule[] is meant to be a static prop).
22
+ for (const m of modules) {
23
+ m.useInit?.();
24
+ }
25
+ // Module theme-layer hooks — flatten and merge above the static base.
26
+ const moduleLayers = [];
27
+ for (const m of modules) {
28
+ if (m.useThemeLayers) {
29
+ for (const layer of m.useThemeLayers()) {
30
+ moduleLayers.push(layer);
31
+ }
32
+ }
33
+ }
34
+ const themeLayers = [...baseThemeLayers, ...moduleLayers];
35
+ const themeMode = useThemeStore((s) => s.mode);
36
+ const { pathname } = useLocation();
37
+ const isExpanded = useChatBubbleStore((s) => s.isExpanded);
38
+ const showPagePanel = useChatBubbleStore((s) => s.showPagePanel);
39
+ // Collapse expanded chat when navigating to root (already full-screen there).
40
+ useEffect(() => {
41
+ if (pathname === "/" && isExpanded) {
42
+ useChatBubbleStore.getState().close();
43
+ }
44
+ }, [pathname, isExpanded]);
45
+ // Sync route + navigation context to app store.
46
+ const setCurrentPage = useAppStore((s) => s.setCurrentPage);
47
+ const setNavContext = useAppStore((s) => s.setNavContext);
48
+ const navConfig = useNavStore((s) => s.config);
49
+ useEffect(() => {
50
+ setCurrentPage(pathname);
51
+ const found = findNavItem(navConfig, pathname);
52
+ const mergedInstructions = [found?.groupInstructions, found?.item.contextInstructions]
53
+ .filter(Boolean)
54
+ .join("\n");
55
+ setNavContext({
56
+ group: found?.group ?? null,
57
+ groupDescription: found?.groupDescription ?? null,
58
+ groupMeta: found?.groupMeta ?? {},
59
+ label: found?.item.label ?? pathname,
60
+ description: found?.item.description ?? null,
61
+ meta: found?.item.meta ?? {},
62
+ contextInstructions: mergedInstructions || null,
63
+ });
64
+ }, [pathname, navConfig, setCurrentPage, setNavContext]);
65
+ // Page content (Outlet) — always rendered to keep page tools and context alive.
66
+ const page = _jsx(Outlet, {});
67
+ // Aggregate module layoutExtras into a single fragment.
68
+ const layoutExtras = [];
69
+ for (const m of modules) {
70
+ if (m.layoutExtras) {
71
+ layoutExtras.push(_jsx(ModuleExtra, { children: m.layoutExtras }, m.name));
72
+ }
73
+ }
74
+ return (_jsx(ThemeRuntimeProvider, { layers: themeLayers, mode: themeMode, children: _jsxs(ThemeScope, { className: "flex h-dvh flex-col overflow-hidden bg-background text-foreground", children: [_jsx(ThemeDocumentMetadata, {}), layoutExtras, _jsxs("div", { className: "flex min-h-0 flex-1 overflow-hidden", children: [_jsx(Sidebar, {}), isExpanded ? (_jsx(ToolPanelLayout, { children: _jsxs("div", { className: "flex h-full min-w-0 flex-1 overflow-hidden", children: [_jsxs("div", { className: "flex min-w-0 flex-1 flex-col overflow-hidden", children: [_jsx(ChatHeader, {}), _jsx(ChatContent, {})] }), showPagePanel && (_jsxs("div", { className: cn("hidden h-full shrink-0 border-l border-border bg-background xl:flex xl:flex-col"), style: { width: 600 }, children: [_jsx(PagePanelActions, {}), _jsx("div", { className: "flex-1 flex flex-col min-h-0 overflow-hidden", children: page })] }))] }) })) : (_jsx(ToolPanelLayout, { children: _jsxs("div", { className: "flex flex-1 flex-col overflow-hidden", children: [_jsx(ChatHeader, {}), page] }) }))] }), _jsx(ChatBubble, {}), _jsx(GlobalSelectionPopover, {})] }) }));
75
+ }
76
+ function ModuleExtra({ children }) {
77
+ return _jsx(_Fragment, { children: children });
78
+ }
79
+ function PagePanelActions() {
80
+ const togglePagePanel = useChatBubbleStore((s) => s.togglePagePanel);
81
+ return (_jsx("div", { className: "shrink-0 flex items-center justify-end px-4 py-3 border-b border-border", children: _jsxs("div", { className: "flex items-center gap-0.5", children: [_jsx(ContextPins, { compact: true, flyoutDirection: "down" }), _jsx(TooltipIconButton, { tooltip: "Close panel", size: "icon", onClick: togglePagePanel, children: _jsx(X, { className: "size-4" }) })] }) }));
82
+ }