@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
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
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -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
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
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.
|
|
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
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
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
|
+
}
|