@runtypelabs/persona 4.30.0 → 4.32.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 (139) hide show
  1. package/README.md +22 -1
  2. package/dist/activity-ui.cjs +2 -2
  3. package/dist/activity-ui.d.cts +11 -3
  4. package/dist/activity-ui.d.ts +11 -3
  5. package/dist/activity-ui.js +1 -1
  6. package/dist/animations/glyph-cycle.d.cts +1 -1
  7. package/dist/animations/glyph-cycle.d.ts +1 -1
  8. package/dist/animations/wipe.d.cts +1 -1
  9. package/dist/animations/wipe.d.ts +1 -1
  10. package/dist/approval-ui.d.cts +2 -2
  11. package/dist/approval-ui.d.ts +2 -2
  12. package/dist/artifacts-ui.d.cts +3 -3
  13. package/dist/artifacts-ui.d.ts +3 -3
  14. package/dist/background-runtime.d.cts +1 -1
  15. package/dist/background-runtime.d.ts +1 -1
  16. package/dist/chunk-C2IbR39s.js +1 -0
  17. package/dist/chunk-CkE90FtA.js +2 -0
  18. package/dist/{chunk-n96BB0YH.js → chunk-CzZBmTLd.js} +1 -1
  19. package/dist/chunk-DABqu7C3.js +2 -0
  20. package/dist/chunk-DDAwu89-.js +1 -0
  21. package/dist/{chunk-CqEWCkJt.js → chunk-DZg9bFK6.js} +1 -1
  22. package/dist/chunk-DbD5qyBw.js +3 -0
  23. package/dist/{chunk-gayfbmEU.js → chunk-FEzOcZof.js} +1 -1
  24. package/dist/{chunk-UyZbbbz3.js → chunk-MdlQetfC.js} +2 -2
  25. package/dist/client-dispatch.js +1 -1
  26. package/dist/client-history.js +1 -1
  27. package/dist/codegen.cjs +1 -1
  28. package/dist/codegen.js +1 -1
  29. package/dist/composer-actions-runtime.d.cts +2 -2
  30. package/dist/composer-actions-runtime.d.ts +2 -2
  31. package/dist/{composer-input-C76gfIoK.d.cts → composer-input-dwg_o9Z-.d.cts} +1 -1
  32. package/dist/{composer-input-C76gfIoK.d.ts → composer-input-dwg_o9Z-.d.ts} +1 -1
  33. package/dist/{composer-store-C7tvaz90.d.cts → composer-store-Dw7Zbqxr.d.cts} +1 -1
  34. package/dist/{composer-store-C7tvaz90.d.ts → composer-store-Dw7Zbqxr.d.ts} +1 -1
  35. package/dist/{context-mention-manager-IfT4JHma.d.cts → context-mention-manager-D2p0-JH-.d.cts} +2 -2
  36. package/dist/{context-mention-manager-IfT4JHma.d.ts → context-mention-manager-D2p0-JH-.d.ts} +2 -2
  37. package/dist/context-mentions-inline.d.cts +2 -2
  38. package/dist/context-mentions-inline.d.ts +2 -2
  39. package/dist/context-mentions.d.cts +3 -3
  40. package/dist/context-mentions.d.ts +3 -3
  41. package/dist/{defaults-Cc_p0PGM.d.cts → defaults-CjX0kKzb.d.cts} +1 -1
  42. package/dist/{defaults-Cc_p0PGM.d.ts → defaults-CjX0kKzb.d.ts} +1 -1
  43. package/dist/dictation-runtime.d.cts +2 -2
  44. package/dist/dictation-runtime.d.ts +2 -2
  45. package/dist/event-stream-view.d.cts +1 -1
  46. package/dist/event-stream-view.d.ts +1 -1
  47. package/dist/{fallback-speech-engine-CSxWpG0A.d.cts → fallback-speech-engine-DmF7MczI.d.cts} +1 -1
  48. package/dist/{fallback-speech-engine-CSxWpG0A.d.ts → fallback-speech-engine-DmF7MczI.d.ts} +1 -1
  49. package/dist/{feature-preferences-eeSOmDxg.d.cts → feature-preferences-CVFDt9Xf.d.cts} +1 -1
  50. package/dist/{feature-preferences-eeSOmDxg.d.ts → feature-preferences-CVFDt9Xf.d.ts} +1 -1
  51. package/dist/forms-ui.d.cts +2 -2
  52. package/dist/forms-ui.d.ts +2 -2
  53. package/dist/{history-confirm-dialog-DXkuAgh-.d.cts → history-confirm-dialog-DJEXsTyE.d.cts} +1 -1
  54. package/dist/{history-confirm-dialog-DXkuAgh-.d.ts → history-confirm-dialog-DJEXsTyE.d.ts} +1 -1
  55. package/dist/history-shell.d.cts +6 -6
  56. package/dist/history-shell.d.ts +6 -6
  57. package/dist/history-view.d.cts +1 -1
  58. package/dist/history-view.d.ts +1 -1
  59. package/dist/index.cjs +49 -47
  60. package/dist/index.cjs.map +1 -1
  61. package/dist/index.d.cts +9 -9
  62. package/dist/index.d.ts +9 -9
  63. package/dist/index.global.js +1 -1
  64. package/dist/index.global.js.map +1 -1
  65. package/dist/index.js +43 -41
  66. package/dist/index.js.map +1 -1
  67. package/dist/{loading-label-runtime-loader-C53YyPJ-.d.cts → loading-label-runtime-loader-CPxnoaoi.d.cts} +1 -1
  68. package/dist/{loading-label-runtime-loader-C53YyPJ-.d.ts → loading-label-runtime-loader-CPxnoaoi.d.ts} +1 -1
  69. package/dist/loading-label-runtime.d.cts +1 -1
  70. package/dist/loading-label-runtime.d.ts +1 -1
  71. package/dist/markdown-parsers.js +18 -15
  72. package/dist/{message-bubble-Cg_sTXUN.d.cts → message-bubble-CkZGlCLC.d.cts} +1 -1
  73. package/dist/{message-bubble-Cg_sTXUN.d.ts → message-bubble-CkZGlCLC.d.ts} +1 -1
  74. package/dist/preferences-runtime.d.cts +1 -1
  75. package/dist/preferences-runtime.d.ts +1 -1
  76. package/dist/{preview-BeMKALry.d.cts → preview-DdUvoLb4.d.cts} +2 -2
  77. package/dist/{preview-BeMKALry.d.ts → preview-DdUvoLb4.d.ts} +2 -2
  78. package/dist/{registry-BBe-Lesw.d.ts → registry-Dz4iHNST.d.cts} +3 -1
  79. package/dist/{registry-BBe-Lesw.d.cts → registry-Dz4iHNST.d.ts} +3 -1
  80. package/dist/{session-C0fSteA4.d.cts → session-Baxp9iRK.d.cts} +24 -9
  81. package/dist/{session-C0fSteA4.d.ts → session-Baxp9iRK.d.ts} +24 -9
  82. package/dist/session-reconnect.d.cts +2 -2
  83. package/dist/session-reconnect.d.ts +2 -2
  84. package/dist/smart-dom-reader.d.cts +1 -1
  85. package/dist/smart-dom-reader.d.ts +1 -1
  86. package/dist/theme-editor-preview.cjs +47 -45
  87. package/dist/theme-editor-preview.d.cts +1 -1
  88. package/dist/theme-editor-preview.d.ts +1 -1
  89. package/dist/theme-editor-preview.js +23 -24
  90. package/dist/theme-editor.d.cts +2 -2
  91. package/dist/theme-editor.d.ts +2 -2
  92. package/dist/theme-reference.d.cts +2 -2
  93. package/dist/theme-reference.d.ts +2 -2
  94. package/dist/{tool-loading-animation-BvdaZfwh.d.cts → tool-loading-animation-un3kA5Ur.d.cts} +1 -1
  95. package/dist/{tool-loading-animation-BvdaZfwh.d.ts → tool-loading-animation-un3kA5Ur.d.ts} +1 -1
  96. package/dist/{turn-activity-ui-BYBlInZQ.d.cts → turn-activity-ui-BGNhtMcH.d.cts} +2 -2
  97. package/dist/{turn-activity-ui-BYBlInZQ.d.ts → turn-activity-ui-BGNhtMcH.d.ts} +2 -2
  98. package/dist/turn-activity-ui.d.cts +1 -1
  99. package/dist/turn-activity-ui.d.ts +1 -1
  100. package/dist/{types-CiXi4ICM.d.cts → types-BOZHQIk0.d.cts} +13 -1
  101. package/dist/{types-CiXi4ICM.d.ts → types-BOZHQIk0.d.ts} +13 -1
  102. package/dist/voice-client-tools.d.cts +1 -1
  103. package/dist/voice-client-tools.d.ts +1 -1
  104. package/dist/voice-runtime.cjs +1 -1
  105. package/dist/voice-runtime.d.cts +2 -2
  106. package/dist/voice-runtime.d.ts +2 -2
  107. package/dist/voice-runtime.js +1 -1
  108. package/dist/voice-worklet-player.d.cts +2 -2
  109. package/dist/voice-worklet-player.d.ts +2 -2
  110. package/dist/{webmcp-runtime-entry-BTbOg3Au.d.cts → webmcp-runtime-entry-DuAG1_yF.d.cts} +1 -1
  111. package/dist/{webmcp-runtime-entry-BTbOg3Au.d.ts → webmcp-runtime-entry-DuAG1_yF.d.ts} +1 -1
  112. package/dist/webmcp-runtime.d.cts +1 -1
  113. package/dist/webmcp-runtime.d.ts +1 -1
  114. package/dist/widget.css +1 -1
  115. package/package.json +2 -2
  116. package/src/activity-ui.ts +23 -0
  117. package/src/components/disclosure-animation.ts +5 -0
  118. package/src/components/tool-bubble.ts +1 -0
  119. package/src/index-core.ts +2 -0
  120. package/src/markdown-parsers-eager.ts +2 -1
  121. package/src/markdown-parsers-entry.ts +1 -0
  122. package/src/markdown-parsers-loader.ts +2 -0
  123. package/src/postprocessors.ts +14 -0
  124. package/src/session.ts +3 -0
  125. package/src/styles/widget.css +6 -0
  126. package/src/types.ts +13 -0
  127. package/src/ui.background-conversations.test.ts +6 -0
  128. package/src/ui.footer-visibility.test.ts +54 -0
  129. package/src/ui.page-cache.test.ts +268 -0
  130. package/src/ui.postprocess.test.ts +80 -1
  131. package/src/ui.reveal-tool-call.test.ts +258 -0
  132. package/src/ui.ts +222 -32
  133. package/src/utils/actions.ts +3 -0
  134. package/src/utils/streaming-markdown.test.ts +103 -0
  135. package/src/utils/streaming-markdown.ts +186 -0
  136. package/dist/chunk-18tPmsiJ.js +0 -1
  137. package/dist/chunk-B3OrH9Ng.js +0 -2
  138. package/dist/chunk-Dp2vWE9V.js +0 -1
  139. package/dist/chunk-VloQ2dnh.js +0 -1
package/src/ui.ts CHANGED
@@ -11,9 +11,8 @@ import { isBrowserLocalApproval } from "./utils/restored-browser-state";
11
11
  import { applyStatusIndicatorState, placeStatusIndicator } from "./utils/status-indicator";
12
12
  import type { DictationSession } from "./dictation-session";
13
13
  import { usesSessionVoice, usesSessionVoiceForConfig } from "./utils/voice-support";
14
- import { escapeHtml, createMarkdownProcessorFromConfig } from "./postprocessors";
14
+ import { escapeHtml, createMarkdownProcessorFromConfig, preprocessStreamingMarkdown } from "./postprocessors";
15
15
  import { resolveSanitizer } from "./utils/sanitize";
16
- import { stabilizeStreamingTables } from "./utils/streaming-table";
17
16
  import { wrapScrollableTables, refreshTableScrollFades } from "./utils/table-scroll-fade";
18
17
  import { getMarkdownParsersSync } from "./markdown-parsers-loader";
19
18
  import { loadClientStream } from "./client-stream-loader";
@@ -67,6 +66,7 @@ import {
67
66
  AgentWidgetContextMentionRef,
68
67
  AgentWidgetStoredDraft,
69
68
  AgentWidgetOpenConversationOptions,
69
+ AgentWidgetRevealToolCallOptions,
70
70
  AgentWidgetSuggestion,
71
71
  AgentWidgetSuggestionSource,
72
72
  AgentWidgetWelcomeIcon,
@@ -368,6 +368,8 @@ const VOICE_STATE_RESTORE_WINDOW = 30 * 1000;
368
368
  const VOICE_RESUME_NOTICE = "Tap the mic to resume your voice call";
369
369
  // Split desktop boundary; must match widget.css artifact media queries (min-width:641px).
370
370
  const ARTIFACT_SPLIT_DESKTOP_MIN = 641;
371
+ // Covers the default 160ms V5 disclosure transition.
372
+ const REVEAL_SETTLE_MS = 200;
371
373
 
372
374
  /** Marks the status region while a composer lock reason owns it. */
373
375
  const COMPOSER_REASON_ATTR = "data-persona-composer-reason";
@@ -663,6 +665,12 @@ type Controller = {
663
665
  * (launcher mode) or textarea is unavailable.
664
666
  */
665
667
  focusInput: () => boolean;
668
+ /**
669
+ * Bring a tool call into view: opens any collapsed group holding it, expands its details
670
+ * (unless `expand: false`), scrolls it to the center of the transcript and briefly marks it
671
+ * with `data-persona-revealed`. Returns false when no rendered tool bubble has that id.
672
+ */
673
+ revealToolCall: (toolCallId: string, options?: AgentWidgetRevealToolCallOptions) => boolean;
666
674
  /**
667
675
  * Programmatically resolve a pending approval.
668
676
  * @param approvalId - The approval ID to resolve
@@ -817,7 +825,8 @@ export const buildPostprocessor = (
817
825
  // displayed entities literally (I'll -> '). Only run the sanitizer when
818
826
  // it can actually parse HTML; escapeHtml output is already inert. Checked per
819
827
  // render (not once at setup): the chunk lands later and the self-heal re-renders.
820
- const parsersReady = getMarkdownParsersSync() !== null;
828
+ const parsers = getMarkdownParsersSync();
829
+ const parsersReady = parsers !== null;
821
830
  let html: string;
822
831
  if (cfg?.postprocessMessage) {
823
832
  const out = cfg.postprocessMessage({
@@ -829,10 +838,9 @@ export const buildPostprocessor = (
829
838
  // sanitizer's degraded fallback. Honors `sanitize: false` (pass-through) as before.
830
839
  html = sanitize ? sanitize(out) : out;
831
840
  } else if (markdownProcessor) {
832
- // While streaming, normalize tables-in-progress so they render as a real
833
- // <table> from the first row with a stable column count (Telegram-style
834
- // space reservation). The final, non-streaming render is left untouched.
835
- const source = context.streaming ? stabilizeStreamingTables(nextText) : nextText;
841
+ // Stabilize incomplete Markdown only for display during streaming. The
842
+ // final render and the stored response always use the original text.
843
+ const source = preprocessStreamingMarkdown(nextText, context.streaming);
836
844
  // Already escapeHtml(text) (single, safe) while parsers are not loaded.
837
845
  const out = markdownProcessor(source);
838
846
  html = sanitize && parsersReady ? sanitize(out) : out;
@@ -1226,6 +1234,10 @@ export const createAgentExperience = (
1226
1234
  getSessionMetadata,
1227
1235
  updateSessionMetadata,
1228
1236
  emit: eventBus.emit,
1237
+ captureResubmitGuard: () => {
1238
+ const generation = restartGeneration;
1239
+ return () => canRestart(generation);
1240
+ },
1229
1241
  documentRef: typeof document !== "undefined" ? document : null
1230
1242
  });
1231
1243
  actionManager.syncFromMetadata();
@@ -1250,10 +1262,31 @@ export const createAgentExperience = (
1250
1262
  // Track pending resubmit state for injection-triggered resubmit
1251
1263
  // When a handler returns resubmit: true, we wait for injectAssistantMessage()
1252
1264
  // to be called before triggering the actual resubmit (to avoid race conditions)
1265
+ // Cached restoration reuses this controller, so work queued by the old page
1266
+ // lifecycle must remain stale even after pageshow makes the page active again.
1267
+ let restartGeneration = 0;
1268
+ let destroyed = false;
1269
+ const canRestart = (generation: number) =>
1270
+ !destroyed && !pageHiding && generation === restartGeneration;
1271
+ const invalidatePendingRestarts = () => {
1272
+ restartGeneration++;
1273
+ pendingResubmit = false;
1274
+ if (pendingResubmitTimeout !== null) clearTimeout(pendingResubmitTimeout);
1275
+ pendingResubmitTimeout = null;
1276
+ };
1277
+ const scheduleResubmit = () => {
1278
+ const generation = restartGeneration;
1279
+ if (!canRestart(generation)) return;
1280
+ setTimeout(() => {
1281
+ if (canRestart(generation) && !session.isStreaming()) session.continueConversation();
1282
+ }, 100);
1283
+ };
1284
+
1253
1285
  let pendingResubmit = false;
1254
1286
  let pendingResubmitTimeout: ReturnType<typeof setTimeout> | null = null;
1255
1287
 
1256
1288
  const handleResubmitRequested = () => {
1289
+ if (!canRestart(restartGeneration)) return;
1257
1290
  pendingResubmit = true;
1258
1291
  // Clear any existing timeout
1259
1292
  if (pendingResubmitTimeout) {
@@ -2836,8 +2869,10 @@ export const createAgentExperience = (
2836
2869
 
2837
2870
  // Check if the click/keypress is on an expand header button
2838
2871
  const headerButton = target.closest('button[data-expand-header="true"]') as HTMLElement;
2839
- if (!headerButton) return;
2872
+ if (headerButton) toggleBubbleHeader(headerButton);
2873
+ };
2840
2874
 
2875
+ const toggleBubbleHeader = (headerButton: HTMLElement) => {
2841
2876
  // Find the parent bubble element
2842
2877
  const bubble = headerButton.closest('.persona-reasoning-bubble, .persona-tool-bubble, .persona-approval-bubble') as HTMLElement;
2843
2878
  if (!bubble) return;
@@ -2850,6 +2885,12 @@ export const createAgentExperience = (
2850
2885
  // DOM grows so the resize observer cannot pull the opened details away.
2851
2886
  markReaderEngaged();
2852
2887
  pauseAutoScroll();
2888
+ const anchor = anchorState;
2889
+ if (anchor) cancelSmoothScroll();
2890
+ const finishDisclosure = anchor && activityUi?.holdScroll(
2891
+ bubble, body, anchor, measureContentHeight, setAnchorSpacerHeight,
2892
+ BOTTOM_THRESHOLD, () => anchorState === anchor
2893
+ );
2853
2894
  const bubbleType = headerButton.getAttribute('data-bubble-type');
2854
2895
  if (bubble.classList.contains("persona-activity-row")) {
2855
2896
  activityLifecycle?.manual(messageId);
@@ -2876,6 +2917,7 @@ export const createAgentExperience = (
2876
2917
  approvalDetailsExpansionState.set(messageId, !expanded);
2877
2918
  approvalUi.updateApprovalDetailsUI(messageId, bubble, config, approvalDetailsExpansionState);
2878
2919
  }
2920
+ finishDisclosure?.();
2879
2921
  // Invalidate cached wrapper so next render rebuilds with current expansion state
2880
2922
  messageCache.delete(messageId);
2881
2923
  };
@@ -2926,6 +2968,24 @@ export const createAgentExperience = (
2926
2968
  // This handles clicks even after idiomorph morphs the DOM and strips inline listeners
2927
2969
  const messageVoteState = new Map<string, "upvote" | "downvote">();
2928
2970
 
2971
+ // Reveal state belongs to the message, not its current DOM node: streaming
2972
+ // renders can morph or replace the bubble before the highlight expires.
2973
+ const revealedToolCalls = new Map<string, number>();
2974
+ let revealCenterTimer: number | undefined;
2975
+ let revealScrollGeneration = 0;
2976
+ const refreshRevealedToolCalls = () => {
2977
+ messagesWrapper.querySelectorAll<HTMLElement>(".persona-tool-bubble[data-tool-call-id]").forEach((bubble) => {
2978
+ bubble.toggleAttribute("data-persona-revealed", revealedToolCalls.has(bubble.dataset.messageId!));
2979
+ });
2980
+ };
2981
+ const clearRevealedToolCalls = () => {
2982
+ window.clearTimeout(revealCenterTimer);
2983
+ revealCenterTimer = undefined;
2984
+ revealedToolCalls.forEach((timer) => window.clearTimeout(timer));
2985
+ revealedToolCalls.clear();
2986
+ refreshRevealedToolCalls();
2987
+ };
2988
+
2929
2989
  // Read-aloud (text-to-speech) button state. The ReadAloudController in the
2930
2990
  // session is the source of truth; these mirror its last-known state so the
2931
2991
  // button visuals can be re-applied after every render/morph (which would
@@ -4026,7 +4086,11 @@ export const createAgentExperience = (
4026
4086
  body.style.cssText = '';
4027
4087
  // A plugin-owned footer is the plugin's DOM: its inline state (the home
4028
4088
  // screen's `display: none` placeholder) is not ours to reset.
4029
- if (!composerIsPluginOwned) footer.style.cssText = '';
4089
+ if (!composerIsPluginOwned) {
4090
+ footer.style.cssText = '';
4091
+ // The reset also wipes the hidden state that layout.showFooter set.
4092
+ if (config.layout?.showFooter === false) footer.style.display = 'none';
4093
+ }
4030
4094
 
4031
4095
  // Preserve the event-stream takeover across a layout-mode change. The
4032
4096
  // cssText reset above wiped the `display: none` that toggleEventStreamOn
@@ -4378,6 +4442,7 @@ export const createAgentExperience = (
4378
4442
  border-top: none !important;
4379
4443
  padding: 8px 16px 12px 16px !important;
4380
4444
  ${chatFlush ? 'background: transparent !important;' : ''}
4445
+ ${config.layout?.showFooter === false ? 'display: none !important;' : ''}
4381
4446
  `;
4382
4447
  }
4383
4448
 
@@ -5032,6 +5097,7 @@ export const createAgentExperience = (
5032
5097
  initialSpacerHeight: number;
5033
5098
  contentHeightAtAnchor: number;
5034
5099
  spacerHeight: number;
5100
+ pending: number;
5035
5101
  } | null = null;
5036
5102
  let anchorRAF: number | null = null;
5037
5103
  // Seeded send-detection so restored history doesn't read as a fresh send.
@@ -5068,6 +5134,9 @@ export const createAgentExperience = (
5068
5134
  // look at the scroll event.
5069
5135
  let readerEngagedThisTurn = false;
5070
5136
  const markReaderEngaged = () => {
5137
+ // Reader input invalidates only reveal scrolling, including its settle pass.
5138
+ // The animation checks this generation before its next scroll write.
5139
+ revealScrollGeneration++;
5071
5140
  readerEngagedThisTurn = true;
5072
5141
  revealTranscriptScrollbar();
5073
5142
  };
@@ -5166,11 +5235,14 @@ export const createAgentExperience = (
5166
5235
  const timestamp = Number(rawVoiceState.timestamp ?? 0);
5167
5236
  voiceState.manuallyDeactivated = Boolean(rawVoiceState.manuallyDeactivated);
5168
5237
  if (wasActive && Date.now() - timestamp < VOICE_STATE_RESTORE_WINDOW) {
5238
+ const generation = restartGeneration;
5169
5239
  setTimeout(() => {
5240
+ if (!canRestart(generation)) return;
5170
5241
  if (!voiceState.active) {
5171
5242
  voiceState.manuallyDeactivated = false;
5172
5243
  if (usesSessionVoice(config.voiceRecognition?.provider)) {
5173
5244
  session.toggleVoice().then(() => {
5245
+ if (!canRestart(generation)) return;
5174
5246
  voiceState.active = isSessionVoiceLive();
5175
5247
  emitVoiceState("restore");
5176
5248
  if (voiceState.active) {
@@ -5192,10 +5264,10 @@ export const createAgentExperience = (
5192
5264
  }
5193
5265
  };
5194
5266
  // Re-stamp a live call as the page unloads: the restore window counts from
5195
- // the navigation, not from when the (possibly long) call was started. A
5196
- // back/forward-cache hide (`persisted`) isn't an unload: the page lives on.
5197
- const handleVoicePageHide = (event: PageTransitionEvent) => {
5198
- pageHiding = !event.persisted;
5267
+ // the navigation, not from when the (possibly long) call was started. Protect
5268
+ // that record from the trailing disconnect statuses, including cached hides.
5269
+ const handleVoicePageHide = () => {
5270
+ pageHiding = true;
5199
5271
  if (voiceState.active) persistVoiceMetadata();
5200
5272
  };
5201
5273
  if (typeof window !== "undefined") {
@@ -5681,7 +5753,7 @@ export const createAgentExperience = (
5681
5753
  // arrow pointing at nothing. Trim to what the reader's current position
5682
5754
  // actually needs plus the anti-bounce slack (shrink-only, like the drain).
5683
5755
  const trimAnchorSpacerToRest = () => {
5684
- if (!anchorState || anchorState.spacerHeight <= 0) return;
5756
+ if (!anchorState || anchorState.spacerHeight <= 0 || anchorState.pending) return;
5685
5757
  const contentHeight = measureContentHeight(anchorState.spacerHeight);
5686
5758
  const required = Math.max(
5687
5759
  0,
@@ -5761,6 +5833,7 @@ export const createAgentExperience = (
5761
5833
  });
5762
5834
 
5763
5835
  anchorState = {
5836
+ pending: 0,
5764
5837
  initialSpacerHeight: spacerHeight,
5765
5838
  contentHeightAtAnchor: contentHeight,
5766
5839
  spacerHeight
@@ -5804,6 +5877,7 @@ export const createAgentExperience = (
5804
5877
  slack: BOTTOM_THRESHOLD
5805
5878
  });
5806
5879
  anchorState = {
5880
+ pending: 0,
5807
5881
  initialSpacerHeight: spacerHeight,
5808
5882
  contentHeightAtAnchor: contentHeight,
5809
5883
  spacerHeight
@@ -5830,7 +5904,7 @@ export const createAgentExperience = (
5830
5904
  scheduleAutoScroll(!isStreaming);
5831
5905
  return;
5832
5906
  }
5833
- if (anchorState && anchorState.initialSpacerHeight > 0) {
5907
+ if (anchorState && anchorState.initialSpacerHeight > 0 && !anchorState.pending) {
5834
5908
  const currentContentHeight = body.scrollHeight - anchorState.spacerHeight;
5835
5909
  const next = computeShrunkSpacerHeight({
5836
5910
  initialSpacerHeight: anchorState.initialSpacerHeight,
@@ -7158,6 +7232,15 @@ export const createAgentExperience = (
7158
7232
  if (messages.length > 0) warmClientStream();
7159
7233
  renderMessagesWithPluginsImpl(container, messages, transform);
7160
7234
  refreshReadAloudButtons();
7235
+ if (revealedToolCalls.size > 0) {
7236
+ for (const [id, timer] of revealedToolCalls) {
7237
+ if (!messages.some((message) => message.id === id)) {
7238
+ window.clearTimeout(timer);
7239
+ revealedToolCalls.delete(id);
7240
+ }
7241
+ }
7242
+ refreshRevealedToolCalls();
7243
+ }
7161
7244
  };
7162
7245
 
7163
7246
  /**
@@ -9132,7 +9215,7 @@ export const createAgentExperience = (
9132
9215
  currentTurnAnchored = reading.anchored;
9133
9216
  anchoredMessageId = reading.anchoredId;
9134
9217
  readerEngagedThisTurn = reading.readerEngaged;
9135
- anchorState = reading.anchor ? { ...reading.anchor } : null;
9218
+ anchorState = reading.anchor ? { ...reading.anchor, pending: 0 } : null;
9136
9219
  setAnchorSpacerHeight(reading.anchor?.spacerHeight ?? 0);
9137
9220
  if (reading.following) autoFollow.resume(); else autoFollow.pause();
9138
9221
  newMessagesSincePause = reading.unread;
@@ -9625,6 +9708,7 @@ export const createAgentExperience = (
9625
9708
  // otherwise dispatch the same text/command twice. It also covers an async
9626
9709
  // `onBeforeSend` (the `preparing` phase).
9627
9710
  let submitInFlight = false;
9711
+ let submitEpoch = 0;
9628
9712
  /** Aborts the in-flight preparation on destroy or a superseding stop. */
9629
9713
  let submissionAbort: AbortController | null = null;
9630
9714
 
@@ -10035,10 +10119,11 @@ export const createAgentExperience = (
10035
10119
  }
10036
10120
  if (submitInFlight) return;
10037
10121
  submitInFlight = true;
10122
+ const epoch = ++submitEpoch;
10038
10123
  try {
10039
10124
  await performSubmit({ resolveInlineCommand: true, ...submitOptions });
10040
10125
  } finally {
10041
- submitInFlight = false;
10126
+ if (epoch === submitEpoch) submitInFlight = false;
10042
10127
  }
10043
10128
  };
10044
10129
 
@@ -10065,8 +10150,9 @@ export const createAgentExperience = (
10065
10150
  ): void => {
10066
10151
  if (submitInFlight) return;
10067
10152
  submitInFlight = true;
10153
+ const epoch = ++submitEpoch;
10068
10154
  const finish = () => {
10069
- submitInFlight = false;
10155
+ if (epoch === submitEpoch) submitInFlight = false;
10070
10156
  };
10071
10157
  try {
10072
10158
  const result = performSubmit({ ...options, overrideText: text });
@@ -10508,7 +10594,7 @@ export const createAgentExperience = (
10508
10594
 
10509
10595
  voiceState.active = false;
10510
10596
  emitVoiceState(source);
10511
- persistVoiceMetadata();
10597
+ if (!pageHiding) persistVoiceMetadata();
10512
10598
  syncComposerCompact();
10513
10599
 
10514
10600
  if (micButton) {
@@ -10698,9 +10784,10 @@ export const createAgentExperience = (
10698
10784
  const pendingSubmissionUnsub = eventBus.on("assistant:complete", () => {
10699
10785
  const pending = composerStore.getState().pendingSubmission;
10700
10786
  const generation = composerConversationGeneration;
10787
+ const restart = restartGeneration;
10701
10788
  if (!pending) return;
10702
10789
  setTimeout(() => {
10703
- if (session.isStreaming() || generation !== composerConversationGeneration || composerStore.getState().pendingSubmission !== pending) return;
10790
+ if (!canRestart(restart) || session.isStreaming() || generation !== composerConversationGeneration || composerStore.getState().pendingSubmission !== pending) return;
10704
10791
  sendPendingSubmission();
10705
10792
  }, 0);
10706
10793
  });
@@ -10713,11 +10800,13 @@ export const createAgentExperience = (
10713
10800
  return;
10714
10801
  }
10715
10802
  const generation = composerConversationGeneration;
10803
+ const restart = restartGeneration;
10716
10804
  setTimeout(() => {
10717
- if (generation !== composerConversationGeneration) return;
10805
+ if (!canRestart(restart) || generation !== composerConversationGeneration) return;
10718
10806
  if (!voiceState.active && !voiceState.manuallyDeactivated) {
10719
10807
  if (usesSessionVoice(config.voiceRecognition?.provider)) {
10720
10808
  session.toggleVoice().then(() => {
10809
+ if (!canRestart(restart) || generation !== composerConversationGeneration) return;
10721
10810
  voiceState.active = session.isVoiceActive();
10722
10811
  emitVoiceState("auto");
10723
10812
  if (session.isVoiceActive()) applyRuntypeMicRecordingStyles();
@@ -10730,17 +10819,50 @@ export const createAgentExperience = (
10730
10819
  });
10731
10820
  destroyCallbacks.push(autoResumeUnsub);
10732
10821
 
10822
+ // A cached document retains its DOM and controller, but must release live
10823
+ // transports and capture before it freezes. A fresh navigation still uses
10824
+ // the existing teardown/voice-metadata path above.
10825
+ let cachedPage = false;
10826
+ const suspendCachedPage = (event: PageTransitionEvent) => {
10827
+ if (!event.persisted || cachedPage) return;
10828
+ cachedPage = true;
10829
+ pageHiding = true;
10830
+ invalidatePendingRestarts();
10831
+ composerConversationGeneration++;
10832
+ submitEpoch++;
10833
+ submitInFlight = false;
10834
+ submissionAbort?.abort();
10835
+ submissionAbort = null;
10836
+ composerStore.setPreparing(false);
10837
+ dismissDictation();
10838
+ stopVoiceRecognition("system");
10839
+ session.cancel();
10840
+ session.stopSpeaking();
10841
+ session.cleanupVoice();
10842
+ throughputTracker?.reset();
10843
+ eventStreamView?.update();
10844
+ };
10845
+ const restoreCachedPage = (event: PageTransitionEvent) => {
10846
+ if (!event.persisted || !cachedPage) return;
10847
+ cachedPage = false;
10848
+ pageHiding = false;
10849
+ // Rebuild the provider lazily so the same controls can start a new call.
10850
+ // Neither stopped replies nor calls restart automatically on Back.
10851
+ if (config.voiceRecognition?.enabled === true && usesSessionVoiceForConfig(config)) session.setupVoice();
10852
+ };
10853
+ window.addEventListener("pagehide", suspendCachedPage);
10854
+ window.addEventListener("pageshow", restoreCachedPage);
10855
+ destroyCallbacks.push(() => {
10856
+ window.removeEventListener("pagehide", suspendCachedPage);
10857
+ window.removeEventListener("pageshow", restoreCachedPage);
10858
+ });
10859
+
10733
10860
  // Handle action:resubmit event - automatically trigger another model call
10734
10861
  // when an action handler needs the model to continue processing (e.g., analyzing search results)
10735
10862
  const resubmitUnsub = eventBus.on("action:resubmit", () => {
10736
10863
  // Short delay to allow UI to update with any injected messages
10737
10864
  // Handlers should call context.triggerResubmit() AFTER their async work completes
10738
- setTimeout(() => {
10739
- if (session && !session.isStreaming()) {
10740
- // Continue conversation without adding a visible user message
10741
- session.continueConversation();
10742
- }
10743
- }, 100);
10865
+ scheduleResubmit();
10744
10866
  });
10745
10867
  destroyCallbacks.push(resubmitUnsub);
10746
10868
 
@@ -11361,6 +11483,8 @@ export const createAgentExperience = (
11361
11483
 
11362
11484
  view.replaceComposer(nextFooter);
11363
11485
  footer = view.composer.footer;
11486
+ // A rebuilt footer starts visible; keep honoring layout.showFooter.
11487
+ if (config.layout?.showFooter === false) footer.style.display = "none";
11364
11488
  // A plugin composer owns its own send affordance, so the built-in
11365
11489
  // send/stop swap has nothing to drive.
11366
11490
  const isDefaultFooter =
@@ -12234,6 +12358,10 @@ export const createAgentExperience = (
12234
12358
  getSessionMetadata,
12235
12359
  updateSessionMetadata,
12236
12360
  emit: eventBus.emit,
12361
+ captureResubmitGuard: () => {
12362
+ const generation = restartGeneration;
12363
+ return () => canRestart(generation);
12364
+ },
12237
12365
  documentRef: typeof document !== "undefined" ? document : null
12238
12366
  });
12239
12367
 
@@ -12691,6 +12819,7 @@ export const createAgentExperience = (
12691
12819
  disposeApprovalRenders();
12692
12820
  artifactsPaneUserHidden = false;
12693
12821
  activityLifecycle?.clear();
12822
+ clearRevealedToolCalls();
12694
12823
  toolExpansionState.clear();
12695
12824
  reasoningExpansionState.clear();
12696
12825
  approvalDetailsExpansionState.clear();
@@ -12883,11 +13012,7 @@ export const createAgentExperience = (
12883
13012
  pendingResubmitTimeout = null;
12884
13013
  }
12885
13014
  // Short delay to ensure message is in context
12886
- setTimeout(() => {
12887
- if (session && !session.isStreaming()) {
12888
- session.continueConversation();
12889
- }
12890
- }, 100);
13015
+ scheduleResubmit();
12891
13016
  }
12892
13017
 
12893
13018
  return result;
@@ -13098,6 +13223,68 @@ export const createAgentExperience = (
13098
13223
  textarea.focus();
13099
13224
  return true;
13100
13225
  },
13226
+ revealToolCall(toolCallId: string, options: AgentWidgetRevealToolCallOptions = {}): boolean {
13227
+ const bubble = Array.from(body.querySelectorAll<HTMLElement>(".persona-tool-bubble[data-tool-call-id]"))
13228
+ .find((el) => el.dataset.toolCallId === toolCallId);
13229
+ if (!bubble) return false;
13230
+ const ownHeader = (owner: HTMLElement) =>
13231
+ Array.from(owner.querySelectorAll<HTMLElement>('button[data-expand-header="true"]'))
13232
+ .find((header) => header.closest(".persona-tool-bubble") === owner);
13233
+ const openHeader = (header: HTMLElement | undefined) => {
13234
+ if (header?.getAttribute("aria-expanded") === "false") toggleBubbleHeader(header);
13235
+ };
13236
+ const groups: HTMLElement[] = [];
13237
+ for (let group = bubble.parentElement?.closest<HTMLElement>(".persona-tool-bubble"); group;
13238
+ group = group.parentElement?.closest<HTMLElement>(".persona-tool-bubble")) groups.unshift(group);
13239
+ groups.forEach((group) => openHeader(ownHeader(group)));
13240
+ const header = ownHeader(bubble);
13241
+ if (options.expand !== false) openHeader(header);
13242
+
13243
+ markReaderEngaged();
13244
+ // A prior jump owns a separate animation whose completion resumes follow.
13245
+ latestScroll.cancel();
13246
+ pauseAutoScroll();
13247
+ const revealGeneration = revealScrollGeneration;
13248
+ const anchor = header ?? bubble;
13249
+ const shouldContinue = () => !destroyed && revealGeneration === revealScrollGeneration && body.contains(anchor);
13250
+ const reduceMotion = typeof window.matchMedia === "function" &&
13251
+ window.matchMedia("(prefers-reduced-motion: reduce)").matches;
13252
+ const targetTop = () => {
13253
+ const raw = offsetTopWithinBody(anchor) - (body.clientHeight - anchor.offsetHeight) / 2;
13254
+ return Math.max(0, Math.min(raw, getScrollBottomOffset(body)));
13255
+ };
13256
+ const center = () => {
13257
+ if (!shouldContinue()) return;
13258
+ // Use the same programmatic-scroll tracking as automatic following so
13259
+ // centering near the bottom cannot be mistaken for reader intent.
13260
+ if (reduceMotion) {
13261
+ cancelSmoothScroll();
13262
+ isAutoScrolling = true;
13263
+ body.scrollTop = targetTop();
13264
+ lastScrollTop = body.scrollTop;
13265
+ isAutoScrolling = false;
13266
+ } else animateScrollTo(body, targetTop, REVEAL_SETTLE_MS, shouldContinue);
13267
+ };
13268
+ window.clearTimeout(revealCenterTimer);
13269
+ center();
13270
+ // Disclosure height transitions move the row after the first pass; center again once they settle.
13271
+ revealCenterTimer = window.setTimeout(() => {
13272
+ revealCenterTimer = undefined;
13273
+ center();
13274
+ }, REVEAL_SETTLE_MS);
13275
+
13276
+ const highlightMs = options.highlightMs ?? 2000;
13277
+ if (highlightMs > 0) {
13278
+ const messageId = bubble.dataset.messageId!;
13279
+ window.clearTimeout(revealedToolCalls.get(messageId));
13280
+ revealedToolCalls.set(messageId, window.setTimeout(() => {
13281
+ revealedToolCalls.delete(messageId);
13282
+ refreshRevealedToolCalls();
13283
+ }, highlightMs));
13284
+ bubble.setAttribute("data-persona-revealed", "");
13285
+ }
13286
+ return true;
13287
+ },
13101
13288
  async resolveApproval(
13102
13289
  approvalId: string,
13103
13290
  decision: 'approved' | 'denied',
@@ -13224,6 +13411,8 @@ export const createAgentExperience = (
13224
13411
  return session.submitNPSFeedback(rating, comment);
13225
13412
  },
13226
13413
  destroy() {
13414
+ destroyed = true;
13415
+ invalidatePendingRestarts();
13227
13416
  renderReadiness.destroy();
13228
13417
  slots.destroy();
13229
13418
  mount.removeEventListener("click", routeLinkClick);
@@ -13240,6 +13429,7 @@ export const createAgentExperience = (
13240
13429
  }
13241
13430
  activityLifecycle?.destroy();
13242
13431
  loadingLabelLifecycle?.destroy();
13432
+ clearRevealedToolCalls();
13243
13433
  toolExpansionState.clear();
13244
13434
  reasoningExpansionState.clear();
13245
13435
  approvalDetailsExpansionState.clear();
@@ -28,6 +28,7 @@ type ActionManagerOptions = {
28
28
  payload: AgentWidgetControllerEventMap[K]
29
29
  ) => void;
30
30
  documentRef: Document | null;
31
+ captureResubmitGuard?: () => () => boolean;
31
32
  };
32
33
 
33
34
  const stripCodeFence = (value: string) => {
@@ -195,7 +196,9 @@ export const createActionManager = (options: ActionManagerOptions) => {
195
196
  try {
196
197
  // Create triggerResubmit function that emits the resubmit event
197
198
  // Handlers should call this AFTER async work completes (not return resubmit: true)
199
+ const canResubmit = options.captureResubmitGuard?.();
198
200
  const triggerResubmit = () => {
201
+ if (canResubmit && !canResubmit()) return;
199
202
  options.emit("action:resubmit", eventPayload);
200
203
  };
201
204
 
@@ -0,0 +1,103 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { stabilizeStreamingMarkdown as stabilize } from "./streaming-markdown";
3
+ import { createMarkdownProcessor } from "../postprocessors";
4
+
5
+ const markdown = createMarkdownProcessor();
6
+
7
+ describe("streaming Markdown display", () => {
8
+ it.each(["**bold**", "*italic*", "_italic_", "__bold__", "***both***", "**bold *italic***", "*italic **bold***", "`a*b`", "``a`b``"])("renders every nonempty content prefix of %s without raw delimiters", (source) => {
9
+ for (let length = 1; length <= source.length; length++) {
10
+ const html = markdown(stabilize(`Start ${source.slice(0, length)}`));
11
+ const text = html.replace(/<[^>]*>/g, "");
12
+ if (!source.includes("`")) expect(text).not.toMatch(/[*_]/);
13
+ }
14
+ expect(markdown(stabilize(source))).toBe(markdown(source));
15
+ });
16
+
17
+ it("stabilizes every character boundary across multiple completed and new spans", () => {
18
+ const source = "Watch **bold**, *italic*, and `inline code`. Try **nested *emphasis*** and _more_ with __bold__ afterward.";
19
+ for (let length = 1; length <= source.length; length++) {
20
+ const html = markdown(stabilize(source.slice(0, length)));
21
+ expect(html.replace(/<[^>]*>/g, "")).not.toMatch(/[*_`]/);
22
+ }
23
+ expect(markdown(stabilize(source))).toBe(markdown(source));
24
+ });
25
+
26
+ it.each([
27
+ "* list item", "_ literal _", "foo_bar_baz", "2 * 3", "2*3", "foo*bar", "<https://example.com/a_b>", '<span data-label="**hello">text</span>', "***", "*", "\\*literal", "\\`literal", "\\[literal", "[ordinary] prose",
28
+ "[x](https://example.com/it's)", "[x](https://example.com/a_(b))", '[x](https://example.com "a title")', "![x](image.png)",
29
+ "```md\n**literal\n| a | b |\n| -\n```", "> ```\n*literal\n> ```", "- ```\n**literal\n ```", " **literal"
30
+ ])("preserves completed or ambiguous input %j", (source) => {
31
+ expect(stabilize(source)).toBe(source);
32
+ });
33
+
34
+ it("shows incomplete link labels as text and only activates complete destinations", () => {
35
+ const source = "[read more](https://example.com/a_(b))";
36
+ for (let length = 1; length < source.length; length++) {
37
+ const html = markdown(stabilize(source.slice(0, length)));
38
+ expect(html).not.toContain("<a ");
39
+ expect(html.replace(/<[^>]*>/g, "")).not.toMatch(/[[\]()]/);
40
+ }
41
+ expect(markdown(stabilize(source))).toContain('<a href="https://example.com/a_(b)">read more</a>');
42
+ });
43
+
44
+ it.each([
45
+ "Use `[` here", "Use `]` here", "Use ``[`]`` here", "Use ``]`[`` here",
46
+ "Use `]``[` here", "Use \\[ and \\] here",
47
+ ])("keeps code brackets inert while streaming link label %s", (label) => {
48
+ const source = `[${label}](https://example.com)`;
49
+ for (let length = 1; length < source.length; length++) {
50
+ const html = markdown(stabilize(source.slice(0, length)));
51
+ expect(html).not.toContain("<a ");
52
+ expect(html).not.toContain("https:");
53
+ }
54
+ expect(stabilize(source)).toBe(source);
55
+ expect(markdown(stabilize(source))).toBe(markdown(source));
56
+ expect(markdown(stabilize(source))).toContain('<a href="https://example.com">');
57
+ const image = `![${label}](image.png)`;
58
+ for (let length = 2; length < image.length; length++) {
59
+ const display = stabilize(image.slice(0, length));
60
+ expect(markdown(display)).not.toContain("<img ");
61
+ if (length >= label.length + 2) expect(display).toBe("");
62
+ }
63
+ expect(stabilize(image)).toBe(image);
64
+ });
65
+
66
+ it.each(["Use ` here", "Use `` here", "Use `` unmatched ` here", "Use ``[nested] ` here", "Use ` [nested] `` here"])("treats unmatched code runs as literal link label text: %s", (label) => {
67
+ const source = `[${label}](https://example.com)`;
68
+ expect(stabilize(source)).toBe(source);
69
+ expect(markdown(stabilize(source))).toBe(markdown(source));
70
+ expect(markdown(stabilize(source))).toContain('<a href="https://example.com">');
71
+ expect(stabilize(`[${label}](https://example`)).not.toContain("https:");
72
+ });
73
+
74
+ it("holds incomplete images, including escaped brackets and nested URL parentheses", () => {
75
+ const source = "![an \\] image](https://example.com/a_(b).png)";
76
+ for (let length = 2; length < source.length; length++) {
77
+ expect(stabilize(`Before ${source.slice(0, length)}`)).toBe("Before ");
78
+ }
79
+ expect(stabilize(source)).toBe(source);
80
+ expect(stabilize("Before ![image](url\\")).toBe("Before ");
81
+ expect(stabilize("Before [link](url\\")).toBe("Before link");
82
+ });
83
+
84
+ it("stabilizes formatting inside unfinished link labels", () => {
85
+ expect(markdown(stabilize("[**hello"))).toContain("<strong>hello</strong>");
86
+ expect(markdown(stabilize("[**hello**](https://example"))).toContain("<strong>hello</strong>");
87
+ });
88
+
89
+ it("keeps inline code contents and escapes inert", () => {
90
+ expect(markdown(stabilize("Here `**[x](url)"))).toContain("<code>**[x](url)</code>");
91
+ expect(stabilize("\\\\**bold")).toBe("\\\\**bold**");
92
+ expect(markdown(stabilize("**hello\\"))).toContain("<strong>hello\\</strong>");
93
+ });
94
+
95
+ it("preserves whitespace outside a temporarily completed emphasis span", () => {
96
+ expect(stabilize("**hello ")).toBe("**hello** ");
97
+ expect(stabilize("**hello\n\nNext")).toBe("**hello**\n\nNext");
98
+ });
99
+
100
+ it("continues table completion outside code fences", () => {
101
+ expect(markdown(stabilize("| A | B |\n| -"))).toContain("<table>");
102
+ });
103
+ });