@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
@@ -1,7 +1,7 @@
1
- import { createElement, createElementInDocument, createNode, cx } from "../utils/dom";
1
+ import { createElement, createNode, cx } from "../utils/dom";
2
2
  import { renderLucideIcon } from "../utils/icons";
3
3
  import { AgentWidgetConfig } from "../types";
4
- import { PORTALED_OVERLAY_Z_INDEX } from "../utils/constants";
4
+ import { attachTooltip } from "../utils/tooltip";
5
5
  import { HEADER_THEME_CSS } from "./header-builder";
6
6
 
7
7
  export interface CloseButtonParts {
@@ -136,59 +136,12 @@ export const createCloseButton = (
136
136
  }
137
137
 
138
138
  wrapper.appendChild(button);
139
-
140
- if (closeButtonShowTooltip && closeButtonTooltipText) {
141
- let portaledTooltip: HTMLElement | null = null;
142
-
143
- const showTooltip = () => {
144
- if (portaledTooltip) return;
145
-
146
- const tooltipDocument = button.ownerDocument;
147
- const tooltipContainer = tooltipDocument.body;
148
- if (!tooltipContainer) return;
149
-
150
- portaledTooltip = createElementInDocument(
151
- tooltipDocument,
152
- "div",
153
- "persona-clear-chat-tooltip"
154
- );
155
- portaledTooltip.textContent = closeButtonTooltipText;
156
-
157
- const arrow = createElementInDocument(tooltipDocument, "div");
158
- arrow.className = "persona-clear-chat-tooltip-arrow";
159
- portaledTooltip.appendChild(arrow);
160
-
161
- const buttonRect = button.getBoundingClientRect();
162
-
163
- portaledTooltip.style.position = "fixed";
164
- portaledTooltip.style.zIndex = String(PORTALED_OVERLAY_Z_INDEX);
165
- portaledTooltip.style.left = `${buttonRect.left + buttonRect.width / 2}px`;
166
- portaledTooltip.style.top = `${buttonRect.top - 8}px`;
167
- portaledTooltip.style.transform = "translate(-50%, -100%)";
168
-
169
- tooltipContainer.appendChild(portaledTooltip);
170
- };
171
-
172
- const hideTooltip = () => {
173
- if (portaledTooltip && portaledTooltip.parentNode) {
174
- portaledTooltip.parentNode.removeChild(portaledTooltip);
175
- portaledTooltip = null;
176
- }
177
- };
178
-
179
- wrapper.addEventListener("mouseenter", showTooltip);
180
- wrapper.addEventListener("mouseleave", hideTooltip);
181
- button.addEventListener("focus", showTooltip);
182
- button.addEventListener("blur", hideTooltip);
183
-
184
- (wrapper as any)._cleanupTooltip = () => {
185
- hideTooltip();
186
- wrapper.removeEventListener("mouseenter", showTooltip);
187
- wrapper.removeEventListener("mouseleave", hideTooltip);
188
- button.removeEventListener("focus", showTooltip);
189
- button.removeEventListener("blur", hideTooltip);
190
- };
191
- }
139
+ attachTooltip({
140
+ anchor: button,
141
+ trigger: wrapper,
142
+ text: () => button.getAttribute("aria-label") ?? closeButtonTooltipText,
143
+ enabled: closeButtonShowTooltip,
144
+ });
192
145
 
193
146
  return { button, wrapper };
194
147
  };
@@ -271,59 +224,12 @@ export const createClearChatButton = (
271
224
  }
272
225
 
273
226
  wrapper.appendChild(button);
274
-
275
- if (clearChatShowTooltip && clearChatTooltipText) {
276
- let portaledTooltip: HTMLElement | null = null;
277
-
278
- const showTooltip = () => {
279
- if (portaledTooltip) return;
280
-
281
- const tooltipDocument = button.ownerDocument;
282
- const tooltipContainer = tooltipDocument.body;
283
- if (!tooltipContainer) return;
284
-
285
- portaledTooltip = createElementInDocument(
286
- tooltipDocument,
287
- "div",
288
- "persona-clear-chat-tooltip"
289
- );
290
- portaledTooltip.textContent = clearChatTooltipText;
291
-
292
- const arrow = createElementInDocument(tooltipDocument, "div");
293
- arrow.className = "persona-clear-chat-tooltip-arrow";
294
- portaledTooltip.appendChild(arrow);
295
-
296
- const buttonRect = button.getBoundingClientRect();
297
-
298
- portaledTooltip.style.position = "fixed";
299
- portaledTooltip.style.zIndex = String(PORTALED_OVERLAY_Z_INDEX);
300
- portaledTooltip.style.left = `${buttonRect.left + buttonRect.width / 2}px`;
301
- portaledTooltip.style.top = `${buttonRect.top - 8}px`;
302
- portaledTooltip.style.transform = "translate(-50%, -100%)";
303
-
304
- tooltipContainer.appendChild(portaledTooltip);
305
- };
306
-
307
- const hideTooltip = () => {
308
- if (portaledTooltip && portaledTooltip.parentNode) {
309
- portaledTooltip.parentNode.removeChild(portaledTooltip);
310
- portaledTooltip = null;
311
- }
312
- };
313
-
314
- wrapper.addEventListener("mouseenter", showTooltip);
315
- wrapper.addEventListener("mouseleave", hideTooltip);
316
- button.addEventListener("focus", showTooltip);
317
- button.addEventListener("blur", hideTooltip);
318
-
319
- (wrapper as any)._cleanupTooltip = () => {
320
- hideTooltip();
321
- wrapper.removeEventListener("mouseenter", showTooltip);
322
- wrapper.removeEventListener("mouseleave", hideTooltip);
323
- button.removeEventListener("focus", showTooltip);
324
- button.removeEventListener("blur", hideTooltip);
325
- };
326
- }
227
+ attachTooltip({
228
+ anchor: button,
229
+ trigger: wrapper,
230
+ text: () => button.getAttribute("aria-label") ?? clearChatTooltipText,
231
+ enabled: clearChatShowTooltip,
232
+ });
327
233
 
328
234
  return { button, wrapper };
329
235
  };
@@ -257,6 +257,55 @@ describe("isSafeImageSrc", () => {
257
257
  });
258
258
 
259
259
  describe("createStandardBubble", () => {
260
+ it("uses a compact user bubble and borderless assistant response in minimal style", () => {
261
+ const userBubble = createStandardBubble(
262
+ makeMessage({ id: "minimal-user", role: "user", content: "Question" }),
263
+ ({ text }) => text,
264
+ { layout: "minimal" }
265
+ );
266
+ const assistantBubble = createStandardBubble(
267
+ makeMessage({
268
+ id: "minimal-assistant",
269
+ role: "assistant",
270
+ content: "Answer",
271
+ }),
272
+ ({ text }) => text,
273
+ { layout: "minimal" }
274
+ );
275
+
276
+ expect(userBubble.classList.contains("persona-bg-persona-accent")).toBe(true);
277
+ expect(userBubble.classList.contains("persona-rounded-lg")).toBe(true);
278
+ expect(userBubble.classList.contains("persona-px-3")).toBe(true);
279
+ expect(assistantBubble.classList.contains("persona-bg-persona-surface")).toBe(
280
+ true
281
+ );
282
+ expect(assistantBubble.classList.contains("persona-rounded-lg")).toBe(true);
283
+ expect(assistantBubble.classList.contains("persona-border")).toBe(false);
284
+ expect(assistantBubble.classList.contains("persona-shadow-sm")).toBe(false);
285
+ });
286
+
287
+ it("removes role background chrome from flat messages", () => {
288
+ const userMessage = createStandardBubble(
289
+ makeMessage({ id: "flat-user", role: "user", content: "Question" }),
290
+ ({ text }) => text,
291
+ { layout: "flat" }
292
+ );
293
+ const assistantMessage = createStandardBubble(
294
+ makeMessage({
295
+ id: "flat-assistant",
296
+ role: "assistant",
297
+ content: "Answer",
298
+ }),
299
+ ({ text }) => text,
300
+ { layout: "flat" }
301
+ );
302
+
303
+ expect(userMessage.style.backgroundColor).toBe("transparent");
304
+ expect(assistantMessage.style.backgroundColor).toBe("transparent");
305
+ expect(userMessage.classList.contains("persona-px-3")).toBe(false);
306
+ expect(assistantMessage.classList.contains("persona-px-3")).toBe(false);
307
+ });
308
+
260
309
  it("skips rendering blocked image previews while keeping safe ones", () => {
261
310
  const bubble = createStandardBubble(
262
311
  makeMessage({
@@ -653,7 +653,7 @@ const getBubbleClasses = (
653
653
  role: "user" | "assistant" | "system",
654
654
  layout: AgentWidgetMessageLayoutConfig["layout"] = "bubble"
655
655
  ): string[] => {
656
- const baseClasses = ["persona-message-bubble", "persona-max-w-[85%]"];
656
+ const baseClasses = ["persona-message-bubble"];
657
657
 
658
658
  switch (layout) {
659
659
  case "flat":
@@ -877,7 +877,10 @@ export const createStandardBubble = (
877
877
  bubble.setAttribute("data-persona-theme-zone", message.role === "user" ? "user-message" : "assistant-message");
878
878
 
879
879
  // Apply component-level color overrides via CSS variables
880
- if (message.role === "user") {
880
+ if (layout === "flat") {
881
+ bubble.style.backgroundColor = "transparent";
882
+ bubble.style.color = "var(--persona-primary, var(--persona-text))";
883
+ } else if (message.role === "user") {
881
884
  bubble.style.backgroundColor = 'var(--persona-message-user-bg, var(--persona-accent))';
882
885
  bubble.style.color = 'var(--persona-message-user-text, white)';
883
886
  } else if (message.role === "assistant") {
@@ -1171,7 +1174,9 @@ export const createStandardBubble = (
1171
1174
  // Create wrapper with avatar
1172
1175
  const wrapper = createElement(
1173
1176
  "div",
1174
- `persona-flex persona-gap-2 ${message.role === "user" ? "persona-flex-row-reverse" : ""}`
1177
+ `persona-message-with-avatar persona-flex persona-gap-2 ${
1178
+ message.role === "user" ? "persona-flex-row-reverse" : ""
1179
+ }`
1175
1180
  );
1176
1181
 
1177
1182
  const avatar = createAvatar(avatarConfig!, message.role);
@@ -1182,10 +1187,6 @@ export const createStandardBubble = (
1182
1187
  wrapper.append(avatar, bubble);
1183
1188
  }
1184
1189
 
1185
- // Adjust bubble max-width when avatar is present
1186
- bubble.classList.remove("persona-max-w-[85%]");
1187
- bubble.classList.add("persona-max-w-[calc(85%-2.5rem)]");
1188
-
1189
1190
  return wrapper;
1190
1191
  };
1191
1192
 
@@ -72,7 +72,6 @@ export const createReasoningBubble = (message: AgentWidgetMessage, config?: Agen
72
72
  "persona-message-bubble",
73
73
  "persona-reasoning-bubble",
74
74
  "persona-w-full",
75
- "persona-max-w-[85%]",
76
75
  "persona-rounded-2xl",
77
76
  "persona-bg-persona-surface",
78
77
  "persona-border",
@@ -352,4 +351,3 @@ export const createReasoningBubble = (message: AgentWidgetMessage, config?: Agen
352
351
  };
353
352
 
354
353
 
355
-
@@ -146,7 +146,6 @@ export const createToolBubble = (message: AgentWidgetMessage, config?: AgentWidg
146
146
  "persona-message-bubble",
147
147
  "persona-tool-bubble",
148
148
  "persona-w-full",
149
- "persona-max-w-[85%]",
150
149
  "persona-rounded-2xl",
151
150
  "persona-bg-persona-surface",
152
151
  "persona-border",
@@ -544,4 +543,3 @@ export const createToolBubble = (message: AgentWidgetMessage, config?: AgentWidg
544
543
  };
545
544
 
546
545
 
547
-
@@ -44,6 +44,7 @@ export type RuntypeExecutionStreamEvent = ({
44
44
  input: number;
45
45
  output: number;
46
46
  };
47
+ totalTokensUsed?: number;
47
48
  type: "execution_complete";
48
49
  }) | ({
49
50
  blockReason?: string;
@@ -303,6 +304,7 @@ export type RuntypeExecutionStreamEvent = ({
303
304
  stepId?: string;
304
305
  success: boolean;
305
306
  toolCallId: string;
307
+ toolCost?: number;
306
308
  toolName?: string;
307
309
  type: "tool_complete";
308
310
  } | {
package/src/index-core.ts CHANGED
@@ -65,6 +65,7 @@ export type {
65
65
  AgentWidgetLayoutConfig,
66
66
  AgentWidgetHeaderLayoutConfig,
67
67
  AgentWidgetMessageLayoutConfig,
68
+ AgentWidgetMessageRoleLayout,
68
69
  AgentWidgetAvatarConfig,
69
70
  AgentWidgetTimestampConfig,
70
71
  WidgetLayoutSlot,
@@ -163,6 +163,8 @@
163
163
  --persona-md-table-header-weight: 600;
164
164
  --persona-md-table-cell-padding: 0.5rem 0.75rem;
165
165
  --persona-md-table-border-radius: 0.375rem;
166
+ /* Width of the horizontal-scroll edge fade on wide tables; set to 0 to disable. */
167
+ --persona-md-table-scroll-fade: 24px;
166
168
 
167
169
  /* Markdown Horizontal Rule Variables */
168
170
  --persona-md-hr-color: var(--persona-divider, #e5e7eb);
@@ -643,6 +645,47 @@
643
645
  max-width: 85%;
644
646
  }
645
647
 
648
+ /* Message row geometry is role-configurable. The row always spans the
649
+ transcript; its direct content track either shrink-wraps or fills that row.
650
+ `--persona-message-row-max-width` is resolved per role in ui.ts. */
651
+ .persona-message-row {
652
+ width: 100%;
653
+ min-width: 0;
654
+ }
655
+
656
+ .persona-message-row > * {
657
+ min-width: 0;
658
+ max-width: var(--persona-message-row-max-width, 85%);
659
+ }
660
+
661
+ .persona-message-row[data-message-width="content"] > * {
662
+ flex: 0 1 auto;
663
+ }
664
+
665
+ .persona-message-row[data-message-width="full"] > * {
666
+ flex: 1 1 auto;
667
+ width: 100%;
668
+ }
669
+
670
+ /* Avatars live inside the role-sized content track. The bubble flexes into
671
+ the space left after the fixed avatar + gap instead of making a full row
672
+ overflow by the avatar width. */
673
+ .persona-message-with-avatar {
674
+ min-width: 0;
675
+ }
676
+
677
+ .persona-message-with-avatar > .persona-message-bubble {
678
+ min-width: 0;
679
+ max-width: 100%;
680
+ }
681
+
682
+ .persona-message-row[data-message-width="full"]
683
+ > .persona-message-with-avatar
684
+ > .persona-message-bubble {
685
+ flex: 1 1 auto;
686
+ width: auto;
687
+ }
688
+
646
689
  .persona-transition {
647
690
  transition: transform 160ms ease, background-color 160ms ease,
648
691
  box-shadow 160ms ease, opacity 160ms ease;
@@ -952,7 +995,8 @@
952
995
  cursor: pointer;
953
996
  }
954
997
 
955
- /* Send button tooltip */
998
+ /* Shared icon-control tooltip. It is portaled to the document body (or the
999
+ widget ShadowRoot) and positioned in JS so panel overflow never clips it. */
956
1000
  .persona-send-button-wrapper {
957
1001
  position: relative;
958
1002
  display: inline-flex;
@@ -960,47 +1004,46 @@
960
1004
  justify-content: center;
961
1005
  }
962
1006
 
963
- .persona-send-button-tooltip {
964
- position: absolute;
965
- bottom: calc(100% + 8px);
966
- left: 50%;
967
- transform: translateX(-50%);
1007
+ .persona-control-tooltip {
1008
+ position: fixed;
1009
+ box-sizing: border-box;
1010
+ max-width: min(320px, calc(100vw - 16px));
968
1011
  background-color: var(--persona-tooltip-background, #111827);
969
1012
  color: var(--persona-tooltip-foreground, #ffffff);
970
1013
  padding: 6px 12px;
971
1014
  border-radius: var(--persona-radius-sm, 0.125rem);
972
1015
  font-size: 12px;
973
- white-space: nowrap;
974
- opacity: 0;
1016
+ line-height: 1.35;
1017
+ white-space: normal;
1018
+ overflow-wrap: anywhere;
1019
+ box-shadow: var(--persona-tooltip-shadow, 0 4px 12px rgba(15, 23, 42, 0.18));
1020
+ opacity: 1;
975
1021
  pointer-events: none;
976
- transition: opacity 0.2s;
977
- z-index: 1000;
1022
+ transition: opacity 120ms ease;
978
1023
  }
979
1024
 
980
- .persona-send-button-tooltip::after {
981
- content: "";
1025
+ .persona-control-tooltip[data-state="measuring"] {
1026
+ visibility: hidden;
1027
+ opacity: 0;
1028
+ }
1029
+
1030
+ .persona-control-tooltip__arrow {
982
1031
  position: absolute;
983
- top: 100%;
984
- left: 50%;
985
- transform: translateX(-50%);
986
- border: 4px solid transparent;
987
- border-top-color: var(--persona-tooltip-background, #111827);
1032
+ left: var(--persona-tooltip-arrow-x, 50%);
1033
+ width: 8px;
1034
+ height: 8px;
1035
+ background: var(--persona-tooltip-background, #111827);
1036
+ transform: translateX(-50%) rotate(45deg);
988
1037
  }
989
1038
 
990
- .persona-send-button-wrapper:hover .persona-send-button-tooltip,
991
- .persona-send-button-wrapper:focus-within .persona-send-button-tooltip {
992
- opacity: 1;
1039
+ .persona-control-tooltip[data-placement="top"] .persona-control-tooltip__arrow {
1040
+ top: calc(100% - 4px);
993
1041
  }
994
1042
 
995
- /* Hide tooltips on touch devices: hover doesn't exist, and they cause clipping issues */
996
- @media (hover: none), (max-width: 500px) {
997
- .persona-send-button-wrapper:hover .persona-send-button-tooltip,
998
- .persona-send-button-wrapper:focus-within .persona-send-button-tooltip {
999
- opacity: 0;
1000
- }
1043
+ .persona-control-tooltip[data-placement="bottom"] .persona-control-tooltip__arrow {
1044
+ bottom: calc(100% - 4px);
1001
1045
  }
1002
1046
 
1003
- /* Clear chat button tooltip */
1004
1047
  .persona-clear-chat-button-wrapper {
1005
1048
  position: relative;
1006
1049
  display: inline-flex;
@@ -1008,30 +1051,9 @@
1008
1051
  justify-content: center;
1009
1052
  }
1010
1053
 
1011
- .persona-clear-chat-tooltip {
1012
- background-color: var(--persona-tooltip-background, #111827);
1013
- color: var(--persona-tooltip-foreground, #ffffff);
1014
- padding: 6px 12px;
1015
- border-radius: var(--persona-radius-sm, 0.125rem);
1016
- font-size: 12px;
1017
- white-space: nowrap;
1018
- pointer-events: none;
1019
- z-index: 10000;
1020
- }
1021
-
1022
- .persona-clear-chat-tooltip-arrow {
1023
- content: "";
1024
- position: absolute;
1025
- top: 100%;
1026
- left: 50%;
1027
- transform: translateX(-50%);
1028
- border: 4px solid transparent;
1029
- border-top-color: var(--persona-tooltip-background, #111827);
1030
- }
1031
-
1032
- @media (hover: none), (max-width: 500px) {
1033
- .persona-clear-chat-tooltip {
1034
- display: none !important;
1054
+ @media (prefers-reduced-motion: reduce) {
1055
+ .persona-control-tooltip {
1056
+ transition: none;
1035
1057
  }
1036
1058
  }
1037
1059
 
@@ -1501,12 +1523,45 @@
1501
1523
  flex-grow: 1;
1502
1524
  }
1503
1525
 
1526
+ /* Wide tables scroll inside this wrapper instead of widening the chat column.
1527
+ The table keeps width:100% so it fills when it fits, but auto-grows past the
1528
+ wrapper when its content is wider, and the wrapper scrolls. Injected by
1529
+ wrapScrollableTables() before each morph. */
1530
+ .persona-table-scroll {
1531
+ display: block;
1532
+ max-width: 100%;
1533
+ overflow-x: auto;
1534
+ margin: 0.5rem 0;
1535
+ }
1536
+
1537
+ /* Edge fades (set by table-scroll-fade.ts) cue that the table scrolls; each
1538
+ side shows only when there is more content that way. The fade is applied
1539
+ instantly, not transitioned: each morph strips and re-applies the fade state,
1540
+ so a transition would animate it from 0 on every streaming chunk (flicker) and
1541
+ once more when the finished paint re-enables it (a single flash). Instant also
1542
+ tracks the scroll position exactly instead of lagging behind the finger. */
1543
+ .persona-table-scroll[data-persona-scroll-x] {
1544
+ -webkit-mask-image: linear-gradient(
1545
+ to right,
1546
+ transparent 0,
1547
+ #000 var(--persona-fade-l, 0px),
1548
+ #000 calc(100% - var(--persona-fade-r, 0px)),
1549
+ transparent 100%
1550
+ );
1551
+ mask-image: linear-gradient(
1552
+ to right,
1553
+ transparent 0,
1554
+ #000 var(--persona-fade-l, 0px),
1555
+ #000 calc(100% - var(--persona-fade-r, 0px)),
1556
+ transparent 100%
1557
+ );
1558
+ }
1559
+
1504
1560
  .persona-message-bubble table {
1505
1561
  width: 100%;
1506
1562
  border-collapse: collapse;
1507
- margin: 0.5rem 0;
1563
+ margin: 0;
1508
1564
  font-size: 0.875rem;
1509
- overflow: hidden;
1510
1565
  border-radius: var(--persona-md-table-border-radius);
1511
1566
  border: 1px solid var(--persona-md-table-border-color);
1512
1567
  }
@@ -1536,13 +1591,18 @@
1536
1591
  background-color: rgba(0, 0, 0, 0.02);
1537
1592
  }
1538
1593
 
1539
- /* While a message is still streaming, lock table column widths so incoming rows
1540
- append without the per-chunk horizontal reflow (Telegram-style space
1541
- reservation). The class is removed on the final render, relaxing the finished
1542
- table to natural content-fit widths. overflow-wrap keeps long cell content
1543
- from spilling past the fixed columns mid-stream. */
1594
+ /* While a message is still streaming, lock column widths to the header row
1595
+ (table-layout: fixed) so incoming rows append without per-chunk reflow. width
1596
+ auto sizes columns from the header and lets cells wrap, so a table that fits
1597
+ once wrapped fills the column (min-width: 100%) with no scroll, while one too
1598
+ wide even wrapped scrolls: it only shows the scroll fade when it genuinely
1599
+ needs it, matching the finished table rather than forcing every cell onto one
1600
+ line. The class is removed on the final render, settling columns to
1601
+ content-fit widths. overflow-wrap keeps long words from spilling the column. */
1544
1602
  .persona-content-streaming table {
1545
1603
  table-layout: fixed;
1604
+ width: auto;
1605
+ min-width: 100%;
1546
1606
  }
1547
1607
 
1548
1608
  .persona-content-streaming th,
@@ -4,6 +4,39 @@ import { COMPONENTS_SECTIONS, CONFIGURE_SECTIONS, INTERFACE_ROLES_SECTION, STYLE
4
4
  import { ALL_ROLES } from "./role-mappings";
5
5
 
6
6
  describe("theme editor scroll-to-bottom controls", () => {
7
+ it("exposes clear style semantics and independent role width controls", () => {
8
+ const section = CONFIGURE_SECTIONS.find((entry) => entry.id === "messages-layout");
9
+ const fieldsByPath = new Map(
10
+ section?.fields.map((field) => [field.path, field]) ?? []
11
+ );
12
+
13
+ expect(fieldsByPath.get("layout.messages.layout")?.options).toEqual([
14
+ { value: "bubble", label: "Bubble — bubbles for both" },
15
+ { value: "minimal", label: "Minimal — user bubble, open assistant" },
16
+ { value: "flat", label: "Flat — open messages for both" },
17
+ ]);
18
+ expect(fieldsByPath.has("layout.messages.user.style")).toBe(false);
19
+ expect(fieldsByPath.has("layout.messages.assistant.style")).toBe(false);
20
+
21
+ expect(fieldsByPath.get("layout.messages.user.width")?.options).toEqual([
22
+ { value: "content", label: "Content" },
23
+ { value: "full", label: "Full" },
24
+ ]);
25
+ expect(fieldsByPath.get("layout.messages.assistant.width")?.options).toEqual([
26
+ { value: "content", label: "Content" },
27
+ { value: "full", label: "Full" },
28
+ ]);
29
+
30
+ const userMaxWidth = fieldsByPath.get("layout.messages.user.maxWidth");
31
+ const assistantMaxWidth = fieldsByPath.get(
32
+ "layout.messages.assistant.maxWidth"
33
+ );
34
+ expect(userMaxWidth?.parseValue?.(" 72ch ")).toBe("72ch");
35
+ expect(userMaxWidth?.parseValue?.(" ")).toBeUndefined();
36
+ expect(assistantMaxWidth?.parseValue?.("80%")).toBe("80%");
37
+ expect(assistantMaxWidth?.parseValue?.("")).toBeUndefined();
38
+ });
39
+
7
40
  it("exposes scroll-to-bottom config controls", () => {
8
41
  const featureSection = CONFIGURE_SECTIONS.find((section) => section.id === "features");
9
42
 
@@ -22,6 +22,11 @@ import {
22
22
  ROLE_BORDERS,
23
23
  } from './role-mappings';
24
24
 
25
+ const parseOptionalCssValue = (value: unknown): string | undefined => {
26
+ const normalized = String(value ?? '').trim();
27
+ return normalized || undefined;
28
+ };
29
+
25
30
  // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
26
31
  // STYLE TAB: brand colors, chat colors, typography, shape, etc.
27
32
  // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
@@ -601,7 +606,11 @@ const headerLayoutSectionDef: SectionDef = {
601
606
  const messagesLayoutSectionDef: SectionDef = {
602
607
  id: 'messages-layout', title: 'Messages', collapsed: true,
603
608
  fields: [
604
- { id: 'layout-messages', label: 'Messages Layout', type: 'select', path: 'layout.messages.layout', defaultValue: 'bubble', options: [{ value: 'bubble', label: 'Bubble' }, { value: 'flat', label: 'Flat' }, { value: 'minimal', label: 'Minimal' }] },
609
+ { id: 'layout-messages', label: 'Message Style', description: 'Minimal uses a user bubble with an open assistant response', type: 'select', path: 'layout.messages.layout', defaultValue: 'bubble', options: [{ value: 'bubble', label: 'Bubble — bubbles for both' }, { value: 'minimal', label: 'Minimal — user bubble, open assistant' }, { value: 'flat', label: 'Flat — open messages for both' }] },
610
+ { id: 'layout-message-user-width', label: 'User Width', description: 'Content hugs the message; Full fills the transcript track', type: 'select', path: 'layout.messages.user.width', defaultValue: 'content', options: [{ value: 'content', label: 'Content' }, { value: 'full', label: 'Full' }] },
611
+ { id: 'layout-message-user-max-width', label: 'User Max Width', description: 'Optional CSS width such as 80%, 42rem, or 72ch; blank uses the width mode default', type: 'text', path: 'layout.messages.user.maxWidth', defaultValue: '', parseValue: parseOptionalCssValue },
612
+ { id: 'layout-message-assistant-width', label: 'Assistant Width', description: 'Content hugs the message; Full fills the transcript track', type: 'select', path: 'layout.messages.assistant.width', defaultValue: 'content', options: [{ value: 'content', label: 'Content' }, { value: 'full', label: 'Full' }] },
613
+ { id: 'layout-message-assistant-max-width', label: 'Assistant Max Width', description: 'Optional CSS width such as 80%, 42rem, or 72ch; blank uses the width mode default', type: 'text', path: 'layout.messages.assistant.maxWidth', defaultValue: '', parseValue: parseOptionalCssValue },
605
614
  { id: 'layout-group', label: 'Group Consecutive', type: 'toggle', path: 'layout.messages.groupConsecutive', defaultValue: false },
606
615
  { id: 'layout-avatar-show', label: 'Show Avatars', type: 'toggle', path: 'layout.messages.avatar.show', defaultValue: false },
607
616
  { id: 'layout-avatar-pos', label: 'Avatar Position', type: 'select', path: 'layout.messages.avatar.position', defaultValue: 'left', options: [{ value: 'left', label: 'Left' }, { value: 'right', label: 'Right' }] },
package/src/types.ts CHANGED
@@ -3975,6 +3975,27 @@ export type AgentWidgetTimestampConfig = {
3975
3975
  format?: (date: Date) => string;
3976
3976
  };
3977
3977
 
3978
+ /**
3979
+ * Width configuration for one message role.
3980
+ *
3981
+ * `content` shrink-wraps the rendered message up to `maxWidth`; `full` fills
3982
+ * the available transcript track up to `maxWidth`. The track already accounts
3983
+ * for transcript padding and, when present, the avatar and its gap.
3984
+ */
3985
+ export type AgentWidgetMessageRoleLayout = {
3986
+ /**
3987
+ * Message width behavior.
3988
+ * - content: size to the rendered content (the backward-compatible default)
3989
+ * - full: fill the available transcript track
3990
+ */
3991
+ width?: "content" | "full";
3992
+ /**
3993
+ * Optional CSS max-width (for example "80%", "42rem", or "72ch").
3994
+ * Defaults to "85%" for content width and "100%" for full width.
3995
+ */
3996
+ maxWidth?: string;
3997
+ };
3998
+
3978
3999
  /**
3979
4000
  * Message layout configuration
3980
4001
  * Allows customization of how chat messages are displayed
@@ -3993,6 +4014,10 @@ export type AgentWidgetMessageLayoutConfig = {
3993
4014
  timestamp?: AgentWidgetTimestampConfig;
3994
4015
  /** Group consecutive messages from the same role */
3995
4016
  groupConsecutive?: boolean;
4017
+ /** Width behavior for user-authored message rows */
4018
+ user?: AgentWidgetMessageRoleLayout;
4019
+ /** Width behavior for assistant-authored message rows and assistant UI variants */
4020
+ assistant?: AgentWidgetMessageRoleLayout;
3996
4021
  /**
3997
4022
  * Custom renderer for user messages
3998
4023
  * When provided, replaces the default user message rendering
@@ -270,7 +270,8 @@ describe("attachment button live config updates", () => {
270
270
  expect(button().getAttribute("aria-label")).toBe("Add a photo");
271
271
  const updatedSvg = button().querySelector("svg")!.outerHTML;
272
272
  expect(updatedSvg).not.toBe(initialSvg);
273
- const tooltip = button().parentElement!.querySelector(".persona-send-button-tooltip");
273
+ button().parentElement!.dispatchEvent(new MouseEvent("mouseenter"));
274
+ const tooltip = document.body.querySelector(".persona-control-tooltip");
274
275
  expect(tooltip?.textContent).toBe("Add a photo");
275
276
 
276
277
  controller.destroy();