@runtypelabs/persona 4.23.2 → 4.24.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 (156) 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 +215 -37
  12. package/dist/approval-ui.d.ts +215 -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 +210 -25
  16. package/dist/artifacts-ui.d.ts +210 -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 +210 -25
  21. package/dist/context-mentions.d.ts +210 -25
  22. package/dist/event-stream-view.d.cts +210 -25
  23. package/dist/event-stream-view.d.ts +210 -25
  24. package/dist/forms-ui.d.cts +245 -27
  25. package/dist/forms-ui.d.ts +245 -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 +270 -39
  31. package/dist/index.d.ts +270 -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 +210 -25
  41. package/dist/session-reconnect.d.ts +210 -25
  42. package/dist/smart-dom-reader.d.cts +210 -25
  43. package/dist/smart-dom-reader.d.ts +210 -25
  44. package/dist/theme-editor-preview.cjs +74 -73
  45. package/dist/theme-editor-preview.d.cts +217 -25
  46. package/dist/theme-editor-preview.d.ts +217 -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 +230 -27
  50. package/dist/theme-editor.d.ts +230 -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/widget.css +1 -1
  57. package/package.json +3 -2
  58. package/src/__fixtures__/defaults-parity.v4.json +776 -0
  59. package/src/approval-ui-entry.ts +0 -1
  60. package/src/approval-ui-loader.ts +5 -4
  61. package/src/approval-ui.ts +0 -1
  62. package/src/client-identity-proof.test.ts +193 -0
  63. package/src/client-session-warm.test.ts +216 -0
  64. package/src/client-visitor-history.test.ts +6 -0
  65. package/src/client.ts +122 -30
  66. package/src/codegen.test.ts +24 -0
  67. package/src/components/activity-row.test.ts +100 -0
  68. package/src/components/activity-row.ts +136 -0
  69. package/src/components/approval-actions.test.ts +8 -5
  70. package/src/components/approval-actions.ts +19 -6
  71. package/src/components/approval-bubble.test.ts +56 -9
  72. package/src/components/approval-bubble.ts +36 -41
  73. package/src/components/composer-builder.ts +5 -2
  74. package/src/components/composer-parts.ts +10 -2
  75. package/src/components/header-builder.test.ts +32 -0
  76. package/src/components/header-builder.ts +7 -4
  77. package/src/components/header-layouts.test.ts +12 -0
  78. package/src/components/header-layouts.ts +3 -2
  79. package/src/components/launcher.ts +11 -2
  80. package/src/components/message-bubble.test.ts +22 -0
  81. package/src/components/message-bubble.ts +7 -3
  82. package/src/components/panel.test.ts +52 -1
  83. package/src/components/panel.ts +47 -8
  84. package/src/components/pill-composer-builder.test.ts +2 -0
  85. package/src/components/pill-composer-builder.ts +0 -1
  86. package/src/components/reasoning-bubble.ts +29 -17
  87. package/src/components/suggestions.test.ts +1 -0
  88. package/src/components/suggestions.ts +0 -16
  89. package/src/components/tool-bubble.ts +131 -114
  90. package/src/components/tool-details.test.ts +87 -0
  91. package/src/components/tool-details.ts +102 -0
  92. package/src/components/welcome.test.ts +8 -0
  93. package/src/components/welcome.ts +4 -2
  94. package/src/defaults-parity.test.ts +158 -0
  95. package/src/defaults.test.ts +69 -0
  96. package/src/defaults.ts +128 -49
  97. package/src/generated/runtype-openapi-contract.ts +2 -2
  98. package/src/index-core.ts +2 -0
  99. package/src/install.test.ts +24 -0
  100. package/src/install.ts +3 -1
  101. package/src/launcher-global.ts +2 -1
  102. package/src/launcher-variants.test.ts +52 -0
  103. package/src/runtime/init-update-reset.test.ts +21 -0
  104. package/src/session.ts +29 -6
  105. package/src/styles/widget-styles.test.ts +19 -0
  106. package/src/styles/widget.css +295 -23
  107. package/src/theme-editor/index.ts +2 -0
  108. package/src/theme-editor/preview-utils.test.ts +8 -0
  109. package/src/theme-editor/preview-utils.ts +7 -3
  110. package/src/theme-editor/sections.test.ts +31 -0
  111. package/src/theme-editor/sections.ts +55 -1
  112. package/src/theme-editor/state-preferences.test.ts +49 -0
  113. package/src/theme-editor/state.ts +73 -15
  114. package/src/theme-reference.test.ts +20 -0
  115. package/src/theme-reference.ts +11 -5
  116. package/src/tool-call-display-defaults.test.ts +33 -1
  117. package/src/types/theme.ts +80 -3
  118. package/src/types.ts +136 -22
  119. package/src/ui.activity-row.test.ts +91 -0
  120. package/src/ui.approval-plugin.test.ts +63 -0
  121. package/src/ui.composer-compact.test.ts +24 -2
  122. package/src/ui.composer-placement.test.ts +23 -0
  123. package/src/ui.detached-panel.test.ts +33 -6
  124. package/src/ui.docked.test.ts +19 -0
  125. package/src/ui.header-update-stability.test.ts +25 -0
  126. package/src/ui.identity-proof.test.ts +58 -0
  127. package/src/ui.launcher-teaser.test.ts +25 -0
  128. package/src/ui.message-width.test.ts +17 -2
  129. package/src/ui.reasoning-display.test.ts +92 -0
  130. package/src/ui.rebuild-composer.test.ts +20 -3
  131. package/src/ui.scroll.test.ts +32 -0
  132. package/src/ui.session-init.test.ts +312 -0
  133. package/src/ui.status-indicator.test.ts +82 -0
  134. package/src/ui.suggest-replies.test.ts +11 -0
  135. package/src/ui.ts +363 -74
  136. package/src/ui.v5-defaults.test.ts +144 -0
  137. package/src/ui.welcome.test.ts +92 -3
  138. package/src/utils/__snapshots__/tokens-parity.test.ts.snap +811 -140
  139. package/src/utils/code-generators.ts +14 -0
  140. package/src/utils/composer-compact.test.ts +0 -2
  141. package/src/utils/composer-compact.ts +2 -4
  142. package/src/utils/composer-placement.ts +2 -3
  143. package/src/utils/config-merge.test.ts +81 -5
  144. package/src/utils/config-merge.ts +13 -3
  145. package/src/utils/defaults-provenance.ts +67 -0
  146. package/src/utils/dock.ts +7 -1
  147. package/src/utils/launcher-variant.ts +16 -0
  148. package/src/utils/panel-config.ts +97 -0
  149. package/src/utils/status-indicator.ts +39 -0
  150. package/src/utils/theme.test.ts +182 -0
  151. package/src/utils/theme.ts +131 -18
  152. package/src/utils/tokens.test.ts +122 -1
  153. package/src/utils/tokens.ts +177 -17
  154. package/src/welcome.test.ts +23 -0
  155. package/src/welcome.ts +16 -9
  156. package/dist/approval-ui-K7R5Q5OC.js +0 -1
package/src/ui.ts CHANGED
@@ -1,3 +1,5 @@
1
+ import { activityVariant, activityDuration, createActivityLifecycle, createActivityGroup } from "./components/activity-row";
2
+ import { applyStatusIndicatorState, placeStatusIndicator } from "./utils/status-indicator";
1
3
  import { usesSessionVoice } from "./utils/voice-support";
2
4
  import { escapeHtml, createMarkdownProcessorFromConfig } from "./postprocessors";
3
5
  import { resolveSanitizer } from "./utils/sanitize";
@@ -13,6 +15,7 @@ import {
13
15
  import {
14
16
  AgentWidgetConfig,
15
17
  AgentWidgetConfigPatch,
18
+ AgentWidgetSessionInitHook,
16
19
  AgentWidgetApprovalDecisionOptions,
17
20
  AgentWidgetMessage,
18
21
  AgentWidgetEvent,
@@ -147,8 +150,9 @@ import {
147
150
  } from "./utils/context-mention-orchestrator";
148
151
  import type { MentionSubmitBundle } from "./utils/context-mention-manager";
149
152
  import { createTextPart, ALL_SUPPORTED_MIME_TYPES } from "./utils/content";
150
- import { applyThemeVariables, createThemeObserver, getActiveTheme } from "./utils/theme";
153
+ import { applyThemeVariables, createThemeObserver, getActiveTheme, getColorScheme } from "./utils/theme";
151
154
  import { resolveTokenValue } from "./utils/tokens";
155
+ import { deepMerge } from "./utils/deep-merge";
152
156
  import { Activity, Check, Copy } from "lucide";
153
157
  import { renderLucideIcon, onExtraIconsReady } from "./utils/icons";
154
158
  import { renderIconNode } from "./utils/icon-node";
@@ -222,6 +226,7 @@ import {
222
226
  attachHeaderToContainer,
223
227
  COMPOSER_BAR_CLEAR_CHAT_ICON_SIZE,
224
228
  COMPOSER_BAR_CLOSE_ICON_SIZE,
229
+ resolvePanelGeometry,
225
230
  } from "./components/panel";
226
231
  import { buildPillComposer } from "./components/pill-composer-builder";
227
232
  import {
@@ -263,8 +268,9 @@ import {
263
268
  getBubbleClasses,
264
269
  CUSTOM_MESSAGE_ACTION_PREFIX,
265
270
  } from "./components/message-bubble";
266
- import { createReasoningBubble, reasoningExpansionState, updateReasoningBubbleUI } from "./components/reasoning-bubble";
267
- import { createToolBubble, toolExpansionState, updateToolBubbleUI } from "./components/tool-bubble";
271
+ import { createReasoningBubble, updateReasoningBubbleUI } from "./components/reasoning-bubble";
272
+ import { copyToolDetail } from "./components/tool-details";
273
+ import { createToolBubble, updateToolBubbleUI } from "./components/tool-bubble";
268
274
  import {
269
275
  buildStructuredAnswers,
270
276
  ensureAskUserQuestionSheet,
@@ -324,8 +330,9 @@ import {
324
330
  } from "./utils/artifact-resize";
325
331
  import { loadFormsUi, getFormsUiSync } from "./forms-ui-loader";
326
332
  import { pluginRegistry } from "./plugins/registry";
327
- import { mergeWithDefaults, DEFAULT_FLOATING_LAUNCHER_WIDTH } from "./defaults";
333
+ import { DEFAULT_HEADER_ICON_SIZE, mergeWithDefaults } from "./defaults";
328
334
  import { mergeConfigUpdate } from "./utils/config-merge";
335
+ import { getPanelAliasProvenance } from "./utils/panel-config";
329
336
  import { createEventBus } from "./utils/events";
330
337
  import {
331
338
  createActionManager,
@@ -701,6 +708,13 @@ type Controller = {
701
708
  ) => Promise<void>;
702
709
  /** Clean local state plus a new owned server conversation. `focus` as on `openConversation`. */
703
710
  startNewConversation: (opts?: { focus?: boolean }) => Promise<void>;
711
+ /**
712
+ * Client token mode: start `/v1/client/init` now, ahead of the first send
713
+ * (e.g. when the visitor hovers your own "Chat with us" button).
714
+ * Fire-and-forget, at most once per session lifetime, errors swallowed; the
715
+ * send reuses the in-flight init. No-op in other modes. See `sessionInit`.
716
+ */
717
+ warmSession: () => void;
704
718
  /** Permanently delete one server record; the active one is replaced. */
705
719
  deleteConversation: (conversationId: string) => Promise<void>;
706
720
  /**
@@ -930,6 +944,18 @@ export const createAgentExperience = (
930
944
  }
931
945
 
932
946
  let config = mergeWithDefaults(initialConfig) as AgentWidgetConfig;
947
+ // Message ids are only unique within a widget session. Keep disclosure state
948
+ // beside the widget that renders it, not in component module scope.
949
+ const toolExpansionState = new Set<string>();
950
+ const reasoningExpansionState = new Set<string>();
951
+ const activityLifecycle = createActivityLifecycle((id, kind) => {
952
+ messageCache.delete(id);
953
+ const bubble = Array.from(messagesWrapper.querySelectorAll<HTMLElement>("[data-message-id]")).find(node => node.dataset.messageId === id);
954
+ if (!bubble) return;
955
+ if (kind === "tool") updateToolBubbleUI(id, bubble, toolExpansionState, config);
956
+ else updateReasoningBubbleUI(id, bubble, reasoningExpansionState);
957
+ });
958
+ const approvalDetailsExpansionState = new Map<string, boolean>();
933
959
  const applyTooltipTiming = (): void => {
934
960
  configureTooltipTiming({
935
961
  delayMs: config.tooltip?.delayMs ?? DEFAULT_TOOLTIP_DELAY_MS,
@@ -983,7 +1009,7 @@ export const createAgentExperience = (
983
1009
  mod.initApprovalUi({
984
1010
  webMcpToolTitle: getWebMcpToolDisplayTitle,
985
1011
  });
986
- const built = mod.createBuiltInApprovalPlugin();
1012
+ const built = mod.createBuiltInApprovalPlugin(approvalDetailsExpansionState);
987
1013
  builtInApprovalPlugin = built.plugin;
988
1014
  teardownBuiltInApprovals = built.teardown;
989
1015
  };
@@ -1361,7 +1387,7 @@ export const createAgentExperience = (
1361
1387
 
1362
1388
  // Get status indicator config
1363
1389
  const statusConfig = config.statusIndicator ?? {};
1364
- const _getStatusText = (status: AgentWidgetSessionStatus): string => {
1390
+ const _getStatusText = (status: AgentWidgetSessionStatus, statusConfig = config.statusIndicator ?? {}): string => {
1365
1391
  if (status === "idle") return statusConfig.idleText ?? statusCopy.idle;
1366
1392
  if (status === "connecting") return statusConfig.connectingText ?? statusCopy.connecting;
1367
1393
  if (status === "connected") return statusConfig.connectedText ?? statusCopy.connected;
@@ -1373,6 +1399,10 @@ export const createAgentExperience = (
1373
1399
 
1374
1400
  /** Update statusText element, rendering a link for idle status when idleLink is configured. */
1375
1401
  function applyStatusToElement(el: HTMLElement, text: string, statusCfg: typeof statusConfig, status: string): void {
1402
+ if (!isComposerBar() && (statusCfg.mode === "transient" || el.classList.contains("persona-mb-2"))) {
1403
+ placeStatusIndicator(el, composerForm, statusCfg);
1404
+ applyStatusIndicatorState(el, statusCfg, status);
1405
+ }
1376
1406
  // A composer lock reason owns the status region until the lock clears.
1377
1407
  if (el.hasAttribute(COMPOSER_REASON_ATTR)) return;
1378
1408
  if (status === "idle" && statusCfg.idleLink) {
@@ -1544,6 +1574,16 @@ export const createAgentExperience = (
1544
1574
  }, 400);
1545
1575
  };
1546
1576
 
1577
+ const isWelcomeFullscreen = () => {
1578
+ const win = mount.ownerDocument.defaultView ?? window;
1579
+ const mobile = (config.launcher?.mobileFullscreen ?? true) &&
1580
+ win.innerWidth <= (config.launcher?.mobileBreakpoint ?? 640) &&
1581
+ (launcherEnabled || isDockedMountMode(config));
1582
+ return mobile || (config.launcher?.fullHeight === true &&
1583
+ !config.launcher.sidebarMode && !isDockedMountMode(config));
1584
+ };
1585
+ const getWelcomeConfig = () => resolveWelcomeConfig(config, isWelcomeFullscreen());
1586
+
1547
1587
  // Derived from the session's user messages on every welcome render; the
1548
1588
  // public `data-persona-conversation-state` contract mirrors it.
1549
1589
  let conversationState: "empty" | "active" = "empty";
@@ -1578,7 +1618,11 @@ export const createAgentExperience = (
1578
1618
  // updateScrollToBottomButtonOffset reads them, never writes.
1579
1619
  const syncComposerOverlayMetrics = () => {
1580
1620
  const placement = resolveComposerPlacement(config, isComposerBar());
1581
- const resolved = resolveWelcomeConfig(config);
1621
+ const resolved = getWelcomeConfig();
1622
+ mount.setAttribute("data-persona-welcome-fullscreen", String(isWelcomeFullscreen()));
1623
+ mount.setAttribute("data-persona-welcome-layout", resolved.layout ?? "top");
1624
+ mount.setAttribute("data-persona-welcome-layout-explicit", config.welcome?.layout ?? "");
1625
+ mount.setAttribute("data-persona-welcome-anchor", resolved.anchor ?? "bottom");
1582
1626
  const footerHidden = footer.style.display === "none";
1583
1627
  const footerHeight = footerHidden ? 0 : footer.offsetHeight;
1584
1628
 
@@ -1587,10 +1631,25 @@ export const createAgentExperience = (
1587
1631
  ? parseAnchorFraction(resolved.anchorComposerTop) ??
1588
1632
  parseAnchorFraction(DEFAULT_ANCHOR_COMPOSER_TOP)!
1589
1633
  : null;
1634
+ // The implicit fullscreen layout centers the complete greeting/composer
1635
+ // group. Explicit anchoring retains its percentage-based geometry.
1636
+ const centerGroup = fraction !== null && resolved.layout === "centered" &&
1637
+ config.welcome?.anchor === undefined &&
1638
+ config.welcome?.anchorComposerTop === undefined;
1639
+ const measuredBodyStyle = centerGroup ? getComputedStyle(body) : null;
1640
+ const previousOverlayHeight = parseFloat(mount.style.getPropertyValue("--persona-composer-overlay-height")) || 0;
1641
+ const previousOverlayLift = parseFloat(mount.style.getPropertyValue("--persona-composer-lift")) || 0;
1642
+ const groupGap = Math.max(0, (parseFloat(measuredBodyStyle?.paddingBottom ?? "") || 0) -
1643
+ (placement === "overlay" ? previousOverlayHeight + previousOverlayLift : 0));
1644
+ const columnTop = centerGroup ? body.getBoundingClientRect().top - container.getBoundingClientRect().top : 0;
1645
+ const greetingHeight = !centerGroup || welcomeHost.hidden ? 0 :
1646
+ (welcomeHost.querySelector<HTMLElement>("[data-persona-welcome-plugin]") ?? welcomeHost).offsetHeight;
1590
1647
  const lift =
1591
1648
  fraction === null || isComposerBar()
1592
1649
  ? 0
1593
- : computeComposerLift({
1650
+ : centerGroup
1651
+ ? Math.max(0, Math.round((container.clientHeight - columnTop - greetingHeight - groupGap - footerHeight) / 2))
1652
+ : computeComposerLift({
1594
1653
  columnHeight: container.clientHeight,
1595
1654
  footerHeight,
1596
1655
  fraction,
@@ -2506,9 +2565,6 @@ export const createAgentExperience = (
2506
2565
  pruneComposerModes(state.activeModeIds, composerChipModes()).length > 0
2507
2566
  );
2508
2567
  };
2509
- // Bound once `voiceState` exists (further down this closure); the compact
2510
- // sweep can run before then only via a microtask, which is later still.
2511
- let isDictationActive: () => boolean = () => false;
2512
2568
  function syncComposerCompact(): void {
2513
2569
  const state = composerStore.getState();
2514
2570
  const wrapped = composerCompactLatch.observe(
@@ -2524,7 +2580,6 @@ export const createAgentExperience = (
2524
2580
  hasChips: hasComposerChips(),
2525
2581
  hasQuote: state.quote !== undefined,
2526
2582
  hasPendingSubmission: state.pendingSubmission !== undefined,
2527
- dictationActive: isDictationActive(),
2528
2583
  })
2529
2584
  );
2530
2585
  }
@@ -2667,6 +2722,16 @@ export const createAgentExperience = (
2667
2722
  // Render custom slots
2668
2723
  renderSlots();
2669
2724
 
2725
+ messagesWrapper.addEventListener("click", (event) => {
2726
+ const target = event.target;
2727
+ if (!(target instanceof Element)) return;
2728
+ const button = target.closest<HTMLButtonElement>("button[data-persona-copy-tool-detail]");
2729
+ if (!button) return;
2730
+ event.preventDefault();
2731
+ event.stopPropagation();
2732
+ void copyToolDetail(button);
2733
+ });
2734
+
2670
2735
  // Add event delegation for reasoning and tool bubble expansion
2671
2736
  // This handles clicks even after idiomorph morphs the DOM
2672
2737
  const handleBubbleExpansion = (event: Event) => {
@@ -2684,7 +2749,17 @@ export const createAgentExperience = (
2684
2749
  const messageId = bubble.getAttribute('data-message-id');
2685
2750
  if (!messageId) return;
2686
2751
 
2752
+ // Disclosure is reading intent, not new streamed content. Pause before the
2753
+ // DOM grows so the resize observer cannot pull the opened details away.
2754
+ markReaderEngaged();
2755
+ pauseAutoScroll();
2687
2756
  const bubbleType = headerButton.getAttribute('data-bubble-type');
2757
+ if (bubble.classList.contains("persona-activity-row")) {
2758
+ activityLifecycle.manual(messageId);
2759
+ const group = bubble.parentElement?.closest<HTMLElement>("[data-persona-tool-group]");
2760
+ if (group?.dataset.messageId) activityLifecycle.manual(group.dataset.messageId);
2761
+ }
2762
+
2688
2763
 
2689
2764
  // Toggle expansion state
2690
2765
  if (bubbleType === 'reasoning') {
@@ -2693,22 +2768,22 @@ export const createAgentExperience = (
2693
2768
  } else {
2694
2769
  reasoningExpansionState.add(messageId);
2695
2770
  }
2696
- updateReasoningBubbleUI(messageId, bubble);
2771
+ updateReasoningBubbleUI(messageId, bubble, reasoningExpansionState);
2697
2772
  } else if (bubbleType === 'tool') {
2698
2773
  if (toolExpansionState.has(messageId)) {
2699
2774
  toolExpansionState.delete(messageId);
2700
2775
  } else {
2701
2776
  toolExpansionState.add(messageId);
2702
2777
  }
2703
- updateToolBubbleUI(messageId, bubble, config);
2778
+ updateToolBubbleUI(messageId, bubble, toolExpansionState, config);
2704
2779
  } else if (bubbleType === 'approval' && approvalUi) {
2705
2780
  // approvalUi is always set here: approval bubbles only exist after the
2706
2781
  // chunk was adopted. The guard keeps a stray click on a stub harmless.
2707
2782
  const approvalConfig = config.approval !== false ? config.approval : undefined;
2708
2783
  const defaultExpanded = (approvalConfig?.detailsDisplay ?? 'collapsed') === 'expanded';
2709
- const expanded = approvalUi.approvalDetailsExpansionState.get(messageId) ?? defaultExpanded;
2710
- approvalUi.approvalDetailsExpansionState.set(messageId, !expanded);
2711
- approvalUi.updateApprovalDetailsUI(messageId, bubble, config);
2784
+ const expanded = approvalDetailsExpansionState.get(messageId) ?? defaultExpanded;
2785
+ approvalDetailsExpansionState.set(messageId, !expanded);
2786
+ approvalUi.updateApprovalDetailsUI(messageId, bubble, config, approvalDetailsExpansionState);
2712
2787
  }
2713
2788
  // Invalidate cached wrapper so next render rebuilds with current expansion state
2714
2789
  messageCache.delete(messageId);
@@ -2723,6 +2798,11 @@ export const createAgentExperience = (
2723
2798
  }
2724
2799
  });
2725
2800
 
2801
+ messagesWrapper.addEventListener('click', (event) => {
2802
+ // Assistive technologies activate buttons with a click rather than a pointer.
2803
+ if (event.detail === 0 && (event.target as HTMLElement).closest('.persona-activity-row')) handleBubbleExpansion(event);
2804
+ });
2805
+
2726
2806
  messagesWrapper.addEventListener('keydown', (event) => {
2727
2807
  const target = event.target as HTMLElement;
2728
2808
  if ((event.key === 'Enter' || event.key === ' ') && target.closest('button[data-expand-header="true"]')) {
@@ -3984,7 +4064,7 @@ export const createAgentExperience = (
3984
4064
  if (mobileFullscreen && ownerWindow.innerWidth <= mobileBreakpoint) return;
3985
4065
  if (!shouldExpandLauncherForArtifacts(config, launcherEnabled)) return;
3986
4066
 
3987
- const base = config.launcher?.width ?? config.launcherWidth ?? DEFAULT_FLOATING_LAUNCHER_WIDTH;
4067
+ const base = resolvePanelGeometry(config).width;
3988
4068
  const expanded =
3989
4069
  config.features?.artifacts?.layout?.expandedPanelWidth ??
3990
4070
  "min(720px, calc(100vw - 24px))";
@@ -4035,6 +4115,13 @@ export const createAgentExperience = (
4035
4115
  // Apply full-height and sidebar styles if enabled
4036
4116
  // This ensures the widget fills its container height with proper flex layout
4037
4117
  const applyFullHeightStyles = () => {
4118
+ const fullScreenTranscript = isMobileFullscreenActive() ||
4119
+ (isComposerBar() && config.launcher?.composerBar?.expandedSize === "fullscreen") ||
4120
+ (config.launcher?.fullHeight === true && !config.launcher?.sidebarMode && !isDockedMountMode(config));
4121
+ messagesWrapper.style.gap = fullScreenTranscript
4122
+ ? "var(--persona-components-message-fullscreenGap, var(--persona-components-message-gap, 12px))"
4123
+ : "var(--persona-components-message-gap, 12px)";
4124
+ messagesWrapper.style.setProperty("--persona-transcript-gap", messagesWrapper.style.gap);
4038
4125
  // Composer-bar mode owns its own sizing/chrome. Geometry comes from
4039
4126
  // `applyComposerBarGeometry()` (per-state inline on the wrapper), the
4040
4127
  // pill carries its own chrome via `.persona-pill-composer`, and the
@@ -4083,7 +4170,14 @@ export const createAgentExperience = (
4083
4170
  const isInlineEmbed = config.launcher?.enabled === false;
4084
4171
  /** Detached appearance: inset card over a canvas instead of flush chrome. */
4085
4172
  const isDetached = config.launcher?.detachedPanel === true;
4086
- const panelPartial = config.theme?.components?.panel;
4173
+ const lightPanelPartial = config.theme?.components?.panel;
4174
+ const darkPanelPartial = config.darkTheme?.components?.panel;
4175
+ const panelPartial: typeof lightPanelPartial = getColorScheme(config) === "dark"
4176
+ ? deepMerge(
4177
+ (lightPanelPartial ?? {}) as Record<string, unknown>,
4178
+ (darkPanelPartial ?? {}) as Record<string, unknown>
4179
+ ) as typeof lightPanelPartial
4180
+ : lightPanelPartial;
4087
4181
  const activeTheme = getActiveTheme(config);
4088
4182
  const resolvePanelChrome = (raw: string | undefined, fallback: string): string => {
4089
4183
  if (raw == null || raw === "") return fallback;
@@ -4110,11 +4204,38 @@ export const createAgentExperience = (
4110
4204
  // Card chrome defaults (floating look): reused to restore detached chrome.
4111
4205
  // Defaults chain through the aliases themeToCssVariables emits so explicit
4112
4206
  // theme.components.panel overrides and these defaults never diverge.
4113
- const cardBorder = 'var(--persona-panel-border, 1px solid var(--persona-border))';
4114
- const cardShadow = 'var(--persona-panel-shadow, var(--persona-palette-shadows-xl, 0 25px 50px -12px rgba(0, 0, 0, 0.25)))';
4115
- const cardRadius = 'var(--persona-panel-radius, var(--persona-radius-xl, 0.75rem))';
4207
+ const modeChrome = (
4208
+ mode: "floating" | "inline" | "sidebar" | "docked" | "mobile",
4209
+ field: "border" | "shadow" | "borderRadius",
4210
+ fallback: string
4211
+ ): string => `var(--persona-components-panel-modes-${mode}-${field}, ${fallback})`;
4212
+ const configuredFloatingPanel = panelPartial?.modes?.floating;
4213
+ const cardBorder = resolvePanelChrome(
4214
+ configuredFloatingPanel?.border ?? panelPartial?.border,
4215
+ modeChrome("floating", "border", 'var(--persona-panel-border, 1px solid var(--persona-border))')
4216
+ );
4217
+ const cardShadow = resolvePanelChrome(
4218
+ configuredFloatingPanel?.shadow ?? panelPartial?.shadow,
4219
+ modeChrome("floating", "shadow", 'var(--persona-panel-shadow, var(--persona-palette-shadows-xl, 0 25px 50px -12px rgba(0, 0, 0, 0.25)))')
4220
+ );
4221
+ const cardRadius = resolvePanelChrome(
4222
+ configuredFloatingPanel?.borderRadius ?? panelPartial?.borderRadius,
4223
+ modeChrome("floating", "borderRadius", 'var(--persona-panel-radius, var(--persona-radius-xl, 0.75rem))')
4224
+ );
4116
4225
  /** Detached restores card chrome except when a host layout goes fullscreen. */
4117
4226
  const detachedCard = isDetached && !shouldGoFullscreen && !dockedHostFullscreen;
4227
+ const panelMode = shouldGoFullscreen || dockedHostFullscreen
4228
+ ? "mobile"
4229
+ : detachedCard
4230
+ ? "floating"
4231
+ : dockedMode
4232
+ ? "docked"
4233
+ : sidebarMode
4234
+ ? "sidebar"
4235
+ : isInlineEmbed
4236
+ ? "inline"
4237
+ : "floating";
4238
+ const configuredModePanel = panelPartial?.modes?.[panelMode];
4118
4239
  // Stamp reflects rendered chrome: cleared when a fullscreen host layout
4119
4240
  // suppresses the card, so the attribute never lies to the detached CSS.
4120
4241
  if (detachedCard) {
@@ -4132,7 +4253,7 @@ export const createAgentExperience = (
4132
4253
  : shouldGoFullscreen
4133
4254
  ? 'none'
4134
4255
  : sidebarMode
4135
- ? (isLeftSidebar ? 'var(--persona-palette-shadows-sidebar-left, 2px 0 12px rgba(0, 0, 0, 0.08))' : 'var(--persona-palette-shadows-sidebar-right, -2px 0 12px rgba(0, 0, 0, 0.08))')
4256
+ ? (isLeftSidebar ? '2px 0 12px rgba(0, 0, 0, 0.08)' : '-2px 0 12px rgba(0, 0, 0, 0.08)')
4136
4257
  // Flush inline embeds fill their container: no elevation by default
4137
4258
  // (detachedPanel or components.panel.shadow opts back in).
4138
4259
  : isInlineEmbed ? 'none' : cardShadow;
@@ -4146,9 +4267,18 @@ export const createAgentExperience = (
4146
4267
  : (sidebarMode || shouldGoFullscreen) ? '0' : cardRadius;
4147
4268
 
4148
4269
  // Apply theme overrides or defaults (components.panel.*)
4149
- const panelBorder = resolvePanelChrome(panelPartial?.border, defaultPanelBorder);
4150
- const panelShadow = resolvePanelChrome(panelPartial?.shadow, defaultPanelShadow);
4151
- const panelBorderRadius = resolvePanelChrome(panelPartial?.borderRadius, defaultPanelBorderRadius);
4270
+ const panelBorder = resolvePanelChrome(
4271
+ configuredModePanel?.border ?? panelPartial?.border,
4272
+ modeChrome(panelMode, "border", defaultPanelBorder)
4273
+ );
4274
+ const panelShadow = resolvePanelChrome(
4275
+ configuredModePanel?.shadow ?? panelPartial?.shadow,
4276
+ modeChrome(panelMode, "shadow", defaultPanelShadow)
4277
+ );
4278
+ const panelBorderRadius = resolvePanelChrome(
4279
+ configuredModePanel?.borderRadius ?? panelPartial?.borderRadius,
4280
+ modeChrome(panelMode, "borderRadius", defaultPanelBorderRadius)
4281
+ );
4152
4282
 
4153
4283
  // Split chrome: 'welded' folds the card border onto the outer panel so it
4154
4284
  // wraps both columns as one card (shadow/radius already on the panel);
@@ -4192,7 +4322,8 @@ export const createAgentExperience = (
4192
4322
  // Flush fills the container flush, so the outer panel squares off by default;
4193
4323
  // the pane keeps its own rounded radius. An explicit panel.borderRadius wins.
4194
4324
  const panelRadiusExplicit =
4195
- panelPartial?.borderRadius != null && panelPartial.borderRadius !== '';
4325
+ (configuredModePanel?.borderRadius != null && configuredModePanel.borderRadius !== '') ||
4326
+ (panelPartial?.borderRadius != null && panelPartial.borderRadius !== '');
4196
4327
  const appliedPanelRadius =
4197
4328
  chatFlush && !panelRadiusExplicit ? '0' : panelBorderRadius;
4198
4329
 
@@ -4317,15 +4448,14 @@ export const createAgentExperience = (
4317
4448
  }
4318
4449
 
4319
4450
  // Re-apply panel width/maxWidth from initial setup
4320
- const launcherWidth = config?.launcher?.width ?? config?.launcherWidth;
4321
- const width = launcherWidth ?? DEFAULT_FLOATING_LAUNCHER_WIDTH;
4451
+ const geometry = resolvePanelGeometry(config);
4322
4452
  if (!sidebarMode && !dockedMode) {
4323
4453
  if (isInlineEmbed && fullHeight) {
4324
4454
  panel.style.width = "100%";
4325
4455
  panel.style.maxWidth = "100%";
4326
4456
  } else {
4327
- panel.style.width = width;
4328
- panel.style.maxWidth = width;
4457
+ panel.style.width = geometry.width;
4458
+ panel.style.maxWidth = geometry.maxWidth;
4329
4459
  }
4330
4460
  } else if (dockedMode) {
4331
4461
  const dockReveal = resolveDockConfig(config).reveal;
@@ -4375,7 +4505,9 @@ export const createAgentExperience = (
4375
4505
  weldedOuterRadius = null;
4376
4506
  }
4377
4507
 
4378
- if (dockedMode && !shouldGoFullscreen && !detachedCard && !detachedSplitActive && !weldedSplitActive && panelPartial?.border === undefined) {
4508
+ const panelBorderExplicit =
4509
+ configuredModePanel?.border != null || panelPartial?.border != null;
4510
+ if (dockedMode && !shouldGoFullscreen && !detachedCard && !detachedSplitActive && !weldedSplitActive && !panelBorderExplicit) {
4379
4511
  container.style.border = 'none';
4380
4512
  const dockSide = resolveDockConfig(config).side;
4381
4513
  if (dockSide === 'right') {
@@ -4389,7 +4521,7 @@ export const createAgentExperience = (
4389
4521
  // mode resolves its border to none, so add the dock-facing hairline there so
4390
4522
  // the split still separates from the host page. Mirrors the flush block's
4391
4523
  // side choice (right dock => left edge faces the page).
4392
- if (dockedMode && !shouldGoFullscreen && weldedSplitActive && panelPartial?.border === undefined) {
4524
+ if (dockedMode && !shouldGoFullscreen && weldedSplitActive && !panelBorderExplicit) {
4393
4525
  const dockSide = resolveDockConfig(config).side;
4394
4526
  if (dockSide === 'right') {
4395
4527
  panel.style.borderLeft = '1px solid var(--persona-border)';
@@ -4478,7 +4610,9 @@ export const createAgentExperience = (
4478
4610
 
4479
4611
  // Apply sidebar-specific styles
4480
4612
  if (sidebarMode) {
4481
- const sidebarWidth = config.launcher?.sidebarWidth ?? '420px';
4613
+ const sidebarWidth = getPanelAliasProvenance(config).sidebarWidth
4614
+ ? config.launcher?.sidebarWidth ?? "420px"
4615
+ : resolveTokenValue(getActiveTheme(config), "components.panel.width") ?? "420px";
4482
4616
 
4483
4617
  // Wrapper - fixed position. Detached insets the card off the edges and
4484
4618
  // shrinks its height by the inset on both ends; flush hugs the edges.
@@ -4592,7 +4726,12 @@ export const createAgentExperience = (
4592
4726
  let warnedComposerBarPlacement = false;
4593
4727
  // mount.style.cssText is wiped by applyFullHeightStyles, so every
4594
4728
  // mount-level var re-stamps here, next to applyContentMaxWidthVar.
4729
+ function syncTranscriptTopFade() {
4730
+ if (config.layout?.topFade && body.scrollTop > 1) body.setAttribute("data-persona-top-fade", "true");
4731
+ else body.removeAttribute("data-persona-top-fade");
4732
+ }
4595
4733
  const applyComposerPlacement = () => {
4734
+ syncTranscriptTopFade();
4596
4735
  const placement = resolveComposerPlacement(config, isComposerBar());
4597
4736
  if (
4598
4737
  isComposerBar() &&
@@ -4605,7 +4744,7 @@ export const createAgentExperience = (
4605
4744
  "[persona] composer.placement is ignored in composer-bar mount mode."
4606
4745
  );
4607
4746
  }
4608
- const welcome = resolveWelcomeConfig(config);
4747
+ const welcome = getWelcomeConfig();
4609
4748
  mount.setAttribute("data-persona-composer-placement", placement);
4610
4749
  mount.setAttribute("data-persona-conversation-state", conversationState);
4611
4750
  // Root-level mirror of the welcome host's own anchor attribute, so the
@@ -4632,12 +4771,9 @@ export const createAgentExperience = (
4632
4771
  const mobileFullscreen = config.launcher?.mobileFullscreen ?? true;
4633
4772
  const mobileBreakpoint = config.launcher?.mobileBreakpoint ?? 640;
4634
4773
  if (mobileFullscreen && ownerWindow.innerWidth <= mobileBreakpoint) return;
4635
- const viewportHeight = ownerWindow.innerHeight;
4636
- const verticalMargin = 64; // leave space for launcher's offset
4637
- const heightOffset = config.launcher?.heightOffset ?? 0;
4638
- const available = Math.max(200, viewportHeight - verticalMargin);
4639
- const clamped = Math.min(640, available);
4640
- panel.style.height = `${Math.max(200, clamped - heightOffset)}px`;
4774
+ const geometry = resolvePanelGeometry(config);
4775
+ panel.style.height = geometry.height;
4776
+ panel.style.maxHeight = geometry.maxHeight;
4641
4777
  };
4642
4778
  applyFullHeightStyles();
4643
4779
  // Apply theme variables after applyFullHeightStyles since it resets mount.style.cssText
@@ -4981,12 +5117,14 @@ export const createAgentExperience = (
4981
5117
  return;
4982
5118
  }
4983
5119
 
4984
- const starters = config.suggestions?.starters;
5120
+ const v5 = config.future?.v5Defaults === true;
5121
+ const fullscreen = isWelcomeFullscreen();
5122
+ const starters = config.suggestions?.starters ?? (v5 ? {} : undefined);
4985
5123
  if (starters) {
4986
5124
  // The starter host lives inside the welcome surface, so a pinned
4987
5125
  // "welcome" placement has nowhere to render when that surface is hidden.
4988
5126
  const welcomeCardVisible = isWelcomeVisible(
4989
- resolveWelcomeConfig(config),
5127
+ getWelcomeConfig(),
4990
5128
  current
4991
5129
  );
4992
5130
  const requestedPlacement = starters.placement ?? "auto";
@@ -5014,10 +5152,10 @@ export const createAgentExperience = (
5014
5152
  config.suggestionChipsConfig,
5015
5153
  {
5016
5154
  surface: "starter",
5017
- variant: starters.variant ?? "card",
5155
+ variant: starters.variant ?? (v5 && !fullscreen ? "chip" : "card"),
5018
5156
  behavior: starters.behavior ?? "send",
5019
5157
  overflow: starters.overflow ?? "wrap",
5020
- maxItems: starters.maxItems ?? 4,
5158
+ maxItems: starters.maxItems ?? (v5 && !fullscreen ? 3 : 4),
5021
5159
  config,
5022
5160
  plugins,
5023
5161
  submitPrompt: submitSuggestionPrompt,
@@ -5038,6 +5176,7 @@ export const createAgentExperience = (
5038
5176
  variant: "chip",
5039
5177
  behavior: "send",
5040
5178
  overflow: "wrap",
5179
+ maxItems: 4,
5041
5180
  config,
5042
5181
  plugins,
5043
5182
  submitPrompt: submitSuggestionPrompt,
@@ -5220,7 +5359,6 @@ export const createAgentExperience = (
5220
5359
  lastUserMessageWasVoice: false,
5221
5360
  lastUserMessageId: null as string | null
5222
5361
  };
5223
- isDictationActive = () => voiceState.active;
5224
5362
  // First stamp: an untouched composer emits no store change, so the attribute
5225
5363
  // would otherwise not appear until the first keystroke.
5226
5364
  syncComposerCompact();
@@ -6163,7 +6301,9 @@ export const createAgentExperience = (
6163
6301
  // Only an explicit config value is stamped inline; the 85%/100% defaults
6164
6302
  // live in widget.css so `components.message.<role>.maxWidth` can win.
6165
6303
  const maxWidth =
6166
- roleLayout?.maxWidth ?? (width === "full" ? "100%" : undefined);
6304
+ roleLayout?.maxWidth ?? (width === "full"
6305
+ ? `var(--persona-message-${sizingRole}-max-width, 100%)`
6306
+ : undefined);
6167
6307
 
6168
6308
  wrapper.classList.add("persona-message-row");
6169
6309
  wrapper.classList.remove(
@@ -6201,6 +6341,15 @@ export const createAgentExperience = (
6201
6341
  messages: AgentWidgetMessage[],
6202
6342
  transform: MessageTransform
6203
6343
  ) => {
6344
+ const activityIds = new Set<string>();
6345
+ for (const message of messages) {
6346
+ const kind = message.variant === "tool" ? "tool" : message.variant === "reasoning" ? "reasoning" : null;
6347
+ if (!kind || activityVariant(config, kind) !== "row") continue;
6348
+ const display = kind === "tool" ? config.features?.toolCallDisplay : config.features?.reasoningDisplay;
6349
+ if (display?.expandable === false) continue;
6350
+ activityIds.add(message.id);
6351
+ activityLifecycle.observe(message, kind, kind === "tool" ? toolExpansionState : reasoningExpansionState, display);
6352
+ }
6204
6353
  // Build new content in a temporary container for morphing
6205
6354
  const tempContainer = document.createElement("div");
6206
6355
 
@@ -6613,7 +6762,7 @@ export const createAgentExperience = (
6613
6762
  };
6614
6763
  liveBubble = approvalPlugin.renderApproval({
6615
6764
  message,
6616
- defaultRenderer: () => approvalMod.createApprovalBubble(message, config),
6765
+ defaultRenderer: () => approvalMod.createApprovalBubble(message, config, approvalDetailsExpansionState),
6617
6766
  config,
6618
6767
  approve: (options) => resolveDecision("approved", options),
6619
6768
  deny: (options) => resolveDecision("denied", options)
@@ -6629,7 +6778,7 @@ export const createAgentExperience = (
6629
6778
  const existing = container.querySelector<HTMLElement>(`#wrapper-${message.id}`);
6630
6779
  existing?.removeAttribute("data-preserve-runtime");
6631
6780
  lastApprovalBubbleFingerprint.delete(message.id);
6632
- bubble = approvalMod.createApprovalBubble(message, config);
6781
+ bubble = approvalMod.createApprovalBubble(message, config, approvalDetailsExpansionState);
6633
6782
  } else {
6634
6783
  // A fresh live bubble to hydrate (needsRebuild), or fingerprint
6635
6784
  // unchanged so we reuse the preserved live wrapper (`bubble: null`).
@@ -6649,14 +6798,14 @@ export const createAgentExperience = (
6649
6798
  if (!showReasoning) return;
6650
6799
  bubble = matchingPlugin.renderReasoning({
6651
6800
  message,
6652
- defaultRenderer: () => createReasoningBubble(message, config),
6801
+ defaultRenderer: () => createReasoningBubble(message, config, reasoningExpansionState),
6653
6802
  config
6654
6803
  });
6655
6804
  } else if (message.variant === "tool" && message.toolCall && matchingPlugin.renderToolCall) {
6656
6805
  if (!showToolCalls) return;
6657
6806
  bubble = matchingPlugin.renderToolCall({
6658
6807
  message,
6659
- defaultRenderer: () => createToolBubble(message, config),
6808
+ defaultRenderer: () => createToolBubble(message, config, toolExpansionState),
6660
6809
  config
6661
6810
  });
6662
6811
  } else if (matchingPlugin.renderMessage) {
@@ -6824,15 +6973,15 @@ export const createAgentExperience = (
6824
6973
  if (!bubble) {
6825
6974
  if (message.variant === "reasoning" && message.reasoning) {
6826
6975
  if (!showReasoning) return;
6827
- bubble = createReasoningBubble(message, config);
6976
+ bubble = createReasoningBubble(message, config, reasoningExpansionState);
6828
6977
  } else if (message.variant === "tool" && message.toolCall) {
6829
6978
  if (!showToolCalls) return;
6830
- bubble = createToolBubble(message, config);
6979
+ bubble = createToolBubble(message, config, toolExpansionState);
6831
6980
  } else if (message.variant === "approval" && message.approval) {
6832
6981
  if (config.approval === false) return;
6833
6982
  const approvalMod = ensureApprovalUi();
6834
6983
  if (!approvalMod) return;
6835
- bubble = approvalMod.createApprovalBubble(message, config);
6984
+ bubble = approvalMod.createApprovalBubble(message, config, approvalDetailsExpansionState);
6836
6985
  } else {
6837
6986
  // Check for custom message renderers in layout config
6838
6987
  const messageLayoutConfig = config.layout?.messages;
@@ -6950,6 +7099,32 @@ export const createAgentExperience = (
6950
7099
  );
6951
7100
  groupWrapper.setAttribute("data-persona-tool-group-row", "true");
6952
7101
 
7102
+ if (config.features?.toolCallDisplay?.groupedMode === "collapsible") {
7103
+ const id = `tool-group-${group[0].id}`;
7104
+ const active = group.some(item => item.toolCall?.status !== "complete");
7105
+ const synthetic: AgentWidgetMessage = { ...group[0], id, toolCall: {
7106
+ id, name: `${group.length} tools`, status: active ? "running" : "complete",
7107
+ chunks: group.flatMap(item => item.toolCall?.chunks ?? []),
7108
+ success: !group.some(item => item.toolCall?.success === false),
7109
+ } };
7110
+ activityIds.add(id);
7111
+ activityLifecycle.observe(synthetic, "tool", toolExpansionState, config.features.toolCallDisplay);
7112
+ const label = `${active ? "Using" : "Used"} ${group.length} tools`;
7113
+ const custom = config.toolCall?.renderGroupedSummary?.({ messages: group, toolCalls: group.map(item => item.toolCall!), defaultSummary: label, config });
7114
+ const row = createActivityGroup(synthetic, config, toolExpansionState.has(id), custom ?? label);
7115
+ row.bubble.classList.add("persona-tool-group");
7116
+ row.body.dataset.personaToolGroupStack = "true";
7117
+ wrappers[0].before(groupWrapper);
7118
+ groupWrapper.appendChild(row.bubble);
7119
+ wrappers.forEach((wrapper, index) => {
7120
+ wrapper.style.setProperty("--persona-message-row-max-width", "100%");
7121
+ wrapper.style.setProperty("--persona-activity-stagger", `${index * 40}ms`);
7122
+ wrapper.classList.add("persona-activity-group-child");
7123
+ row.body.appendChild(wrapper);
7124
+ });
7125
+ return;
7126
+ }
7127
+
6953
7128
  const groupContainer = document.createElement("div");
6954
7129
  groupContainer.className =
6955
7130
  "persona-tool-group persona-flex persona-w-full persona-flex-col persona-gap-2";
@@ -7006,6 +7181,8 @@ export const createAgentExperience = (
7006
7181
  });
7007
7182
  }
7008
7183
 
7184
+ activityLifecycle.prune(activityIds);
7185
+
7009
7186
  // Remove cache entries for messages that no longer exist
7010
7187
  pruneCache(messageCache, activeMessageIds);
7011
7188
 
@@ -8018,6 +8195,7 @@ export const createAgentExperience = (
8018
8195
  statusText.setAttribute("aria-live", "polite");
8019
8196
  statusText.setAttribute(COMPOSER_REASON_ATTR, "");
8020
8197
  statusText.textContent = reason;
8198
+ applyStatusIndicatorState(statusText, config.statusIndicator ?? {}, "idle");
8021
8199
  return;
8022
8200
  }
8023
8201
  if (!statusText.hasAttribute(COMPOSER_REASON_ATTR)) return;
@@ -8176,7 +8354,8 @@ export const createAgentExperience = (
8176
8354
  composerLiftAnimation = animateComposerLiftChange(
8177
8355
  footer,
8178
8356
  next === "active" ? previousLift : readLiftPx(),
8179
- next === "active" ? "drop" : "rise"
8357
+ next === "active" ? "drop" : "rise",
8358
+ getWelcomeConfig().layout === "centered" ? 320 : 260
8180
8359
  );
8181
8360
  }
8182
8361
  // The reservation shrinks by the lift in one frame, above the anchor.
@@ -8194,8 +8373,8 @@ export const createAgentExperience = (
8194
8373
  // so the pending stand-in the overlay body class would hide stays visible.
8195
8374
  let welcomePluginSuppressed = false;
8196
8375
  const updateWelcome = (messages?: AgentWidgetMessage[]) => {
8197
- const resolved = resolveWelcomeConfig(config);
8198
- const welcomeKey = `${resolved.variant}|${resolved.dismiss}|${resolved.anchor ?? "bottom"}|${resolved.align ?? ""}|${resolved.iconPlacement}|${resolved.kicker ?? ""}|${resolved.title}|${resolved.subtitle}`;
8376
+ const resolved = getWelcomeConfig();
8377
+ const welcomeKey = `${resolved.variant}|${resolved.layout}|${resolved.dismiss}|${resolved.anchor ?? "bottom"}|${resolved.align ?? ""}|${resolved.iconPlacement}|${resolved.kicker ?? ""}|${resolved.title}|${resolved.subtitle}`;
8199
8378
  if (welcomeKey !== lastWelcomeKey || resolved.icon !== lastWelcomeIcon) {
8200
8379
  lastWelcomeKey = welcomeKey;
8201
8380
  lastWelcomeIcon = resolved.icon;
@@ -8348,8 +8527,8 @@ export const createAgentExperience = (
8348
8527
  // A plugin calling requestRender() from inside its own render would recurse.
8349
8528
  if (welcomeArbitrating) return;
8350
8529
  welcomeArbitrating = true;
8351
- const resolved = resolveWelcomeConfig(config);
8352
- lastWelcomeArbitrationKey = `${resolved.variant}|${resolved.dismiss}|${resolved.title}|${resolved.subtitle}|${resolved.message ?? ""}`;
8530
+ const resolved = getWelcomeConfig();
8531
+ lastWelcomeArbitrationKey = `${resolved.variant}|${resolved.layout}|${resolved.dismiss}|${resolved.title}|${resolved.subtitle}|${resolved.message ?? ""}`;
8353
8532
  lastWelcomeArbitrationIcon = resolved.icon;
8354
8533
  try {
8355
8534
  runWelcomeCleanups();
@@ -8398,8 +8577,8 @@ export const createAgentExperience = (
8398
8577
 
8399
8578
  /** Re-arbitrate only when the resolved welcome config actually changed. */
8400
8579
  const refreshWelcomePlugins = () => {
8401
- const resolved = resolveWelcomeConfig(config);
8402
- const key = `${resolved.variant}|${resolved.dismiss}|${resolved.title}|${resolved.subtitle}|${resolved.message ?? ""}`;
8580
+ const resolved = getWelcomeConfig();
8581
+ const key = `${resolved.variant}|${resolved.layout}|${resolved.dismiss}|${resolved.title}|${resolved.subtitle}|${resolved.message ?? ""}`;
8403
8582
  if (
8404
8583
  key === lastWelcomeArbitrationKey &&
8405
8584
  resolved.icon === lastWelcomeArbitrationIcon
@@ -8639,7 +8818,7 @@ export const createAgentExperience = (
8639
8818
  spans.forEach((span) => {
8640
8819
  const startedAt = Number(span.getAttribute("data-tool-elapsed"));
8641
8820
  if (!startedAt) return;
8642
- span.textContent = formatElapsedMs(now - startedAt);
8821
+ span.textContent = span.closest(".persona-activity-row") ? activityDuration(now - startedAt) : formatElapsedMs(now - startedAt);
8643
8822
  });
8644
8823
  }, 100);
8645
8824
  };
@@ -11413,9 +11592,15 @@ export const createAgentExperience = (
11413
11592
  historySurface = null;
11414
11593
  });
11415
11594
 
11416
- // Pre-initialize client session when in client token mode so feedback works
11417
- // before the user sends their first message (e.g. on restored/persisted messages)
11418
- if (config.clientToken) {
11595
+ // Client-token init on mount is reserved for widgets that need a session at
11596
+ // boot: visitor history (boot reconciliation) and `sessionInit: 'mount'`.
11597
+ // Everything else inits on intent (the `sessionInit` triggers below) or at
11598
+ // send, since init is no longer free server-side (it pre-allocates the
11599
+ // conversation's session owner).
11600
+ if (
11601
+ config.clientToken &&
11602
+ (config.features?.history?.enabled === true || config.sessionInit === "mount")
11603
+ ) {
11419
11604
  session
11420
11605
  .initClientSession()
11421
11606
  .then(() => {
@@ -11435,6 +11620,73 @@ export const createAgentExperience = (
11435
11620
  });
11436
11621
  }
11437
11622
 
11623
+ // Early session init on visitor intent (`sessionInit`). Delegated on the
11624
+ // mount so composer rebuilds stay covered, and read `config` at event time so
11625
+ // `update()` applies without re-wiring.
11626
+ const warmSession = () => session.warmClientSession();
11627
+ // One delegated listener. `input` counts only real typing, paste, drop or
11628
+ // IME (an InputEvent with an inputType; the widget's programmatic edits
11629
+ // dispatch a plain Event) that leaves text. In 'focus' mode a pointer-down in
11630
+ // the composer or a Tab into it also counts; `.focus()` (autofocus on
11631
+ // open/mount) produces neither.
11632
+ const handleSessionInitIntent = (event: Event) => {
11633
+ const mode = config.sessionInit ?? "input";
11634
+ const input = composerBindings?.input ?? textarea;
11635
+ const target = event.target as Node;
11636
+ if (
11637
+ input &&
11638
+ (event.type === "input"
11639
+ ? (mode === "input" || mode === "focus") &&
11640
+ target === input &&
11641
+ !!(event as InputEvent).inputType &&
11642
+ !!(input as HTMLTextAreaElement).value.trim()
11643
+ : mode === "focus" &&
11644
+ (event.type === "keyup"
11645
+ ? target === input && (event as KeyboardEvent).key === "Tab"
11646
+ : !!(composerBindings?.form ?? composerForm)?.contains(target)))
11647
+ ) {
11648
+ warmSession();
11649
+ }
11650
+ };
11651
+ const sessionInitEvents = ["input", "pointerdown", "keyup"];
11652
+ sessionInitEvents.forEach((type) => mount.addEventListener(type, handleSessionInitIntent));
11653
+ // The custom trigger (a `sessionInit` function) runs on mount and again
11654
+ // whenever `update()` swaps in a different function, cleaning up the old one.
11655
+ let sessionInitHook: AgentWidgetSessionInitHook | undefined;
11656
+ let sessionInitHookCleanup: ReturnType<AgentWidgetSessionInitHook>;
11657
+ // Host hook code may throw; neither its setup nor its cleanup may leave
11658
+ // mount, update() or destroy() half done. The send still inits either way.
11659
+ const releaseSessionInitHook = () => {
11660
+ const cleanup = sessionInitHookCleanup;
11661
+ sessionInitHookCleanup = undefined;
11662
+ try {
11663
+ if (typeof cleanup === "function") cleanup();
11664
+ } catch {
11665
+ // ignored, see above
11666
+ }
11667
+ };
11668
+ const syncSessionInitHook = () => {
11669
+ const next =
11670
+ config.clientToken && typeof config.sessionInit === "function"
11671
+ ? config.sessionInit
11672
+ : undefined;
11673
+ if (next === sessionInitHook) return;
11674
+ releaseSessionInitHook();
11675
+ sessionInitHook = next;
11676
+ try {
11677
+ sessionInitHookCleanup = next?.({ warm: warmSession, mount });
11678
+ } catch {
11679
+ // ignored, see above
11680
+ }
11681
+ };
11682
+ syncSessionInitHook();
11683
+ destroyCallbacks.push(() => {
11684
+ sessionInitEvents.forEach((type) =>
11685
+ mount.removeEventListener(type, handleSessionInitIntent)
11686
+ );
11687
+ releaseSessionInitHook();
11688
+ });
11689
+
11438
11690
  // Wire up optional SSE tap (host) + event stream buffer to capture SSE
11439
11691
  // events. `feedEventStream` stages events while the lazy chunk is in flight.
11440
11692
  if (showEventStreamToggle || config.onSSEEvent) {
@@ -11607,6 +11859,7 @@ export const createAgentExperience = (
11607
11859
  statusText.setAttribute("role", "status");
11608
11860
  statusText.setAttribute("aria-live", "polite");
11609
11861
  statusText.textContent = text;
11862
+ applyStatusIndicatorState(statusText, config.statusIndicator ?? {}, "idle");
11610
11863
  if (composerNoticeTimer) clearTimeout(composerNoticeTimer);
11611
11864
  composerNoticeTimer = setTimeout(() => {
11612
11865
  composerNoticeTimer = null;
@@ -12429,7 +12682,7 @@ export const createAgentExperience = (
12429
12682
  }
12430
12683
  emitVoiceState(source);
12431
12684
  persistVoiceMetadata();
12432
- // Live dictation is a composer occupant; the store emits nothing for it.
12685
+ // Reconcile content geometry without treating recording as extra content.
12433
12686
  syncComposerCompact();
12434
12687
  if (micButton) {
12435
12688
  // Store original styles (including icon info for restoration)
@@ -12846,8 +13099,10 @@ export const createAgentExperience = (
12846
13099
  footerResizeObserver.disconnect();
12847
13100
  footerResizeObserver.observe(footer);
12848
13101
  footerResizeObserver.observe(container);
13102
+ footerResizeObserver.observe(welcomeHost);
12849
13103
  };
12850
13104
 
13105
+ let lastWelcomeFullscreen = isWelcomeFullscreen();
12851
13106
  const recalcPanelHeight = () => {
12852
13107
  // Composer-bar mode lets CSS own all sizing: collapsed pill is auto-sized
12853
13108
  // by the footer; expanded fullscreen/modal are driven by CSS attribute
@@ -12909,10 +13164,9 @@ export const createAgentExperience = (
12909
13164
 
12910
13165
  // In sidebar/fullHeight mode, don't override the width - it's handled by applyFullHeightStyles
12911
13166
  if (!sidebarMode && !dockedMode) {
12912
- const launcherWidth = config?.launcher?.width ?? config?.launcherWidth;
12913
- const width = launcherWidth ?? DEFAULT_FLOATING_LAUNCHER_WIDTH;
12914
- panel.style.width = width;
12915
- panel.style.maxWidth = width;
13167
+ const geometry = resolvePanelGeometry(config);
13168
+ panel.style.width = geometry.width;
13169
+ panel.style.maxWidth = geometry.maxWidth;
12916
13170
  }
12917
13171
  applyLauncherArtifactPanelWidth();
12918
13172
 
@@ -12923,6 +13177,12 @@ export const createAgentExperience = (
12923
13177
  // overwrites updateOpenState()'s display:none when docked+closed. Re-sync after every recalc.
12924
13178
  updateScrollToBottomButtonOffset();
12925
13179
  syncComposerOverlayMetrics();
13180
+ const welcomeFullscreen = isWelcomeFullscreen();
13181
+ if (welcomeFullscreen !== lastWelcomeFullscreen) {
13182
+ lastWelcomeFullscreen = welcomeFullscreen;
13183
+ updateWelcome();
13184
+ renderSuggestions();
13185
+ }
12926
13186
  updateOpenState();
12927
13187
 
12928
13188
  // Sync scroll lock and host stacking when viewport mode changes (e.g. orientation change)
@@ -12985,6 +13245,7 @@ export const createAgentExperience = (
12985
13245
  let lastBottomOffset = getScrollBottomOffset(body);
12986
13246
 
12987
13247
  const handleScroll = () => {
13248
+ syncTranscriptTopFade();
12988
13249
  const scrollTop = body.scrollTop;
12989
13250
  // When content mutates (e.g. stream-animation plugins re-rendering text)
12990
13251
  // or the viewport grows (composer shrinking back), the maximum scroll
@@ -13203,6 +13464,8 @@ export const createAgentExperience = (
13203
13464
  }
13204
13465
  // Clear messages in session (this will trigger onMessagesChanged which re-renders)
13205
13466
  session.clearMessages();
13467
+ if (config.layout?.topFade) body.scrollTop = 0;
13468
+ body.removeAttribute("data-persona-top-fade");
13206
13469
  messageCache.clear();
13207
13470
  resumeAutoScroll();
13208
13471
 
@@ -13418,6 +13681,10 @@ export const createAgentExperience = (
13418
13681
  updateCopy();
13419
13682
  renderSuggestions();
13420
13683
  setComposerDisabled(session.isStreaming());
13684
+ if (config.statusIndicator?.mode === "transient" && !isComposerBar()) {
13685
+ const status = session.getStatus();
13686
+ applyStatusToElement(statusText, _getStatusText(status), config.statusIndicator, status);
13687
+ }
13421
13688
  // The rebuilt mic is stamped idle by its builder. A rebuild mid-recording
13422
13689
  // has to restore the live state, which also re-arms the level loop
13423
13690
  // against the new footer and wrapper.
@@ -13589,6 +13856,7 @@ export const createAgentExperience = (
13589
13856
 
13590
13857
  const controller: Controller = {
13591
13858
  update(nextConfig: AgentWidgetConfigPatch) {
13859
+ const previousDefaultsVersion = config.future?.v5Defaults === true;
13592
13860
  const previousToolCallConfig = config.toolCall;
13593
13861
  const previousMessageActions = config.messageActions;
13594
13862
  const previousLayoutMessages = config.layout?.messages;
@@ -13862,7 +14130,8 @@ export const createAgentExperience = (
13862
14130
  || (config.features?.showToolCalls ?? true) !== (previousShowToolCalls ?? true)
13863
14131
  || JSON.stringify(config.features?.toolCallDisplay) !== JSON.stringify(previousToolCallDisplay)
13864
14132
  || JSON.stringify(config.features?.reasoningDisplay) !== JSON.stringify(previousReasoningDisplay);
13865
- const messagesConfigChanged = toolCallConfigChanged || messageActionsChanged || layoutMessagesChanged
14133
+ const messagesConfigChanged = (config.future?.v5Defaults === true) !== previousDefaultsVersion
14134
+ || toolCallConfigChanged || messageActionsChanged || layoutMessagesChanged
13866
14135
  || loadingIndicatorChanged || iterationDisplayChanged || featuresChanged;
13867
14136
  if (messagesConfigChanged && session) {
13868
14137
  configVersion++;
@@ -13899,7 +14168,7 @@ export const createAgentExperience = (
13899
14168
  // Hide icon if either headerIconHidden is true OR layout.header.showIcon is false
13900
14169
  const shouldHideIcon = headerIconHidden || layoutShowIcon === false;
13901
14170
  const headerIconName = launcher.headerIconName;
13902
- const headerIconSize = launcher.headerIconSize ?? "48px";
14171
+ const headerIconSize = launcher.headerIconSize ?? DEFAULT_HEADER_ICON_SIZE;
13903
14172
 
13904
14173
  if (iconHolder) {
13905
14174
  const headerEl = header;
@@ -13940,6 +14209,8 @@ export const createAgentExperience = (
13940
14209
  const iconSize = parseFloat(headerIconSize) || 24;
13941
14210
  const iconSvg = renderLucideIcon(headerIconName, iconSize * 0.6, "currentColor", 1);
13942
14211
  if (iconSvg) {
14212
+ iconSvg.style.width = `calc(${iconSize}px * var(--persona-components-header-iconScale, 0.6))`;
14213
+ iconSvg.style.height = `calc(${iconSize}px * var(--persona-components-header-iconScale, 0.6))`;
13943
14214
  iconHolder.replaceChildren(iconSvg);
13944
14215
  } else {
13945
14216
  // Fallback to agentIconText if Lucide icon fails
@@ -14306,6 +14577,7 @@ export const createAgentExperience = (
14306
14577
  // Re-key the visitor store before any client rebuild sees the new config.
14307
14578
  syncVisitorStore();
14308
14579
  session.updateConfig(config);
14580
+ syncSessionInitHook();
14309
14581
  if (
14310
14582
  (config.features?.history?.enabled === true) !== previousHistoryEnabled ||
14311
14583
  config.features?.history?.provider !== previousHistoryProvider ||
@@ -14816,6 +15088,8 @@ export const createAgentExperience = (
14816
15088
  }
14817
15089
 
14818
15090
  syncComposerLayout();
15091
+ // Placement may change through a live option or defaults-version switch.
15092
+ applyComposerPlacement();
14819
15093
 
14820
15094
  // Modes can be added or removed live: drop selections whose mode is gone
14821
15095
  // before anything reads `activeModeIds`, and mount/unmount the mode chips.
@@ -14900,8 +15174,8 @@ export const createAgentExperience = (
14900
15174
 
14901
15175
  // Update status indicator visibility and text
14902
15176
  const statusIndicatorConfig = config.statusIndicator ?? {};
14903
- const isVisible = statusIndicatorConfig.visible ?? true;
14904
- statusText.style.display = isVisible ? "" : "none";
15177
+ placeStatusIndicator(statusText, composerForm, statusIndicatorConfig);
15178
+ applyStatusIndicatorState(statusText, statusIndicatorConfig, session?.getStatus() ?? "idle");
14905
15179
 
14906
15180
  // Update status text if status is currently set
14907
15181
  if (session) {
@@ -14911,6 +15185,8 @@ export const createAgentExperience = (
14911
15185
  if (s === "connecting") return statusIndicatorConfig.connectingText ?? statusCopy.connecting;
14912
15186
  if (s === "connected") return statusIndicatorConfig.connectedText ?? statusCopy.connected;
14913
15187
  if (s === "error") return statusIndicatorConfig.errorText ?? statusCopy.error;
15188
+ if (s === "paused") return statusIndicatorConfig.pausedText ?? statusCopy.paused;
15189
+ if (s === "resuming") return statusIndicatorConfig.resumingText ?? statusCopy.resuming;
14914
15190
  return statusCopy[s];
14915
15191
  };
14916
15192
  applyStatusToElement(statusText, getCurrentStatusText(currentStatus), statusIndicatorConfig, currentStatus);
@@ -14951,7 +15227,13 @@ export const createAgentExperience = (
14951
15227
  clearChat() {
14952
15228
  // Clear messages in session (this will trigger onMessagesChanged which re-renders)
14953
15229
  artifactsPaneUserHidden = false;
15230
+ activityLifecycle.clear();
15231
+ toolExpansionState.clear();
15232
+ reasoningExpansionState.clear();
15233
+ approvalDetailsExpansionState.clear();
14954
15234
  session.clearMessages();
15235
+ if (config.layout?.topFade) body.scrollTop = 0;
15236
+ body.removeAttribute("data-persona-top-fade");
14955
15237
  messageCache.clear();
14956
15238
  resumeAutoScroll();
14957
15239
 
@@ -15207,6 +15489,9 @@ export const createAgentExperience = (
15207
15489
  startNewConversation(opts?: { focus?: boolean }): Promise<void> {
15208
15490
  return startNewConversation({ focusComposer: opts?.focus === true });
15209
15491
  },
15492
+ warmSession(): void {
15493
+ warmSession();
15494
+ },
15210
15495
  deleteConversation(conversationId: string): Promise<void> {
15211
15496
  return deleteHistoryConversation(conversationId);
15212
15497
  },
@@ -15475,6 +15760,10 @@ export const createAgentExperience = (
15475
15760
  clearInterval(toolElapsedTimerId);
15476
15761
  toolElapsedTimerId = null;
15477
15762
  }
15763
+ activityLifecycle.clear();
15764
+ toolExpansionState.clear();
15765
+ reasoningExpansionState.clear();
15766
+ approvalDetailsExpansionState.clear();
15478
15767
  destroyCallbacks.forEach((cb) => cb());
15479
15768
  wrapper.remove();
15480
15769
  pillRoot?.remove();