@runtypelabs/persona 4.13.0 → 4.15.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 (106) hide show
  1. package/README.md +4 -4
  2. package/dist/animations/glyph-cycle.d.cts +1 -1
  3. package/dist/animations/glyph-cycle.d.ts +1 -1
  4. package/dist/animations/{types-DveIaNx6.d.cts → types-BpWGm7_6.d.cts} +17 -0
  5. package/dist/animations/{types-DveIaNx6.d.ts → types-BpWGm7_6.d.ts} +17 -0
  6. package/dist/animations/wipe.d.cts +1 -1
  7. package/dist/animations/wipe.d.ts +1 -1
  8. package/dist/chunk-QJ4LV2KW.js +1 -0
  9. package/dist/codegen.cjs +12 -11
  10. package/dist/codegen.js +14 -13
  11. package/dist/{context-mentions-ONG7A7M6.js → context-mentions-BTSTIBFC.js} +1 -1
  12. package/dist/context-mentions.cjs +11 -8
  13. package/dist/context-mentions.d.cts +339 -22
  14. package/dist/context-mentions.d.ts +339 -22
  15. package/dist/context-mentions.js +11 -8
  16. package/dist/index.cjs +68 -67
  17. package/dist/index.cjs.map +1 -1
  18. package/dist/index.d.cts +483 -48
  19. package/dist/index.d.ts +483 -48
  20. package/dist/index.global.js +61 -58
  21. package/dist/index.global.js.map +1 -1
  22. package/dist/index.js +70 -69
  23. package/dist/index.js.map +1 -1
  24. package/dist/launcher.global.js +5 -2
  25. package/dist/launcher.global.js.map +1 -1
  26. package/dist/smart-dom-reader.d.cts +339 -22
  27. package/dist/smart-dom-reader.d.ts +339 -22
  28. package/dist/theme-editor-preview.cjs +67 -67
  29. package/dist/theme-editor-preview.d.cts +355 -23
  30. package/dist/theme-editor-preview.d.ts +355 -23
  31. package/dist/theme-editor-preview.js +75 -75
  32. package/dist/theme-editor.cjs +10 -10
  33. package/dist/theme-editor.d.cts +376 -24
  34. package/dist/theme-editor.d.ts +376 -24
  35. package/dist/theme-editor.js +10 -10
  36. package/dist/theme-reference.cjs +1 -1
  37. package/dist/theme-reference.d.cts +38 -0
  38. package/dist/theme-reference.d.ts +38 -0
  39. package/dist/theme-reference.js +1 -1
  40. package/dist/widget.css +1 -1
  41. package/package.json +1 -1
  42. package/src/artifacts-session.test.ts +106 -0
  43. package/src/ask-user-question-tool.ts +10 -7
  44. package/src/client.test.ts +2 -2
  45. package/src/client.ts +4 -1
  46. package/src/components/artifact-card.ts +13 -2
  47. package/src/components/artifact-inline.ts +11 -2
  48. package/src/components/artifact-preview.ts +1 -1
  49. package/src/components/composer-builder.test.ts +2 -0
  50. package/src/components/composer-builder.ts +1 -1
  51. package/src/components/composer-parts.test.ts +105 -4
  52. package/src/components/composer-parts.ts +54 -16
  53. package/src/components/message-bubble.test.ts +7 -1
  54. package/src/components/message-bubble.ts +11 -8
  55. package/src/components/messages.ts +5 -2
  56. package/src/components/panel.ts +31 -5
  57. package/src/components/pill-composer-builder.test.ts +18 -0
  58. package/src/components/suggestions.test.ts +233 -0
  59. package/src/components/suggestions.ts +463 -81
  60. package/src/components/widget-view.ts +4 -0
  61. package/src/defaults.ts +5 -3
  62. package/src/generated/runtype-openapi-contract.ts +17 -0
  63. package/src/index-core.ts +52 -2
  64. package/src/launcher-global.ts +1 -1
  65. package/src/plugins/types.ts +71 -3
  66. package/src/presets.ts +3 -3
  67. package/src/session.ts +55 -12
  68. package/src/styles/widget-styles.test.ts +162 -0
  69. package/src/styles/widget.css +440 -1
  70. package/src/suggest-replies-tool.test.ts +233 -2
  71. package/src/suggest-replies-tool.ts +115 -39
  72. package/src/theme-editor/index.ts +5 -0
  73. package/src/theme-editor/presets.test.ts +43 -0
  74. package/src/theme-editor/presets.ts +101 -87
  75. package/src/theme-editor/preview-utils.test.ts +34 -0
  76. package/src/theme-editor/preview-utils.ts +77 -2
  77. package/src/theme-editor/sections.test.ts +72 -7
  78. package/src/theme-editor/sections.ts +113 -13
  79. package/src/theme-editor/state-preferences.test.ts +15 -0
  80. package/src/theme-editor/state.ts +46 -0
  81. package/src/theme-editor/types.ts +15 -1
  82. package/src/theme-editor/webmcp/tools.test.ts +13 -0
  83. package/src/theme-editor/webmcp/tools.ts +5 -0
  84. package/src/theme-reference.ts +9 -2
  85. package/src/types/theme.ts +32 -0
  86. package/src/types.ts +291 -25
  87. package/src/ui.artifact-pane-gating.test.ts +5 -5
  88. package/src/ui.config-preferences.test.ts +166 -0
  89. package/src/ui.detached-panel.test.ts +1 -1
  90. package/src/ui.suggest-replies.test.ts +1066 -1
  91. package/src/ui.ts +311 -45
  92. package/src/ui.typing-indicator.test.ts +135 -0
  93. package/src/utils/artifact-display.test.ts +180 -16
  94. package/src/utils/artifact-display.ts +155 -14
  95. package/src/utils/artifact-file.test.ts +1 -1
  96. package/src/utils/code-generators.test.ts +40 -0
  97. package/src/utils/code-generators.ts +22 -0
  98. package/src/utils/config-merge.test.ts +84 -0
  99. package/src/utils/config-merge.ts +6 -1
  100. package/src/utils/feature-preferences.test.ts +326 -0
  101. package/src/utils/feature-preferences.ts +498 -0
  102. package/src/utils/icons.test.ts +26 -0
  103. package/src/utils/icons.ts +8 -0
  104. package/src/utils/theme.test.ts +171 -0
  105. package/src/utils/tokens.ts +71 -7
  106. package/dist/chunk-MMUPR2JW.js +0 -1
@@ -5,15 +5,18 @@
5
5
  * `clientTools[]` (set `features.askUserQuestion.expose: true`): the same
6
6
  * wire surface WebMCP page tools ride. The server registers it as a LOCAL
7
7
  * tool under its bare name (`origin: 'sdk'` tools are not `webmcp:`-prefixed),
8
- * so when the model calls it the execution pauses with a `step_await`
9
- * (`awaitReason: "local_tool_required"`), the widget's answer-pill sheet
10
- * renders, and `session.resolveAskUserQuestion()` resumes the execution with
11
- * the structured answers.
8
+ * so when the model calls it the execution pauses with a `step_await`, the
9
+ * widget's answer-pill sheet renders, and `session.resolveAskUserQuestion()`
10
+ * resumes the execution with the structured answers. The public await frame
11
+ * for a local-tool pause carries no `awaitReason`; that discriminator is
12
+ * internal, and its absence is what marks a client-resolvable pause.
12
13
  *
13
14
  * This replaces the previous integrator burden of hand-declaring the tool in
14
15
  * a flow's `runtimeTools` and keeping that schema in sync with the renderer.
15
16
  * Flows that already declare `ask_user_question` server-side should leave
16
- * `expose` off: the model would otherwise see the tool twice.
17
+ * `expose` off: the API dedupes a double declaration by name with
18
+ * `clientTools[]` winning, so the model still sees exactly one tool, but the
19
+ * widget's description and schema silently replace the flow's.
17
20
  */
18
21
 
19
22
  import {
@@ -22,7 +25,7 @@ import {
22
25
  } from "./components/ask-user-question-bubble";
23
26
  import {
24
27
  SUGGEST_REPLIES_CLIENT_TOOL,
25
- shouldExposeSuggestReplies,
28
+ resolveFollowUpsFeature,
26
29
  } from "./suggest-replies-tool";
27
30
  import type { AgentWidgetConfig, ClientToolDefinition } from "./types";
28
31
 
@@ -129,7 +132,7 @@ export const builtInClientToolsForDispatch = (
129
132
  if (ask?.expose === true && ask.enabled !== false) {
130
133
  tools.push(ASK_USER_QUESTION_CLIENT_TOOL);
131
134
  }
132
- if (shouldExposeSuggestReplies(config)) {
135
+ if (resolveFollowUpsFeature(config).expose) {
133
136
  tools.push(SUGGEST_REPLIES_CLIENT_TOOL);
134
137
  }
135
138
  return tools;
@@ -4505,10 +4505,10 @@ describe('AgentWidgetClient - Artifact display modes', () => {
4505
4505
  });
4506
4506
  });
4507
4507
 
4508
- it('resolves the mode per artifact type via display.byType', async () => {
4508
+ it('resolves the mode per artifact kind via display.byKind', async () => {
4509
4509
  const display: AgentWidgetArtifactsFeature['display'] = {
4510
4510
  default: 'card',
4511
- byType: { component: 'inline' },
4511
+ byKind: { component: 'inline' },
4512
4512
  };
4513
4513
 
4514
4514
  const mdEvents = await dispatchAndCollect(markdownArtifactFrames(), {
package/src/client.ts CHANGED
@@ -3184,7 +3184,10 @@ export class AgentWidgetClient {
3184
3184
  artifactIdsWithCards.add(artId);
3185
3185
  const displayMode = resolveArtifactDisplayMode(
3186
3186
  this.config.features?.artifacts,
3187
- at
3187
+ {
3188
+ artifactType: at,
3189
+ ...(artFile ? { file: artFile } : {}),
3190
+ }
3188
3191
  );
3189
3192
  const artComponent =
3190
3193
  typeof payload.component === "string" ? payload.component : undefined;
@@ -1,5 +1,9 @@
1
1
  import type { ComponentContext, ComponentRenderer } from "./registry";
2
- import type { PersonaArtifactFileMeta, PersonaArtifactRecord } from "../types";
2
+ import type {
3
+ PersonaArtifactFileMeta,
4
+ PersonaArtifactPresentation,
5
+ PersonaArtifactRecord
6
+ } from "../types";
3
7
  import { fileTypeLabel, basenameOf } from "../utils/artifact-file";
4
8
  import {
5
9
  applyArtifactStatus,
@@ -33,6 +37,12 @@ function renderDefaultArtifactCard(
33
37
  const status = props.status === "streaming" ? "streaming" : "complete";
34
38
  const artifactType =
35
39
  typeof props.artifactType === "string" ? props.artifactType : "markdown";
40
+ const presentation =
41
+ props.presentation &&
42
+ typeof props.presentation === "object" &&
43
+ !Array.isArray(props.presentation)
44
+ ? (props.presentation as PersonaArtifactPresentation)
45
+ : undefined;
36
46
  const subtitle = file
37
47
  ? fileTypeLabel(file)
38
48
  : artifactType === "component"
@@ -83,7 +93,8 @@ function renderDefaultArtifactCard(
83
93
  title: rawTitle,
84
94
  status: "streaming",
85
95
  ...(typeof props.markdown === "string" ? { markdown: props.markdown } : {}),
86
- ...(file ? { file } : {})
96
+ ...(file ? { file } : {}),
97
+ ...(presentation ? { presentation } : {})
87
98
  };
88
99
  const resolved = resolveArtifactStatusLabel(record, artifactsCfg, "card");
89
100
  applyArtifactStatus(subtitleEl, resolved, artifactsCfg);
@@ -2,6 +2,7 @@ import type { ComponentContext, ComponentRenderer } from "./registry";
2
2
  import type {
3
3
  AgentWidgetArtifactsFeature,
4
4
  PersonaArtifactFileMeta,
5
+ PersonaArtifactPresentation,
5
6
  PersonaArtifactRecord
6
7
  } from "../types";
7
8
  import { createElement } from "../utils/dom";
@@ -126,6 +127,12 @@ function recordFromProps(props: Record<string, unknown>): PersonaArtifactRecord
126
127
  const title =
127
128
  typeof props.title === "string" && props.title ? props.title : undefined;
128
129
  const status = props.status === "streaming" ? "streaming" : "complete";
130
+ const presentation =
131
+ props.presentation &&
132
+ typeof props.presentation === "object" &&
133
+ !Array.isArray(props.presentation)
134
+ ? (props.presentation as PersonaArtifactPresentation)
135
+ : undefined;
129
136
  if (props.artifactType === "component") {
130
137
  const embedded = props.componentProps;
131
138
  const componentProps =
@@ -138,7 +145,8 @@ function recordFromProps(props: Record<string, unknown>): PersonaArtifactRecord
138
145
  title,
139
146
  status,
140
147
  component: typeof props.component === "string" ? props.component : "",
141
- props: componentProps
148
+ props: componentProps,
149
+ ...(presentation ? { presentation } : {})
142
150
  };
143
151
  }
144
152
  const file =
@@ -151,7 +159,8 @@ function recordFromProps(props: Record<string, unknown>): PersonaArtifactRecord
151
159
  title,
152
160
  status,
153
161
  markdown: typeof props.markdown === "string" ? props.markdown : "",
154
- ...(file ? { file } : {})
162
+ ...(file ? { file } : {}),
163
+ ...(presentation ? { presentation } : {})
155
164
  };
156
165
  }
157
166
 
@@ -670,7 +670,7 @@ export function renderArtifactPreviewBody(
670
670
 
671
671
  // Render the syntax-highlighted source. On the inline path the <pre> lives in
672
672
  // a scroll window (`fixed` → reserved height + internal scroll + follow/fade);
673
- // on the pane path it is a bare child of `el`, byte-identical to before.
673
+ // on the pane path it is a bare child of `el`, unchanged from before.
674
674
  const renderSource = (
675
675
  text: string,
676
676
  opts: { language?: string; path?: string } | undefined,
@@ -17,6 +17,8 @@ describe("buildComposer (full column-stacked composer)", () => {
17
17
  expect(elements.composerForm.tagName).toBe("FORM");
18
18
  expect(elements.composerForm.getAttribute("data-persona-composer-form")).toBe("");
19
19
  expect(elements.composerForm.classList.contains("persona-flex-col")).toBe(true);
20
+ expect(elements.composerForm.classList.contains("persona-border-persona-border")).toBe(true);
21
+ expect(elements.composerForm.classList.contains("persona-border-gray-200")).toBe(false);
20
22
 
21
23
  expect(elements.textarea.getAttribute("data-persona-composer-input")).toBe("");
22
24
  expect(elements.sendButton.getAttribute("data-persona-composer-submit")).toBe("");
@@ -67,7 +67,7 @@ export const buildComposer = (context: ComposerBuildContext): ComposerElements =
67
67
 
68
68
  const composerForm = createNode("form", {
69
69
  className:
70
- "persona-widget-composer persona-flex persona-flex-col persona-gap-2 persona-rounded-2xl persona-border persona-border-gray-200 persona-bg-persona-input-background persona-px-4 persona-py-3",
70
+ "persona-widget-composer persona-flex persona-flex-col persona-gap-2 persona-rounded-2xl persona-border persona-border-persona-border persona-bg-persona-input-background persona-px-4 persona-py-3",
71
71
  attrs: { "data-persona-composer-form": "" },
72
72
  style: { outline: "none" },
73
73
  });
@@ -19,6 +19,8 @@ describe("createComposerTextarea", () => {
19
19
  expect(textarea.tagName).toBe("TEXTAREA");
20
20
  expect(textarea.getAttribute("data-persona-composer-input")).toBe("");
21
21
  expect(textarea.classList.contains("persona-composer-textarea")).toBe(true);
22
+ expect(textarea.classList.contains("persona-text-persona-text")).toBe(true);
23
+ expect(textarea.classList.contains("persona-text-persona-primary")).toBe(false);
22
24
  });
23
25
 
24
26
  it("attachAutoResize wires an input listener that grows up to maxHeight", () => {
@@ -42,8 +44,59 @@ describe("createComposerTextarea", () => {
42
44
  Object.defineProperty(textarea, "scrollHeight", { configurable: true, value: 10000 });
43
45
  textarea.dispatchEvent(new Event("input"));
44
46
  expect(textarea.style.height).toBe("200px");
47
+ // The override keeps winning on later input events (pill composer path).
48
+ textarea.dispatchEvent(new Event("input"));
49
+ expect(textarea.style.height).toBe("200px");
50
+ expect(textarea.style.maxHeight).toBe("200px");
51
+ document.body.removeChild(textarea);
52
+ });
53
+
54
+ it("caps at 3 lines of the rendered line height when themed", () => {
55
+ const { textarea, attachAutoResize } = createComposerTextarea(baseConfig);
56
+ textarea.style.lineHeight = "24px";
57
+ document.body.appendChild(textarea);
58
+ attachAutoResize();
59
+ Object.defineProperty(textarea, "scrollHeight", { configurable: true, value: 10000 });
60
+ textarea.dispatchEvent(new Event("input"));
61
+ expect(textarea.style.height).toBe("72px");
62
+ expect(textarea.style.maxHeight).toBe("72px");
45
63
  document.body.removeChild(textarea);
46
64
  });
65
+
66
+ it("defaults to 60px (3 × 20px) before any resize", () => {
67
+ const { textarea } = createComposerTextarea(baseConfig);
68
+ expect(textarea.style.maxHeight).toBe("60px");
69
+ expect(textarea.style.overflowY).toBe("auto");
70
+ });
71
+
72
+ it("falls back to 60px when the line height is `normal`", () => {
73
+ const { textarea, attachAutoResize } = createComposerTextarea(baseConfig);
74
+ textarea.style.lineHeight = "normal";
75
+ document.body.appendChild(textarea);
76
+ attachAutoResize();
77
+ Object.defineProperty(textarea, "scrollHeight", { configurable: true, value: 10000 });
78
+ textarea.dispatchEvent(new Event("input"));
79
+ expect(textarea.style.height).toBe("60px");
80
+ document.body.removeChild(textarea);
81
+ });
82
+
83
+ it("falls back to 60px for unitless line heights and detached nodes", () => {
84
+ const unitless = createComposerTextarea(baseConfig);
85
+ unitless.textarea.style.lineHeight = "1.5";
86
+ document.body.appendChild(unitless.textarea);
87
+ unitless.attachAutoResize();
88
+ Object.defineProperty(unitless.textarea, "scrollHeight", { configurable: true, value: 10000 });
89
+ unitless.textarea.dispatchEvent(new Event("input"));
90
+ expect(unitless.textarea.style.height).toBe("60px");
91
+ document.body.removeChild(unitless.textarea);
92
+
93
+ // Never appended: getComputedStyle still must not throw or produce NaN.
94
+ const detached = createComposerTextarea(baseConfig);
95
+ detached.attachAutoResize();
96
+ Object.defineProperty(detached.textarea, "scrollHeight", { configurable: true, value: 10000 });
97
+ detached.textarea.dispatchEvent(new Event("input"));
98
+ expect(detached.textarea.style.height).toBe("60px");
99
+ });
47
100
  });
48
101
 
49
102
  describe("createSendButton", () => {
@@ -95,10 +148,12 @@ describe("createSendButton", () => {
95
148
  expect(s.borderWidth).toBe("2px");
96
149
  expect(s.borderStyle).toBe("solid");
97
150
  expect(s.borderColor).toBe("rgb(7, 8, 9)");
98
- expect(s.paddingLeft).toBe("6px");
99
- expect(s.paddingRight).toBe("6px");
100
- expect(s.paddingTop).toBe("4px");
101
- expect(s.paddingBottom).toBe("4px");
151
+ // Icon mode zeroes padding even when configured: the glyph is drawn at
152
+ // the button size, so padding would crush it.
153
+ expect(s.paddingLeft).toBe("0px");
154
+ expect(s.paddingRight).toBe("0px");
155
+ expect(s.paddingTop).toBe("0px");
156
+ expect(s.paddingBottom).toBe("0px");
102
157
  // An explicit backgroundColor means the fallback class is not added.
103
158
  expect(send.button.classList.contains("persona-bg-persona-primary")).toBe(false);
104
159
  });
@@ -140,6 +195,52 @@ describe("createSendButton", () => {
140
195
  expect(iconCount(send.button)).toBe(1);
141
196
  });
142
197
 
198
+ it("zeroes padding so configured padding cannot crush the glyph", () => {
199
+ const send = createSendButton({
200
+ ...baseConfig,
201
+ sendButton: {
202
+ useIcon: true,
203
+ iconName: "send",
204
+ size: "40px",
205
+ paddingX: "12px",
206
+ paddingY: "10px",
207
+ },
208
+ });
209
+ expect(send.button.style.paddingLeft).toBe("0px");
210
+ expect(send.button.style.paddingTop).toBe("0px");
211
+ });
212
+
213
+ it("draws the glyph at half the button by default and honors iconSize", () => {
214
+ const send = createSendButton({
215
+ ...baseConfig,
216
+ sendButton: { useIcon: true, iconName: "send", size: "32px" },
217
+ });
218
+ expect(send.button.querySelector("svg")?.getAttribute("width")).toBe("16");
219
+
220
+ const sized = createSendButton({
221
+ ...baseConfig,
222
+ sendButton: {
223
+ useIcon: true,
224
+ iconName: "send",
225
+ size: "32px",
226
+ iconSize: "26px",
227
+ iconStrokeWidth: 2.5,
228
+ },
229
+ });
230
+ const svg = sized.button.querySelector("svg");
231
+ expect(svg?.getAttribute("width")).toBe("26");
232
+ expect(svg?.getAttribute("stroke-width")).toBe("2.5");
233
+ });
234
+
235
+ it("keeps configured padding in text mode", () => {
236
+ const send = createSendButton({
237
+ ...baseConfig,
238
+ sendButton: { useIcon: false, paddingX: "12px", paddingY: "10px" },
239
+ });
240
+ expect(send.button.style.paddingLeft).toBe("12px");
241
+ expect(send.button.style.paddingTop).toBe("10px");
242
+ });
243
+
143
244
  it("does not stack a stale icon when an external re-render swapped the live icon node", () => {
144
245
  const send = createSendButton(iconConfig);
145
246
  // Simulate a DOM morph/re-render (e.g. a host calling controller.update())
@@ -32,26 +32,54 @@ export const createComposerTextarea = (config?: AgentWidgetConfig): ComposerText
32
32
  textarea.setAttribute("data-persona-composer-input", "");
33
33
  textarea.placeholder = config?.copy?.inputPlaceholder ?? "Type your message…";
34
34
  textarea.className =
35
- "persona-w-full persona-min-h-[24px] persona-resize-none persona-border-none persona-bg-transparent persona-text-sm persona-text-persona-primary focus:persona-outline-none focus:persona-border-none persona-composer-textarea";
35
+ "persona-w-full persona-min-h-[24px] persona-resize-none persona-border-none persona-bg-transparent persona-text-sm persona-text-persona-text focus:persona-outline-none focus:persona-border-none persona-composer-textarea";
36
36
  textarea.rows = 1;
37
37
 
38
38
  textarea.style.fontFamily =
39
39
  'var(--persona-input-font-family, var(--persona-font-family, -apple-system, BlinkMacSystemFont, "Segoe UI", "Helvetica Neue", Arial, sans-serif))';
40
40
  textarea.style.fontWeight = "var(--persona-input-font-weight, var(--persona-font-weight, 400))";
41
41
 
42
- // Auto-resize: expand up to 3 lines for the full composer (line-height ~20px
43
- // for text-sm). The pill composer overrides this maxHeight after construction
44
- // (allowing more growth in expanded mode), and the closure below honors
45
- // whatever maxHeight is set at the time of the input event.
42
+ // Auto-resize: expand up to 3 lines for the full composer. The pill composer
43
+ // overrides this maxHeight after construction (allowing more growth in
44
+ // expanded mode), and the closure below honors whatever maxHeight is set at
45
+ // the time of the input event.
46
46
  const defaultMaxLines = 3;
47
- const lineHeight = 20;
48
- textarea.style.maxHeight = `${defaultMaxLines * lineHeight}px`;
47
+ // Used only when the rendered line height can't be resolved (detached node,
48
+ // jsdom, `normal`); matches the `persona-text-sm` / composer default.
49
+ const fallbackLineHeight = 20;
50
+
51
+ // `theme.components.composer.lineHeight` (--persona-composer-line-height) can
52
+ // change the rendered line height, so 3 lines is only correct when measured
53
+ // from the live element. Read lazily: at construction the textarea is usually
54
+ // still detached and getComputedStyle would report nothing useful.
55
+ const readLineHeight = (): number => {
56
+ const view = textarea.ownerDocument?.defaultView;
57
+ if (!view?.getComputedStyle) return fallbackLineHeight;
58
+ // Only px values are meaningful here: `normal` and unitless ratios aren't
59
+ // line box heights we can multiply.
60
+ const computed = view.getComputedStyle(textarea).lineHeight;
61
+ if (!computed || !computed.trim().endsWith("px")) return fallbackLineHeight;
62
+ const parsed = parseFloat(computed);
63
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : fallbackLineHeight;
64
+ };
65
+
66
+ // Tracks the last maxHeight this module wrote, so a caller's post-construction
67
+ // override is distinguishable from our own default and always wins.
68
+ let managedMaxHeight = `${defaultMaxLines * fallbackLineHeight}px`;
69
+ textarea.style.maxHeight = managedMaxHeight;
49
70
  textarea.style.overflowY = "auto";
50
71
 
51
72
  // Read maxHeight at event time so callers can change it after construction.
52
73
  const readMaxHeight = (): number => {
53
- const parsed = parseFloat(textarea.style.maxHeight);
54
- return Number.isFinite(parsed) && parsed > 0 ? parsed : defaultMaxLines * lineHeight;
74
+ const current = textarea.style.maxHeight;
75
+ if (current !== managedMaxHeight) {
76
+ const overridden = parseFloat(current);
77
+ if (Number.isFinite(overridden) && overridden > 0) return overridden;
78
+ }
79
+ const derived = defaultMaxLines * readLineHeight();
80
+ managedMaxHeight = `${derived}px`;
81
+ textarea.style.maxHeight = managedMaxHeight;
82
+ return derived;
55
83
  };
56
84
 
57
85
  const attachAutoResize = () => {
@@ -142,10 +170,13 @@ export const createSendButton = (config?: AgentWidgetConfig): SendButtonParts =>
142
170
  borderWidth: sendButtonConfig.borderWidth || undefined,
143
171
  borderStyle: sendButtonConfig.borderWidth ? "solid" : undefined,
144
172
  borderColor: sendButtonConfig.borderColor || undefined,
145
- paddingLeft: sendButtonConfig.paddingX || undefined,
146
- paddingRight: sendButtonConfig.paddingX || undefined,
147
- paddingTop: sendButtonConfig.paddingY || undefined,
148
- paddingBottom: sendButtonConfig.paddingY || undefined,
173
+ // Padding is text-mode-only: icon mode is a fixed `size` box with the
174
+ // glyph drawn at that size, so padding just crushes the SVG (the
175
+ // always-present defaults squeezed a 40px glyph into 16x20).
176
+ paddingLeft: useIcon ? "0" : sendButtonConfig.paddingX || undefined,
177
+ paddingRight: useIcon ? "0" : sendButtonConfig.paddingX || undefined,
178
+ paddingTop: useIcon ? "0" : sendButtonConfig.paddingY || undefined,
179
+ paddingBottom: useIcon ? "0" : sendButtonConfig.paddingY || undefined,
149
180
  },
150
181
  });
151
182
 
@@ -154,11 +185,18 @@ export const createSendButton = (config?: AgentWidgetConfig): SendButtonParts =>
154
185
  let stopIcon: SVGElement | null = null;
155
186
 
156
187
  if (useIcon) {
157
- const iconSize = parseFloat(buttonSize) || 24;
188
+ // Default glyph box is half the button, the closest clean ratio to the
189
+ // pre-fix live rendering (a 40px glyph crushed to a ~16px content box).
190
+ // Dense glyphs like the default "send" plane fill most of their viewBox;
191
+ // sparse ones (arrow-up) may want a larger explicit `iconSize`.
192
+ const iconSize =
193
+ parseFloat(sendButtonConfig.iconSize ?? "") ||
194
+ Math.round((parseFloat(buttonSize) || 24) * 0.5);
195
+ const iconStroke = sendButtonConfig.iconStrokeWidth ?? 2;
158
196
  const iconColor = textColor?.trim() || "currentColor";
159
197
 
160
198
  if (iconName) {
161
- sendIcon = renderLucideIcon(iconName, iconSize, iconColor, 2);
199
+ sendIcon = renderLucideIcon(iconName, iconSize, iconColor, iconStroke);
162
200
  if (sendIcon) {
163
201
  button.appendChild(sendIcon);
164
202
  } else {
@@ -168,7 +206,7 @@ export const createSendButton = (config?: AgentWidgetConfig): SendButtonParts =>
168
206
  button.textContent = iconText;
169
207
  }
170
208
 
171
- stopIcon = renderLucideIcon(stopIconName, iconSize, iconColor, 2);
209
+ stopIcon = renderLucideIcon(stopIconName, iconSize, iconColor, iconStroke);
172
210
  } else {
173
211
  button.textContent = sendLabel;
174
212
  }
@@ -276,7 +276,7 @@ describe("createStandardBubble", () => {
276
276
  expect(userBubble.classList.contains("persona-bg-persona-accent")).toBe(true);
277
277
  expect(userBubble.classList.contains("persona-rounded-lg")).toBe(true);
278
278
  expect(userBubble.classList.contains("persona-px-3")).toBe(true);
279
- expect(assistantBubble.classList.contains("persona-bg-persona-surface")).toBe(
279
+ expect(assistantBubble.classList.contains("persona-bg-persona-container")).toBe(
280
280
  true
281
281
  );
282
282
  expect(assistantBubble.classList.contains("persona-rounded-lg")).toBe(true);
@@ -302,6 +302,12 @@ describe("createStandardBubble", () => {
302
302
 
303
303
  expect(userMessage.style.backgroundColor).toBe("transparent");
304
304
  expect(assistantMessage.style.backgroundColor).toBe("transparent");
305
+ expect(assistantMessage.style.color).toBe(
306
+ "var(--persona-message-assistant-text, var(--persona-text))"
307
+ );
308
+ expect(assistantMessage.classList.contains("persona-text-persona-text")).toBe(
309
+ true
310
+ );
305
311
  expect(userMessage.classList.contains("persona-px-3")).toBe(false);
306
312
  expect(assistantMessage.classList.contains("persona-px-3")).toBe(false);
307
313
  });
@@ -649,7 +649,7 @@ const createTimestamp = (
649
649
  /**
650
650
  * Get bubble classes based on layout preset
651
651
  */
652
- const getBubbleClasses = (
652
+ export const getBubbleClasses = (
653
653
  role: "user" | "assistant" | "system",
654
654
  layout: AgentWidgetMessageLayoutConfig["layout"] = "bubble"
655
655
  ): string[] => {
@@ -668,7 +668,7 @@ const getBubbleClasses = (
668
668
  } else {
669
669
  baseClasses.push(
670
670
  "persona-message-assistant-bubble",
671
- "persona-text-persona-primary",
671
+ "persona-text-persona-text",
672
672
  "persona-py-2"
673
673
  );
674
674
  }
@@ -693,8 +693,8 @@ const getBubbleClasses = (
693
693
  } else {
694
694
  baseClasses.push(
695
695
  "persona-message-assistant-bubble",
696
- "persona-bg-persona-surface",
697
- "persona-text-persona-primary",
696
+ "persona-bg-persona-container",
697
+ "persona-text-persona-text",
698
698
  "persona-px-3",
699
699
  "persona-py-2",
700
700
  "persona-rounded-lg"
@@ -723,10 +723,10 @@ const getBubbleClasses = (
723
723
  } else {
724
724
  baseClasses.push(
725
725
  "persona-message-assistant-bubble",
726
- "persona-bg-persona-surface",
726
+ "persona-bg-persona-container",
727
727
  "persona-border",
728
728
  "persona-border-persona-message-border",
729
- "persona-text-persona-primary",
729
+ "persona-text-persona-text",
730
730
  "persona-px-5",
731
731
  "persona-py-3"
732
732
  );
@@ -879,12 +879,15 @@ export const createStandardBubble = (
879
879
  // Apply component-level color overrides via CSS variables
880
880
  if (layout === "flat") {
881
881
  bubble.style.backgroundColor = "transparent";
882
- bubble.style.color = "var(--persona-primary, var(--persona-text))";
882
+ bubble.style.color =
883
+ message.role === "user"
884
+ ? "var(--persona-primary, var(--persona-text))"
885
+ : "var(--persona-message-assistant-text, var(--persona-text))";
883
886
  } else if (message.role === "user") {
884
887
  bubble.style.backgroundColor = 'var(--persona-message-user-bg, var(--persona-accent))';
885
888
  bubble.style.color = 'var(--persona-message-user-text, white)';
886
889
  } else if (message.role === "assistant") {
887
- bubble.style.backgroundColor = 'var(--persona-message-assistant-bg, var(--persona-surface))';
890
+ bubble.style.backgroundColor = 'var(--persona-message-assistant-bg, var(--persona-container))';
888
891
  bubble.style.color = 'var(--persona-message-assistant-text, var(--persona-text))';
889
892
  }
890
893
 
@@ -9,7 +9,10 @@ import {
9
9
  isAskUserQuestionMessage,
10
10
  removeAskUserQuestionSheet,
11
11
  } from "./ask-user-question-bubble";
12
- import { isSuggestRepliesMessage } from "../suggest-replies-tool";
12
+ import {
13
+ isSuggestRepliesMessage,
14
+ resolveFollowUpsFeature,
15
+ } from "../suggest-replies-tool";
13
16
 
14
17
  export const renderMessages = (
15
18
  container: HTMLElement,
@@ -43,7 +46,7 @@ export const renderMessages = (
43
46
  return;
44
47
  } else if (
45
48
  isSuggestRepliesMessage(message) &&
46
- config?.features?.suggestReplies?.enabled !== false
49
+ resolveFollowUpsFeature(config).enabled
47
50
  ) {
48
51
  // No transcript bubble: the chips above the composer are the only UI.
49
52
  // When the feature is disabled the message falls through to the generic
@@ -1,6 +1,7 @@
1
1
  import { createElement, createNode } from "../utils/dom";
2
2
  import { DEFAULT_FLOATING_LAUNCHER_WIDTH } from "../defaults";
3
- import { AgentWidgetConfig } from "../types";
3
+ import { AgentWidgetConfig, AgentWidgetSuggestionSurface } from "../types";
4
+ import { suggestionSurfaceAttr } from "./suggestions";
4
5
  import { positionMap } from "../utils/positioning";
5
6
  import { isComposerBarMountMode, isDockedMountMode } from "../utils/dock";
6
7
  import { DEFAULT_OVERLAY_Z_INDEX } from "../utils/constants";
@@ -132,6 +133,10 @@ export interface PanelElements {
132
133
  container: HTMLElement;
133
134
  body: HTMLElement;
134
135
  messagesWrapper: HTMLElement;
136
+ /** Starter prompts rendered inside the welcome card. */
137
+ starterSuggestions: HTMLElement;
138
+ /** Follow-up suggestions rendered after the transcript. */
139
+ transcriptSuggestions: HTMLElement;
135
140
  /**
136
141
  * Absolute-positioned slot above the composer footer. Interactive sheets
137
142
  * (e.g. the answer-pill sheet for the ask_user_question tool) mount here
@@ -178,6 +183,17 @@ export interface PanelElements {
178
183
  peekTextNode?: HTMLElement;
179
184
  }
180
185
 
186
+ const createSuggestionHost = (
187
+ surface: AgentWidgetSuggestionSurface
188
+ ): HTMLElement => {
189
+ const host = createNode("div", {
190
+ className: "persona-suggestions",
191
+ attrs: { "data-persona-suggestions": suggestionSurfaceAttr(surface) },
192
+ });
193
+ host.hidden = true;
194
+ return host;
195
+ };
196
+
181
197
  /**
182
198
  * Composer-bar panel: minimal close-only header (× in the top-right of the
183
199
  * chat chrome). The pill (`footer`) and `peekBanner` are NOT children of
@@ -276,6 +292,7 @@ const buildComposerBarPanel = (
276
292
  config?.copy?.welcomeSubtitle ??
277
293
  "Ask anything about your account or products.",
278
294
  });
295
+ const starterSuggestions = createSuggestionHost("starter");
279
296
  const introCard = createNode(
280
297
  "div",
281
298
  {
@@ -287,13 +304,15 @@ const buildComposerBarPanel = (
287
304
  },
288
305
  },
289
306
  introTitle,
290
- introSubtitle
307
+ introSubtitle,
308
+ starterSuggestions
291
309
  );
292
310
 
293
311
  const messagesWrapper = createElement(
294
312
  "div",
295
313
  "persona-flex persona-flex-col persona-gap-3"
296
314
  );
315
+ const transcriptSuggestions = createSuggestionHost("followUp");
297
316
  const contentMaxWidth = config?.layout?.contentMaxWidth;
298
317
  if (contentMaxWidth) {
299
318
  messagesWrapper.style.maxWidth = contentMaxWidth;
@@ -308,7 +327,7 @@ const buildComposerBarPanel = (
308
327
  body.classList.remove("persona-gap-6");
309
328
  body.classList.add("persona-gap-3");
310
329
  }
311
- body.append(introCard, messagesWrapper);
330
+ body.append(introCard, messagesWrapper, transcriptSuggestions);
312
331
 
313
332
  // Composer overlay (interactive sheets like ask_user_question slide up here).
314
333
  // Anchored to the bottom of the container (which is `position: relative`),
@@ -338,6 +357,8 @@ const buildComposerBarPanel = (
338
357
  container,
339
358
  body,
340
359
  messagesWrapper,
360
+ starterSuggestions,
361
+ transcriptSuggestions,
341
362
  composerOverlay,
342
363
  suggestions: composerElements.suggestions,
343
364
  textarea: composerElements.textarea,
@@ -413,6 +434,7 @@ export const buildPanel = (config?: AgentWidgetConfig, showClose = true): PanelE
413
434
  config?.copy?.welcomeSubtitle ??
414
435
  "Ask anything about your account or products.",
415
436
  });
437
+ const starterSuggestions = createSuggestionHost("starter");
416
438
  // Background and box-shadow flow through the themable `components.introCard`
417
439
  // tokens (--persona-intro-card-bg / --persona-intro-card-shadow); both
418
440
  // default to flat (transparent / none). Docked mode always stays flat,
@@ -430,13 +452,15 @@ export const buildPanel = (config?: AgentWidgetConfig, showClose = true): PanelE
430
452
  },
431
453
  },
432
454
  introTitle,
433
- introSubtitle
455
+ introSubtitle,
456
+ starterSuggestions
434
457
  );
435
458
 
436
459
  const messagesWrapper = createElement(
437
460
  "div",
438
461
  "persona-flex persona-flex-col persona-gap-3"
439
462
  );
463
+ const transcriptSuggestions = createSuggestionHost("followUp");
440
464
 
441
465
  const contentMaxWidth = config?.layout?.contentMaxWidth;
442
466
  if (contentMaxWidth) {
@@ -452,7 +476,7 @@ export const buildPanel = (config?: AgentWidgetConfig, showClose = true): PanelE
452
476
  body.classList.remove("persona-gap-6");
453
477
  body.classList.add("persona-gap-3");
454
478
  }
455
- body.append(introCard, messagesWrapper);
479
+ body.append(introCard, messagesWrapper, transcriptSuggestions);
456
480
 
457
481
  // composer-bar mode early-returned above with its own pill builder; this
458
482
  // path always uses the standard column-stacked composer card.
@@ -500,6 +524,8 @@ export const buildPanel = (config?: AgentWidgetConfig, showClose = true): PanelE
500
524
  container,
501
525
  body,
502
526
  messagesWrapper,
527
+ starterSuggestions,
528
+ transcriptSuggestions,
503
529
  composerOverlay,
504
530
  suggestions: composerElements.suggestions,
505
531
  textarea: composerElements.textarea,