@runtypelabs/persona 4.23.2 → 4.25.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.
Files changed (166) hide show
  1. package/dist/animations/glyph-cycle.d.cts +1 -1
  2. package/dist/animations/glyph-cycle.d.ts +1 -1
  3. package/dist/animations/{types-CI7OckWI.d.cts → types-Db61aI9J.d.cts} +2 -0
  4. package/dist/animations/{types-CI7OckWI.d.ts → types-Db61aI9J.d.ts} +2 -0
  5. package/dist/animations/wipe.d.cts +1 -1
  6. package/dist/animations/wipe.d.ts +1 -1
  7. package/dist/animations-extra.d.cts +2 -0
  8. package/dist/animations-extra.d.ts +2 -0
  9. package/dist/approval-ui-3NMYNXBD.js +1 -0
  10. package/dist/approval-ui.cjs +2 -2
  11. package/dist/approval-ui.d.cts +278 -37
  12. package/dist/approval-ui.d.ts +278 -37
  13. package/dist/approval-ui.js +2 -2
  14. package/dist/{artifacts-ui-F4N6VMKF.js → artifacts-ui-IGB5YKLO.js} +4 -4
  15. package/dist/artifacts-ui.d.cts +273 -25
  16. package/dist/artifacts-ui.d.ts +273 -25
  17. package/dist/{chunk-NIKYKSNZ.js → chunk-7CO4T5KB.js} +1 -1
  18. package/dist/codegen.cjs +11 -11
  19. package/dist/codegen.js +13 -13
  20. package/dist/context-mentions.d.cts +273 -25
  21. package/dist/context-mentions.d.ts +273 -25
  22. package/dist/event-stream-view.d.cts +273 -25
  23. package/dist/event-stream-view.d.ts +273 -25
  24. package/dist/forms-ui.d.cts +319 -27
  25. package/dist/forms-ui.d.ts +319 -27
  26. package/dist/history-view.d.cts +2 -0
  27. package/dist/history-view.d.ts +2 -0
  28. package/dist/index.cjs +70 -69
  29. package/dist/index.cjs.map +1 -1
  30. package/dist/index.d.cts +344 -39
  31. package/dist/index.d.ts +344 -39
  32. package/dist/index.global.js +57 -54
  33. package/dist/index.global.js.map +1 -1
  34. package/dist/index.js +70 -69
  35. package/dist/index.js.map +1 -1
  36. package/dist/install.global.js +1 -1
  37. package/dist/install.global.js.map +1 -1
  38. package/dist/launcher.global.js +2 -2
  39. package/dist/launcher.global.js.map +1 -1
  40. package/dist/session-reconnect.d.cts +273 -25
  41. package/dist/session-reconnect.d.ts +273 -25
  42. package/dist/smart-dom-reader.d.cts +273 -25
  43. package/dist/smart-dom-reader.d.ts +273 -25
  44. package/dist/theme-editor-preview.cjs +74 -73
  45. package/dist/theme-editor-preview.d.cts +280 -25
  46. package/dist/theme-editor-preview.d.ts +280 -25
  47. package/dist/theme-editor-preview.js +51 -50
  48. package/dist/theme-editor.cjs +10 -10
  49. package/dist/theme-editor.d.cts +293 -27
  50. package/dist/theme-editor.d.ts +293 -27
  51. package/dist/theme-editor.js +10 -10
  52. package/dist/theme-reference.cjs +1 -1
  53. package/dist/theme-reference.d.cts +1522 -11
  54. package/dist/theme-reference.d.ts +1522 -11
  55. package/dist/theme-reference.js +1 -1
  56. package/dist/voice-runtime-7DA7MJH4.js +1 -0
  57. package/dist/voice-runtime.cjs +1 -1
  58. package/dist/voice-runtime.d.cts +33 -0
  59. package/dist/voice-runtime.d.ts +33 -0
  60. package/dist/voice-runtime.js +1 -1
  61. package/dist/widget.css +1 -1
  62. package/package.json +3 -2
  63. package/src/__fixtures__/defaults-parity.v4.json +776 -0
  64. package/src/approval-ui-entry.ts +0 -1
  65. package/src/approval-ui-loader.ts +5 -4
  66. package/src/approval-ui.ts +0 -1
  67. package/src/client-identity-proof.test.ts +193 -0
  68. package/src/client-session-warm.test.ts +216 -0
  69. package/src/client-visitor-history.test.ts +6 -0
  70. package/src/client.ts +122 -30
  71. package/src/codegen.test.ts +24 -0
  72. package/src/components/activity-row.test.ts +100 -0
  73. package/src/components/activity-row.ts +136 -0
  74. package/src/components/approval-actions.test.ts +8 -5
  75. package/src/components/approval-actions.ts +19 -6
  76. package/src/components/approval-bubble.test.ts +56 -9
  77. package/src/components/approval-bubble.ts +36 -41
  78. package/src/components/composer-builder.ts +5 -2
  79. package/src/components/composer-parts.ts +10 -2
  80. package/src/components/header-builder.test.ts +32 -0
  81. package/src/components/header-builder.ts +7 -4
  82. package/src/components/header-layouts.test.ts +12 -0
  83. package/src/components/header-layouts.ts +3 -2
  84. package/src/components/launcher.ts +11 -2
  85. package/src/components/message-bubble.test.ts +22 -0
  86. package/src/components/message-bubble.ts +7 -3
  87. package/src/components/panel.test.ts +52 -1
  88. package/src/components/panel.ts +47 -8
  89. package/src/components/pill-composer-builder.test.ts +2 -0
  90. package/src/components/pill-composer-builder.ts +0 -1
  91. package/src/components/reasoning-bubble.ts +29 -17
  92. package/src/components/suggestions.test.ts +1 -0
  93. package/src/components/suggestions.ts +0 -16
  94. package/src/components/tool-bubble.ts +131 -114
  95. package/src/components/tool-details.test.ts +87 -0
  96. package/src/components/tool-details.ts +102 -0
  97. package/src/components/welcome.test.ts +8 -0
  98. package/src/components/welcome.ts +4 -2
  99. package/src/defaults-parity.test.ts +158 -0
  100. package/src/defaults.test.ts +69 -0
  101. package/src/defaults.ts +128 -49
  102. package/src/generated/runtype-openapi-contract.ts +2 -2
  103. package/src/index-core.ts +4 -0
  104. package/src/install.test.ts +24 -0
  105. package/src/install.ts +3 -1
  106. package/src/launcher-global.ts +2 -1
  107. package/src/launcher-variants.test.ts +52 -0
  108. package/src/runtime/init-update-reset.test.ts +21 -0
  109. package/src/session.ts +59 -7
  110. package/src/session.voice.test.ts +72 -1
  111. package/src/styles/widget-styles.test.ts +19 -0
  112. package/src/styles/widget.css +295 -23
  113. package/src/theme-editor/index.ts +2 -0
  114. package/src/theme-editor/preview-utils.test.ts +8 -0
  115. package/src/theme-editor/preview-utils.ts +7 -3
  116. package/src/theme-editor/sections.test.ts +31 -0
  117. package/src/theme-editor/sections.ts +55 -1
  118. package/src/theme-editor/state-preferences.test.ts +49 -0
  119. package/src/theme-editor/state.ts +73 -15
  120. package/src/theme-reference.test.ts +20 -0
  121. package/src/theme-reference.ts +11 -5
  122. package/src/tool-call-display-defaults.test.ts +33 -1
  123. package/src/types/theme.ts +80 -3
  124. package/src/types.ts +207 -22
  125. package/src/ui.activity-row.test.ts +91 -0
  126. package/src/ui.approval-plugin.test.ts +63 -0
  127. package/src/ui.composer-compact.test.ts +24 -2
  128. package/src/ui.composer-placement.test.ts +23 -0
  129. package/src/ui.detached-panel.test.ts +33 -6
  130. package/src/ui.docked.test.ts +19 -0
  131. package/src/ui.header-update-stability.test.ts +25 -0
  132. package/src/ui.identity-proof.test.ts +58 -0
  133. package/src/ui.launcher-teaser.test.ts +25 -0
  134. package/src/ui.message-width.test.ts +17 -2
  135. package/src/ui.reasoning-display.test.ts +92 -0
  136. package/src/ui.rebuild-composer.test.ts +20 -3
  137. package/src/ui.scroll.test.ts +32 -0
  138. package/src/ui.session-init.test.ts +312 -0
  139. package/src/ui.status-indicator.test.ts +82 -0
  140. package/src/ui.suggest-replies.test.ts +11 -0
  141. package/src/ui.ts +422 -74
  142. package/src/ui.v5-defaults.test.ts +144 -0
  143. package/src/ui.voice-prewarm.test.ts +222 -0
  144. package/src/ui.welcome.test.ts +92 -3
  145. package/src/utils/__snapshots__/tokens-parity.test.ts.snap +811 -140
  146. package/src/utils/code-generators.ts +14 -0
  147. package/src/utils/composer-compact.test.ts +0 -2
  148. package/src/utils/composer-compact.ts +2 -4
  149. package/src/utils/composer-placement.ts +2 -3
  150. package/src/utils/config-merge.test.ts +81 -5
  151. package/src/utils/config-merge.ts +13 -3
  152. package/src/utils/defaults-provenance.ts +67 -0
  153. package/src/utils/dock.ts +7 -1
  154. package/src/utils/launcher-variant.ts +16 -0
  155. package/src/utils/panel-config.ts +97 -0
  156. package/src/utils/status-indicator.ts +39 -0
  157. package/src/utils/theme.test.ts +182 -0
  158. package/src/utils/theme.ts +131 -18
  159. package/src/utils/tokens.test.ts +122 -1
  160. package/src/utils/tokens.ts +177 -17
  161. package/src/voice/runtype-voice-provider.ts +238 -27
  162. package/src/voice/voice.test.ts +272 -2
  163. package/src/welcome.test.ts +23 -0
  164. package/src/welcome.ts +16 -9
  165. package/dist/approval-ui-K7R5Q5OC.js +0 -1
  166. package/dist/voice-runtime-EFR3R5ES.js +0 -1
package/src/client.ts CHANGED
@@ -307,6 +307,14 @@ export class AgentWidgetClient {
307
307
  private currentClientTurnId: string | null = null;
308
308
  private clientSession: ClientSession | null = null;
309
309
  private sessionInitPromise: Promise<ClientSession> | null = null;
310
+ /**
311
+ * Early-init latch for `warmSession()`. `false` = armed. Otherwise it holds
312
+ * the session value (`null` or an expired session) the one early attempt was
313
+ * made against, so a failed attempt is not retried on every keystroke. It is
314
+ * re-armed when a session is installed or cleared, so each session lifetime
315
+ * gets one early init.
316
+ */
317
+ private sessionWarmLatch: ClientSession | null | false = false;
310
318
 
311
319
  // Diff-only / send-once WebMCP tool dispatch (client-token mode ONLY).
312
320
  // Fingerprint of the clientTools[] last *sent in full* and confirmed by a
@@ -621,9 +629,41 @@ export class AgentWidgetClient {
621
629
  }
622
630
  }
623
631
 
632
+ /**
633
+ * Fire-and-forget early `initSession()` for client-token mode, driven by the
634
+ * widget's `sessionInit` trigger (or a host calling it directly, e.g. on
635
+ * hover). It fires at most once per session lifetime and is a no-op while a
636
+ * session is live or an init is already in flight. Errors are swallowed, with
637
+ * no `onSessionExpired` and no UI: a send calls `initSession()` again and
638
+ * reuses the in-flight promise, so any failure resurfaces there exactly as it
639
+ * would without the early init. Sends nothing new on the wire. Outside
640
+ * client-token mode this does nothing.
641
+ *
642
+ * Resolves with the session this call initialized, or `null` when it was
643
+ * skipped or failed. It never rejects.
644
+ */
645
+ public async warmSession(): Promise<ClientSession | null> {
646
+ const current = this.clientSession;
647
+ if (
648
+ !this.isClientTokenMode() ||
649
+ this.sessionInitPromise ||
650
+ (current && new Date() < current.expiresAt) ||
651
+ this.sessionWarmLatch === current
652
+ ) {
653
+ return null;
654
+ }
655
+ this.sessionWarmLatch = current;
656
+ // Swallowed on purpose: the send's own initSession() reports failures.
657
+ return this.initSession().catch(() => null);
658
+ }
659
+
624
660
  /**
625
661
  * Initialize session for client token mode.
626
- * Called automatically on first message if not already initialized.
662
+ * Called by the send if no live session exists. The widget normally calls
663
+ * it earlier, when the visitor shows intent to send (see the `sessionInit`
664
+ * config option and `warmSession()`), and concurrent calls share one
665
+ * in-flight request, so a send racing an early init issues a single
666
+ * `/v1/client/init`.
627
667
  */
628
668
  public async initSession(): Promise<ClientSession> {
629
669
  if (!this.isClientTokenMode()) {
@@ -640,21 +680,28 @@ export class AgentWidgetClient {
640
680
  return this.sessionInitPromise;
641
681
  }
642
682
 
643
- this.sessionInitPromise = this._doInitSession();
644
- try {
645
- const session = await this.sessionInitPromise;
646
- this.clientSession = session;
647
- // A freshly-minted session must resend the full WebMCP tool list on its
648
- // next turn: drop any diff-only fingerprint cached under a prior session,
649
- // so we never claim "unchanged" against a session the server didn't store
650
- // the set under. (Belt-and-suspenders with the sessionId comparison in the
651
- // send decision and the server's 409 resend signal.)
652
- this.resetClientToolsFingerprint();
653
- this.config.onSessionInit?.(session);
654
- return session;
655
- } finally {
656
- this.sessionInitPromise = null;
657
- }
683
+ // Callers that dedupe share this promise, so the stale check below covers
684
+ // them too (the send reusing an early init included).
685
+ const pending: Promise<ClientSession> = (this.sessionInitPromise = this._doInitSession()
686
+ .then((session) => {
687
+ // A clear or replacement (credential change, start-new, proof re-init)
688
+ // while this was in flight makes the result stale: never install it.
689
+ if (this.sessionInitPromise !== pending) return this.initSession();
690
+ this.clientSession = session;
691
+ this.sessionWarmLatch = false;
692
+ // A freshly-minted session must resend the full WebMCP tool list on its
693
+ // next turn: drop any diff-only fingerprint cached under a prior session,
694
+ // so we never claim "unchanged" against a session the server didn't store
695
+ // the set under. (Belt-and-suspenders with the sessionId comparison in the
696
+ // send decision and the server's 409 resend signal.)
697
+ this.resetClientToolsFingerprint();
698
+ this.config.onSessionInit?.(session);
699
+ return session;
700
+ })
701
+ .finally(() => {
702
+ if (this.sessionInitPromise === pending) this.sessionInitPromise = null;
703
+ }));
704
+ return pending;
658
705
  }
659
706
 
660
707
  /** Visitor history rides on client-token init only, and latches off after a 403 degrade. */
@@ -966,6 +1013,7 @@ export class AgentWidgetClient {
966
1013
  const installed = this.finishInit(session, previousConversationId, false);
967
1014
  this.clientSession = installed;
968
1015
  this.sessionInitPromise = null;
1016
+ this.sessionWarmLatch = false;
969
1017
  this.resetClientToolsFingerprint();
970
1018
  return installed;
971
1019
  }
@@ -1032,6 +1080,7 @@ export class AgentWidgetClient {
1032
1080
  if (settled) return;
1033
1081
  settled = true;
1034
1082
  this.clientSession = session;
1083
+ this.sessionWarmLatch = false;
1035
1084
  this.resetClientToolsFingerprint();
1036
1085
  },
1037
1086
  discard: () => {
@@ -1102,6 +1151,7 @@ export class AgentWidgetClient {
1102
1151
  public clearClientSession(): void {
1103
1152
  this.clientSession = null;
1104
1153
  this.sessionInitPromise = null;
1154
+ this.sessionWarmLatch = false;
1105
1155
  this.resetClientToolsFingerprint();
1106
1156
  }
1107
1157
 
@@ -1212,6 +1262,7 @@ export class AgentWidgetClient {
1212
1262
 
1213
1263
  if (response.status === 401) {
1214
1264
  this.clientSession = null;
1265
+ this.sessionWarmLatch = false;
1215
1266
  this.config.onSessionExpired?.();
1216
1267
  throw new Error('Session expired. Please refresh to continue.');
1217
1268
  }
@@ -1231,10 +1282,9 @@ export class AgentWidgetClient {
1231
1282
  messageId: string,
1232
1283
  type: 'upvote' | 'downvote' | 'copy'
1233
1284
  ): Promise<void> {
1234
- const session = this.getClientSession();
1235
- if (!session) {
1236
- throw new Error('No active session. Please initialize session first.');
1237
- }
1285
+ // Feedback on a restored transcript can precede any init (`sessionInit`
1286
+ // defers it to intent); the feedback click is intent enough.
1287
+ const session = this.getClientSession() ?? (await this.initSession());
1238
1288
 
1239
1289
  return this.sendFeedback({
1240
1290
  sessionId: session.sessionId,
@@ -1251,10 +1301,7 @@ export class AgentWidgetClient {
1251
1301
  * @param comment - Optional comment
1252
1302
  */
1253
1303
  public async submitCSATFeedback(rating: number, comment?: string): Promise<void> {
1254
- const session = this.getClientSession();
1255
- if (!session) {
1256
- throw new Error('No active session. Please initialize session first.');
1257
- }
1304
+ const session = this.getClientSession() ?? (await this.initSession());
1258
1305
 
1259
1306
  return this.sendFeedback({
1260
1307
  sessionId: session.sessionId,
@@ -1272,10 +1319,7 @@ export class AgentWidgetClient {
1272
1319
  * @param comment - Optional comment
1273
1320
  */
1274
1321
  public async submitNPSFeedback(rating: number, comment?: string): Promise<void> {
1275
- const session = this.getClientSession();
1276
- if (!session) {
1277
- throw new Error('No active session. Please initialize session first.');
1278
- }
1322
+ const session = this.getClientSession() ?? (await this.initSession());
1279
1323
 
1280
1324
  return this.sendFeedback({
1281
1325
  sessionId: session.sessionId,
@@ -1311,6 +1355,40 @@ export class AgentWidgetClient {
1311
1355
  return this.config.getIdentityProof ? 'verified-user' : 'browser';
1312
1356
  }
1313
1357
 
1358
+ private async resolveChatIdentityProof(signal?: AbortSignal): Promise<ClientChatRequest['identityProof']> {
1359
+ signal?.throwIfAborted();
1360
+ const provider = this.config.identityProvider;
1361
+ if (provider === undefined) return undefined;
1362
+ const getIdentityProof = this.config.getIdentityProof;
1363
+ if (!provider.trim() || !getIdentityProof) {
1364
+ throw new Error('Chat identity requires identityProvider and getIdentityProof.');
1365
+ }
1366
+ let token: string | null;
1367
+ let onAbort: (() => void) | undefined;
1368
+ try {
1369
+ const proof = Promise.resolve().then(() => getIdentityProof());
1370
+ token = signal
1371
+ ? await Promise.race([
1372
+ proof,
1373
+ new Promise<never>((_, reject) => {
1374
+ onAbort = () => reject(signal.reason);
1375
+ signal.addEventListener('abort', onAbort, { once: true });
1376
+ if (signal.aborted) onAbort();
1377
+ }),
1378
+ ])
1379
+ : await proof;
1380
+ } catch {
1381
+ signal?.throwIfAborted();
1382
+ throw new Error('The identity proof provider failed.');
1383
+ } finally {
1384
+ if (onAbort) signal?.removeEventListener('abort', onAbort);
1385
+ }
1386
+ if (typeof token !== 'string' || !token.trim()) {
1387
+ throw new Error('A fresh identity proof is required to send this message.');
1388
+ }
1389
+ return { provider, token };
1390
+ }
1391
+
1314
1392
  /**
1315
1393
  * Resolve the proof for one logical request. `null` is an intentional
1316
1394
  * browser-scope fallback only while the visitor has never been bound.
@@ -1850,6 +1928,7 @@ export class AgentWidgetClient {
1850
1928
  }
1851
1929
  session = this.finishInit(renewed, previous.conversationId ?? null, false);
1852
1930
  this.clientSession = session;
1931
+ this.sessionWarmLatch = false;
1853
1932
  this.resetClientToolsFingerprint();
1854
1933
  this.config.onSessionInit?.(session);
1855
1934
  };
@@ -1909,16 +1988,23 @@ export class AgentWidgetClient {
1909
1988
  const recoveryVisitorToken =
1910
1989
  session.durableRecovery?.enabled === true ? await this.readVisitorToken() : null;
1911
1990
  assertCurrentTurn();
1912
- return this.sendWithClientToolsDiff(session.sessionId, basePayload.clientTools, (toolFields) => {
1991
+ return this.sendWithClientToolsDiff(session.sessionId, basePayload.clientTools, async (toolFields) => {
1992
+ assertCurrentTurn();
1993
+ const identityProof = await this.resolveChatIdentityProof(options.signal);
1994
+ assertCurrentTurn();
1913
1995
  const chatRequest: ClientChatRequest = {
1914
1996
  ...baseChatRequest,
1915
1997
  sessionId: session.sessionId,
1916
1998
  ...toolFields,
1999
+ ...(identityProof && { identityProof }),
1917
2000
  };
1918
2001
 
1919
2002
  if (this.debug) {
1920
2003
  // eslint-disable-next-line no-console
1921
- console.debug("[AgentWidgetClient] client token dispatch", chatRequest);
2004
+ console.debug("[AgentWidgetClient] client token dispatch", {
2005
+ ...chatRequest,
2006
+ ...(identityProof && { identityProof: { provider: identityProof.provider, token: '[REDACTED]' } }),
2007
+ });
1922
2008
  }
1923
2009
 
1924
2010
  return fetch(this.getClientApiUrl('chat'), {
@@ -1951,6 +2037,12 @@ export class AgentWidgetClient {
1951
2037
  if (!response.ok) {
1952
2038
  const data = await response.json().catch(() => ({ error: 'Chat request failed' }));
1953
2039
 
2040
+ if (response.status === 401 && data.error === 'invalid_identity_proof') {
2041
+ const error = new HistoryClientError('invalid_identity_proof', PROOF_REJECTED_MESSAGE);
2042
+ forward({ type: 'error', error });
2043
+ throw error;
2044
+ }
2045
+
1954
2046
  if (response.status === 401) {
1955
2047
  // Session expired
1956
2048
  this.clearClientSession();
@@ -66,6 +66,30 @@ describe("codegen subpath", () => {
66
66
  });
67
67
  });
68
68
 
69
+ it("preserves an authored v5 defaults opt-in in every output format", () => {
70
+ const futureConfig = { ...config, future: { v5Defaults: true } };
71
+ for (const format of [
72
+ "esm",
73
+ "script-installer",
74
+ "script-manual",
75
+ "script-advanced",
76
+ "react-component",
77
+ "react-advanced",
78
+ ] as const) {
79
+ const code = fromSubpath(futureConfig, format);
80
+ expect(code).toContain("v5Defaults");
81
+ expect(code).toContain("true");
82
+ }
83
+
84
+ const installer = fromSubpath(futureConfig, "script-installer");
85
+ const payload = JSON.parse(installer.match(/data-config='([^']*)'/)![1]);
86
+ expect(payload.future).toEqual({ v5Defaults: true });
87
+
88
+ expect(
89
+ fromSubpath({ ...config, future: { v5Defaults: false } }, "esm")
90
+ ).toContain("future: { v5Defaults: false }");
91
+ });
92
+
69
93
  describe("mount target option", () => {
70
94
  it("defaults to body when no target is given", () => {
71
95
  expect(fromSubpath(config, "react-component")).toContain("target: 'body'");
@@ -0,0 +1,100 @@
1
+ // @vitest-environment jsdom
2
+ import { afterEach, describe, expect, it, vi } from 'vitest';
3
+ import { activityDuration, createActivityLifecycle } from './activity-row';
4
+ import { createToolBubble } from './tool-bubble';
5
+ import { createReasoningBubble } from './reasoning-bubble';
6
+ import type { AgentWidgetMessage } from '../types';
7
+ const message = (status: 'pending' | 'running' | 'complete' = 'running', chunks: string[] = []): AgentWidgetMessage => ({ id: 'tool', role: 'assistant', content: '', createdAt: '2026-01-01', variant: 'tool', toolCall: { id: 'tool', name: 'Search', status, chunks, durationMs: 65000 } });
8
+ afterEach(() => vi.useRealTimers());
9
+ describe.each([false, true])('activity rows (V5 %s)', v5Defaults => {
10
+ it('defaults to the selected variant and supports explicit overrides', () => {
11
+ const config = { future: { v5Defaults } };
12
+ expect(createToolBubble(message(), config).classList.contains('persona-activity-row')).toBe(v5Defaults);
13
+ for (const variant of ['card', 'row'] as const) {
14
+ const node = createToolBubble(message('complete'), { ...config, features: { toolCallDisplay: { variant } } });
15
+ expect(node.classList.contains('persona-activity-row')).toBe(variant === 'row');
16
+ if (variant === 'row') { expect(node.textContent).toContain('Used Search'); expect(node.textContent).toContain('1m 5s'); }
17
+ }
18
+ });
19
+ it('resolves icon visibility and removes the icon slot on successful completion', () => {
20
+ for (const kind of ['tool', 'reasoning'] as const) {
21
+ for (const mode of [undefined, 'always', 'active', 'never'] as const) {
22
+ const key = kind === 'tool' ? 'toolCallDisplay' : 'reasoningDisplay';
23
+ const config = { future: { v5Defaults }, features: { [key]: { variant: 'row' as const, iconVisibility: mode } } };
24
+ const m = message('complete');
25
+ const node = kind === 'tool' ? createToolBubble(m, config) : createReasoningBubble({ ...m, toolCall: undefined, variant: 'reasoning', reasoning: { id: 'r', status: 'complete', chunks: ['Details'] } }, config);
26
+ expect(!!node.querySelector('.persona-activity-icon')).toBe((mode ?? (v5Defaults ? 'active' : 'always')) === 'always');
27
+ expect(node.querySelector('.persona-activity-chevron')).not.toBeNull();
28
+ }
29
+ }
30
+ });
31
+ it('keeps attention and active indicators unless explicitly disabled', () => {
32
+ for (const status of ['pending', 'running', 'complete'] as const) {
33
+ const m = message(status); if (status === 'complete') m.toolCall!.success = false;
34
+ for (const iconVisibility of ['active', 'never'] as const) {
35
+ const node = createToolBubble(m, { future: { v5Defaults }, features: { toolCallDisplay: { variant: 'row', iconVisibility } } });
36
+ expect(!!node.querySelector('.persona-activity-icon')).toBe(iconVisibility === 'active');
37
+ }
38
+ }
39
+ });
40
+ it('keeps custom content and exposes accessible row controls', () => {
41
+ const node = createToolBubble(message(), { future: { v5Defaults }, features: { toolCallDisplay: { variant: 'row' } }, toolCall: { renderCollapsedSummary: () => 'Custom summary' } });
42
+ expect(node.textContent?.replace(/\u00a0/g, ' ')).toContain('Custom summary');
43
+ const button = node.querySelector('button')!;
44
+ expect(button.getAttribute('aria-expanded')).toBe('false');
45
+ expect(button.getAttribute('aria-controls')).toBe(node.querySelector('.persona-activity-body')?.id);
46
+ expect(Array.from(button.children).map(el => el.className)).toEqual(expect.arrayContaining(['persona-activity-icon']));
47
+ expect(button.children[1].classList.contains('persona-activity-label')).toBe(true);
48
+ expect(button.children[2].classList.contains('persona-activity-chevron')).toBe(true);
49
+ });
50
+ it('formats completed reasoning and retains card override', () => {
51
+ const m: AgentWidgetMessage = { ...message(), variant: 'reasoning', toolCall: undefined, reasoning: { id: 'r', status: 'complete', chunks: ['Details'], durationMs: 100 } };
52
+ const node = createReasoningBubble(m, { future: { v5Defaults }, features: { reasoningDisplay: { variant: 'row' } } });
53
+ expect(node.textContent).toContain('Thought for a moment');
54
+ });
55
+ });
56
+ it.each([['pending', 'pending'], ['running', 'running'], ['complete', 'done']] as const)('maps %s status to %s', (status, expected) => {
57
+ const node = createToolBubble(message(status), { features: { toolCallDisplay: { variant: 'row' } } });
58
+ expect(node.dataset.activityState).toBe(expected);
59
+ expect(node.querySelector('.persona-activity-icon svg')).not.toBeNull();
60
+ });
61
+ it('maps failure, denied, and approval-waiting icons', () => {
62
+ const m = message('complete'); m.toolCall!.success = false;
63
+ expect(createToolBubble(m, { future: { v5Defaults: true } }).dataset.activityState).toBe('error');
64
+ for (const status of ['denied', 'pending'] as const) {
65
+ m.approval = { id: 'a', status, agentId: 'a', executionId: 'e', toolName: 'Search', description: '' };
66
+ expect(createToolBubble(m, { future: { v5Defaults: true } }).dataset.activityState).toBe(status === 'denied' ? 'denied' : 'awaiting-approval');
67
+ }
68
+ });
69
+ it('formats short and multi-minute durations', () => {
70
+ expect([undefined, 200, 12000, 65000].map(activityDuration)).toEqual(['a moment', 'a moment', '12s', '1m 5s']);
71
+ });
72
+ describe('activity lifecycle', () => {
73
+ it('waits for a token and collapses exactly once after completion', () => {
74
+ vi.useFakeTimers(); const changed = vi.fn(); const lifecycle = createActivityLifecycle(changed); const expanded = new Set<string>();
75
+ lifecycle.observe(message(), 'tool', expanded); expect(expanded.size).toBe(0);
76
+ lifecycle.observe(message('running', ['token']), 'tool', expanded); expect(expanded.has('tool')).toBe(true);
77
+ lifecycle.observe(message('complete', ['token']), 'tool', expanded);
78
+ vi.advanceTimersByTime(999); expect(expanded.has('tool')).toBe(true);
79
+ vi.advanceTimersByTime(1); expect(expanded.size).toBe(0); expect(changed).toHaveBeenCalledTimes(1);
80
+ expanded.add('tool'); lifecycle.observe(message('complete', ['token']), 'tool', expanded); vi.advanceTimersByTime(2000); expect(expanded.has('tool')).toBe(true);
81
+ });
82
+ it('manual control cancels automatic collapse and expansion', () => {
83
+ vi.useFakeTimers(); const changed = vi.fn(); const lifecycle = createActivityLifecycle(changed); const expanded = new Set<string>();
84
+ lifecycle.observe(message('running', ['token']), 'tool', expanded);
85
+ lifecycle.observe(message('complete', ['token']), 'tool', expanded); lifecycle.manual('tool');
86
+ vi.advanceTimersByTime(2000); expect(expanded.has('tool')).toBe(true); expect(changed).not.toHaveBeenCalled();
87
+ lifecycle.clear(); expanded.clear(); lifecycle.manual('tool'); lifecycle.observe(message('running', ['token']), 'tool', expanded); expect(expanded.size).toBe(0);
88
+ });
89
+ it('supports disabling automatics and cleans pending timers on clear/prune', () => {
90
+ vi.useFakeTimers(); const changed = vi.fn(); const lifecycle = createActivityLifecycle(changed); const expanded = new Set<string>();
91
+ lifecycle.observe(message('running', ['token']), 'tool', expanded, { autoExpand: false, autoCollapseDelay: false });
92
+ lifecycle.observe(message('complete', ['token']), 'tool', expanded, { autoCollapseDelay: false });
93
+ expect(expanded.size).toBe(0); expect(vi.getTimerCount()).toBe(0);
94
+ for (const cleanup of ['clear', 'prune'] as const) {
95
+ lifecycle.clear(); lifecycle.observe(message('running', ['token']), 'tool', expanded); lifecycle.observe(message('complete', ['token']), 'tool', expanded);
96
+ if (cleanup === 'clear') lifecycle.clear(); else lifecycle.prune(new Set());
97
+ vi.advanceTimersByTime(2000); expect(changed).not.toHaveBeenCalled();
98
+ }
99
+ });
100
+ });
@@ -0,0 +1,136 @@
1
+ import { Circle, Clock } from "lucide";
2
+ import { renderIconNode } from "../utils/icon-node";
3
+ import type { AgentWidgetConfig, AgentWidgetMessage } from "../types";
4
+ import { DEFAULT_TOOL_CALL_DISPLAY, DEFAULT_REASONING_DISPLAY, DEFAULTS_V5 } from "../defaults";
5
+ import { createElement } from "../utils/dom";
6
+ import { renderLucideIcon } from "../utils/icons";
7
+ import { appendHeaderToggle } from "./expandable-bubble";
8
+
9
+ export type ActivityKind = "tool" | "reasoning";
10
+
11
+ /** Standalone builders and the full UI use the same versioned defaults. */
12
+ export function activityDisplay(config: AgentWidgetConfig | undefined, kind: ActivityKind): AgentWidgetConfig {
13
+ const key = kind === "tool" ? "toolCallDisplay" : "reasoningDisplay";
14
+ return { ...config, features: { ...config?.features, [key]: {
15
+ ...(kind === "tool" ? DEFAULT_TOOL_CALL_DISPLAY : DEFAULT_REASONING_DISPLAY),
16
+ ...(config?.future?.v5Defaults === true ? DEFAULTS_V5.features?.[key] : {}),
17
+ ...config?.features?.[key],
18
+ } } };
19
+ }
20
+
21
+ export function activityVariant(config: AgentWidgetConfig | undefined, kind: ActivityKind): "card" | "row" {
22
+ const key = kind === "tool" ? "toolCallDisplay" : "reasoningDisplay";
23
+ return config?.features?.[key]?.variant ?? (config?.future?.v5Defaults ? "row" : "card");
24
+ }
25
+
26
+ export function activityDuration(ms: number | undefined): string {
27
+ const seconds = Math.max(0, Math.floor((ms ?? 0) / 1000));
28
+ if (seconds < 1) return "a moment";
29
+ return seconds < 60 ? `${seconds}s` : `${Math.floor(seconds / 60)}m ${seconds % 60}s`;
30
+ }
31
+
32
+ /** Reuses card content/render hooks while replacing only its visual chrome. */
33
+ export function applyActivityRow(bubble: HTMLElement, message: AgentWidgetMessage, config: AgentWidgetConfig, kind: ActivityKind): HTMLElement {
34
+ if (activityVariant(config, kind) !== "row") return bubble;
35
+ bubble.className = `persona-message-bubble persona-${kind}-bubble persona-activity-row`;
36
+ bubble.dataset.activityKind = kind;
37
+ if (kind === "tool" && config.toolCall?.shadow === undefined) bubble.style.boxShadow = "var(--persona-tool-bubble-shadow, none)";
38
+ const header = bubble.querySelector<HTMLButtonElement>(":scope > button");
39
+ if (!header) return bubble;
40
+ header.className = "persona-activity-header";
41
+ const data = kind === "tool" ? message.toolCall : message.reasoning;
42
+ const active = data?.status !== "complete";
43
+ const approval = message.approval?.status ?? message.toolCall?.approvalStatus;
44
+ const state = approval === "denied" ? "denied" : approval === "pending" ? "awaiting-approval"
45
+ : message.toolCall?.success === false ? "error" : !active ? "done"
46
+ : data?.status === "pending" ? "pending" : "running";
47
+ bubble.dataset.activityState = state;
48
+ const icon = createElement("span", "persona-activity-icon");
49
+ icon.dataset.activityIcon = state;
50
+ const names = { pending: "circle", running: "loader-circle", done: "check", error: "x", denied: "x", "awaiting-approval": "clock" };
51
+ const customIcon = kind === "reasoning" ? config.features?.reasoningDisplay?.iconName : undefined;
52
+ const glyph = customIcon ? renderLucideIcon(customIcon, 16, "currentColor", 2)
53
+ : state === "pending" ? renderIconNode(Circle, 16, "currentColor", 2)
54
+ : state === "awaiting-approval" ? renderIconNode(Clock, 16, "currentColor", 2)
55
+ : renderLucideIcon(names[state], 16, "currentColor", 2);
56
+ if (glyph) icon.appendChild(glyph);
57
+ header.querySelector(".persona-reasoning-header-icon")?.remove();
58
+ const displayKey = kind === "tool" ? "toolCallDisplay" : "reasoningDisplay";
59
+ const visibility = config.features?.[displayKey]?.iconVisibility ??
60
+ (config.future?.v5Defaults ? "active" : "always");
61
+ if (visibility === "always" || (visibility === "active" && state !== "done")) header.prepend(icon);
62
+ if (kind === "tool" && data) {
63
+ const duration = createElement("span", "persona-activity-duration");
64
+ if (active && data.startedAt) duration.setAttribute("data-tool-elapsed", String(data.startedAt));
65
+ const elapsed = (kind === "tool" ? message.toolCall?.duration : undefined) ?? data.durationMs ?? (data.completedAt !== undefined && data.startedAt !== undefined ? data.completedAt - data.startedAt : undefined);
66
+ duration.textContent = elapsed === undefined ? "" : activityDuration(elapsed);
67
+ header.appendChild(duration);
68
+ }
69
+ const label = header.querySelector<HTMLElement>(":scope > div:not(.persona-ml-auto)");
70
+ label?.classList.add("persona-activity-label");
71
+ const meta = header.querySelector<HTMLElement>(".persona-ml-auto");
72
+ meta?.classList.add("persona-activity-chevron");
73
+ const body = bubble.querySelector<HTMLElement>(":scope > .persona-border-t");
74
+ body?.classList.add("persona-activity-body");
75
+ if (body) { body.id = `activity-details-${message.id}`; header.setAttribute("aria-controls", body.id); }
76
+ return bubble;
77
+ }
78
+
79
+ /** Per-widget lifecycle: one open and one delayed close; user intent is sticky. */
80
+ export function createActivityLifecycle(onCollapse: (id: string, kind: ActivityKind) => void) {
81
+ const seen = new Set<string>();
82
+ const manual = new Set<string>();
83
+ const finished = new Set<string>();
84
+ const timers = new Map<string, ReturnType<typeof setTimeout>>();
85
+ const cancel = (id: string) => { clearTimeout(timers.get(id)); timers.delete(id); };
86
+ return {
87
+ observe(message: AgentWidgetMessage, kind: ActivityKind, expanded: Set<string>, options: { autoExpand?: boolean; autoCollapseDelay?: number | false } = {}) {
88
+ const data = kind === "tool" ? message.toolCall : message.reasoning;
89
+ if (!data || manual.has(message.id)) return;
90
+ if (!seen.has(message.id) && data.status !== "complete" && data.chunks?.some(Boolean)) {
91
+ seen.add(message.id);
92
+ if (options.autoExpand !== false) expanded.add(message.id);
93
+ }
94
+ if (seen.has(message.id) && data.status === "complete" && !finished.has(message.id)) {
95
+ finished.add(message.id);
96
+ if (options.autoCollapseDelay === false) return;
97
+ timers.set(message.id, setTimeout(() => {
98
+ timers.delete(message.id);
99
+ expanded.delete(message.id);
100
+ onCollapse(message.id, kind);
101
+ }, options.autoCollapseDelay ?? 1000));
102
+ }
103
+ },
104
+ manual(id: string) { manual.add(id); cancel(id); },
105
+ prune(ids: Set<string>) {
106
+ for (const id of new Set([...seen, ...manual, ...finished])) {
107
+ if (!ids.has(id)) { cancel(id); seen.delete(id); manual.delete(id); finished.delete(id); }
108
+ }
109
+ },
110
+ clear() { for (const id of timers.keys()) cancel(id); seen.clear(); manual.clear(); finished.clear(); },
111
+ };
112
+ }
113
+
114
+ /** A collapsible group uses the same accessible row and expansion contract. */
115
+ export function createActivityGroup(message: AgentWidgetMessage, config: AgentWidgetConfig, expanded: boolean, label: string | HTMLElement): { bubble: HTMLElement; body: HTMLElement } {
116
+ const bubble = createElement("div", "");
117
+ bubble.id = `bubble-${message.id}`;
118
+ bubble.dataset.messageId = message.id;
119
+ const header = createElement("button", "") as HTMLButtonElement;
120
+ header.type = "button";
121
+ header.dataset.expandHeader = "true";
122
+ header.dataset.bubbleType = "tool";
123
+ header.setAttribute("aria-expanded", String(expanded));
124
+ const copy = createElement("div", "");
125
+ if (typeof label === "string") copy.textContent = label; else copy.appendChild(label);
126
+ appendHeaderToggle(header, copy, { expandable: true, expanded, iconColor: config.toolCall?.toggleTextColor || config.toolCall?.headerTextColor || "currentColor", metaGap: true });
127
+ const body = createElement("div", "persona-border-t");
128
+ body.id = `activity-details-${message.id}`;
129
+ body.style.display = expanded ? "" : "none";
130
+ header.setAttribute("aria-controls", body.id);
131
+ bubble.append(header, body);
132
+ applyActivityRow(bubble, message, { ...config, features: { ...config.features, toolCallDisplay: { ...config.features?.toolCallDisplay, variant: "row" } } }, "tool");
133
+ bubble.dataset.personaToolGroup = "true";
134
+ if (config.features?.toolCallDisplay?.loadingAnimation === "shimmer") bubble.dataset.activityGroupShimmer = "true";
135
+ return { bubble, body };
136
+ }
@@ -1,8 +1,7 @@
1
1
  // @vitest-environment jsdom
2
2
 
3
- import { afterEach, describe, expect, it, vi } from "vitest";
3
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
4
4
  import { createBuiltInApprovalPlugin } from "./approval-actions";
5
- import { approvalDetailsExpansionState } from "./approval-bubble";
6
5
  import type {
7
6
  AgentWidgetApproval,
8
7
  AgentWidgetConfig,
@@ -43,9 +42,10 @@ const makeMessage = (
43
42
  // Each widget owns its own plugin instance + teardown (state is per-instance).
44
43
  // Track teardowns so afterEach can release any leftover document listeners.
45
44
  let teardowns: Array<() => void> = [];
45
+ let expansionState: Map<string, boolean>;
46
46
 
47
47
  const makePlugin = (): { plugin: AgentWidgetPlugin; teardown: () => void } => {
48
- const handle = createBuiltInApprovalPlugin();
48
+ const handle = createBuiltInApprovalPlugin(expansionState);
49
49
  teardowns.push(handle.teardown);
50
50
  return handle;
51
51
  };
@@ -81,10 +81,13 @@ const click = (el: Element | null | undefined): void => {
81
81
  afterEach(() => {
82
82
  teardowns.forEach((t) => t());
83
83
  teardowns = [];
84
- approvalDetailsExpansionState.clear();
85
84
  document.body.innerHTML = "";
86
85
  });
87
86
 
87
+ beforeEach(() => {
88
+ expansionState = new Map<string, boolean>();
89
+ });
90
+
88
91
  describe("built-in approval — flag off (default)", () => {
89
92
  it("renders a single Allow + Deny, no split control", () => {
90
93
  const { el } = render();
@@ -226,7 +229,7 @@ describe("built-in approval — parameters disclosure", () => {
226
229
  expect(pre?.hidden).toBe(true);
227
230
  click(el?.querySelector(".persona-approval-head"));
228
231
  expect(pre?.hidden).toBe(false);
229
- expect(approvalDetailsExpansionState.get("msg-1")).toBe(true);
232
+ expect(expansionState.get("msg-1")).toBe(true);
230
233
  });
231
234
 
232
235
  it("detailsDisplay:'expanded' shows params up front", () => {
@@ -34,7 +34,7 @@ import type {
34
34
  AgentWidgetApprovalConfig,
35
35
  } from "../types";
36
36
  import type { AgentWidgetPlugin } from "../plugins/types";
37
- import { humanizeToolName, approvalDetailsExpansionState } from "./approval-bubble";
37
+ import { humanizeToolName } from "./approval-bubble";
38
38
 
39
39
  type Approval = NonNullable<AgentWidgetMessage["approval"]>;
40
40
  type Decide = (options?: { remember?: boolean }) => void;
@@ -103,10 +103,11 @@ const resolveApprovalConfig = (
103
103
 
104
104
  const isDetailsExpanded = (
105
105
  messageId: string,
106
+ expansionState: Map<string, boolean>,
106
107
  approvalConfig?: AgentWidgetApprovalConfig
107
108
  ): boolean => {
108
109
  const mode = approvalConfig?.detailsDisplay ?? "collapsed";
109
- return approvalDetailsExpansionState.get(messageId) ?? mode === "expanded";
110
+ return expansionState.get(messageId) ?? mode === "expanded";
110
111
  };
111
112
 
112
113
  const kbd = (label: string): HTMLElement => {
@@ -173,6 +174,7 @@ const buildResolvedTrace = (approval: Approval): HTMLElement => {
173
174
 
174
175
  const buildPending = (
175
176
  state: InstanceState,
177
+ expansionState: Map<string, boolean>,
176
178
  message: AgentWidgetMessage,
177
179
  approval: Approval,
178
180
  approvalConfig: AgentWidgetApprovalConfig | undefined,
@@ -199,7 +201,7 @@ const buildPending = (
199
201
  const hasDescription = Boolean(approval.description) && detailsMode !== "hidden";
200
202
  const hasParams = approval.parameters != null && detailsMode !== "hidden";
201
203
  const hasDetails = hasDescription || hasParams;
202
- const expanded = hasDetails && isDetailsExpanded(message.id, approvalConfig);
204
+ const expanded = hasDetails && isDetailsExpanded(message.id, expansionState, approvalConfig);
203
205
 
204
206
  // The card uses the whole header as the disclosure toggle (chevron-only, no
205
207
  // separate text button), but the legacy `showDetailsLabel`/`hideDetailsLabel`
@@ -351,7 +353,7 @@ const buildPending = (
351
353
  pre.hidden = !willOpen;
352
354
  head.setAttribute("aria-expanded", willOpen ? "true" : "false");
353
355
  head.setAttribute("aria-label", willOpen ? hideDetailsLabel : showDetailsLabel);
354
- approvalDetailsExpansionState.set(message.id, willOpen);
356
+ expansionState.set(message.id, willOpen);
355
357
  }
356
358
  return;
357
359
  }
@@ -389,7 +391,9 @@ const buildPending = (
389
391
  * callbacks — both close over the SAME per-instance state, so destroying one
390
392
  * widget never disturbs another widget's open approvals on the same page.
391
393
  */
392
- export const createBuiltInApprovalPlugin = (): {
394
+ export const createBuiltInApprovalPlugin = (
395
+ expansionState = new Map<string, boolean>()
396
+ ): {
393
397
  plugin: AgentWidgetPlugin;
394
398
  teardown: () => void;
395
399
  } => {
@@ -419,7 +423,16 @@ export const createBuiltInApprovalPlugin = (): {
419
423
  // re-render of an older card doesn't reorder it ahead of a newer one.
420
424
  detachMessage(state, message.id);
421
425
  const enableAlways = approvalConfig?.enableAlwaysAllow === true;
422
- const card = buildPending(state, message, approval, approvalConfig, approve, deny, enableAlways);
426
+ const card = buildPending(
427
+ state,
428
+ expansionState,
429
+ message,
430
+ approval,
431
+ approvalConfig,
432
+ approve,
433
+ deny,
434
+ enableAlways
435
+ );
423
436
 
424
437
  if (enableAlways) {
425
438
  if (!state.pendingOrder.includes(message.id)) state.pendingOrder.push(message.id);