@runtypelabs/persona 4.33.0 → 4.35.1

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.
Files changed (220) hide show
  1. package/dist/activity-ui.d.cts +3 -15
  2. package/dist/activity-ui.d.ts +3 -15
  3. package/dist/animations/glyph-cycle.d.cts +1 -3
  4. package/dist/animations/glyph-cycle.d.ts +1 -3
  5. package/dist/animations/wipe.d.cts +1 -3
  6. package/dist/animations/wipe.d.ts +1 -3
  7. package/dist/approval-ui.cjs +2 -2
  8. package/dist/approval-ui.d.cts +3 -15
  9. package/dist/approval-ui.d.ts +3 -15
  10. package/dist/approval-ui.js +1 -1
  11. package/dist/artifacts-ui.cjs +1 -1
  12. package/dist/artifacts-ui.d.cts +3 -21
  13. package/dist/artifacts-ui.d.ts +3 -21
  14. package/dist/background-runtime.d.cts +2 -3
  15. package/dist/background-runtime.d.ts +2 -3
  16. package/dist/{chunk-DW7Vg_l2.js → chunk-B1P8w9_X.js} +1 -1
  17. package/dist/{chunk-D6U0E82p.js → chunk-B30Jdgg6.js} +1 -1
  18. package/dist/{chunk-gu34lr-P.js → chunk-Brdx4NCz.js} +1 -1
  19. package/dist/{chunk-6CUrH_jm.js → chunk-Bzo9GaAi.js} +1 -1
  20. package/dist/{chunk-Ds_twaZn.js → chunk-C4v-HNhS.js} +1 -1
  21. package/dist/{chunk-Bkma5hIU.js → chunk-CE4seJxF.js} +1 -1
  22. package/dist/chunk-CNBMBx-Y.js +2 -0
  23. package/dist/{chunk-ql5N2I_t.js → chunk-CWzHNuVK.js} +1 -1
  24. package/dist/{chunk-DbGLjU2P.js → chunk-CYpt7PsO.js} +1 -1
  25. package/dist/chunk-Cd8mp8Sv.js +1 -0
  26. package/dist/chunk-CsfE8WRG.js +1 -0
  27. package/dist/{chunk-DOZmcve8.js → chunk-Csr59G5V.js} +1 -1
  28. package/dist/{chunk-Co3VVwGe.js → chunk-D2pHRNEq.js} +0 -0
  29. package/dist/chunk-D3kZO8u-.js +1 -0
  30. package/dist/chunk-DAthf5H3.js +1 -0
  31. package/dist/{chunk-CXQJG7pb.js → chunk-DZ_aillY.js} +2 -2
  32. package/dist/{chunk-6C23QQdP.js → chunk-DgJV9zbK.js} +1 -1
  33. package/dist/chunk-Dv6KtIT1.js +1 -0
  34. package/dist/chunk-Dws8IrdR.js +1 -0
  35. package/dist/chunk-ThxxD8oK.js +1 -0
  36. package/dist/{chunk-C-EHtpbB.js → chunk-h5aOI_Pa.js} +1 -1
  37. package/dist/chunk-kizhspe5.js +1 -0
  38. package/dist/client-dispatch.js +1 -1
  39. package/dist/client-history.js +1 -1
  40. package/dist/client-stream.js +1 -1
  41. package/dist/{code-generators-DqauYyfX.d.cts → code-generators-DPAqTcAz.d.cts} +0 -2
  42. package/dist/{code-generators-DqauYyfX.d.ts → code-generators-DPAqTcAz.d.ts} +0 -2
  43. package/dist/codegen.cjs +1 -1
  44. package/dist/codegen.d.cts +1 -1
  45. package/dist/codegen.d.ts +1 -1
  46. package/dist/codegen.js +1 -1
  47. package/dist/composer-actions-runtime.cjs +1 -1
  48. package/dist/composer-actions-runtime.d.cts +4 -20
  49. package/dist/composer-actions-runtime.d.ts +4 -20
  50. package/dist/composer-actions-runtime.js +1 -1
  51. package/dist/{composer-input-DnqGCXZz.d.ts → composer-input-BSDNPOqV.d.cts} +1 -7
  52. package/dist/{composer-input-DnqGCXZz.d.cts → composer-input-BSDNPOqV.d.ts} +1 -7
  53. package/dist/{composer-store-6Iwy6WLI.d.ts → composer-store-DC-gAjtN.d.cts} +1 -3
  54. package/dist/{composer-store-6Iwy6WLI.d.cts → composer-store-DC-gAjtN.d.ts} +1 -3
  55. package/dist/config-diagnostics.d.cts +0 -2
  56. package/dist/config-diagnostics.d.ts +0 -2
  57. package/dist/{context-mention-manager-B-rHF98n.d.ts → context-mention-manager-CCpeoGL2.d.cts} +2 -6
  58. package/dist/{context-mention-manager-B-rHF98n.d.cts → context-mention-manager-CCpeoGL2.d.ts} +2 -6
  59. package/dist/context-mentions-inline.cjs +1 -1
  60. package/dist/context-mentions-inline.d.cts +2 -4
  61. package/dist/context-mentions-inline.d.ts +2 -4
  62. package/dist/context-mentions-inline.js +1 -1
  63. package/dist/context-mentions.cjs +2 -2
  64. package/dist/context-mentions.d.cts +3 -9
  65. package/dist/context-mentions.d.ts +3 -9
  66. package/dist/context-mentions.js +1 -1
  67. package/dist/{defaults-BuOjqUAG.d.ts → defaults-tj5RcN8r.d.cts} +1 -5
  68. package/dist/{defaults-BuOjqUAG.d.cts → defaults-tj5RcN8r.d.ts} +1 -5
  69. package/dist/dictation-runtime.cjs +1 -1
  70. package/dist/dictation-runtime.d.cts +4 -13
  71. package/dist/dictation-runtime.d.ts +4 -13
  72. package/dist/dictation-runtime.js +1 -1
  73. package/dist/{dom-context-DHTabIn7.d.cts → dom-context-Bw-_9Evh.d.cts} +0 -2
  74. package/dist/{dom-context-DHTabIn7.d.ts → dom-context-Bw-_9Evh.d.ts} +0 -2
  75. package/dist/event-stream-view.cjs +1 -1
  76. package/dist/event-stream-view.d.cts +3 -16
  77. package/dist/event-stream-view.d.ts +3 -16
  78. package/dist/{fallback-speech-engine-SqEfkGda.d.ts → fallback-speech-engine-gkhbjLiZ.d.cts} +1 -3
  79. package/dist/{fallback-speech-engine-SqEfkGda.d.cts → fallback-speech-engine-gkhbjLiZ.d.ts} +1 -3
  80. package/dist/{feature-preferences-DqAmje-2.d.ts → feature-preferences-CerDWGZ7.d.cts} +1 -5
  81. package/dist/{feature-preferences-DqAmje-2.d.cts → feature-preferences-CerDWGZ7.d.ts} +1 -5
  82. package/dist/forms-ui.d.cts +2 -4
  83. package/dist/forms-ui.d.ts +2 -4
  84. package/dist/{history-confirm-dialog-B3L7QNcO.d.cts → history-confirm-dialog-CrkSB3Ra.d.cts} +2 -14
  85. package/dist/{history-confirm-dialog-B3L7QNcO.d.ts → history-confirm-dialog-CrkSB3Ra.d.ts} +2 -14
  86. package/dist/history-shell.cjs +1 -1
  87. package/dist/history-shell.d.cts +8 -27
  88. package/dist/history-shell.d.ts +8 -27
  89. package/dist/history-shell.js +1 -1
  90. package/dist/history-view.d.cts +1 -1
  91. package/dist/history-view.d.ts +1 -1
  92. package/dist/icons-extra.cjs +1 -1
  93. package/dist/icons-extra.d.cts +0 -2
  94. package/dist/icons-extra.d.ts +0 -2
  95. package/dist/index.cjs +44 -44
  96. package/dist/index.cjs.map +1 -1
  97. package/dist/index.d.cts +10 -10
  98. package/dist/index.d.ts +10 -10
  99. package/dist/index.global.js +1 -1
  100. package/dist/index.global.js.map +1 -1
  101. package/dist/index.js +50 -50
  102. package/dist/index.js.map +1 -1
  103. package/dist/install.global.js +1 -1
  104. package/dist/install.global.js.map +1 -1
  105. package/dist/launcher.global.js +2 -2
  106. package/dist/launcher.global.js.map +1 -1
  107. package/dist/{loading-label-runtime-loader-JecjOfJS.d.ts → loading-label-runtime-loader-fWIRrhUF.d.cts} +1 -3
  108. package/dist/{loading-label-runtime-loader-JecjOfJS.d.cts → loading-label-runtime-loader-fWIRrhUF.d.ts} +1 -3
  109. package/dist/loading-label-runtime.cjs +1 -1
  110. package/dist/loading-label-runtime.d.cts +1 -1
  111. package/dist/loading-label-runtime.d.ts +1 -1
  112. package/dist/loading-label-runtime.js +1 -1
  113. package/dist/markdown-parsers.js +1 -1
  114. package/dist/{message-bubble-CBYtpDsT.d.ts → message-bubble-Czc0lOHr.d.cts} +20 -3
  115. package/dist/{message-bubble-CBYtpDsT.d.cts → message-bubble-Czc0lOHr.d.ts} +20 -3
  116. package/dist/{plugin-kit-C1mRjLgW.d.cts → plugin-kit-C30jE-cw.d.cts} +3 -3
  117. package/dist/{plugin-kit-C1mRjLgW.d.ts → plugin-kit-C30jE-cw.d.ts} +3 -3
  118. package/dist/plugin-kit.cjs +1 -1
  119. package/dist/plugin-kit.d.cts +1 -1
  120. package/dist/plugin-kit.d.ts +1 -1
  121. package/dist/plugin-kit.js +1 -1
  122. package/dist/preferences-runtime.cjs +1 -1
  123. package/dist/preferences-runtime.d.cts +1 -1
  124. package/dist/preferences-runtime.d.ts +1 -1
  125. package/dist/preferences-runtime.js +1 -1
  126. package/dist/{preview-_UJwwt2L.d.cts → preview-D-osV3FU.d.cts} +2 -6
  127. package/dist/{preview-_UJwwt2L.d.ts → preview-D-osV3FU.d.ts} +2 -6
  128. package/dist/{registry-B5ZV3K-h.d.ts → registry-B6w91tx0.d.cts} +1 -8
  129. package/dist/{registry-B5ZV3K-h.d.cts → registry-B6w91tx0.d.ts} +1 -8
  130. package/dist/{session-Bys8_XuB.d.cts → session-BUQrs3Of.d.cts} +10 -93
  131. package/dist/{session-Bys8_XuB.d.ts → session-BUQrs3Of.d.ts} +10 -93
  132. package/dist/session-reconnect.d.cts +2 -4
  133. package/dist/session-reconnect.d.ts +2 -4
  134. package/dist/smart-dom-reader.d.cts +2 -8
  135. package/dist/smart-dom-reader.d.ts +2 -8
  136. package/dist/testing.d.cts +0 -2
  137. package/dist/testing.d.ts +0 -2
  138. package/dist/theme-editor-preview.cjs +50 -50
  139. package/dist/theme-editor-preview.d.cts +1 -1
  140. package/dist/theme-editor-preview.d.ts +1 -1
  141. package/dist/theme-editor-preview.js +25 -25
  142. package/dist/theme-editor.cjs +25 -5
  143. package/dist/theme-editor.d.cts +2 -22
  144. package/dist/theme-editor.d.ts +2 -22
  145. package/dist/theme-editor.js +1 -1
  146. package/dist/theme-reference.cjs +1 -1
  147. package/dist/theme-reference.d.cts +2 -4
  148. package/dist/theme-reference.d.ts +2 -4
  149. package/dist/theme-reference.js +1 -1
  150. package/dist/{tool-loading-animation-d5qAQcwv.d.ts → tool-loading-animation-B7XUf8TN.d.cts} +1 -7
  151. package/dist/{tool-loading-animation-d5qAQcwv.d.cts → tool-loading-animation-B7XUf8TN.d.ts} +1 -7
  152. package/dist/{tooltip-BkW-8MnD.d.cts → tooltip-DbXkAuJL.d.cts} +0 -2
  153. package/dist/{tooltip-BkW-8MnD.d.ts → tooltip-DbXkAuJL.d.ts} +0 -2
  154. package/dist/{turn-activity-ui-q6fHVnx-.d.cts → turn-activity-ui-CohuNMHT.d.cts} +2 -12
  155. package/dist/{turn-activity-ui-q6fHVnx-.d.ts → turn-activity-ui-CohuNMHT.d.ts} +2 -12
  156. package/dist/turn-activity-ui.d.cts +1 -1
  157. package/dist/turn-activity-ui.d.ts +1 -1
  158. package/dist/{types-B_mj21jg.d.cts → types-9i0xcAMT.d.cts} +128 -29
  159. package/dist/{types-B_mj21jg.d.ts → types-9i0xcAMT.d.ts} +128 -29
  160. package/dist/ui-extras.js +3 -3
  161. package/dist/voice-client-tools.d.cts +1 -3
  162. package/dist/voice-client-tools.d.ts +1 -3
  163. package/dist/voice-runtime.cjs +1 -1
  164. package/dist/voice-runtime.d.cts +2 -12
  165. package/dist/voice-runtime.d.ts +2 -12
  166. package/dist/voice-runtime.js +2 -2
  167. package/dist/voice-worklet-player.d.cts +2 -6
  168. package/dist/voice-worklet-player.d.ts +2 -6
  169. package/dist/{webmcp-runtime-entry-ClEO3qdI.d.ts → webmcp-runtime-entry-XNaXa-9h.d.cts} +9 -5
  170. package/dist/{webmcp-runtime-entry-ClEO3qdI.d.cts → webmcp-runtime-entry-XNaXa-9h.d.ts} +9 -5
  171. package/dist/webmcp-runtime.cjs +1 -1
  172. package/dist/webmcp-runtime.d.cts +1 -1
  173. package/dist/webmcp-runtime.d.ts +1 -1
  174. package/dist/webmcp-runtime.js +1 -1
  175. package/dist/widget.css +1 -1
  176. package/package.json +5 -5
  177. package/src/client-dispatch.ts +13 -0
  178. package/src/client.ts +37 -2
  179. package/src/client.webmcp-require-confirm.test.ts +212 -0
  180. package/src/components/composer-model-picker.ts +0 -14
  181. package/src/components/composer-overflow-menu.ts +0 -15
  182. package/src/components/composer-tokens.ts +0 -20
  183. package/src/components/event-stream-view.test.ts +5 -0
  184. package/src/components/message-bubble.ts +26 -6
  185. package/src/components/scroll-to-bottom.test.ts +135 -0
  186. package/src/components/scroll-to-bottom.ts +50 -13
  187. package/src/components/user-message-collapse.ts +117 -0
  188. package/src/defaults.ts +6 -0
  189. package/src/generated/runtype-openapi-contract.ts +13 -0
  190. package/src/plugin-kit.test.ts +101 -0
  191. package/src/plugin-kit.ts +29 -1
  192. package/src/session.ts +6 -2
  193. package/src/session.webmcp.test.ts +32 -0
  194. package/src/styles/widget.css +56 -0
  195. package/src/theme-reference.ts +2 -2
  196. package/src/types/theme.ts +29 -0
  197. package/src/types.ts +84 -5
  198. package/src/ui.composer-models.test.ts +5 -7
  199. package/src/ui.composer-overflow.test.ts +14 -21
  200. package/src/ui.scroll-additive.test.ts +219 -117
  201. package/src/ui.scroll.test.ts +176 -13
  202. package/src/ui.ts +230 -107
  203. package/src/ui.turn-activity.test.ts +7 -7
  204. package/src/ui.user-message-collapse.test.ts +540 -0
  205. package/src/utils/auto-follow.test.ts +0 -38
  206. package/src/utils/auto-follow.ts +0 -35
  207. package/src/utils/context-mention-controller.test.ts +56 -0
  208. package/src/utils/theme.test.ts +23 -0
  209. package/src/utils/tokens.ts +16 -0
  210. package/src/webmcp-bridge.test.ts +102 -0
  211. package/src/webmcp-runtime-entry.ts +29 -1
  212. package/dist/chunk-BAkaTZpd.js +0 -1
  213. package/dist/chunk-BEDTp-dm.js +0 -1
  214. package/dist/chunk-C7-m0EmI.js +0 -1
  215. package/dist/chunk-CNhThrLX.js +0 -1
  216. package/dist/chunk-CVMIncEY.js +0 -1
  217. package/dist/chunk-CzLEhoB_.js +0 -2
  218. package/dist/chunk-DVgzoVCB.js +0 -1
  219. package/dist/chunk-d54BJdKd.js +0 -1
  220. package/dist/chunk-dLJWKfOb.js +0 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@runtypelabs/persona",
3
- "version": "4.33.0",
3
+ "version": "4.35.1",
4
4
  "description": "Themeable, pluggable streaming agent widget for websites, in plain JS with support for voice input and reasoning / tool output.",
5
5
  "type": "module",
6
6
  "main": "dist/index.cjs",
@@ -191,7 +191,7 @@
191
191
  "@types/jsdom": "^30.0.0",
192
192
  "@types/node": "^20.12.7",
193
193
  "@typescript/native-preview": "7.0.0-dev.20260707.2",
194
- "@vitest/ui": "^5.0.3",
194
+ "@vitest/ui": "^5.0.5",
195
195
  "acorn": "^8.15.0",
196
196
  "fake-indexeddb": "^6.2.5",
197
197
  "jsdom": "^28.1.0",
@@ -199,9 +199,9 @@
199
199
  "rimraf": "^5.0.5",
200
200
  "size-limit": "^12.1.0",
201
201
  "terser": "^5.49.0",
202
- "tsdown": "^0.23.0",
202
+ "tsdown": "^0.23.2",
203
203
  "typescript": "^6.0.3",
204
- "vitest": "^5.0.3",
204
+ "vitest": "^5.0.5",
205
205
  "webmcp-polyfill-v6": "npm:@mcp-b/webmcp-polyfill@6.0.0"
206
206
  },
207
207
  "engines": {
@@ -235,7 +235,7 @@
235
235
  "check:size": "node scripts/check-size.mjs",
236
236
  "check:dist-vite8": "node scripts/check-dist-no-in-for-init.mjs",
237
237
  "build:styles": "node scripts/build-styles.mjs",
238
- "lint": "oxlint",
238
+ "lint": "node ../../scripts/lint.mjs",
239
239
  "typecheck": "pnpm run check:runtype-types && node ../../scripts/typecheck.mjs --noEmit",
240
240
  "test": "vitest",
241
241
  "test:ui": "vitest --ui",
@@ -394,6 +394,8 @@ export class ClientDispatch {
394
394
  welcomeMessage: data.config.welcomeMessage,
395
395
  placeholder: data.config.placeholder,
396
396
  theme: data.config.theme as ClientSession["config"]["theme"],
397
+ // Kept only as a non-empty array; the bridge ignores non-string entries.
398
+ ...readWebMcpInitConfig(data.config),
397
399
  },
398
400
  };
399
401
  }
@@ -1087,3 +1089,14 @@ export class ClientDispatch {
1087
1089
  }
1088
1090
 
1089
1091
  }
1092
+
1093
+ /** `config.webmcp` from `/v1/client/init`, kept only with a non-empty pattern array. */
1094
+ const readWebMcpInitConfig = (
1095
+ config: unknown
1096
+ ): Pick<ClientSession["config"], "webmcp"> => {
1097
+ const list = (config as { webmcp?: { requireConfirmFor?: unknown } }).webmcp
1098
+ ?.requireConfirmFor;
1099
+ return Array.isArray(list) && list.length
1100
+ ? { webmcp: { requireConfirmFor: list as string[] } }
1101
+ : {};
1102
+ };
package/src/client.ts CHANGED
@@ -181,7 +181,18 @@ export class AgentWidgetClient {
181
181
  * matches is superseded, and every SSE frame it still receives is dropped
182
182
  */
183
183
  #currentClientTurnId: string | null = null;
184
- private clientSession: ClientSession | null = null;
184
+ #clientSession: ClientSession | null = null;
185
+ /** Last surface `requireConfirmFor` installed with a session; `null` until one loads. */
186
+ #knownRequireConfirmFor: readonly string[] | null = null;
187
+ // Every install path assigns here, so the surface policy is recorded the
188
+ // moment a session lands, not when a tool call next reads it.
189
+ private get clientSession(): ClientSession | null {
190
+ return this.#clientSession;
191
+ }
192
+ private set clientSession(session: ClientSession | null) {
193
+ this.#clientSession = session;
194
+ if (session) this.#knownRequireConfirmFor = session.config?.webmcp?.requireConfirmFor ?? [];
195
+ }
185
196
  #sessionInitPromise: Promise<ClientSession> | null = null;
186
197
  /**
187
198
  * Early-init latch for `warmSession()`. `false` = armed. Otherwise it holds
@@ -275,6 +286,7 @@ export class AgentWidgetClient {
275
286
  recordToolDisplayTitles: recordWebMcpToolDisplayTitles,
276
287
  getToolDisplayTitle: getWebMcpToolDisplayTitle,
277
288
  loadPolyfill: loadWebMcpPolyfillModule,
289
+ getRequireConfirmFor: () => this.#surfaceRequireConfirmFor(),
278
290
  });
279
291
  if (this.#pendingWebMcpConfirmHandler !== undefined) {
280
292
  bridge.setConfirmHandler(this.#pendingWebMcpConfirmHandler);
@@ -408,12 +420,35 @@ export class AgentWidgetClient {
408
420
  if (!b) {
409
421
  return { status: "failed", error: { message: "WebMCP runtime failed to load." } };
410
422
  }
411
- return b.executeVoiceToolCall(name, args, options);
423
+ return b.executeVoiceToolCall(name, args, await this.#voiceToolOptions(options));
412
424
  },
413
425
  };
414
426
  return surface;
415
427
  }
416
428
 
429
+ /**
430
+ * The bound surface's `requireConfirmFor` for the WebMCP bridge. Sticky: the
431
+ * policy of the last installed session survives `clearClientSession()` (same
432
+ * token, same surface), so a session cleared mid-call cannot drop it. `null`
433
+ * means client-token mode with no policy loaded yet; the bridge then
434
+ * confirms every call. `undefined` outside client-token mode.
435
+ */
436
+ #surfaceRequireConfirmFor(): readonly string[] | null | undefined {
437
+ return this.isClientTokenMode() ? this.#knownRequireConfirmFor : undefined;
438
+ }
439
+
440
+ /**
441
+ * A voice call can start before any typed turn has run client-token init.
442
+ * Try to load the surface policy first so read-only tools keep their fast
443
+ * tier; on failure the policy stays unknown and the bridge gates the call.
444
+ */
445
+ async #voiceToolOptions<T extends { autoConfirmReadOnly: boolean }>(options: T): Promise<T> {
446
+ if (options.autoConfirmReadOnly && this.isClientTokenMode() && !this.clientSession) {
447
+ await this.initSession().catch(() => undefined);
448
+ }
449
+ return options;
450
+ }
451
+
417
452
  public executeWebMcpToolCall(
418
453
  wireToolName: string,
419
454
  args: unknown,
@@ -0,0 +1,212 @@
1
+ // @vitest-environment jsdom
2
+
3
+ import { afterEach, describe, expect, it, vi } from "vitest";
4
+
5
+ import { AgentWidgetClient } from "./client";
6
+ import type { AgentWidgetConfig, ClientInitResponse, WebMcpConfirmHandler } from "./types";
7
+
8
+ const API_URL = "https://api.runtype.com";
9
+
10
+ const initBody = (config: Record<string, unknown> = {}): ClientInitResponse =>
11
+ ({
12
+ sessionId: "sess_1",
13
+ expiresAt: new Date(Date.now() + 5 * 60_000).toISOString(),
14
+ flow: { id: "flow_1", name: "Flow", description: null },
15
+ config: { welcomeMessage: null, placeholder: "Ask...", theme: null, ...config },
16
+ }) as ClientInitResponse;
17
+
18
+ const installInit = (body: ClientInitResponse) => {
19
+ global.fetch = vi.fn(async () => ({
20
+ ok: true,
21
+ status: 200,
22
+ json: async () => body,
23
+ })) as unknown as typeof fetch;
24
+ };
25
+
26
+ const makeClient = (webmcp?: AgentWidgetConfig["webmcp"]) =>
27
+ new AgentWidgetClient({
28
+ apiUrl: API_URL,
29
+ clientToken: "ct_live_x",
30
+ ...(webmcp ? { webmcp } : {}),
31
+ });
32
+
33
+ afterEach(() => {
34
+ vi.restoreAllMocks();
35
+ delete (document as Document & { modelContext?: unknown }).modelContext;
36
+ });
37
+
38
+ describe("client-token init: config.webmcp.requireConfirmFor", () => {
39
+ it("keeps the surface's patterns on the session", async () => {
40
+ installInit(initBody({ webmcp: { requireConfirmFor: ["checkout_*"] } }));
41
+ const session = await makeClient().initSession();
42
+ expect(session.config.webmcp).toEqual({ requireConfirmFor: ["checkout_*"] });
43
+ });
44
+
45
+ it.each([
46
+ ["absent", {}],
47
+ ["empty", { webmcp: { requireConfirmFor: [] } }],
48
+ ["malformed", { webmcp: { requireConfirmFor: "checkout_*" } }],
49
+ ])("omits config.webmcp when the list is %s", async (_label, config) => {
50
+ installInit(initBody(config));
51
+ const session = await makeClient().initSession();
52
+ expect(session.config).not.toHaveProperty("webmcp");
53
+ });
54
+ });
55
+
56
+ describe("client-token init feeds the WebMCP confirm gate", () => {
57
+ it("flags a matched page tool for the host's onConfirm after init", async () => {
58
+ const execute = vi.fn(async () => JSON.stringify("ran"));
59
+ (document as Document & { modelContext?: unknown }).modelContext = {
60
+ getTools: async () => [
61
+ { name: "checkout_submit", description: "Place the order", title: "", window, origin: location.origin },
62
+ { name: "search", description: "Search", title: "", window, origin: location.origin },
63
+ ],
64
+ executeTool: execute,
65
+ };
66
+ installInit(initBody({ webmcp: { requireConfirmFor: ["checkout_*"] } }));
67
+ const onConfirm = vi.fn<WebMcpConfirmHandler>(async () => true);
68
+ const client = makeClient({ enabled: true, onConfirm });
69
+ await client.initSession();
70
+
71
+ await client.executeWebMcpToolCall("webmcp:checkout_submit", {});
72
+ await client.executeWebMcpToolCall("webmcp:search", {});
73
+
74
+ expect(onConfirm).toHaveBeenCalledTimes(2);
75
+ expect(onConfirm.mock.calls[0]?.[0]).toMatchObject({
76
+ toolName: "checkout_submit",
77
+ confirmRequired: true,
78
+ });
79
+ expect(onConfirm.mock.calls[1]?.[0]).not.toHaveProperty("confirmRequired");
80
+ expect(execute).toHaveBeenCalledTimes(2);
81
+ });
82
+ });
83
+
84
+ describe("voice before any client-token init", () => {
85
+ const installReadOnlyTools = () => {
86
+ (document as Document & { modelContext?: unknown }).modelContext = {
87
+ getTools: async () =>
88
+ ["checkout_preview", "next_slide"].map((name) => ({
89
+ name,
90
+ description: name,
91
+ title: "",
92
+ window,
93
+ origin: location.origin,
94
+ annotations: { readOnlyHint: true },
95
+ })),
96
+ executeTool: vi.fn(async () => JSON.stringify("ran")),
97
+ };
98
+ };
99
+ const voiceExecute = (client: AgentWidgetClient, name: string) =>
100
+ client
101
+ .getVoiceClientTools()!
102
+ .execute(name, {}, { signal: new AbortController().signal, autoConfirmReadOnly: true });
103
+
104
+ it("loads the surface policy so a matched read-only tool still confirms", async () => {
105
+ installReadOnlyTools();
106
+ installInit(initBody({ webmcp: { requireConfirmFor: ["checkout_*"] } }));
107
+ const onConfirm = vi.fn<WebMcpConfirmHandler>(async () => false);
108
+ const client = makeClient({ enabled: true, onConfirm });
109
+
110
+ const matched = await voiceExecute(client, "webmcp:checkout_preview");
111
+ const unmatched = await voiceExecute(client, "webmcp:next_slide");
112
+
113
+ expect(global.fetch).toHaveBeenCalledTimes(1);
114
+ expect(onConfirm).toHaveBeenCalledTimes(1);
115
+ expect(onConfirm.mock.calls[0]?.[0]).toMatchObject({
116
+ toolName: "checkout_preview",
117
+ confirmRequired: true,
118
+ });
119
+ expect(matched.status).toBe("denied");
120
+ expect(unmatched.status).toBe("completed");
121
+ });
122
+
123
+ it("keeps every voice call gated when init fails", async () => {
124
+ installReadOnlyTools();
125
+ global.fetch = vi.fn(async () => {
126
+ throw new Error("offline");
127
+ }) as unknown as typeof fetch;
128
+ const onConfirm = vi.fn<WebMcpConfirmHandler>(async () => false);
129
+ const client = makeClient({ enabled: true, onConfirm });
130
+
131
+ const result = await voiceExecute(client, "webmcp:next_slide");
132
+
133
+ expect(onConfirm).toHaveBeenCalledTimes(1);
134
+ expect(result.status).toBe("denied");
135
+ });
136
+ });
137
+
138
+ describe("surface policy lifetime", () => {
139
+ const installTools = () => {
140
+ (document as Document & { modelContext?: unknown }).modelContext = {
141
+ getTools: async () =>
142
+ ["checkout_submit", "search"].map((name) => ({
143
+ name,
144
+ description: name,
145
+ title: "",
146
+ window,
147
+ origin: location.origin,
148
+ })),
149
+ executeTool: vi.fn(async () => JSON.stringify("ran")),
150
+ };
151
+ };
152
+
153
+ it("keeps the last loaded policy after clearClientSession()", async () => {
154
+ installTools();
155
+ installInit(initBody({ webmcp: { requireConfirmFor: ["checkout_*"] } }));
156
+ const onConfirm = vi.fn<WebMcpConfirmHandler>(async () => true);
157
+ const client = makeClient({ enabled: true, onConfirm });
158
+ await client.initSession();
159
+ // Read the policy once while the session is live, then drop the session.
160
+ await client.executeWebMcpToolCall("webmcp:search", {});
161
+ client.clearClientSession();
162
+
163
+ await client.executeWebMcpToolCall("webmcp:checkout_submit", {});
164
+ await client.executeWebMcpToolCall("webmcp:search", {});
165
+
166
+ expect(onConfirm.mock.calls.map((c) => c[0]?.confirmRequired)).toEqual([
167
+ undefined,
168
+ true,
169
+ undefined,
170
+ ]);
171
+ });
172
+
173
+ it("records a policy when a session installs, not when a call reads it", async () => {
174
+ installTools();
175
+ // First session: no policy. A tool call reads (and would have cached) [].
176
+ installInit(initBody());
177
+ const onConfirm = vi.fn<WebMcpConfirmHandler>(async () => true);
178
+ const client = makeClient({ enabled: true, onConfirm });
179
+ await client.initSession();
180
+ await client.executeWebMcpToolCall("webmcp:checkout_submit", {});
181
+
182
+ // Second session adds a policy, then is cleared before any tool call reads it.
183
+ client.clearClientSession();
184
+ installInit(initBody({ webmcp: { requireConfirmFor: ["checkout_*"] } }));
185
+ await client.initSession();
186
+ client.clearClientSession();
187
+
188
+ await client.executeWebMcpToolCall("webmcp:checkout_submit", {});
189
+
190
+ expect(onConfirm.mock.calls.map((c) => c[0]?.confirmRequired)).toEqual([undefined, true]);
191
+ });
192
+
193
+ it("flags every call while no client-token policy has loaded", async () => {
194
+ installTools();
195
+ const onConfirm = vi.fn<WebMcpConfirmHandler>(async () => true);
196
+ const client = makeClient({ enabled: true, onConfirm });
197
+
198
+ await client.executeWebMcpToolCall("webmcp:search", {});
199
+
200
+ expect(onConfirm.mock.calls[0]?.[0]).toMatchObject({ toolName: "search", confirmRequired: true });
201
+ });
202
+
203
+ it("leaves proxy mode (no client token) unflagged", async () => {
204
+ installTools();
205
+ const onConfirm = vi.fn<WebMcpConfirmHandler>(async () => true);
206
+ const client = new AgentWidgetClient({ apiUrl: API_URL, webmcp: { enabled: true, onConfirm } });
207
+
208
+ await client.executeWebMcpToolCall("webmcp:checkout_submit", {});
209
+
210
+ expect(onConfirm.mock.calls[0]?.[0]).not.toHaveProperty("confirmRequired");
211
+ });
212
+ });
@@ -18,25 +18,12 @@ import { createPopover, type PopoverHandle } from "../plugin-kit";
18
18
  import type { ComposerCustomAction, ComposerModel } from "../types";
19
19
  import { createElement, createNode } from "../utils/dom";
20
20
  import type { renderLucideIcon } from "../utils/icons";
21
- import { forwardMenuTokens } from "./composer-tokens";
22
21
 
23
22
  export const COMPOSER_MODEL_PICKER_ACTION_ID = "core:model";
24
23
  export const COMPOSER_MODEL_PICKER_ORDER = 700;
25
24
 
26
25
  const ROW_ICON_SIZE = 16;
27
26
 
28
- /**
29
- * Tokens the popover panel reads, forwarded onto the portaled panel on every
30
- * open; unset ones clear so the stylesheet's fallbacks stand.
31
- */
32
- const MENU_TOKEN_VARS = [
33
- "--persona-components-composer-modelPicker-menuBackground",
34
- "--persona-components-composer-modelPicker-menuBorderRadius",
35
- "--persona-components-composer-modelPicker-rowHoverBackground",
36
- "--persona-components-composer-modelPicker-labelColor",
37
- "--persona-components-composer-modelPicker-descriptionColor",
38
- ] as const;
39
-
40
27
  export interface ComposerModelPickerOptions {
41
28
  renderIcon: typeof renderLucideIcon;
42
29
  getModels: () => ReadonlyArray<ComposerModel>;
@@ -212,7 +199,6 @@ export function createComposerModelPickerAction(
212
199
  onDismiss: () => trigger.setAttribute("aria-expanded", "false"),
213
200
  });
214
201
  }
215
- forwardMenuTokens(trigger, menu, MENU_TOKEN_VARS);
216
202
  popover.open();
217
203
  trigger.setAttribute("aria-expanded", "true");
218
204
  const ids = [...rows.keys()];
@@ -22,7 +22,6 @@ import {
22
22
  COMPOSER_CONTROL_CLASS,
23
23
  COMPOSER_CONTROL_GLYPH_CLASS,
24
24
  COMPOSER_CONTROL_ICON_FALLBACK_PX,
25
- forwardMenuTokens,
26
25
  } from "./composer-tokens";
27
26
 
28
27
  export interface ComposerOverflowMenuOptions {
@@ -66,19 +65,6 @@ export interface ComposerOverflowMenu {
66
65
  const focusableSelector =
67
66
  'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])';
68
67
 
69
- /**
70
- * Panel surface tokens, forwarded onto the portaled panel on every open; unset
71
- * ones clear so the stylesheet's fallbacks stand.
72
- */
73
- const MENU_TOKEN_VARS = [
74
- "--persona-components-composer-overflowMenu-background",
75
- "--persona-components-composer-overflowMenu-borderColor",
76
- "--persona-components-composer-overflowMenu-borderRadius",
77
- "--persona-components-composer-overflowMenu-foreground",
78
- "--persona-components-composer-overflowMenu-shadow",
79
- ...["padding", "rowGap", "rowPadding", "rowBorderRadius", "fontSize", "lineHeight", "iconSize", "iconGap", "hoverBackground", "focusBackground", "descriptionColor", "descriptionFontSize", "dividerColor", "dividerSpacing", "minWidth", "maxHeight"].map((name) => `--persona-components-composer-overflowMenu-${name}`),
80
- ] as const;
81
-
82
68
  /**
83
69
  * Set on the menu row that holds keyboard focus. Rows that shade on
84
70
  * `:focus-visible` do not need it; wrapper rows around a live control do.
@@ -296,7 +282,6 @@ export function createComposerOverflowMenu(
296
282
  };
297
283
 
298
284
  const refresh = (): void => {
299
- forwardMenuTokens(triggerButton, panel, MENU_TOKEN_VARS);
300
285
  panel.setAttribute("data-persona-menu-description-layout", options.getConfig?.()?.descriptionLayout ?? "stacked");
301
286
  const width = options.getConfig?.()?.width;
302
287
  panel.style.width = width === "composer"
@@ -12,23 +12,3 @@ export const COMPOSER_CONTROL_GLYPH_CLASS = "persona-composer-control--glyph";
12
12
  export const COMPOSER_CONTROL_FALLBACK_PX = 40;
13
13
  /** Icon-size token default in CSS pixels, for the same JS-derived cases. */
14
14
  export const COMPOSER_CONTROL_ICON_FALLBACK_PX = 24;
15
-
16
- /**
17
- * Forward theme vars onto a portaled menu panel, which lives outside the
18
- * themed mount. The panel is reused across opens, so an unset var must clear
19
- * any previously stamped value for the stylesheet fallback to paint again.
20
- */
21
- export const forwardMenuTokens = (
22
- from: HTMLElement,
23
- to: HTMLElement,
24
- names: readonly string[]
25
- ): void => {
26
- const view = from.ownerDocument?.defaultView;
27
- if (!view) return;
28
- const computed = view.getComputedStyle(from);
29
- for (const name of names) {
30
- const value = computed.getPropertyValue(name).trim();
31
- if (value) to.style.setProperty(name, value);
32
- else to.style.removeProperty(name);
33
- }
34
- };
@@ -36,6 +36,11 @@ function createMockElement(tag = "div"): any {
36
36
  clientHeight: 300,
37
37
  // For <select>
38
38
  options: [] as any[],
39
+ append(...nodes: (Node | string)[]) {
40
+ for (const node of nodes) {
41
+ el.appendChild(typeof node === "string" ? { nodeType: 3, textContent: node } : node);
42
+ }
43
+ },
39
44
  appendChild(child: any) {
40
45
  // Handle DocumentFragment: move its children into this element
41
46
  if (child.tagName === "FRAGMENT") {
@@ -25,6 +25,11 @@ import { createMentionTokenElement } from "../utils/mention-token";
25
25
  import { createIconButton } from "../utils/buttons";
26
26
  import { renderLucideIcon } from "../utils/icons";
27
27
  import { IMAGE_ONLY_MESSAGE_FALLBACK_TEXT } from "../utils/content";
28
+ import {
29
+ applyUserCollapseState,
30
+ createUserCollapseRegion,
31
+ type UserMessageCollapseRender,
32
+ } from "./user-message-collapse";
28
33
  import {
29
34
  applyStreamBuffer,
30
35
  createSkeletonPlaceholder,
@@ -998,6 +1003,11 @@ export type CreateStandardBubbleOptions = {
998
1003
  * Full widget config (needed for loading indicator context)
999
1004
  */
1000
1005
  widgetConfig?: import("../types").AgentWidgetConfig;
1006
+ /**
1007
+ * Long user message collapse (`features.userMessageCollapse`). When set on
1008
+ * a user bubble, its content is wrapped in a clip region with the toggle.
1009
+ */
1010
+ userCollapse?: UserMessageCollapseRender;
1001
1011
  };
1002
1012
 
1003
1013
  /**
@@ -1238,6 +1248,12 @@ export const createStandardBubble = (
1238
1248
  }
1239
1249
  }
1240
1250
 
1251
+ // Everything a long user message can spill (attachments, chips, text) goes
1252
+ // in the collapse region; the timestamp and actions stay outside the clip.
1253
+ const userCollapse = message.role === "user" ? options?.userCollapse : undefined;
1254
+ const collapseRegion = userCollapse ? createUserCollapseRegion(message.id, userCollapse.maxHeight) : null;
1255
+ const contentParent = collapseRegion ?? bubble;
1256
+
1241
1257
  if (imageParts.length > 0) {
1242
1258
  const imagePreviews = createMessageImagePreviews(
1243
1259
  imageParts,
@@ -1250,7 +1266,7 @@ export const createStandardBubble = (
1250
1266
  );
1251
1267
 
1252
1268
  if (imagePreviews) {
1253
- bubble.appendChild(imagePreviews);
1269
+ contentParent.appendChild(imagePreviews);
1254
1270
  } else if (shouldHideTextUntilPreviewFails && textContentDiv) {
1255
1271
  textContentDiv.style.display = "";
1256
1272
  }
@@ -1260,7 +1276,7 @@ export const createStandardBubble = (
1260
1276
  if (audioParts.length > 0) {
1261
1277
  const audioPreviews = createMessageAudioPreviews(audioParts);
1262
1278
  if (audioPreviews) {
1263
- bubble.appendChild(audioPreviews);
1279
+ contentParent.appendChild(audioPreviews);
1264
1280
  }
1265
1281
  }
1266
1282
 
@@ -1268,7 +1284,7 @@ export const createStandardBubble = (
1268
1284
  if (videoParts.length > 0) {
1269
1285
  const videoPreviews = createMessageVideoPreviews(videoParts);
1270
1286
  if (videoPreviews) {
1271
- bubble.appendChild(videoPreviews);
1287
+ contentParent.appendChild(videoPreviews);
1272
1288
  }
1273
1289
  }
1274
1290
 
@@ -1276,7 +1292,7 @@ export const createStandardBubble = (
1276
1292
  if (fileParts.length > 0) {
1277
1293
  const filePreviews = createMessageFilePreviews(fileParts);
1278
1294
  if (filePreviews) {
1279
- bubble.appendChild(filePreviews);
1295
+ contentParent.appendChild(filePreviews);
1280
1296
  }
1281
1297
  }
1282
1298
 
@@ -1286,10 +1302,14 @@ export const createStandardBubble = (
1286
1302
  ? null
1287
1303
  : createMessageMentionChips(message.contextMentions);
1288
1304
  if (mentionChips) {
1289
- bubble.appendChild(mentionChips);
1305
+ contentParent.appendChild(mentionChips);
1290
1306
  }
1291
1307
 
1292
- bubble.appendChild(contentDiv);
1308
+ contentParent.appendChild(contentDiv);
1309
+ if (collapseRegion && userCollapse) {
1310
+ bubble.appendChild(collapseRegion);
1311
+ applyUserCollapseState(bubble, collapseRegion, message.id, userCollapse);
1312
+ }
1293
1313
 
1294
1314
  // Add timestamp below if configured
1295
1315
  if (showTimestamp && timestampPosition === "below" && message.createdAt) {