@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.
@@ -1,171 +0,0 @@
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 DELETED
@@ -1,171 +0,0 @@
1
- import type { ComponentType, ReactNode } from "react";
2
- import type { ThemeLayer } from "@iloveagents/foundry-web-ui";
3
-
4
- /**
5
- * Per-mount inputs the shell hands the module's history-adapter
6
- * factory. Either ``urlMatch`` (resumed chat — the user clicked a
7
- * Recents entry) or ``aguiThreadId`` alone (fresh chat — assistant-ui
8
- * just minted a UUID) drives the canonical conversation id, depending
9
- * on what the module's backend treats as authoritative.
10
- */
11
- export interface ChatConversationFactoryArgs {
12
- /**
13
- * The conversation id captured from the URL via
14
- * :attr:`ChatConversationConfig.pathPattern`. Set when the user
15
- * resumed a known chat; ``undefined`` on a fresh thread.
16
- */
17
- urlMatch?: string;
18
- /**
19
- * The assistant-ui adapter's stable thread id (a client-minted
20
- * UUID, unique per "New Thread" click). Always set. Use this as the
21
- * key for a lazy ``create_or_get`` when ``urlMatch`` is missing so
22
- * fresh chats also persist.
23
- */
24
- aguiThreadId: string;
25
- }
26
-
27
- /**
28
- * URL-driven chat persistence wiring. Modules declare this so the
29
- * shell can stand up an assistant-ui :type:`ThreadHistoryAdapter` on
30
- * every mount of :type:`AGUIRuntimeProvider` — both resumed chats
31
- * (matched URL) and fresh chats.
32
- *
33
- * The shell stays free of feature-specific knowledge: it captures
34
- * the URL match (if any) and the assistant-ui-minted thread id, and
35
- * hands both to :attr:`buildHistoryAdapter`. The module owns the
36
- * actual backend API shape and "fresh chat vs. resume" semantics.
37
- */
38
- export interface ChatConversationConfig {
39
- /**
40
- * Regex with exactly one capture group that yields the conversation
41
- * id from ``window.location.pathname``. Example:
42
- * ``/^\/chat\/([^/]+)$/``.
43
- */
44
- pathPattern: RegExp;
45
- /**
46
- * Optional hook giving the module's view of "what conversation is
47
- * the user currently engaged with" — separate from the URL.
48
- *
49
- * Why this exists: ChatGPT-style UX wants the chat runtime to stay
50
- * alive while the user browses Workspaces, Jobs, etc. If we drove
51
- * the runtime's ``threadId`` from the URL only, every non-chat
52
- * navigation would remount the runtime and wipe in-flight messages.
53
- *
54
- * When provided, the shell prefers this value over the URL match
55
- * for the ``AGUIRuntimeProvider.threadId`` prop. The module is
56
- * expected to update its sticky state on URL transitions itself
57
- * (e.g. via :type:`useTrackActiveChatFromUrl` in Spaces).
58
- *
59
- * Return ``null`` when the user has no active chat (initial app
60
- * load, or just clicked "New Thread"). The shell then lets the
61
- * AG-UI adapter mint a fresh UUID, same as before this hook.
62
- */
63
- useStickyConversationId?: () => string | null;
64
- /**
65
- * Build an assistant-ui :type:`ThreadHistoryAdapter` for the
66
- * current mount. Called inside the runtime provider once the
67
- * AG-UI adapter has minted (or accepted) its thread id, so both
68
- * ``urlMatch`` and ``aguiThreadId`` are available.
69
- *
70
- * The returned adapter is wired into ``useLocalRuntime``'s
71
- * ``adapters.history`` slot — assistant-ui calls ``load()`` on
72
- * mount and ``append()`` after every completed turn.
73
- *
74
- * Modules typically:
75
- * - On resume (``urlMatch`` set): use it directly as the
76
- * conversation id for load/append.
77
- * - On fresh (``urlMatch`` undefined): lazily ``create_or_get``
78
- * a server row keyed by ``aguiThreadId``, cache the resulting
79
- * conversation id, then load/append against that.
80
- */
81
- buildHistoryAdapter: (args: ChatConversationFactoryArgs) => import("@assistant-ui/react").ThreadHistoryAdapter;
82
- }
83
-
84
- /** Route entry contributed by a module or the host app. */
85
- export interface ShellPage {
86
- path: string;
87
- element: ReactNode;
88
- }
89
-
90
- /**
91
- * A ChatModule is the unit of composition for `bootstrapShell`. Each field is
92
- * optional — modules contribute what they need:
93
- *
94
- * - `useInit`: React hook body called once per layout render (rules-of-hooks
95
- * apply). Use for cross-store sync, registry registration, etc.
96
- * Modules wanting to mutate `useNavStore` config (sidebar nav items)
97
- * do it inside `useInit` — see Spaces' `useSpacesNavSync()` for the
98
- * canonical pattern.
99
- * - `toolUIs`: ReactNode rendered inside `<AGUIRuntimeProvider>`. Use to
100
- * register `makeAssistantToolUI` instances.
101
- * - `layoutExtras`: ReactNode rendered inside the shell layout (banners,
102
- * global dialogs, popovers).
103
- * - `wrappers`: ComponentType<{children}>[] applied outer→inner from the
104
- * array. Wrap providers like SpacesQueryProvider here.
105
- * - `useThemeLayers`: hook returning ThemeLayer[]; merged on top of the
106
- * static `theme` prop in module-array order.
107
- * - `pages`: ShellPage[] appended to Routes; customer-supplied `pages` win
108
- * on path collision.
109
- * - `fetchInterceptor`: zero-arg installer called once before `createRoot`.
110
- * Use to install `window.fetch` wrappers.
111
- */
112
- export interface ChatModule {
113
- name: string;
114
- useInit?: () => void;
115
- toolUIs?: ReactNode;
116
- layoutExtras?: ReactNode;
117
- wrappers?: ComponentType<{ children: ReactNode }>[];
118
- useThemeLayers?: () => ThemeLayer[];
119
- pages?: ShellPage[];
120
- fetchInterceptor?: () => void;
121
- /**
122
- * URL-driven chat persistence.
123
- *
124
- * Selection order (see ``ChatConversationAwareRuntime`` in
125
- * ``shell-app.tsx``):
126
- *
127
- * 1. URL match. If a module's ``pathPattern`` matches the current
128
- * pathname and captures a non-empty group, that module's config
129
- * wins and its captured id is fed to the history adapter as
130
- * ``urlMatch``.
131
- * 2. Fallback. If nothing matches the URL, the shell falls back to
132
- * the FIRST registered config (``configs[0]``) so a fresh-chat
133
- * runtime still gets a history adapter and the chat persists
134
- * from the very first message. ``urlMatch`` is ``undefined``
135
- * in this case.
136
- * 3. None. With zero configs registered, the runtime stays in
137
- * legacy in-memory mode (no persistence).
138
- *
139
- * Modules without a config don't participate in selection.
140
- */
141
- chatConversation?: ChatConversationConfig;
142
- }
143
-
144
- /**
145
- * Adapter for plugging in an auth Provider. The Provider renders children
146
- * once authenticated and is responsible for resolving its own config
147
- * (the default reads MSAL settings from `import.meta.env`). Tests and
148
- * non-MSAL deployments override with a different Provider.
149
- */
150
- export interface AuthAdapter {
151
- Provider: ComponentType<{ children: ReactNode }>;
152
- }
153
-
154
- export interface BootstrapShellOptions {
155
- modules?: ChatModule[];
156
- /** Customer-supplied pages — win over module pages on `path` collision. */
157
- pages?: ShellPage[];
158
- /** Static base theme layers; modules' useThemeLayers stack on top. */
159
- theme?: ThemeLayer[];
160
- /** Override default MSAL-backed auth provider (e.g. for tests). */
161
- authProvider?: AuthAdapter;
162
- /** Wrap the tree in <StrictMode>. Default: true. */
163
- strictMode?: boolean;
164
- /** DOM element to mount into. Default: document.getElementById("root"). */
165
- rootElement?: HTMLElement;
166
- }
167
-
168
- /** Pass-through helper that adds nothing at runtime — pure type marker. */
169
- export function defineChatModule(m: ChatModule): ChatModule {
170
- return m;
171
- }
package/tsconfig.json DELETED
@@ -1,17 +0,0 @@
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
- }
package/vitest.config.ts DELETED
@@ -1,9 +0,0 @@
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
- });