@runtypelabs/persona 4.10.0 → 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 (61) 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-4ROVJ1gA.d.cts → types-DveIaNx6.d.cts} +11 -316
  4. package/dist/animations/{types-4ROVJ1gA.d.ts → types-DveIaNx6.d.ts} +11 -316
  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 +35 -316
  12. package/dist/context-mentions.d.ts +35 -316
  13. package/dist/index.cjs +67 -67
  14. package/dist/index.cjs.map +1 -1
  15. package/dist/index.d.cts +40 -317
  16. package/dist/index.d.ts +40 -317
  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 +35 -316
  22. package/dist/smart-dom-reader.d.ts +35 -316
  23. package/dist/theme-editor-preview.cjs +66 -66
  24. package/dist/theme-editor-preview.d.cts +35 -316
  25. package/dist/theme-editor-preview.d.ts +35 -316
  26. package/dist/theme-editor-preview.js +55 -55
  27. package/dist/theme-editor.cjs +5 -5
  28. package/dist/theme-editor.d.cts +35 -316
  29. package/dist/theme-editor.d.ts +35 -316
  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 +11 -317
  44. package/src/index-core.ts +7 -1
  45. package/src/runtime/host-layout.test.ts +94 -0
  46. package/src/runtime/host-layout.ts +9 -1
  47. package/src/runtime/init.test.ts +33 -0
  48. package/src/runtime/init.ts +4 -0
  49. package/src/styles/widget.css +118 -58
  50. package/src/theme-editor/sections.test.ts +33 -0
  51. package/src/theme-editor/sections.ts +10 -1
  52. package/src/types.ts +25 -0
  53. package/src/ui.attachments-drop.test.ts +2 -1
  54. package/src/ui.message-width.test.ts +407 -0
  55. package/src/ui.mount-fill-min-width.test.ts +81 -0
  56. package/src/ui.ts +158 -238
  57. package/src/utils/table-scroll-fade.test.ts +123 -0
  58. package/src/utils/table-scroll-fade.ts +75 -0
  59. package/src/utils/tooltip.test.ts +226 -0
  60. package/src/utils/tooltip.ts +245 -0
  61. package/dist/chunk-IPVK3KOM.js +0 -1
@@ -4,6 +4,51 @@ import { afterEach, describe, expect, it, vi } from "vitest";
4
4
 
5
5
  import { createWidgetHostLayout } from "./host-layout";
6
6
 
7
+ describe("createWidgetHostLayout direct", () => {
8
+ afterEach(() => {
9
+ document.body.innerHTML = "";
10
+ });
11
+
12
+ it("makes the host shrinkable when the launcher is disabled", () => {
13
+ const target = document.createElement("div");
14
+ document.body.appendChild(target);
15
+
16
+ const layout = createWidgetHostLayout(target, { launcher: { enabled: false } });
17
+ // Flex items floor at min-width:auto; the host must stay shrinkable so a wide
18
+ // artifact split shrinks within the mount rather than enlarging the host.
19
+ expect(layout.host.style.minWidth).toBe("0px");
20
+ expect(layout.host.style.flex).toBe("1 1 auto");
21
+
22
+ layout.destroy();
23
+ });
24
+
25
+ it("leaves the host min-width unset when the launcher is enabled", () => {
26
+ const target = document.createElement("div");
27
+ document.body.appendChild(target);
28
+
29
+ const layout = createWidgetHostLayout(target, { launcher: { enabled: true } });
30
+ expect(layout.host.style.minWidth).toBe("");
31
+
32
+ layout.destroy();
33
+ });
34
+
35
+ it("restores/clears the shrinkable host baseline when the launcher toggles at runtime", () => {
36
+ const target = document.createElement("div");
37
+ document.body.appendChild(target);
38
+
39
+ const layout = createWidgetHostLayout(target, { launcher: { enabled: false } });
40
+ expect(layout.host.style.minWidth).toBe("0px");
41
+
42
+ layout.updateConfig({ launcher: { enabled: true } });
43
+ expect(layout.host.style.minWidth).toBe("");
44
+
45
+ layout.updateConfig({ launcher: { enabled: false } });
46
+ expect(layout.host.style.minWidth).toBe("0px");
47
+
48
+ layout.destroy();
49
+ });
50
+ });
51
+
7
52
  describe("createWidgetHostLayout docked", () => {
8
53
  afterEach(() => {
9
54
  document.body.innerHTML = "";
@@ -39,6 +84,55 @@ describe("createWidgetHostLayout docked", () => {
39
84
  layout.destroy();
40
85
  });
41
86
 
87
+ it("keeps a shrinkable host baseline for non-emerge dock modes but pins it for emerge", () => {
88
+ for (const reveal of ["resize", "overlay", "push"] as const) {
89
+ const parent = document.createElement("div");
90
+ document.body.appendChild(parent);
91
+ const target = document.createElement("div");
92
+ parent.appendChild(target);
93
+
94
+ const layout = createWidgetHostLayout(target, {
95
+ launcher: { mountMode: "docked", autoExpand: false, dock: { width: "320px", reveal } },
96
+ });
97
+ // The docked host must shrink within its mount so a wide artifact split
98
+ // stays contained instead of enlarging the host.
99
+ expect(layout.host.style.minWidth, reveal).toBe("0px");
100
+
101
+ layout.destroy();
102
+ document.body.innerHTML = "";
103
+ }
104
+
105
+ const parent = document.createElement("div");
106
+ document.body.appendChild(parent);
107
+ const target = document.createElement("div");
108
+ parent.appendChild(target);
109
+ const layout = createWidgetHostLayout(target, {
110
+ launcher: { mountMode: "docked", autoExpand: true, dock: { width: "320px", reveal: "emerge" } },
111
+ });
112
+ // Emerge intentionally pins the host to its fixed width, overriding the baseline.
113
+ expect(layout.host.style.minWidth).toBe("320px");
114
+ layout.destroy();
115
+ });
116
+
117
+ it("restores the shrinkable host baseline when switching away from emerge", () => {
118
+ const parent = document.createElement("div");
119
+ document.body.appendChild(parent);
120
+ const target = document.createElement("div");
121
+ parent.appendChild(target);
122
+
123
+ const layout = createWidgetHostLayout(target, {
124
+ launcher: { mountMode: "docked", autoExpand: true, dock: { width: "320px", reveal: "emerge" } },
125
+ });
126
+ expect(layout.host.style.minWidth).toBe("320px");
127
+
128
+ layout.updateConfig({
129
+ launcher: { mountMode: "docked", autoExpand: true, dock: { width: "320px", reveal: "resize" } },
130
+ });
131
+ expect(layout.host.style.minWidth).toBe("0px");
132
+
133
+ layout.destroy();
134
+ });
135
+
42
136
  it("disables dock width transition when dock.animate is false", () => {
43
137
  const parent = document.createElement("div");
44
138
  document.body.appendChild(parent);
@@ -119,6 +119,9 @@ const setDirectHostStyles = (host: HTMLElement, config?: AgentWidgetConfig): voi
119
119
  host.style.flexDirection = launcherEnabled ? "" : "column";
120
120
  host.style.flex = launcherEnabled ? "" : "1 1 auto";
121
121
  host.style.minHeight = launcherEnabled ? "" : "0";
122
+ // Flex items floor at min-width:auto (content width); without this the host
123
+ // grows past a shrinkable mount when a wide artifact split opens inside it.
124
+ host.style.minWidth = launcherEnabled ? "" : "0";
122
125
  };
123
126
 
124
127
  const clearOverlayDockSlotStyles = (dockSlot: HTMLElement): void => {
@@ -170,7 +173,9 @@ const resetContentSlotFlexSizing = (contentSlot: HTMLElement): void => {
170
173
 
171
174
  const clearEmergeDockStyles = (host: HTMLElement, dockSlot: HTMLElement): void => {
172
175
  host.style.width = "";
173
- host.style.minWidth = "";
176
+ // Restore the docked shrinkable baseline, not "": non-emerge dock modes must
177
+ // stay shrinkable. Emerge re-pins minWidth to its fixed width after this.
178
+ host.style.minWidth = "0";
174
179
  host.style.maxWidth = "";
175
180
  host.style.boxSizing = "";
176
181
  dockSlot.style.alignItems = "";
@@ -298,6 +303,9 @@ const applyDockStyles = (
298
303
  host.className = "persona-host";
299
304
  host.style.height = "100%";
300
305
  host.style.minHeight = "0";
306
+ // Shrinkable baseline so a wide artifact split shrinks within the dock host
307
+ // instead of enlarging it. Emerge overwrites this with its fixed width below.
308
+ host.style.minWidth = "0";
301
309
  host.style.display = "flex";
302
310
  host.style.flexDirection = "column";
303
311
  host.style.flex = "1 1 auto";
@@ -92,6 +92,39 @@ describe("initAgentWidget windowKey and ready notifications", () => {
92
92
  createAgentExperienceMock.mockImplementation((_mount, config) => createMockController(config));
93
93
  });
94
94
 
95
+ it("makes the mount shrinkable when it fills the host (launcher disabled)", async () => {
96
+ const { initAgentWidget } = await import("./init");
97
+ document.body.innerHTML = `<div id="target"></div>`;
98
+
99
+ const handle = initAgentWidget({
100
+ target: "#target",
101
+ config: { launcher: { enabled: false } },
102
+ });
103
+
104
+ const mount = createAgentExperienceMock.mock.calls[0][0] as HTMLElement;
105
+ // shouldFillHost mounts must match the host's shrinkable baseline, else a
106
+ // wide artifact split forces the mount past its content-based minimum width.
107
+ expect(mount.style.minWidth).toBe("0px");
108
+ expect(mount.style.minHeight).toBe("0px");
109
+
110
+ handle.destroy();
111
+ });
112
+
113
+ it("leaves the mount min-width unset when the launcher is enabled (floating)", async () => {
114
+ const { initAgentWidget } = await import("./init");
115
+ document.body.innerHTML = `<div id="target"></div>`;
116
+
117
+ const handle = initAgentWidget({
118
+ target: "#target",
119
+ config: { launcher: { enabled: true } },
120
+ });
121
+
122
+ const mount = createAgentExperienceMock.mock.calls[0][0] as HTMLElement;
123
+ expect(mount.style.minWidth).toBe("");
124
+
125
+ handle.destroy();
126
+ });
127
+
95
128
  it("assigns the handle to window[windowKey] when windowKey is provided", async () => {
96
129
  const { initAgentWidget } = await import("./init");
97
130
  document.body.innerHTML = `<div id="target"></div>`;
@@ -63,12 +63,16 @@ export const initAgentWidget = (
63
63
  const mount = ownerDocument.createElement("div");
64
64
  mount.setAttribute("data-persona-root", "true");
65
65
 
66
+ // Initial fill styling only. On every relayout the controller's
67
+ // applyFullHeightStyles resets mount.style.cssText and re-applies these
68
+ // (including the shrinkable min-width:0), so it owns the runtime truth.
66
69
  if (shouldFillHost) {
67
70
  mount.style.height = "100%";
68
71
  mount.style.display = "flex";
69
72
  mount.style.flexDirection = "column";
70
73
  mount.style.flex = "1";
71
74
  mount.style.minHeight = "0";
75
+ mount.style.minWidth = "0";
72
76
  }
73
77
 
74
78
  if (useShadow) {
@@ -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();