@runtypelabs/persona 4.10.1 → 4.11.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 (56) 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-Bx_rvjff.d.cts → types-DveIaNx6.d.cts} +2 -0
  4. package/dist/animations/{types-Bx_rvjff.d.ts → types-DveIaNx6.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/chunk-MMUPR2JW.js +1 -0
  8. package/dist/codegen.cjs +1 -1
  9. package/dist/codegen.js +1 -1
  10. package/dist/{context-mentions-7S5KVUTG.js → context-mentions-ONG7A7M6.js} +1 -1
  11. package/dist/context-mentions.d.cts +26 -0
  12. package/dist/context-mentions.d.ts +26 -0
  13. package/dist/index.cjs +67 -67
  14. package/dist/index.cjs.map +1 -1
  15. package/dist/index.d.cts +27 -1
  16. package/dist/index.d.ts +27 -1
  17. package/dist/index.global.js +55 -55
  18. package/dist/index.global.js.map +1 -1
  19. package/dist/index.js +78 -78
  20. package/dist/index.js.map +1 -1
  21. package/dist/smart-dom-reader.d.cts +26 -0
  22. package/dist/smart-dom-reader.d.ts +26 -0
  23. package/dist/theme-editor-preview.cjs +66 -66
  24. package/dist/theme-editor-preview.d.cts +26 -0
  25. package/dist/theme-editor-preview.d.ts +26 -0
  26. package/dist/theme-editor-preview.js +55 -55
  27. package/dist/theme-editor.cjs +5 -5
  28. package/dist/theme-editor.d.cts +26 -0
  29. package/dist/theme-editor.d.ts +26 -0
  30. package/dist/theme-editor.js +9 -9
  31. package/dist/widget.css +1 -1
  32. package/package.json +1 -1
  33. package/src/components/approval-bubble.ts +0 -1
  34. package/src/components/composer-parts.ts +19 -19
  35. package/src/components/context-mention-button.test.ts +5 -1
  36. package/src/components/context-mention-button.ts +6 -4
  37. package/src/components/header-builder.test.ts +3 -3
  38. package/src/components/header-parts.ts +14 -108
  39. package/src/components/message-bubble.test.ts +49 -0
  40. package/src/components/message-bubble.ts +8 -7
  41. package/src/components/reasoning-bubble.ts +0 -2
  42. package/src/components/tool-bubble.ts +0 -2
  43. package/src/generated/runtype-openapi-contract.ts +2 -0
  44. package/src/index-core.ts +1 -0
  45. package/src/styles/widget.css +118 -58
  46. package/src/theme-editor/sections.test.ts +33 -0
  47. package/src/theme-editor/sections.ts +10 -1
  48. package/src/types.ts +25 -0
  49. package/src/ui.attachments-drop.test.ts +2 -1
  50. package/src/ui.message-width.test.ts +407 -0
  51. package/src/ui.ts +145 -238
  52. package/src/utils/table-scroll-fade.test.ts +123 -0
  53. package/src/utils/table-scroll-fade.ts +75 -0
  54. package/src/utils/tooltip.test.ts +226 -0
  55. package/src/utils/tooltip.ts +245 -0
  56. package/dist/chunk-IPVK3KOM.js +0 -1
package/src/ui.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import { escapeHtml, createMarkdownProcessorFromConfig } from "./postprocessors";
2
2
  import { resolveSanitizer } from "./utils/sanitize";
3
3
  import { stabilizeStreamingTables } from "./utils/streaming-table";
4
+ import { wrapScrollableTables, refreshTableScrollFades } from "./utils/table-scroll-fade";
4
5
  import { onMarkdownParsersReady, getMarkdownParsersSync } from "./markdown-parsers-loader";
5
6
  import { AgentWidgetSession, AgentWidgetSessionStatus } from "./session";
6
7
  import {
@@ -45,7 +46,8 @@ import { createTextPart, ALL_SUPPORTED_MIME_TYPES } from "./utils/content";
45
46
  import { applyThemeVariables, createThemeObserver, getActiveTheme } from "./utils/theme";
46
47
  import { resolveTokenValue } from "./utils/tokens";
47
48
  import { renderLucideIcon } from "./utils/icons";
48
- import { createElement, createElementInDocument } from "./utils/dom";
49
+ import { createElement } from "./utils/dom";
50
+ import { attachTooltip } from "./utils/tooltip";
49
51
  import { downloadInfoFor } from "./utils/artifact-file";
50
52
  import { artifactCopyText } from "./components/artifact-preview";
51
53
  import { morphMessages } from "./utils/morph";
@@ -66,7 +68,7 @@ import {
66
68
  resolveFollowStateFromScroll,
67
69
  resolveFollowStateFromWheel
68
70
  } from "./utils/auto-follow";
69
- import { statusCopy, DEFAULT_OVERLAY_Z_INDEX, PORTALED_OVERLAY_Z_INDEX } from "./utils/constants";
71
+ import { statusCopy, DEFAULT_OVERLAY_Z_INDEX } from "./utils/constants";
70
72
  import {
71
73
  applyStreamBuffer,
72
74
  createSkeletonPlaceholder,
@@ -4171,6 +4173,46 @@ export const createAgentExperience = (
4171
4173
 
4172
4174
 
4173
4175
  // Message rendering with plugin support (implementation)
4176
+ const applyMessageRowLayout = (
4177
+ wrapper: HTMLElement,
4178
+ role: AgentWidgetMessage["role"]
4179
+ ): void => {
4180
+ const sizingRole = role === "user" ? "user" : "assistant";
4181
+ const roleLayout = config.layout?.messages?.[sizingRole];
4182
+ const width = roleLayout?.width ?? "content";
4183
+ const maxWidth =
4184
+ roleLayout?.maxWidth ?? (width === "full" ? "100%" : "85%");
4185
+
4186
+ wrapper.classList.add("persona-message-row");
4187
+ wrapper.classList.remove(
4188
+ "persona-message-row-user",
4189
+ "persona-message-row-assistant",
4190
+ "persona-message-row-system",
4191
+ "persona-message-width-content",
4192
+ "persona-message-width-full"
4193
+ );
4194
+ wrapper.classList.add(
4195
+ `persona-message-row-${role}`,
4196
+ `persona-message-width-${width}`
4197
+ );
4198
+ wrapper.classList.toggle("persona-justify-end", role === "user");
4199
+ wrapper.setAttribute("data-message-role", role);
4200
+ wrapper.setAttribute("data-message-width", width);
4201
+ wrapper.style.setProperty("--persona-message-row-max-width", maxWidth);
4202
+ };
4203
+
4204
+ const createMessageRow = (
4205
+ id: string,
4206
+ role: AgentWidgetMessage["role"]
4207
+ ): HTMLElement => {
4208
+ const wrapper = document.createElement("div");
4209
+ wrapper.className = "persona-flex";
4210
+ wrapper.id = `wrapper-${id}`;
4211
+ wrapper.setAttribute("data-wrapper-id", id);
4212
+ applyMessageRowLayout(wrapper, role);
4213
+ return wrapper;
4214
+ };
4215
+
4174
4216
  const renderMessagesWithPluginsImpl = (
4175
4217
  container: HTMLElement,
4176
4218
  messages: AgentWidgetMessage[],
@@ -4212,6 +4254,7 @@ export const createAgentExperience = (
4212
4254
 
4213
4255
  // Track active message IDs for cache pruning
4214
4256
  const activeMessageIds = new Set<string>();
4257
+ const messageRolesById = new Map<string, AgentWidgetMessage["role"]>();
4215
4258
  // Track ask_user_question tool-call ids whose bubbles were rendered this
4216
4259
  // pass: used to prune stale sheets from the composer overlay afterward.
4217
4260
  const liveAskToolIds = new Set<string>();
@@ -4265,6 +4308,7 @@ export const createAgentExperience = (
4265
4308
 
4266
4309
  messages.forEach((message) => {
4267
4310
  activeMessageIds.add(message.id);
4311
+ messageRolesById.set(message.id, message.role);
4268
4312
 
4269
4313
  const askWithPlugin = hasAskPlugin && isAskUserQuestionMessage(message);
4270
4314
  const approvalWithPlugin =
@@ -4444,10 +4488,7 @@ export const createAgentExperience = (
4444
4488
 
4445
4489
  // Append a stub wrapper for the morph pass; hydrate the real bubble
4446
4490
  // into it post-morph so its event listeners survive.
4447
- const stub = document.createElement("div");
4448
- stub.className = "persona-flex";
4449
- stub.id = `wrapper-${message.id}`;
4450
- stub.setAttribute("data-wrapper-id", message.id);
4491
+ const stub = createMessageRow(message.id, message.role);
4451
4492
  stub.setAttribute("data-ask-plugin-stub", "true");
4452
4493
  stub.setAttribute("data-preserve-runtime", "true");
4453
4494
  tempContainer.appendChild(stub);
@@ -4522,10 +4563,7 @@ export const createAgentExperience = (
4522
4563
  } else {
4523
4564
  // A fresh live bubble to hydrate (needsRebuild), or fingerprint
4524
4565
  // unchanged so we reuse the preserved live wrapper (`bubble: null`).
4525
- const stub = document.createElement("div");
4526
- stub.className = "persona-flex";
4527
- stub.id = `wrapper-${message.id}`;
4528
- stub.setAttribute("data-wrapper-id", message.id);
4566
+ const stub = createMessageRow(message.id, message.role);
4529
4567
  stub.setAttribute("data-approval-plugin-stub", "true");
4530
4568
  stub.setAttribute("data-preserve-runtime", "true");
4531
4569
  tempContainer.appendChild(stub);
@@ -4642,7 +4680,6 @@ export const createAgentExperience = (
4642
4680
  const componentWrapper = document.createElement("div");
4643
4681
  componentWrapper.className = [
4644
4682
  "persona-message-bubble",
4645
- "persona-max-w-[85%]",
4646
4683
  "persona-rounded-2xl",
4647
4684
  "persona-bg-persona-surface",
4648
4685
  "persona-border",
@@ -4698,15 +4735,9 @@ export const createAgentExperience = (
4698
4735
  // Otherwise fall through to the standard render path so the message
4699
4736
  // text is at least visible.
4700
4737
  if (liveBubble || lastFp != null) {
4701
- const stub = document.createElement("div");
4702
- stub.className = "persona-flex";
4703
- stub.id = `wrapper-${message.id}`;
4704
- stub.setAttribute("data-wrapper-id", message.id);
4738
+ const stub = createMessageRow(message.id, message.role);
4705
4739
  stub.setAttribute("data-component-directive-stub", "true");
4706
4740
  stub.setAttribute("data-preserve-runtime", "true");
4707
- if (!wrapChrome) {
4708
- stub.classList.add("persona-w-full");
4709
- }
4710
4741
  tempContainer.appendChild(stub);
4711
4742
  componentDirectiveHydrate.push({
4712
4743
  messageId: message.id,
@@ -4763,17 +4794,7 @@ export const createAgentExperience = (
4763
4794
  }
4764
4795
  }
4765
4796
 
4766
- const wrapper = document.createElement("div");
4767
- wrapper.className = "persona-flex";
4768
- // Set id for idiomorph matching
4769
- wrapper.id = `wrapper-${message.id}`;
4770
- wrapper.setAttribute("data-wrapper-id", message.id);
4771
- if (message.role === "user") {
4772
- wrapper.classList.add("persona-justify-end");
4773
- }
4774
- if (bubble?.getAttribute("data-persona-component-directive") === "true") {
4775
- wrapper.classList.add("persona-w-full");
4776
- }
4797
+ const wrapper = createMessageRow(message.id, message.role);
4777
4798
  wrapper.appendChild(bubble);
4778
4799
  setCachedWrapper(messageCache, message.id, fingerprint, wrapper);
4779
4800
  tempContainer.appendChild(wrapper);
@@ -4832,10 +4853,11 @@ export const createAgentExperience = (
4832
4853
  return;
4833
4854
  }
4834
4855
 
4835
- const groupWrapper = document.createElement("div");
4836
- groupWrapper.className = "persona-flex";
4837
- groupWrapper.id = `wrapper-tool-group-${groupIndex}-${group[0].id}`;
4838
- groupWrapper.setAttribute("data-wrapper-id", `tool-group-${groupIndex}-${group[0].id}`);
4856
+ const groupWrapper = createMessageRow(
4857
+ `tool-group-${groupIndex}-${group[0].id}`,
4858
+ "assistant"
4859
+ );
4860
+ groupWrapper.setAttribute("data-persona-tool-group-row", "true");
4839
4861
 
4840
4862
  const groupContainer = document.createElement("div");
4841
4863
  groupContainer.className =
@@ -4875,6 +4897,10 @@ export const createAgentExperience = (
4875
4897
  wrapper.remove();
4876
4898
  return;
4877
4899
  }
4900
+ // The outer group row owns role sizing. Inner message rows fill that
4901
+ // resolved track instead of applying the role max-width a second
4902
+ // time (which would turn an 85% cap into 72.25%).
4903
+ wrapper.style.setProperty("--persona-message-row-max-width", "100%");
4878
4904
  const item = document.createElement("div");
4879
4905
  item.className = "persona-tool-group-item persona-relative";
4880
4906
  item.setAttribute("data-persona-tool-group-item", "true");
@@ -4938,7 +4964,6 @@ export const createAgentExperience = (
4938
4964
  const showBubble = config.loadingIndicator?.showBubble !== false; // default true
4939
4965
  typingBubble.className = showBubble
4940
4966
  ? [
4941
- "persona-max-w-[85%]",
4942
4967
  "persona-rounded-2xl",
4943
4968
  "persona-text-sm",
4944
4969
  "persona-leading-relaxed",
@@ -4950,7 +4975,6 @@ export const createAgentExperience = (
4950
4975
  "persona-py-3"
4951
4976
  ].join(" ")
4952
4977
  : [
4953
- "persona-max-w-[85%]",
4954
4978
  "persona-text-sm",
4955
4979
  "persona-leading-relaxed",
4956
4980
  "persona-text-persona-primary"
@@ -4960,11 +4984,10 @@ export const createAgentExperience = (
4960
4984
 
4961
4985
  typingBubble.appendChild(typingIndicator);
4962
4986
 
4963
- const typingWrapper = document.createElement("div");
4964
- typingWrapper.className = "persona-flex";
4965
- // Set id for idiomorph matching
4966
- typingWrapper.id = "wrapper-typing-indicator";
4967
- typingWrapper.setAttribute("data-wrapper-id", "typing-indicator");
4987
+ const typingWrapper = createMessageRow(
4988
+ "typing-indicator",
4989
+ "assistant"
4990
+ );
4968
4991
  typingWrapper.appendChild(typingBubble);
4969
4992
  tempContainer.appendChild(typingWrapper);
4970
4993
  }
@@ -5002,7 +5025,6 @@ export const createAgentExperience = (
5002
5025
  const showBubble = config.loadingIndicator?.showBubble !== false; // default true
5003
5026
  idleBubble.className = showBubble
5004
5027
  ? [
5005
- "persona-max-w-[85%]",
5006
5028
  "persona-rounded-2xl",
5007
5029
  "persona-text-sm",
5008
5030
  "persona-leading-relaxed",
@@ -5015,7 +5037,6 @@ export const createAgentExperience = (
5015
5037
  "persona-py-3"
5016
5038
  ].join(" ")
5017
5039
  : [
5018
- "persona-max-w-[85%]",
5019
5040
  "persona-text-sm",
5020
5041
  "persona-leading-relaxed",
5021
5042
  "persona-text-persona-primary"
@@ -5024,27 +5045,35 @@ export const createAgentExperience = (
5024
5045
 
5025
5046
  idleBubble.appendChild(idleIndicator);
5026
5047
 
5027
- const idleWrapper = document.createElement("div");
5028
- idleWrapper.className = "persona-flex";
5029
- // Set id for idiomorph matching
5030
- idleWrapper.id = "wrapper-idle-indicator";
5031
- idleWrapper.setAttribute("data-wrapper-id", "idle-indicator");
5048
+ const idleWrapper = createMessageRow("idle-indicator", "assistant");
5032
5049
  idleWrapper.appendChild(idleBubble);
5033
5050
  tempContainer.appendChild(idleWrapper);
5034
5051
  }
5035
5052
  }
5036
5053
 
5054
+ // Wrap wide tables in a horizontal-scroll container before morphing so the
5055
+ // wrapper exists on both sides of the diff (survives streaming re-renders).
5056
+ wrapScrollableTables(tempContainer);
5057
+
5037
5058
  // Use idiomorph to morph the container contents
5038
5059
  morphMessages(container, tempContainer);
5039
5060
 
5061
+ // Set initial edge-fade state on the live table wrappers and attach the
5062
+ // delegated scroll listener (once) that keeps the fades in sync.
5063
+ refreshTableScrollFades(container);
5064
+
5040
5065
  // Hydrate plugin-rendered ask-question bubbles into their stub wrappers.
5041
5066
  // Idiomorph imports new nodes via `document.importNode`, which strips
5042
5067
  // listeners, so we built only an empty stub during morph and now inject
5043
5068
  // the real, listener-bearing bubble directly into the live DOM.
5044
5069
  if (askPluginHydrate.length > 0) {
5045
5070
  for (const { messageId, fingerprint, bubble } of askPluginHydrate) {
5046
- const wrapper = container.querySelector(`#wrapper-${messageId}`);
5071
+ const wrapper = container.querySelector<HTMLElement>(`#wrapper-${messageId}`);
5047
5072
  if (!wrapper) continue;
5073
+ applyMessageRowLayout(
5074
+ wrapper,
5075
+ messageRolesById.get(messageId) ?? "assistant"
5076
+ );
5048
5077
  if (bubble === null) {
5049
5078
  // No fresh bubble built this pass: either the plugin opted out
5050
5079
  // and a previously-mounted bubble already lives here (preserved by
@@ -5070,8 +5099,12 @@ export const createAgentExperience = (
5070
5099
  // the ask-question hydration above.
5071
5100
  if (componentDirectiveHydrate.length > 0) {
5072
5101
  for (const { messageId, fingerprint, bubble } of componentDirectiveHydrate) {
5073
- const wrapper = container.querySelector(`#wrapper-${messageId}`);
5102
+ const wrapper = container.querySelector<HTMLElement>(`#wrapper-${messageId}`);
5074
5103
  if (!wrapper) continue;
5104
+ applyMessageRowLayout(
5105
+ wrapper,
5106
+ messageRolesById.get(messageId) ?? "assistant"
5107
+ );
5075
5108
  if (bubble === null) {
5076
5109
  // Fingerprint matched the previous pass: the live wrapper (kept
5077
5110
  // alive by `data-preserve-runtime`) still holds the listener-bearing
@@ -5094,8 +5127,12 @@ export const createAgentExperience = (
5094
5127
  // mirroring the ask-question / component-directive hydration above.
5095
5128
  if (approvalPluginHydrate.length > 0) {
5096
5129
  for (const { messageId, fingerprint, bubble } of approvalPluginHydrate) {
5097
- const wrapper = container.querySelector(`#wrapper-${messageId}`);
5130
+ const wrapper = container.querySelector<HTMLElement>(`#wrapper-${messageId}`);
5098
5131
  if (!wrapper) continue;
5132
+ applyMessageRowLayout(
5133
+ wrapper,
5134
+ messageRolesById.get(messageId) ?? "assistant"
5135
+ );
5099
5136
  if (bubble === null) {
5100
5137
  // Fingerprint matched the previous pass (or the plugin opted out
5101
5138
  // after a prior render): the live wrapper, kept alive by
@@ -6789,7 +6826,8 @@ export const createAgentExperience = (
6789
6826
  ) as HTMLButtonElement;
6790
6827
 
6791
6828
  micButton.type = "button";
6792
- micButton.setAttribute("aria-label", "Start voice recognition");
6829
+ const tooltipText = voiceConfig?.tooltipText ?? "Start voice recognition";
6830
+ micButton.setAttribute("aria-label", tooltipText);
6793
6831
 
6794
6832
  const micIconName = voiceConfig?.iconName ?? "mic";
6795
6833
  const buttonSize = sendButtonConfig?.size ?? "40px";
@@ -6850,15 +6888,13 @@ export const createAgentExperience = (
6850
6888
  }
6851
6889
 
6852
6890
  micButtonWrapper.appendChild(micButton);
6853
-
6854
- // Add tooltip if enabled
6855
- const tooltipText = voiceConfig?.tooltipText ?? "Start voice recognition";
6856
6891
  const showTooltip = voiceConfig?.showTooltip ?? false;
6857
- if (showTooltip && tooltipText) {
6858
- const tooltip = createElement("div", "persona-send-button-tooltip");
6859
- tooltip.textContent = tooltipText;
6860
- micButtonWrapper.appendChild(tooltip);
6861
- }
6892
+ attachTooltip({
6893
+ anchor: micButton,
6894
+ trigger: micButtonWrapper,
6895
+ text: () => micButton.getAttribute("aria-label") ?? tooltipText,
6896
+ enabled: showTooltip,
6897
+ });
6862
6898
 
6863
6899
  return { micButton, micButtonWrapper };
6864
6900
  };
@@ -7373,6 +7409,9 @@ export const createAgentExperience = (
7373
7409
  if (typeof ResizeObserver !== "undefined") {
7374
7410
  const contentResizeObserver = new ResizeObserver(() => {
7375
7411
  handleContentResize();
7412
+ // A resize can start or stop a table overflowing without a render or a
7413
+ // scroll, which are the only other things that recompute the fades.
7414
+ refreshTableScrollFades(messagesWrapper);
7376
7415
  });
7377
7416
  contentResizeObserver.observe(messagesWrapper);
7378
7417
  contentResizeObserver.observe(body);
@@ -8198,76 +8237,13 @@ export const createAgentExperience = (
8198
8237
  closeButton.setAttribute("aria-label", closeButtonTooltipText);
8199
8238
 
8200
8239
  if (closeButtonWrapper) {
8201
- // Clean up old tooltip event listeners if they exist
8202
- if ((closeButtonWrapper as any)._cleanupTooltip) {
8203
- (closeButtonWrapper as any)._cleanupTooltip();
8204
- delete (closeButtonWrapper as any)._cleanupTooltip;
8205
- }
8206
-
8207
- // Set up new portaled tooltip with event listeners
8208
- if (closeButtonShowTooltip && closeButtonTooltipText) {
8209
- let portaledTooltip: HTMLElement | null = null;
8210
-
8211
- const showTooltip = () => {
8212
- if (portaledTooltip || !closeButton) return; // Already showing or button doesn't exist
8213
-
8214
- const tooltipDocument = closeButton.ownerDocument;
8215
- const tooltipContainer = tooltipDocument.body;
8216
- if (!tooltipContainer) return;
8217
-
8218
- // Create tooltip element
8219
- portaledTooltip = createElementInDocument(
8220
- tooltipDocument,
8221
- "div",
8222
- "persona-clear-chat-tooltip"
8223
- );
8224
- portaledTooltip.textContent = closeButtonTooltipText;
8225
-
8226
- // Add arrow
8227
- const arrow = createElementInDocument(tooltipDocument, "div");
8228
- arrow.className = "persona-clear-chat-tooltip-arrow";
8229
- portaledTooltip.appendChild(arrow);
8230
-
8231
- // Get button position
8232
- const buttonRect = closeButton.getBoundingClientRect();
8233
-
8234
- // Position tooltip above button
8235
- portaledTooltip.style.position = "fixed";
8236
- portaledTooltip.style.zIndex = String(PORTALED_OVERLAY_Z_INDEX);
8237
- portaledTooltip.style.left = `${buttonRect.left + buttonRect.width / 2}px`;
8238
- portaledTooltip.style.top = `${buttonRect.top - 8}px`;
8239
- portaledTooltip.style.transform = "translate(-50%, -100%)";
8240
-
8241
- // Append to body
8242
- tooltipContainer.appendChild(portaledTooltip);
8243
- };
8244
-
8245
- const hideTooltip = () => {
8246
- if (portaledTooltip && portaledTooltip.parentNode) {
8247
- portaledTooltip.parentNode.removeChild(portaledTooltip);
8248
- portaledTooltip = null;
8249
- }
8250
- };
8251
-
8252
- // Add event listeners
8253
- closeButtonWrapper.addEventListener("mouseenter", showTooltip);
8254
- closeButtonWrapper.addEventListener("mouseleave", hideTooltip);
8255
- closeButton.addEventListener("focus", showTooltip);
8256
- closeButton.addEventListener("blur", hideTooltip);
8257
-
8258
- // Store cleanup function on the wrapper for later use
8259
- (closeButtonWrapper as any)._cleanupTooltip = () => {
8260
- hideTooltip();
8261
- if (closeButtonWrapper) {
8262
- closeButtonWrapper.removeEventListener("mouseenter", showTooltip);
8263
- closeButtonWrapper.removeEventListener("mouseleave", hideTooltip);
8264
- }
8265
- if (closeButton) {
8266
- closeButton.removeEventListener("focus", showTooltip);
8267
- closeButton.removeEventListener("blur", hideTooltip);
8268
- }
8269
- };
8270
- }
8240
+ attachTooltip({
8241
+ anchor: closeButton,
8242
+ trigger: closeButtonWrapper,
8243
+ text: () =>
8244
+ closeButton?.getAttribute("aria-label") ?? closeButtonTooltipText,
8245
+ enabled: closeButtonShowTooltip,
8246
+ });
8271
8247
  }
8272
8248
  }
8273
8249
 
@@ -8433,76 +8409,14 @@ export const createAgentExperience = (
8433
8409
  clearChatButton.setAttribute("aria-label", clearChatTooltipText);
8434
8410
 
8435
8411
  if (clearChatButtonWrapper) {
8436
- // Clean up old tooltip event listeners if they exist
8437
- if ((clearChatButtonWrapper as any)._cleanupTooltip) {
8438
- (clearChatButtonWrapper as any)._cleanupTooltip();
8439
- delete (clearChatButtonWrapper as any)._cleanupTooltip;
8440
- }
8441
-
8442
- // Set up new portaled tooltip with event listeners
8443
- if (clearChatShowTooltip && clearChatTooltipText) {
8444
- let portaledTooltip: HTMLElement | null = null;
8445
-
8446
- const showTooltip = () => {
8447
- if (portaledTooltip || !clearChatButton) return; // Already showing or button doesn't exist
8448
-
8449
- const tooltipDocument = clearChatButton.ownerDocument;
8450
- const tooltipContainer = tooltipDocument.body;
8451
- if (!tooltipContainer) return;
8452
-
8453
- // Create tooltip element
8454
- portaledTooltip = createElementInDocument(
8455
- tooltipDocument,
8456
- "div",
8457
- "persona-clear-chat-tooltip"
8458
- );
8459
- portaledTooltip.textContent = clearChatTooltipText;
8460
-
8461
- // Add arrow
8462
- const arrow = createElementInDocument(tooltipDocument, "div");
8463
- arrow.className = "persona-clear-chat-tooltip-arrow";
8464
- portaledTooltip.appendChild(arrow);
8465
-
8466
- // Get button position
8467
- const buttonRect = clearChatButton.getBoundingClientRect();
8468
-
8469
- // Position tooltip above button
8470
- portaledTooltip.style.position = "fixed";
8471
- portaledTooltip.style.zIndex = String(PORTALED_OVERLAY_Z_INDEX);
8472
- portaledTooltip.style.left = `${buttonRect.left + buttonRect.width / 2}px`;
8473
- portaledTooltip.style.top = `${buttonRect.top - 8}px`;
8474
- portaledTooltip.style.transform = "translate(-50%, -100%)";
8475
-
8476
- // Append to body
8477
- tooltipContainer.appendChild(portaledTooltip);
8478
- };
8479
-
8480
- const hideTooltip = () => {
8481
- if (portaledTooltip && portaledTooltip.parentNode) {
8482
- portaledTooltip.parentNode.removeChild(portaledTooltip);
8483
- portaledTooltip = null;
8484
- }
8485
- };
8486
-
8487
- // Add event listeners
8488
- clearChatButtonWrapper.addEventListener("mouseenter", showTooltip);
8489
- clearChatButtonWrapper.addEventListener("mouseleave", hideTooltip);
8490
- clearChatButton.addEventListener("focus", showTooltip);
8491
- clearChatButton.addEventListener("blur", hideTooltip);
8492
-
8493
- // Store cleanup function on the button for later use
8494
- (clearChatButtonWrapper as any)._cleanupTooltip = () => {
8495
- hideTooltip();
8496
- if (clearChatButtonWrapper) {
8497
- clearChatButtonWrapper.removeEventListener("mouseenter", showTooltip);
8498
- clearChatButtonWrapper.removeEventListener("mouseleave", hideTooltip);
8499
- }
8500
- if (clearChatButton) {
8501
- clearChatButton.removeEventListener("focus", showTooltip);
8502
- clearChatButton.removeEventListener("blur", hideTooltip);
8503
- }
8504
- };
8505
- }
8412
+ attachTooltip({
8413
+ anchor: clearChatButton,
8414
+ trigger: clearChatButtonWrapper,
8415
+ text: () =>
8416
+ clearChatButton?.getAttribute("aria-label") ??
8417
+ clearChatTooltipText,
8418
+ enabled: clearChatShowTooltip,
8419
+ });
8506
8420
  }
8507
8421
  }
8508
8422
  }
@@ -8641,24 +8555,16 @@ export const createAgentExperience = (
8641
8555
  micButton.style.paddingBottom = "";
8642
8556
  }
8643
8557
 
8644
- // Update tooltip
8645
- const tooltip = micButtonWrapper?.querySelector(".persona-send-button-tooltip") as HTMLElement | null;
8646
8558
  const tooltipText = voiceConfig.tooltipText ?? "Start voice recognition";
8647
8559
  const showTooltip = voiceConfig.showTooltip ?? false;
8648
- if (showTooltip && tooltipText) {
8649
- if (!tooltip) {
8650
- // Create tooltip if it doesn't exist
8651
- const newTooltip = document.createElement("div");
8652
- newTooltip.className = "persona-send-button-tooltip";
8653
- newTooltip.textContent = tooltipText;
8654
- micButtonWrapper?.insertBefore(newTooltip, micButton);
8655
- } else {
8656
- tooltip.textContent = tooltipText;
8657
- tooltip.style.display = "";
8658
- }
8659
- } else if (tooltip) {
8660
- // Hide tooltip if disabled
8661
- tooltip.style.display = "none";
8560
+ micButton.setAttribute("aria-label", tooltipText);
8561
+ if (micButtonWrapper) {
8562
+ attachTooltip({
8563
+ anchor: micButton,
8564
+ trigger: micButtonWrapper,
8565
+ text: () => micButton?.getAttribute("aria-label") ?? tooltipText,
8566
+ enabled: showTooltip,
8567
+ });
8662
8568
  }
8663
8569
 
8664
8570
  // Show and update disabled state
@@ -8750,11 +8656,13 @@ export const createAgentExperience = (
8750
8656
 
8751
8657
  attachmentButtonWrapper.appendChild(attachmentButton);
8752
8658
 
8753
- // Add tooltip
8754
8659
  const attachTooltipText = attachmentsConfig.buttonTooltipText ?? "Attach file";
8755
- const tooltip = createElement("div", "persona-send-button-tooltip");
8756
- tooltip.textContent = attachTooltipText;
8757
- attachmentButtonWrapper.appendChild(tooltip);
8660
+ attachTooltip({
8661
+ anchor: attachmentButton,
8662
+ trigger: attachmentButtonWrapper,
8663
+ text: () =>
8664
+ attachmentButton?.getAttribute("aria-label") ?? attachTooltipText,
8665
+ });
8758
8666
 
8759
8667
  // Insert into left actions container (fall back to the form when a
8760
8668
  // custom composer has no left cluster).
@@ -8816,10 +8724,14 @@ export const createAgentExperience = (
8816
8724
  );
8817
8725
  if (iconSvg) attachmentButton.appendChild(iconSvg);
8818
8726
  else attachmentButton.textContent = "📎";
8819
- const attachTooltip = attachmentButtonWrapper?.querySelector(
8820
- ".persona-send-button-tooltip"
8821
- );
8822
- if (attachTooltip) attachTooltip.textContent = tooltipText;
8727
+ if (attachmentButtonWrapper) {
8728
+ attachTooltip({
8729
+ anchor: attachmentButton,
8730
+ trigger: attachmentButtonWrapper,
8731
+ text: () =>
8732
+ attachmentButton?.getAttribute("aria-label") ?? tooltipText,
8733
+ });
8734
+ }
8823
8735
  }
8824
8736
  } else {
8825
8737
  // Hide attachment button if disabled
@@ -8948,21 +8860,16 @@ export const createAgentExperience = (
8948
8860
  sendButton.style.paddingBottom = "";
8949
8861
  }
8950
8862
 
8951
- // Update tooltip
8952
- const tooltip = sendButtonWrapper?.querySelector(".persona-send-button-tooltip") as HTMLElement | null;
8953
- if (showTooltip && tooltipText) {
8954
- if (!tooltip) {
8955
- // Create tooltip if it doesn't exist
8956
- const newTooltip = document.createElement("div");
8957
- newTooltip.className = "persona-send-button-tooltip";
8958
- newTooltip.textContent = tooltipText;
8959
- sendButtonWrapper?.insertBefore(newTooltip, sendButton);
8960
- } else {
8961
- tooltip.textContent = tooltipText;
8962
- tooltip.style.display = "";
8963
- }
8964
- } else if (tooltip) {
8965
- tooltip.style.display = "none";
8863
+ if (!session.isStreaming()) {
8864
+ sendButton.setAttribute("aria-label", tooltipText);
8865
+ }
8866
+ if (sendButtonWrapper) {
8867
+ attachTooltip({
8868
+ anchor: sendButton,
8869
+ trigger: sendButtonWrapper,
8870
+ text: () => sendButton.getAttribute("aria-label") ?? tooltipText,
8871
+ enabled: showTooltip,
8872
+ });
8966
8873
  }
8967
8874
 
8968
8875
  // Update contentMaxWidth on messages wrapper and composer. Same