@noodleseed/assistant 1.8.0 → 1.9.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,30 @@ 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="dark"
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.
273
+
250
274
  Outside React, subscribe to the DOM-free AI SDK `UIMessage` state without registering a custom element or
251
275
  touching browser storage:
252
276
 
@@ -331,11 +355,13 @@ leaves the same input interaction pending so the renderer can submit a corrected
331
355
  flow collects all elicited input before its first connector operation.
332
356
 
333
357
  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:
358
+ `ui://` resource identity, optional title, bounded/redacted public result, and—on current services—the
359
+ self-contained App document. This is an availability signal, not proof of rendering. A customer-owned React
360
+ renderer either mounts the actual App with `NoodleAppView` or deliberately substitutes a component already
361
+ trusted by the application and selected by `resourceUri`/tool. The JSON `result` is data for a native
362
+ component; serializing it is not a rendering of the linked App. Never fetch the `ui://` URI, inject
363
+ `part.data.html`, or assign it to `srcdoc` yourself. The standard element also forwards the same detail as a
364
+ DOM event:
339
365
 
340
366
  ```ts
341
367
  element.addEventListener("assistant-view-available", (event) => {
@@ -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,11 @@ 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 destroyPromise;
3104
3110
  const renderTimer = setTimeout(() => {
3105
3111
  if (initialized || !frame.isConnected) return;
3106
3112
  const note = document.createElement("div");
@@ -3112,6 +3118,7 @@ function mountAssistantApp(detail, actions, options = {}) {
3112
3118
  frame.addEventListener(
3113
3119
  "load",
3114
3120
  () => {
3121
+ if (destroyed) return;
3115
3122
  const target = frame.contentWindow;
3116
3123
  if (!target) return;
3117
3124
  const bridge = new DX(
@@ -3128,19 +3135,24 @@ function mountAssistantApp(detail, actions, options = {}) {
3128
3135
  hostContext: createAssistantAppHostContext(actions.theme)
3129
3136
  }
3130
3137
  );
3138
+ mountedBridge = bridge;
3131
3139
  bridge.onsandboxready = () => {
3140
+ if (destroyed) return;
3141
+ const current = options.getDetail?.() ?? detail;
3132
3142
  void bridge.sendSandboxResourceReady({
3133
- html: detail.html,
3143
+ html: current.html,
3134
3144
  sandbox: "allow-scripts allow-forms",
3135
- ...detail.resourceMeta ?? {}
3145
+ ...current.resourceMeta ?? {}
3136
3146
  });
3137
3147
  };
3138
3148
  bridge.onsizechange = ({ height }) => {
3149
+ if (destroyed) return;
3139
3150
  if (typeof height === "number" && Number.isFinite(height)) {
3140
3151
  frame.style.height = `${Math.min(1200, Math.max(120, Math.ceil(height)))}px`;
3141
3152
  }
3142
3153
  };
3143
3154
  bridge.onmessage = async ({ content }) => {
3155
+ if (destroyed) return {};
3144
3156
  const text2 = content.flatMap(
3145
3157
  (part) => part.type === "text" && typeof part.text === "string" ? [part.text] : []
3146
3158
  ).join("\n").trim();
@@ -3148,13 +3160,16 @@ function mountAssistantApp(detail, actions, options = {}) {
3148
3160
  return {};
3149
3161
  };
3150
3162
  bridge.onupdatemodelcontext = async (update) => {
3163
+ if (destroyed) return {};
3151
3164
  actions.updateModelContext(update);
3152
3165
  return {};
3153
3166
  };
3154
3167
  bridge.onopenlink = async ({ url }) => {
3168
+ if (destroyed) return { isError: true };
3169
+ const current = options.getDetail?.() ?? detail;
3155
3170
  const allowed = isAllowedAppLink(
3156
3171
  url,
3157
- detail.allowedOpenDomains ?? [],
3172
+ current.allowedOpenDomains ?? [],
3158
3173
  globalThis.location?.href ?? "https://invalid.example"
3159
3174
  );
3160
3175
  if (!allowed) return { isError: true };
@@ -3163,30 +3178,74 @@ function mountAssistantApp(detail, actions, options = {}) {
3163
3178
  };
3164
3179
  bridge.onrequestdisplaymode = async ({ mode }) => {
3165
3180
  const accepted = mode === "fullscreen" ? "fullscreen" : "inline";
3181
+ if (destroyed) return { mode: accepted };
3166
3182
  card.toggleAttribute("data-fullscreen", accepted === "fullscreen");
3167
3183
  return { mode: accepted };
3168
3184
  };
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);
3185
+ bridge.oncalltool = async (params) => {
3186
+ if (destroyed) {
3187
+ return {
3188
+ content: [{ type: "text", text: "App view is closing." }],
3189
+ isError: true
3190
+ };
3191
+ }
3192
+ return await actions.client.requestApp("tools/call", params);
3193
+ };
3194
+ bridge.onlistresources = async (params) => {
3195
+ if (destroyed) {
3196
+ return { resources: [] };
3197
+ }
3198
+ return await actions.client.requestApp("resources/list", params ?? {});
3199
+ };
3200
+ bridge.onreadresource = async (params) => {
3201
+ if (destroyed) {
3202
+ return { contents: [] };
3203
+ }
3204
+ return await actions.client.requestApp("resources/read", params);
3205
+ };
3172
3206
  bridge.oninitialized = () => {
3207
+ if (destroyed) return;
3173
3208
  initialized = true;
3174
3209
  clearTimeout(renderTimer);
3175
- void bridge.sendToolInput({ arguments: detail.arguments ?? {} });
3210
+ const current = options.getDetail?.() ?? detail;
3211
+ void bridge.sendToolInput({ arguments: current.arguments ?? {} });
3176
3212
  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 } : {}
3213
+ content: [{ type: "text", text: JSON.stringify(current.result) }],
3214
+ ...typeof current.result === "object" && current.result !== null && !Array.isArray(current.result) ? { structuredContent: current.result } : {}
3179
3215
  });
3180
3216
  };
3181
- void bridge.connect(new E(target, target)).catch(() => {
3182
- clearTimeout(renderTimer);
3183
- card.remove();
3217
+ const transport = new E(target, target);
3218
+ mountedTransport = transport;
3219
+ void bridge.connect(transport).catch(() => {
3220
+ void destroy();
3184
3221
  });
3185
3222
  },
3186
3223
  { once: true }
3187
3224
  );
3225
+ const destroy = () => {
3226
+ if (destroyPromise) return destroyPromise;
3227
+ destroyed = true;
3228
+ clearTimeout(renderTimer);
3229
+ destroyPromise = (async () => {
3230
+ try {
3231
+ if (initialized) {
3232
+ await mountedBridge?.teardownResource({}, { timeout: APP_TEARDOWN_TIMEOUT_MS });
3233
+ }
3234
+ } catch {
3235
+ }
3236
+ await mountedTransport?.close().catch(() => {
3237
+ });
3238
+ card.remove();
3239
+ mountedApps.delete(card);
3240
+ })();
3241
+ return destroyPromise;
3242
+ };
3243
+ mountedApps.set(card, { destroy });
3188
3244
  return card;
3189
3245
  }
3246
+ function unmountAssistantApp(card) {
3247
+ return mountedApps.get(card)?.destroy() ?? Promise.resolve();
3248
+ }
3190
3249
 
3191
3250
  // src/appearance.ts
3192
3251
  var common = {
@@ -8136,6 +8195,8 @@ function registerNoodleAssistant() {
8136
8195
  }
8137
8196
 
8138
8197
  export {
8198
+ mountAssistantApp,
8199
+ unmountAssistantApp,
8139
8200
  ASSISTANT_TAG_NAME,
8140
8201
  NoodleAssistantElement,
8141
8202
  registerNoodleAssistant
@@ -8145,4 +8206,4 @@ export {
8145
8206
  dompurify/dist/purify.es.mjs:
8146
8207
  (*! @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
8208
  */
8148
- //# sourceMappingURL=chunk-JMBUJSFP.js.map
8209
+ //# sourceMappingURL=chunk-4B3XXGRP.js.map