@noodleseed/assistant 1.8.0 → 1.10.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 CHANGED
@@ -2,8 +2,8 @@
2
2
 
3
3
  Customer-branded embedded assistant surfaces for Noodle Seed deployments.
4
4
 
5
- The package exports the canonical `<noodle-assistant>` Web Component, a managed React wrapper from
6
- `@noodleseed/assistant/react`, a renderer-free React hook from
5
+ The package exports the canonical `<noodle-assistant>` Web Component, a managed React wrapper and secure
6
+ `NoodleAppView` MCP App renderer from `@noodleseed/assistant/react`, a renderer-free React hook from
7
7
  `@noodleseed/assistant/react/client`, a DOM-free client from `@noodleseed/assistant/client`, and the
8
8
  backend-only `createAssistantSession` helper from `@noodleseed/assistant/server`. Light, dark, and automatic
9
9
  themes work without configuration; the component inherits the deployed MCP server's brand kit while slots,
@@ -247,6 +247,32 @@ interaction streams a continuation. `principalKey` is browser-local and never se
247
247
  whenever the authenticated user or tenant changes so the hook aborts and clears the previous session and
248
248
  transcript.
249
249
 
250
+ When the customer-owned transcript should render the linked MCP App itself, pass the typed `data-view`
251
+ payload and the same client to the supported host component:
252
+
253
+ ```tsx
254
+ import { NoodleAppView } from "@noodleseed/assistant/react";
255
+
256
+ if (part.type === "data-view") {
257
+ return (
258
+ <NoodleAppView
259
+ key={`${part.data.id}:${part.data.resourceUri}`}
260
+ client={client}
261
+ view={part.data}
262
+ theme={resolvedTheme}
263
+ onError={(failure) => reportAssistantError(failure)}
264
+ />
265
+ );
266
+ }
267
+ ```
268
+
269
+ `NoodleAppView` owns the double iframe and AppBridge. Its lifecycle identity is the supplied client plus
270
+ `view.id` plus `view.resourceUri`: ordinary parent rerenders and fresh view/callback objects retain the
271
+ iframe, while a semantic view replacement or unmount requests standard App teardown and closes the bridge.
272
+ Do not also key an ancestor by the whole view object or a callback. Pass the embedding application's
273
+ resolved `"light"` or `"dark"` theme; later changes are published through MCP Apps host context without
274
+ replacing the iframe.
275
+
250
276
  Outside React, subscribe to the DOM-free AI SDK `UIMessage` state without registering a custom element or
251
277
  touching browser storage:
252
278
 
@@ -331,11 +357,13 @@ leaves the same input interaction pending so the renderer can submit a corrected
331
357
  flow collects all elicited input before its first connector operation.
332
358
 
333
359
  A completed widget-linked tool emits typed `view_available` data with its call/interaction id, tool,
334
- `ui://` resource identity, optional title, and bounded/redacted public result. This is an availability
335
- signal, not proof of rendering. Map the identity to a component already trusted by your application; never
336
- fetch the `ui://` URI, inject `part.data.html`, or assign it to `srcdoc`. The standard element alone mounts
337
- the service-supplied document through its double-iframe MCP Apps host, and it also forwards the same detail
338
- as a DOM event:
360
+ `ui://` resource identity, optional title, bounded/redacted public result, and—on current services—the
361
+ self-contained App document. This is an availability signal, not proof of rendering. A customer-owned React
362
+ renderer either mounts the actual App with `NoodleAppView` or deliberately substitutes a component already
363
+ trusted by the application and selected by `resourceUri`/tool. The JSON `result` is data for a native
364
+ component; serializing it is not a rendering of the linked App. Never fetch the `ui://` URI, inject
365
+ `part.data.html`, or assign it to `srcdoc` yourself. The standard element also forwards the same detail as a
366
+ DOM event:
339
367
 
340
368
  ```ts
341
369
  element.addEventListener("assistant-view-available", (event) => {
@@ -367,10 +395,19 @@ exact control over assistant-specific roles:
367
395
  ```tsx
368
396
  <NoodleAssistant
369
397
  sessionEndpoint="/api/assistant/session"
398
+ theme={resolvedTheme}
370
399
  appearance={{
371
400
  light: {
372
- panel: { surface: "#FFFFFF", text: "#101828", border: "#E4E7EC" },
373
- composer: { surface: "#F9FAFB", text: "#101828", border: "#D0D5DD" },
401
+ panel: {
402
+ surface: "var(--app-surface)",
403
+ text: "var(--app-text)",
404
+ border: "var(--app-border)",
405
+ },
406
+ composer: {
407
+ surface: "var(--app-input)",
408
+ text: "var(--app-text)",
409
+ border: "var(--app-border)",
410
+ },
374
411
  confirmation: { surface: "#F8FAFC", text: "#101828", border: "#CBD5E1" },
375
412
  primaryButton: { surface: "#635BFF", text: "#FFFFFF" },
376
413
  },
@@ -379,11 +416,38 @@ exact control over assistant-specific roles:
379
416
  />
380
417
  ```
381
418
 
382
- The same object is available as `element.appearance`. It covers canvas, panel, header, assistant/user
383
- messages, composer, suggestions, confirmation, primary/secondary buttons, launcher, code, and MCP App frame
384
- roles in light and dark modes. Exact colors are preserved; insufficient contrast emits the typed
385
- `assistant-appearance-warning` event. Stable public CSS variables remain available for stylesheet-based
386
- integration:
419
+ The same object is available as `element.appearance`. CSS custom properties inherit through the assistant
420
+ host into its shadow tree, so `var(--app-token)` references reuse the embedding application's existing
421
+ tokens without copying literal colors. Exact parseable literal colors are preserved and checked;
422
+ insufficient contrast emits the typed `assistant-appearance-warning` event. Contrast for unresolved CSS
423
+ references remains the host application's responsibility.
424
+
425
+ The complete typed appearance role map is:
426
+
427
+ | Appearance role | Public CSS custom properties |
428
+ | --- | --- |
429
+ | `canvas` | `--ns-assistant-canvas` |
430
+ | `text` | `--ns-assistant-text` |
431
+ | `mutedText` | `--ns-assistant-muted-text` |
432
+ | `link` | `--ns-assistant-link` |
433
+ | `focus` | `--ns-assistant-focus` |
434
+ | `success` | `--ns-assistant-success` |
435
+ | `warning` | `--ns-assistant-warning` |
436
+ | `danger` | `--ns-assistant-danger` |
437
+ | `panel` | `--ns-assistant-panel`, `--ns-assistant-panel-text`, `--ns-assistant-panel-border` |
438
+ | `header` | `--ns-assistant-header`, `--ns-assistant-header-text`, `--ns-assistant-header-border` |
439
+ | `assistantMessage` | `--ns-assistant-assistant-message`, `--ns-assistant-assistant-message-text`, `--ns-assistant-assistant-message-border` |
440
+ | `userMessage` | `--ns-assistant-user-message`, `--ns-assistant-user-message-text`, `--ns-assistant-user-message-border` |
441
+ | `composer` | `--ns-assistant-composer`, `--ns-assistant-composer-text`, `--ns-assistant-composer-border` |
442
+ | `suggestion` | `--ns-assistant-suggestion`, `--ns-assistant-suggestion-text`, `--ns-assistant-suggestion-border` |
443
+ | `confirmation` | `--ns-assistant-confirmation`, `--ns-assistant-confirmation-text`, `--ns-assistant-confirmation-border` |
444
+ | `primaryButton` | `--ns-assistant-primary-button`, `--ns-assistant-primary-button-text`, `--ns-assistant-primary-button-border` |
445
+ | `secondaryButton` | `--ns-assistant-secondary-button`, `--ns-assistant-secondary-button-text`, `--ns-assistant-secondary-button-border` |
446
+ | `launcher` | `--ns-assistant-launcher`, `--ns-assistant-launcher-text`, `--ns-assistant-launcher-border` |
447
+ | `code` | `--ns-assistant-code`, `--ns-assistant-code-text`, `--ns-assistant-code-border` |
448
+ | `app` | `--ns-assistant-app`, `--ns-assistant-app-text`, `--ns-assistant-app-border` |
449
+
450
+ Stable public CSS variables also remain available directly for stylesheet-based integration:
387
451
 
388
452
  ```css
389
453
  noodle-assistant {
@@ -401,6 +465,11 @@ defaults. The public slots are `launcher-icon`,
401
465
  embedding-page integration API, not a way to put HTML or callbacks in deployment configuration. Internal
402
466
  shadow-DOM selectors and classes are not public API.
403
467
 
468
+ `theme="auto"` follows the browser's operating-system color preference. When the embedding application has
469
+ its own theme toggle, pass its resolved `"light"` or `"dark"` value to `NoodleAssistant` and every
470
+ `NoodleAppView`. Updates change the assistant in place and notify mounted MCP Apps through standard host
471
+ context.
472
+
404
473
  Server branding controls customer name, themed logo/mark/avatar assets, semantic light/dark colors, density,
405
474
  radius, typography, and automatic theme. `embeddedAssistant(...)` controls floating/inline/drawer layout,
406
475
  position and dimensions, mobile and launcher/header/avatar/timestamp behavior, visible labels, suggested
@@ -133,4 +133,4 @@ interface AssistantLabels {
133
133
  readonly sessionError: string;
134
134
  }
135
135
 
136
- export type { AssistantThemeMode as A, AssistantConfiguration as a };
136
+ export type { AssistantConfiguration as A, AssistantThemeMode as a };
@@ -133,4 +133,4 @@ interface AssistantLabels {
133
133
  readonly sessionError: string;
134
134
  }
135
135
 
136
- export type { AssistantThemeMode as A, AssistantConfiguration as a };
136
+ export type { AssistantConfiguration as A, AssistantThemeMode as a };
@@ -3072,6 +3072,8 @@ function createAssistantAppHostContext(theme) {
3072
3072
  };
3073
3073
  }
3074
3074
  var APP_RENDER_TIMEOUT_MS = 1e4;
3075
+ var APP_TEARDOWN_TIMEOUT_MS = 1e3;
3076
+ var mountedApps = /* @__PURE__ */ new WeakMap();
3075
3077
  function resolveSandboxUrl(value) {
3076
3078
  if (!value) return void 0;
3077
3079
  try {
@@ -3100,7 +3102,12 @@ function mountAssistantApp(detail, actions, options = {}) {
3100
3102
  if (sandboxUrl) frame.src = sandboxUrl;
3101
3103
  else frame.srcdoc = PROXY_DOCUMENT;
3102
3104
  card.append(frame);
3105
+ let mountedBridge;
3106
+ let mountedTransport;
3107
+ let destroyed = false;
3103
3108
  let initialized = false;
3109
+ let hostContext = createAssistantAppHostContext(actions.theme);
3110
+ let destroyPromise;
3104
3111
  const renderTimer = setTimeout(() => {
3105
3112
  if (initialized || !frame.isConnected) return;
3106
3113
  const note = document.createElement("div");
@@ -3112,6 +3119,7 @@ function mountAssistantApp(detail, actions, options = {}) {
3112
3119
  frame.addEventListener(
3113
3120
  "load",
3114
3121
  () => {
3122
+ if (destroyed) return;
3115
3123
  const target = frame.contentWindow;
3116
3124
  if (!target) return;
3117
3125
  const bridge = new DX(
@@ -3125,22 +3133,27 @@ function mountAssistantApp(detail, actions, options = {}) {
3125
3133
  message: { text: {} }
3126
3134
  },
3127
3135
  {
3128
- hostContext: createAssistantAppHostContext(actions.theme)
3136
+ hostContext
3129
3137
  }
3130
3138
  );
3139
+ mountedBridge = bridge;
3131
3140
  bridge.onsandboxready = () => {
3141
+ if (destroyed) return;
3142
+ const current = options.getDetail?.() ?? detail;
3132
3143
  void bridge.sendSandboxResourceReady({
3133
- html: detail.html,
3144
+ html: current.html,
3134
3145
  sandbox: "allow-scripts allow-forms",
3135
- ...detail.resourceMeta ?? {}
3146
+ ...current.resourceMeta ?? {}
3136
3147
  });
3137
3148
  };
3138
3149
  bridge.onsizechange = ({ height }) => {
3150
+ if (destroyed) return;
3139
3151
  if (typeof height === "number" && Number.isFinite(height)) {
3140
3152
  frame.style.height = `${Math.min(1200, Math.max(120, Math.ceil(height)))}px`;
3141
3153
  }
3142
3154
  };
3143
3155
  bridge.onmessage = async ({ content }) => {
3156
+ if (destroyed) return {};
3144
3157
  const text2 = content.flatMap(
3145
3158
  (part) => part.type === "text" && typeof part.text === "string" ? [part.text] : []
3146
3159
  ).join("\n").trim();
@@ -3148,13 +3161,16 @@ function mountAssistantApp(detail, actions, options = {}) {
3148
3161
  return {};
3149
3162
  };
3150
3163
  bridge.onupdatemodelcontext = async (update) => {
3164
+ if (destroyed) return {};
3151
3165
  actions.updateModelContext(update);
3152
3166
  return {};
3153
3167
  };
3154
3168
  bridge.onopenlink = async ({ url }) => {
3169
+ if (destroyed) return { isError: true };
3170
+ const current = options.getDetail?.() ?? detail;
3155
3171
  const allowed = isAllowedAppLink(
3156
3172
  url,
3157
- detail.allowedOpenDomains ?? [],
3173
+ current.allowedOpenDomains ?? [],
3158
3174
  globalThis.location?.href ?? "https://invalid.example"
3159
3175
  );
3160
3176
  if (!allowed) return { isError: true };
@@ -3163,30 +3179,83 @@ function mountAssistantApp(detail, actions, options = {}) {
3163
3179
  };
3164
3180
  bridge.onrequestdisplaymode = async ({ mode }) => {
3165
3181
  const accepted = mode === "fullscreen" ? "fullscreen" : "inline";
3182
+ if (destroyed) return { mode: accepted };
3166
3183
  card.toggleAttribute("data-fullscreen", accepted === "fullscreen");
3167
3184
  return { mode: accepted };
3168
3185
  };
3169
- bridge.oncalltool = async (params) => await actions.client.requestApp("tools/call", params);
3170
- bridge.onlistresources = async (params) => await actions.client.requestApp("resources/list", params ?? {});
3171
- bridge.onreadresource = async (params) => await actions.client.requestApp("resources/read", params);
3186
+ bridge.oncalltool = async (params) => {
3187
+ if (destroyed) {
3188
+ return {
3189
+ content: [{ type: "text", text: "App view is closing." }],
3190
+ isError: true
3191
+ };
3192
+ }
3193
+ return await actions.client.requestApp("tools/call", params);
3194
+ };
3195
+ bridge.onlistresources = async (params) => {
3196
+ if (destroyed) {
3197
+ return { resources: [] };
3198
+ }
3199
+ return await actions.client.requestApp("resources/list", params ?? {});
3200
+ };
3201
+ bridge.onreadresource = async (params) => {
3202
+ if (destroyed) {
3203
+ return { contents: [] };
3204
+ }
3205
+ return await actions.client.requestApp("resources/read", params);
3206
+ };
3172
3207
  bridge.oninitialized = () => {
3208
+ if (destroyed) return;
3173
3209
  initialized = true;
3174
3210
  clearTimeout(renderTimer);
3175
- void bridge.sendToolInput({ arguments: detail.arguments ?? {} });
3211
+ bridge.setHostContext(hostContext);
3212
+ const current = options.getDetail?.() ?? detail;
3213
+ void bridge.sendToolInput({ arguments: current.arguments ?? {} });
3176
3214
  void bridge.sendToolResult({
3177
- content: [{ type: "text", text: JSON.stringify(detail.result) }],
3178
- ...typeof detail.result === "object" && detail.result !== null && !Array.isArray(detail.result) ? { structuredContent: detail.result } : {}
3215
+ content: [{ type: "text", text: JSON.stringify(current.result) }],
3216
+ ...typeof current.result === "object" && current.result !== null && !Array.isArray(current.result) ? { structuredContent: current.result } : {}
3179
3217
  });
3180
3218
  };
3181
- void bridge.connect(new E(target, target)).catch(() => {
3182
- clearTimeout(renderTimer);
3183
- card.remove();
3219
+ const transport = new E(target, target);
3220
+ mountedTransport = transport;
3221
+ void bridge.connect(transport).catch(() => {
3222
+ void destroy();
3184
3223
  });
3185
3224
  },
3186
3225
  { once: true }
3187
3226
  );
3227
+ const updateTheme = (theme) => {
3228
+ if (destroyed) return;
3229
+ hostContext = createAssistantAppHostContext(theme);
3230
+ if (initialized) mountedBridge?.setHostContext(hostContext);
3231
+ };
3232
+ const destroy = () => {
3233
+ if (destroyPromise) return destroyPromise;
3234
+ destroyed = true;
3235
+ clearTimeout(renderTimer);
3236
+ destroyPromise = (async () => {
3237
+ try {
3238
+ if (initialized) {
3239
+ await mountedBridge?.teardownResource({}, { timeout: APP_TEARDOWN_TIMEOUT_MS });
3240
+ }
3241
+ } catch {
3242
+ }
3243
+ await mountedTransport?.close().catch(() => {
3244
+ });
3245
+ card.remove();
3246
+ mountedApps.delete(card);
3247
+ })();
3248
+ return destroyPromise;
3249
+ };
3250
+ mountedApps.set(card, { destroy, updateTheme });
3188
3251
  return card;
3189
3252
  }
3253
+ function updateAssistantAppTheme(card, theme) {
3254
+ mountedApps.get(card)?.updateTheme(theme);
3255
+ }
3256
+ function unmountAssistantApp(card) {
3257
+ return mountedApps.get(card)?.destroy() ?? Promise.resolve();
3258
+ }
3190
3259
 
3191
3260
  // src/appearance.ts
3192
3261
  var common = {
@@ -7109,7 +7178,7 @@ function contrastWarnings(theme, mode) {
7109
7178
  for (const [role, foreground, background] of pairs) {
7110
7179
  if (!foreground || !background) continue;
7111
7180
  const ratio = contrastRatio2(foreground, background);
7112
- if (ratio >= 4.5) continue;
7181
+ if (ratio === void 0 || ratio >= 4.5) continue;
7113
7182
  warnings.push({
7114
7183
  code: "low_contrast",
7115
7184
  theme: mode,
@@ -7123,13 +7192,16 @@ function contrastWarnings(theme, mode) {
7123
7192
  return warnings;
7124
7193
  }
7125
7194
  function contrastRatio2(first, second) {
7126
- const lighter = Math.max(luminance(first), luminance(second));
7127
- const darker = Math.min(luminance(first), luminance(second));
7195
+ const firstLuminance = luminance(first);
7196
+ const secondLuminance = luminance(second);
7197
+ if (firstLuminance === void 0 || secondLuminance === void 0) return void 0;
7198
+ const lighter = Math.max(firstLuminance, secondLuminance);
7199
+ const darker = Math.min(firstLuminance, secondLuminance);
7128
7200
  return (lighter + 0.05) / (darker + 0.05);
7129
7201
  }
7130
7202
  function luminance(color) {
7131
7203
  const match = /^#([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})$/i.exec(color);
7132
- if (!match) return 1;
7204
+ if (!match) return void 0;
7133
7205
  const values = match.slice(1).map((value) => {
7134
7206
  const channel = Number.parseInt(value, 16) / 255;
7135
7207
  return channel <= 0.04045 ? channel / 12.92 : ((channel + 0.055) / 1.055) ** 2.4;
@@ -7962,6 +8034,9 @@ var NoodleAssistantElement = class extends HTMLElementBase {
7962
8034
  })
7963
8035
  );
7964
8036
  }
8037
+ for (const app of this.shadowRoot?.querySelectorAll(".noodle-app-card") ?? []) {
8038
+ updateAssistantAppTheme(app, mode);
8039
+ }
7965
8040
  }
7966
8041
  #setSessionState(state) {
7967
8042
  this.#sessionState = state;
@@ -8136,6 +8211,9 @@ function registerNoodleAssistant() {
8136
8211
  }
8137
8212
 
8138
8213
  export {
8214
+ mountAssistantApp,
8215
+ updateAssistantAppTheme,
8216
+ unmountAssistantApp,
8139
8217
  ASSISTANT_TAG_NAME,
8140
8218
  NoodleAssistantElement,
8141
8219
  registerNoodleAssistant
@@ -8145,4 +8223,4 @@ export {
8145
8223
  dompurify/dist/purify.es.mjs:
8146
8224
  (*! @license DOMPurify 3.4.11 | (c) Cure53 and other contributors | Released under the Apache license 2.0 and Mozilla Public License 2.0 | github.com/cure53/DOMPurify/blob/3.4.11/LICENSE *)
8147
8225
  */
8148
- //# sourceMappingURL=chunk-JMBUJSFP.js.map
8226
+ //# sourceMappingURL=chunk-RSNMY27B.js.map