@iloveagents/foundry-web-shell 0.1.5 → 0.2.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/CHANGELOG.md CHANGED
@@ -1,5 +1,46 @@
1
1
  # @iloveagents/foundry-web-shell
2
2
 
3
+ ## 0.2.0
4
+
5
+ ### Minor Changes
6
+
7
+ - shell: ChatConversationConfig API + sticky-runtime threadId
8
+
9
+ Lets a downstream module (e.g. `@ltwlf/spaces-web-ui`) attach a
10
+ `ChatConversationConfig` to its `ChatModule`. The shell then:
11
+ - Pre-mints a fresh thread UUID via `useRef` so the AG-UI runtime's
12
+ `threadId` is defined from first render — no `undefined → defined`
13
+ remount mid-conversation when the user sends the first message.
14
+ - Calls `config.useStickyConversationId()` (when declared) and
15
+ feeds the resulting id into the runtime so the chat stays "live"
16
+ while the user browses non-chat routes (Workspaces, Jobs, etc.).
17
+ - Per-config keyed child components (`StickyAwareRuntime` vs
18
+ `UrlOnlyRuntime`) so optional hooks satisfy Rules-of-Hooks across
19
+ multi-module config swaps.
20
+
21
+ ### Patch Changes
22
+
23
+ - Move `@iloveagents/foundry-agent`, `-web-primitives`, `-web-ui` from
24
+ `peerDependencies` to `dependencies`. They always ship together as a
25
+ coordinated fixed group from this monorepo — they were never
26
+ independently-versioned peers. Declaring them as peerDeps caused
27
+ changesets' `shouldBumpMajor` cascade to promote the entire group to
28
+ a major version on every minor changeset. See the root `foundry-agent`
29
+ CHANGELOG entry for details.
30
+
31
+ - Updated dependencies
32
+ - @iloveagents/foundry-web-ui@0.2.0
33
+ - @iloveagents/foundry-agent@0.2.0
34
+ - @iloveagents/foundry-web-primitives@0.2.0
35
+
36
+ ### NOTE: 1.0.1 was accidental
37
+
38
+ Version 1.0.1 was published briefly on 2026-05-27 due to the peerDep
39
+ cascade bug described above (1.0.0 was reserved but not published —
40
+ 1.0.1 was the retry). It has been unpublished. Both 1.0.0 and 1.0.1
41
+ are now permanently reserved on npm and CANNOT be re-published. Do
42
+ not depend on 1.0.x.
43
+
3
44
  ## 0.1.5
4
45
 
5
46
  ### Patch Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@iloveagents/foundry-web-shell",
3
- "version": "0.1.5",
3
+ "version": "0.2.0",
4
4
  "license": "SEE LICENSE IN LICENSE",
5
5
  "type": "module",
6
6
  "types": "./src/index.ts",
@@ -16,10 +16,12 @@
16
16
  "react-router": "^7.0.0",
17
17
  "@assistant-ui/react": "^0.12.25",
18
18
  "lucide-react": ">=0.400.0",
19
- "zustand": "^5.0.0",
20
- "@iloveagents/foundry-agent": "0.1.5",
21
- "@iloveagents/foundry-web-primitives": "0.1.5",
22
- "@iloveagents/foundry-web-ui": "0.1.5"
19
+ "zustand": "^5.0.0"
20
+ },
21
+ "dependencies": {
22
+ "@iloveagents/foundry-web-primitives": "0.2.0",
23
+ "@iloveagents/foundry-web-ui": "0.2.0",
24
+ "@iloveagents/foundry-agent": "0.2.0"
23
25
  },
24
26
  "devDependencies": {
25
27
  "typescript": "~5.9.3",
package/src/index.ts CHANGED
@@ -6,6 +6,7 @@ export { defaultAgentFetch } from "./service-fetch-default.ts";
6
6
  export { defineChatModule } from "./types.ts";
7
7
  export type {
8
8
  ChatModule,
9
+ ChatConversationConfig,
9
10
  ShellPage,
10
11
  AuthAdapter,
11
12
  BootstrapShellOptions,
package/src/shell-app.tsx CHANGED
@@ -1,8 +1,12 @@
1
- import { type ReactNode, Suspense, useMemo } from "react";
2
- import { BrowserRouter, Routes, Route } from "react-router";
1
+ import { type ReactNode, Suspense, useCallback, useMemo, useRef } from "react";
2
+ import { BrowserRouter, Routes, Route, useLocation } from "react-router";
3
3
  import { AGUIRuntimeProvider } from "@iloveagents/foundry-web-ui";
4
+ import type {
5
+ AGUIChatConversationFactoryArgs,
6
+ AGUIHistoryAdapterFactory,
7
+ } from "@iloveagents/foundry-web-ui";
4
8
  import type { ThemeLayer } from "@iloveagents/foundry-web-ui";
5
- import type { ChatModule, AuthAdapter, ShellPage } from "./types.ts";
9
+ import type { ChatConversationConfig, ChatModule, AuthAdapter, ShellPage } from "./types.ts";
6
10
  import { ShellAuth } from "./auth-default.tsx";
7
11
  import { ShellLayout } from "./shell-layout.tsx";
8
12
  import { defaultAgentFetch } from "./service-fetch-default.ts";
@@ -65,12 +69,22 @@ export function ShellApp({
65
69
 
66
70
  const fetchFn = agentFetch ?? defaultAgentFetch;
67
71
 
72
+ // Aggregate chatConversation configs from all modules. First-match
73
+ // wins at URL-match time (rare to have more than one anyway).
74
+ const chatConversationConfigs = useMemo(
75
+ () => modules.flatMap((m) => (m.chatConversation ? [m.chatConversation] : [])),
76
+ [modules],
77
+ );
78
+
68
79
  return (
69
80
  <BrowserRouter>
70
81
  <ShellAuth adapter={authProvider}>
71
82
  <ComposedWrappers wrappers={wrappers}>
72
- <AGUIRuntimeProvider fetchFn={fetchFn}>
73
- {toolUIs}
83
+ <ChatConversationAwareRuntime
84
+ fetchFn={fetchFn}
85
+ configs={chatConversationConfigs}
86
+ toolUIs={toolUIs}
87
+ >
74
88
  <Suspense fallback={null}>
75
89
  <Routes>
76
90
  <Route element={<ShellLayout modules={modules} baseThemeLayers={baseThemeLayers} />}>
@@ -80,17 +94,248 @@ export function ShellApp({
80
94
  </Route>
81
95
  </Routes>
82
96
  </Suspense>
83
- </AGUIRuntimeProvider>
97
+ </ChatConversationAwareRuntime>
84
98
  </ComposedWrappers>
85
99
  </ShellAuth>
86
100
  </BrowserRouter>
87
101
  );
88
102
  }
89
103
 
104
+ /**
105
+ * Reads the current pathname (only meaningful inside ``<BrowserRouter>``),
106
+ * matches it against any module-declared :type:`ChatConversationConfig`,
107
+ * and hands the matched config + URL-extracted id down into
108
+ * :type:`AGUIRuntimeProvider` as a :type:`AGUIHistoryAdapterFactory`.
109
+ *
110
+ * Why a factory (not a pre-built adapter)
111
+ * =======================================
112
+ * The history adapter needs *both* the URL-extracted conversation id
113
+ * (when present) *and* the AG-UI adapter's freshly-minted thread id
114
+ * (always set — covers fresh chats). The AG-UI adapter only exists
115
+ * once :type:`AGUIRuntimeProvider` mounts, so the factory is invoked
116
+ * inside that mount with both pieces available. This makes "fresh
117
+ * chats also persist" a built-in property of the contract, not an
118
+ * afterthought.
119
+ */
120
+ function ChatConversationAwareRuntime({
121
+ children,
122
+ toolUIs,
123
+ fetchFn,
124
+ configs,
125
+ }: {
126
+ children: ReactNode;
127
+ toolUIs: ReactNode[];
128
+ fetchFn: typeof fetch;
129
+ configs: ChatConversationConfig[];
130
+ }) {
131
+ const { pathname } = useLocation();
132
+
133
+ // Pick the matching config (URL-pattern match) OR fall back to the
134
+ // first config registered — modules without a URL match still want
135
+ // their fresh-chat persistence. With ≥1 config the runtime always
136
+ // gets a factory; without configs it stays in the legacy in-memory
137
+ // mode.
138
+ const { config, urlMatch } = useMemo(() => {
139
+ for (const cfg of configs) {
140
+ const m = pathname.match(cfg.pathPattern);
141
+ if (m && m[1]) {
142
+ return { config: cfg, urlMatch: m[1] };
143
+ }
144
+ }
145
+ return { config: configs[0], urlMatch: undefined as string | undefined };
146
+ }, [pathname, configs]);
147
+
148
+ if (!config) {
149
+ // No chatConversation configs registered → legacy in-memory
150
+ // runtime, no history / no sticky concerns.
151
+ return (
152
+ <AGUIRuntimeProvider fetchFn={fetchFn}>
153
+ {toolUIs}
154
+ {children}
155
+ </AGUIRuntimeProvider>
156
+ );
157
+ }
158
+
159
+ // Per-config child component, KEYED by config identity. Why a
160
+ // key/child split: ``config.useStickyConversationId`` is optional.
161
+ // Calling it inline with the optional chain ``config?.useSticky?.()``
162
+ // would be a Rules-of-Hooks violation the moment a second
163
+ // ChatConversationConfig registers with different hook-shape — URL
164
+ // navigation flips which cfg matches, the hook count flips, React
165
+ // crashes. The key makes a config swap a full remount, which is a
166
+ // legitimate way to change the hook lineage without violating the
167
+ // rules. ``configIndex`` is stable per config because ``configs`` is
168
+ // memoised in ShellApp.
169
+ const configIndex = configs.indexOf(config);
170
+ if (config.useStickyConversationId) {
171
+ return (
172
+ <StickyAwareRuntime
173
+ key={`sticky:${configIndex}`}
174
+ config={config}
175
+ useSticky={config.useStickyConversationId}
176
+ urlMatch={urlMatch}
177
+ fetchFn={fetchFn}
178
+ toolUIs={toolUIs}
179
+ >
180
+ {children}
181
+ </StickyAwareRuntime>
182
+ );
183
+ }
184
+ return (
185
+ <UrlOnlyRuntime
186
+ key={`url:${configIndex}`}
187
+ config={config}
188
+ urlMatch={urlMatch}
189
+ fetchFn={fetchFn}
190
+ toolUIs={toolUIs}
191
+ >
192
+ {children}
193
+ </UrlOnlyRuntime>
194
+ );
195
+ }
196
+
197
+ interface ChildRuntimeProps {
198
+ config: ChatConversationConfig;
199
+ urlMatch: string | undefined;
200
+ fetchFn: typeof fetch;
201
+ toolUIs: ReactNode[];
202
+ children: ReactNode;
203
+ }
204
+
205
+ /**
206
+ * Branch for configs that DECLARE :attr:`useStickyConversationId`.
207
+ * Calls the hook unconditionally so React's hook-count invariant
208
+ * holds across every render of this mount.
209
+ */
210
+ function StickyAwareRuntime({
211
+ config,
212
+ useSticky,
213
+ urlMatch,
214
+ fetchFn,
215
+ toolUIs,
216
+ children,
217
+ }: ChildRuntimeProps & { useSticky: () => string | null }) {
218
+ const sticky = useSticky();
219
+ return (
220
+ <RuntimeBody
221
+ config={config}
222
+ sticky={sticky}
223
+ urlMatch={urlMatch}
224
+ fetchFn={fetchFn}
225
+ toolUIs={toolUIs}
226
+ >
227
+ {children}
228
+ </RuntimeBody>
229
+ );
230
+ }
231
+
232
+ /**
233
+ * Branch for configs that DO NOT declare :attr:`useStickyConversationId`.
234
+ * No hook is ever called for sticky state — the runtime threadId
235
+ * follows URL match and the shell-minted fresh UUID only.
236
+ */
237
+ function UrlOnlyRuntime({ config, urlMatch, fetchFn, toolUIs, children }: ChildRuntimeProps) {
238
+ return (
239
+ <RuntimeBody
240
+ config={config}
241
+ sticky={null}
242
+ urlMatch={urlMatch}
243
+ fetchFn={fetchFn}
244
+ toolUIs={toolUIs}
245
+ >
246
+ {children}
247
+ </RuntimeBody>
248
+ );
249
+ }
250
+
251
+ /**
252
+ * Shared rendering body — same useRef/useMemo/useCallback shape for
253
+ * both branches. The hook lineage here is stable per mount because
254
+ * the parent decides which branch (and which key) to use.
255
+ */
256
+ function RuntimeBody({
257
+ config,
258
+ sticky,
259
+ urlMatch,
260
+ fetchFn,
261
+ toolUIs,
262
+ children,
263
+ }: ChildRuntimeProps & { sticky: string | null }) {
264
+ // Pre-mint a fresh UUID so the runtime's ``threadId`` is set from
265
+ // the very first render — never goes through ``undefined → defined``,
266
+ // which would otherwise force a remount of the assistant-ui runtime
267
+ // (and wipe in-flight messages) the moment the user sent their first
268
+ // message on a fresh chat. The backend collapse (``conversation_id ==
269
+ // agui_thread_id``) ensures the id the runtime uses from the start
270
+ // matches the row the lazy ``ensureConversationId`` creates — same
271
+ // value, no mid-conversation switch.
272
+ //
273
+ // Mint a new one only when the user actually starts a new thread:
274
+ // ``sticky`` transitions from a value back to ``null`` (the module
275
+ // clears it on the ``/`` "New Thread" landing).
276
+ const freshIdRef = useRef<string>(_genUuid());
277
+ const prevStickyRef = useRef<string | null>(sticky);
278
+ if (sticky === null && prevStickyRef.current !== null) {
279
+ // User just navigated back to "/" (New Thread). The previous chat
280
+ // is being abandoned; mint a fresh UUID for the next session.
281
+ freshIdRef.current = _genUuid();
282
+ }
283
+ prevStickyRef.current = sticky;
284
+
285
+ // Effective id seen by AGUIRuntimeProvider — always defined.
286
+ // sticky (current conversation) wins, else fall back to the
287
+ // shell-minted UUID for fresh chats.
288
+ const effectiveThreadId: string = sticky ?? urlMatch ?? freshIdRef.current;
289
+
290
+ const historyAdapterFactory = useCallback<AGUIHistoryAdapterFactory>(
291
+ (args: AGUIChatConversationFactoryArgs) =>
292
+ config.buildHistoryAdapter({
293
+ urlMatch: args.urlMatch,
294
+ aguiThreadId: args.aguiThreadId,
295
+ }),
296
+ [config],
297
+ );
298
+
299
+ return (
300
+ <AGUIRuntimeProvider
301
+ fetchFn={fetchFn}
302
+ threadId={effectiveThreadId}
303
+ // ``urlMatch`` carries the URL-derived resume signal verbatim.
304
+ // ``effectiveThreadId`` is always defined (sticky / urlMatch /
305
+ // freshly minted UUID) so passing it in place of ``urlMatch``
306
+ // would erase the "fresh vs resumed" distinction the history
307
+ // adapter needs to decide whether to load() past messages.
308
+ urlMatch={urlMatch}
309
+ historyAdapterFactory={historyAdapterFactory}
310
+ >
311
+ {toolUIs}
312
+ {children}
313
+ </AGUIRuntimeProvider>
314
+ );
315
+ }
316
+
90
317
  function ToolUIGroup({ children }: { children: ReactNode }) {
91
318
  return <>{children}</>;
92
319
  }
93
320
 
321
+ /**
322
+ * RFC 4122 v4 UUID. Use ``crypto.randomUUID`` when available; fall
323
+ * back to a math-random shim for the rare environments without it
324
+ * (older Safari on non-HTTPS dev hosts). The shim is fine for our
325
+ * use — these UUIDs identify a conversation row in our backend; they
326
+ * don't need cryptographic strength.
327
+ */
328
+ function _genUuid(): string {
329
+ if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {
330
+ return crypto.randomUUID();
331
+ }
332
+ return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, (c) => {
333
+ const r = (Math.random() * 16) | 0;
334
+ const v = c === "x" ? r : (r & 0x3) | 0x8;
335
+ return v.toString(16);
336
+ });
337
+ }
338
+
94
339
  interface ComposedWrappersProps {
95
340
  wrappers: React.ComponentType<{ children: ReactNode }>[];
96
341
  children: ReactNode;
package/src/types.ts CHANGED
@@ -1,6 +1,86 @@
1
1
  import type { ComponentType, ReactNode } from "react";
2
2
  import type { ThemeLayer } from "@iloveagents/foundry-web-ui";
3
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
+
4
84
  /** Route entry contributed by a module or the host app. */
5
85
  export interface ShellPage {
6
86
  path: string;
@@ -38,6 +118,27 @@ export interface ChatModule {
38
118
  useThemeLayers?: () => ThemeLayer[];
39
119
  pages?: ShellPage[];
40
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;
41
142
  }
42
143
 
43
144
  /**