@runtypelabs/persona 4.14.0 → 4.16.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 (119) 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 +520 -21
  14. package/dist/context-mentions.d.ts +520 -21
  15. package/dist/context-mentions.js +11 -8
  16. package/dist/index.cjs +69 -68
  17. package/dist/index.cjs.map +1 -1
  18. package/dist/index.d.cts +595 -46
  19. package/dist/index.d.ts +595 -46
  20. package/dist/index.global.js +60 -57
  21. package/dist/index.global.js.map +1 -1
  22. package/dist/index.js +69 -68
  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 +520 -21
  27. package/dist/smart-dom-reader.d.ts +520 -21
  28. package/dist/testing.cjs +3 -3
  29. package/dist/testing.d.cts +7 -0
  30. package/dist/testing.d.ts +7 -0
  31. package/dist/testing.js +3 -3
  32. package/dist/theme-editor-preview.cjs +67 -67
  33. package/dist/theme-editor-preview.d.cts +534 -22
  34. package/dist/theme-editor-preview.d.ts +534 -22
  35. package/dist/theme-editor-preview.js +57 -57
  36. package/dist/theme-editor.cjs +10 -10
  37. package/dist/theme-editor.d.cts +521 -23
  38. package/dist/theme-editor.d.ts +521 -23
  39. package/dist/theme-editor.js +10 -10
  40. package/dist/theme-reference.cjs +1 -1
  41. package/dist/theme-reference.d.cts +104 -3
  42. package/dist/theme-reference.d.ts +104 -3
  43. package/dist/theme-reference.js +1 -1
  44. package/dist/widget.css +1 -1
  45. package/package.json +1 -1
  46. package/src/ask-user-question-tool.ts +10 -7
  47. package/src/components/artifact-pane.test.ts +27 -0
  48. package/src/components/artifact-pane.ts +7 -3
  49. package/src/components/artifact-preview.ts +1 -1
  50. package/src/components/composer-parts.test.ts +103 -4
  51. package/src/components/composer-parts.ts +53 -15
  52. package/src/components/header-builder.ts +27 -2
  53. package/src/components/header-layouts.test.ts +56 -0
  54. package/src/components/header-layouts.ts +29 -9
  55. package/src/components/launcher-surface.test.ts +403 -0
  56. package/src/components/launcher.ts +297 -2
  57. package/src/components/messages.ts +5 -2
  58. package/src/components/panel.ts +92 -71
  59. package/src/components/pill-composer-builder.test.ts +18 -0
  60. package/src/components/suggestions.test.ts +273 -0
  61. package/src/components/suggestions.ts +528 -86
  62. package/src/components/welcome.test.ts +121 -0
  63. package/src/components/welcome.ts +305 -0
  64. package/src/components/widget-view.test.ts +5 -3
  65. package/src/components/widget-view.ts +18 -7
  66. package/src/defaults.ts +7 -5
  67. package/src/generated/runtype-openapi-contract.ts +17 -0
  68. package/src/index-core.ts +29 -2
  69. package/src/launcher-global.ts +11 -8
  70. package/src/plugins/types.ts +136 -3
  71. package/src/presets.ts +3 -3
  72. package/src/session.ts +10 -8
  73. package/src/styles/widget-styles.test.ts +382 -0
  74. package/src/styles/widget.css +778 -4
  75. package/src/suggest-replies-tool.test.ts +233 -2
  76. package/src/suggest-replies-tool.ts +115 -39
  77. package/src/testing/mock-stream.test.ts +19 -0
  78. package/src/testing/mock-stream.ts +20 -0
  79. package/src/theme-editor/preview-utils.test.ts +34 -0
  80. package/src/theme-editor/preview-utils.ts +77 -2
  81. package/src/theme-editor/sections.test.ts +33 -0
  82. package/src/theme-editor/sections.ts +83 -6
  83. package/src/theme-reference.ts +34 -5
  84. package/src/types/theme.ts +72 -3
  85. package/src/types.ts +312 -18
  86. package/src/ui.body-top-slot.test.ts +95 -0
  87. package/src/ui.content-width.test.ts +91 -0
  88. package/src/ui.detached-panel.test.ts +1 -1
  89. package/src/ui.docked.test.ts +7 -4
  90. package/src/ui.launcher-teaser.test.ts +162 -0
  91. package/src/ui.rebuild-composer.test.ts +491 -0
  92. package/src/ui.render-welcome.test.ts +505 -0
  93. package/src/ui.scroll-additive.test.ts +91 -21
  94. package/src/ui.scroll.test.ts +302 -14
  95. package/src/ui.suggest-replies.test.ts +1112 -1
  96. package/src/ui.ts +1008 -149
  97. package/src/ui.welcome.test.ts +504 -0
  98. package/src/utils/artifact-file.test.ts +1 -1
  99. package/src/utils/artifact-gate.test.ts +27 -0
  100. package/src/utils/artifact-gate.ts +9 -0
  101. package/src/utils/attachment-manager.test.ts +37 -0
  102. package/src/utils/attachment-manager.ts +15 -0
  103. package/src/utils/auto-follow.test.ts +23 -0
  104. package/src/utils/auto-follow.ts +11 -1
  105. package/src/utils/code-generators.test.ts +40 -0
  106. package/src/utils/code-generators.ts +22 -0
  107. package/src/utils/config-merge.test.ts +24 -0
  108. package/src/utils/config-merge.ts +2 -0
  109. package/src/utils/content-width.test.ts +37 -0
  110. package/src/utils/content-width.ts +17 -0
  111. package/src/utils/icons.test.ts +26 -0
  112. package/src/utils/icons.ts +8 -0
  113. package/src/utils/plugin-storage.test.ts +94 -0
  114. package/src/utils/plugin-storage.ts +98 -0
  115. package/src/utils/theme.test.ts +216 -0
  116. package/src/utils/tokens.ts +113 -3
  117. package/src/welcome.test.ts +213 -0
  118. package/src/welcome.ts +130 -0
  119. package/dist/chunk-MMUPR2JW.js +0 -1
@@ -1,11 +1,37 @@
1
- import { createElement } from "../utils/dom";
1
+ import { createElement, createNode } from "../utils/dom";
2
+ import { renderLucideIcon } from "../utils/icons";
2
3
  import { AgentWidgetSession } from "../session";
3
- import { AgentWidgetMessage, AgentWidgetSuggestionChipsConfig } from "../types";
4
+ import {
5
+ AgentWidgetConfig,
6
+ AgentWidgetMessage,
7
+ AgentWidgetResolvedSuggestion,
8
+ AgentWidgetSuggestion,
9
+ AgentWidgetSuggestionChipsConfig,
10
+ AgentWidgetSuggestionBehavior,
11
+ AgentWidgetSuggestionSource,
12
+ AgentWidgetSuggestionSurface,
13
+ AgentWidgetSuggestionVariant,
14
+ } from "../types";
15
+ import type { AgentWidgetPlugin } from "../plugins/types";
16
+
17
+ export type NormalizedSuggestion = Omit<
18
+ AgentWidgetResolvedSuggestion,
19
+ "behavior"
20
+ > & {
21
+ behavior?: AgentWidgetSuggestionBehavior;
22
+ };
23
+
24
+ /** DOM attribute values stay kebab-case; TS surface values stay camelCase. */
25
+ export const suggestionSurfaceAttr = (
26
+ surface: AgentWidgetSuggestionSurface
27
+ ): string => (surface === "followUp" ? "follow-up" : surface);
4
28
 
5
29
  export interface SuggestionButtons {
6
30
  buttons: HTMLButtonElement[];
31
+ elements: HTMLElement[];
32
+ destroy: () => void;
7
33
  render: (
8
- chips: string[] | undefined,
34
+ items: AgentWidgetSuggestion[] | undefined,
9
35
  session: AgentWidgetSession,
10
36
  textarea: HTMLTextAreaElement,
11
37
  messages?: AgentWidgetMessage[],
@@ -15,113 +41,523 @@ export interface SuggestionButtons {
15
41
  }
16
42
 
17
43
  export interface SuggestionRenderOptions {
44
+ /** Whether these are welcome starters or agent-produced follow-ups. */
45
+ surface?: AgentWidgetSuggestionSurface;
46
+ /** Presentation density. */
47
+ variant?: AgentWidgetSuggestionVariant;
48
+ /** Default click behavior, overridable per item. */
49
+ behavior?: AgentWidgetSuggestionBehavior;
50
+ /** Chip overflow behavior. */
51
+ overflow?: "scroll" | "wrap";
52
+ /** Renderer cap. */
53
+ maxItems?: number;
18
54
  /**
19
- * Chips pushed by the agent's `suggest_replies` tool rather than the
20
- * static `suggestionChips` config. Skips the before-first-user-message
21
- * gate (the caller already applied the latest-turn visibility rule) and
22
- * dispatches `persona:suggestReplies:*` DOM events.
55
+ * Who produced these items. Defaults to `config`; `agent` and `host` mark
56
+ * the follow-up surface, which keeps the legacy `persona:suggestReplies:*`
57
+ * events and skips the starters' before-first-user-message gate.
23
58
  */
24
- agentPushed?: boolean;
59
+ source?: AgentWidgetSuggestionSource;
60
+ /** Live widget config exposed to plugin hooks. */
61
+ config?: AgentWidgetConfig;
62
+ /** Priority-sorted plugins active for this widget instance. */
63
+ plugins?: readonly AgentWidgetPlugin[];
64
+ }
65
+
66
+ export const normalizeSuggestion = (
67
+ item: AgentWidgetSuggestion,
68
+ index = 0
69
+ ): NormalizedSuggestion | null => {
70
+ if (typeof item === "string") {
71
+ const value = item.trim();
72
+ if (!value) return null;
73
+ return {
74
+ id: value,
75
+ label: value,
76
+ prompt: value,
77
+ emphasis: "default",
78
+ };
79
+ }
80
+
81
+ const label = item.label.trim();
82
+ if (!label) return null;
83
+ const prompt = item.prompt?.trim() || label;
84
+ return {
85
+ id: item.id?.trim() || prompt || `suggestion-${index}`,
86
+ label,
87
+ prompt,
88
+ description: item.description?.trim() || undefined,
89
+ icon: item.icon,
90
+ iconColor: item.iconColor?.trim() || undefined,
91
+ behavior: item.behavior,
92
+ emphasis: item.emphasis ?? "default",
93
+ };
94
+ };
95
+
96
+ const fontFamilyValue = (
97
+ family: "sans-serif" | "serif" | "mono"
98
+ ): string => {
99
+ switch (family) {
100
+ case "serif":
101
+ return 'Georgia, "Times New Roman", Times, serif';
102
+ case "mono":
103
+ return '"Courier New", Courier, "Lucida Console", Monaco, monospace';
104
+ default:
105
+ return '-apple-system, BlinkMacSystemFont, "Segoe UI", "Helvetica Neue", Arial, sans-serif';
106
+ }
107
+ };
108
+
109
+ // Module-level, keyed on the widget config: one widget owns several suggestion
110
+ // managers, so a per-instance flag hints once per surface instead of once.
111
+ const hintedOverflowVariant = new WeakSet<AgentWidgetConfig>();
112
+
113
+ const prefersReducedMotion = (): boolean =>
114
+ typeof window !== "undefined" &&
115
+ typeof window.matchMedia === "function" &&
116
+ window.matchMedia("(prefers-reduced-motion: reduce)").matches;
117
+
118
+ /**
119
+ * Staggered fade-up for a newly shown set. WAAPI, not CSS transitions: morph
120
+ * re-renders strip post-render CSS state, and a running animation survives.
121
+ */
122
+ const animateEntrance = (elements: readonly HTMLElement[]): void => {
123
+ if (prefersReducedMotion()) return;
124
+ elements.forEach((element, index) => {
125
+ if (typeof element.animate !== "function") return;
126
+ element.animate(
127
+ [
128
+ { opacity: "0", transform: "translateY(4px)" },
129
+ { opacity: "1", transform: "none" },
130
+ ],
131
+ {
132
+ duration: 250,
133
+ delay: index * 60,
134
+ easing: "ease-out",
135
+ fill: "backwards",
136
+ }
137
+ );
138
+ });
139
+ };
140
+
141
+ export interface SuggestionElementContext {
142
+ /** Fully normalized item, including the effective send/fill behavior. */
143
+ item: AgentWidgetResolvedSuggestion;
144
+ index: number;
145
+ surface: AgentWidgetSuggestionSurface;
146
+ source: AgentWidgetSuggestionSource;
147
+ variant: AgentWidgetSuggestionVariant;
148
+ streaming: boolean;
149
+ config: AgentWidgetConfig;
150
+ plugins: readonly AgentWidgetPlugin[];
151
+ chipsConfig?: AgentWidgetSuggestionChipsConfig;
152
+ session: AgentWidgetSession;
153
+ /** Read live: inline mentions and composer rebuilds swap the input element. */
154
+ getTextarea: () => HTMLTextAreaElement;
155
+ /** Node the cancelable `persona:suggestion:*` events dispatch from. */
156
+ eventTarget: HTMLElement;
25
157
  }
26
158
 
159
+ /**
160
+ * Build one suggestion element with the full select pipeline attached:
161
+ * cancelable DOM events, `onSuggestionSelect` hooks, then send or fill. The
162
+ * suggestion rows and the `renderWelcome` ctx's `renderStarter` share this, so
163
+ * a plugin-rendered starter behaves identically to a built-in one.
164
+ */
165
+ export const createSuggestionElement = (
166
+ context: SuggestionElementContext
167
+ ): HTMLElement => {
168
+ const {
169
+ item,
170
+ index,
171
+ surface,
172
+ source,
173
+ variant,
174
+ streaming,
175
+ config,
176
+ plugins,
177
+ chipsConfig,
178
+ session,
179
+ getTextarea,
180
+ eventTarget,
181
+ } = context;
182
+ const isFollowUp = surface === "followUp";
183
+ const itemBehavior = item.behavior;
184
+
185
+ const select = () => {
186
+ if (session.isStreaming()) return;
187
+ const detail = {
188
+ suggestion: { ...item },
189
+ surface,
190
+ source,
191
+ behavior: itemBehavior,
192
+ };
193
+ const shouldContinue = eventTarget.dispatchEvent(
194
+ new CustomEvent("persona:suggestion:selected", {
195
+ detail,
196
+ bubbles: true,
197
+ composed: true,
198
+ cancelable: true,
199
+ })
200
+ );
201
+ if (isFollowUp) {
202
+ eventTarget.dispatchEvent(
203
+ new CustomEvent("persona:suggestReplies:selected", {
204
+ detail: { suggestion: item.prompt },
205
+ bubbles: true,
206
+ composed: true,
207
+ })
208
+ );
209
+ }
210
+ if (!shouldContinue) return;
211
+
212
+ for (const plugin of plugins) {
213
+ if (
214
+ plugin.onSuggestionSelect?.({
215
+ suggestion: { ...item },
216
+ surface,
217
+ source,
218
+ variant,
219
+ config,
220
+ }) === false
221
+ ) {
222
+ return;
223
+ }
224
+ }
225
+
226
+ const textarea = getTextarea();
227
+ if (itemBehavior === "fill") {
228
+ textarea.value = item.prompt;
229
+ textarea.dispatchEvent(new Event("input", { bubbles: true, composed: true }));
230
+ textarea.focus();
231
+ return;
232
+ }
233
+
234
+ textarea.value = "";
235
+ session.sendMessage(item.prompt);
236
+ };
237
+
238
+ const defaultRenderer = (): HTMLElement => {
239
+ const button = createElement(
240
+ "button",
241
+ `persona-suggestion persona-suggestion--${variant}`
242
+ ) as HTMLButtonElement;
243
+ button.type = "button";
244
+ button.disabled = streaming;
245
+ button.dataset.suggestionId = item.id;
246
+ button.dataset.emphasis = item.emphasis;
247
+ button.dataset.behavior = itemBehavior;
248
+
249
+ // Legacy `suggestionChipsConfig` compat is chip-only: its inline
250
+ // styles would otherwise override the card/list padding tokens.
251
+ if (variant === "chip") {
252
+ if (chipsConfig?.fontFamily) {
253
+ button.style.fontFamily = fontFamilyValue(chipsConfig.fontFamily);
254
+ }
255
+ if (chipsConfig?.fontWeight) {
256
+ button.style.fontWeight = chipsConfig.fontWeight;
257
+ }
258
+ if (chipsConfig?.paddingX) {
259
+ button.style.paddingLeft = chipsConfig.paddingX;
260
+ button.style.paddingRight = chipsConfig.paddingX;
261
+ }
262
+ if (chipsConfig?.paddingY) {
263
+ button.style.paddingTop = chipsConfig.paddingY;
264
+ button.style.paddingBottom = chipsConfig.paddingY;
265
+ }
266
+ }
267
+
268
+ if (item.icon) {
269
+ const icon = renderLucideIcon(
270
+ item.icon,
271
+ "var(--persona-suggestion-icon-size)",
272
+ "currentColor",
273
+ 1.8
274
+ );
275
+ if (icon) {
276
+ icon.classList.add("persona-suggestion__icon");
277
+ // The glyph strokes in currentColor, so this tints it alone; inline
278
+ // also beats the emphasis-primary icon rule.
279
+ if (item.iconColor) icon.style.color = item.iconColor;
280
+ button.appendChild(icon);
281
+ }
282
+ }
283
+
284
+ const copy = createElement("span", "persona-suggestion__copy");
285
+ copy.appendChild(
286
+ createNode("span", {
287
+ className: "persona-suggestion__label",
288
+ text: item.label,
289
+ })
290
+ );
291
+ // Chips are single-line labels; descriptions belong to card and list.
292
+ if (item.description && variant !== "chip") {
293
+ copy.appendChild(
294
+ createNode("span", {
295
+ className: "persona-suggestion__description",
296
+ text: item.description,
297
+ })
298
+ );
299
+ }
300
+ button.appendChild(copy);
301
+
302
+ if (variant !== "chip") {
303
+ const arrow = renderLucideIcon("arrow-right", 16, "currentColor", 1.8);
304
+ if (arrow) {
305
+ arrow.classList.add("persona-suggestion__arrow");
306
+ button.appendChild(arrow);
307
+ }
308
+ }
309
+
310
+ button.addEventListener("click", select);
311
+ return button;
312
+ };
313
+
314
+ let element: HTMLElement | null = null;
315
+ for (const plugin of plugins) {
316
+ if (!plugin.renderSuggestion) continue;
317
+ element = plugin.renderSuggestion({
318
+ suggestion: { ...item },
319
+ index,
320
+ surface,
321
+ source,
322
+ variant,
323
+ streaming,
324
+ config,
325
+ defaultRenderer,
326
+ select,
327
+ });
328
+ if (element) break;
329
+ }
330
+ element ??= defaultRenderer();
331
+ element.dataset.suggestionId ||= item.id;
332
+ element.dataset.behavior ||= itemBehavior;
333
+ element.dataset.emphasis ||= item.emphasis;
334
+ if (!(element instanceof HTMLButtonElement)) {
335
+ element.setAttribute("aria-disabled", streaming ? "true" : "false");
336
+ }
337
+ return element;
338
+ };
339
+
27
340
  export const createSuggestions = (container: HTMLElement): SuggestionButtons => {
28
341
  const suggestionButtons: HTMLButtonElement[] = [];
29
- // render() runs on every message change; only announce agent-pushed chips
30
- // when the visible set actually changes, not on each re-render pass.
31
- let lastAgentShownKey: string | null = null;
342
+ const suggestionElements: HTMLElement[] = [];
343
+ let lastShownKey: string | null = null;
344
+ let overflowFrame: number | null = null;
345
+
346
+ const clearOverflowAffordance = () => {
347
+ container.removeAttribute("data-scroll-left");
348
+ container.removeAttribute("data-scroll-right");
349
+ };
350
+
351
+ const syncOverflowAffordance = () => {
352
+ overflowFrame = null;
353
+ if (
354
+ container.hidden ||
355
+ container.dataset.variant !== "chip" ||
356
+ container.dataset.overflow !== "scroll"
357
+ ) {
358
+ clearOverflowAffordance();
359
+ return;
360
+ }
361
+
362
+ const maxScroll = Math.max(0, container.scrollWidth - container.clientWidth);
363
+ if (maxScroll <= 1) {
364
+ clearOverflowAffordance();
365
+ return;
366
+ }
367
+
368
+ const isRtl = getComputedStyle(container).direction === "rtl";
369
+ const position = Math.min(
370
+ maxScroll,
371
+ Math.max(0, Math.abs(container.scrollLeft))
372
+ );
373
+ // Scroll snap and the scroller's 2px visual padding can settle a browser
374
+ // just off either boundary. Treat that subpixel-sized drift as the edge so
375
+ // a misleading opposite fade does not appear before the user scrolls.
376
+ const edgeTolerance = 4;
377
+ const atStart = position <= edgeTolerance;
378
+ const atEnd = position >= maxScroll - edgeTolerance;
379
+ const hasLeftOverflow = isRtl ? !atEnd : !atStart;
380
+ const hasRightOverflow = isRtl ? !atStart : !atEnd;
381
+
382
+ container.toggleAttribute("data-scroll-left", hasLeftOverflow);
383
+ container.toggleAttribute("data-scroll-right", hasRightOverflow);
384
+ };
385
+
386
+ const scheduleOverflowAffordance = () => {
387
+ if (overflowFrame !== null || typeof requestAnimationFrame !== "function") {
388
+ if (typeof requestAnimationFrame !== "function") {
389
+ syncOverflowAffordance();
390
+ }
391
+ return;
392
+ }
393
+ overflowFrame = requestAnimationFrame(syncOverflowAffordance);
394
+ };
395
+
396
+ container.addEventListener("scroll", scheduleOverflowAffordance, {
397
+ passive: true,
398
+ });
399
+ const resizeObserver =
400
+ typeof ResizeObserver === "function"
401
+ ? new ResizeObserver(scheduleOverflowAffordance)
402
+ : null;
403
+ resizeObserver?.observe(container);
32
404
 
33
405
  const render = (
34
- chips: string[] | undefined,
406
+ items: AgentWidgetSuggestion[] | undefined,
35
407
  session: AgentWidgetSession,
36
408
  textarea: HTMLTextAreaElement,
37
409
  messages?: AgentWidgetMessage[],
38
410
  chipsConfig?: AgentWidgetSuggestionChipsConfig,
39
411
  opts?: SuggestionRenderOptions
40
412
  ) => {
41
- container.innerHTML = "";
413
+ container.replaceChildren();
42
414
  suggestionButtons.length = 0;
43
- const agentPushed = opts?.agentPushed === true;
44
- if (!agentPushed) lastAgentShownKey = null;
45
- if (!chips || !chips.length) return;
46
-
47
- // Hide config suggestions after the first user message is sent.
48
- // Agent-pushed chips skip this gate: their visibility is the caller's
49
- // latest-turn rule (last suggest_replies call with no user message after).
50
- // Use provided messages or get from session
51
- if (!agentPushed) {
52
- const messagesToCheck = messages ?? (session ? session.getMessages() : []);
53
- const hasUserMessage = messagesToCheck.some((msg) => msg.role === "user");
54
- if (hasUserMessage) return;
415
+ suggestionElements.length = 0;
416
+
417
+ const source: AgentWidgetSuggestionSource = opts?.source ?? "config";
418
+ const surface =
419
+ opts?.surface ?? (source === "config" ? "starter" : "followUp");
420
+ // Follow-ups render mid-conversation and keep the legacy event names.
421
+ const isFollowUp = surface === "followUp";
422
+ const variant = opts?.variant ?? "chip";
423
+ const behavior = opts?.behavior ?? "send";
424
+ const overflow = opts?.overflow ?? "wrap";
425
+ const widgetConfig = opts?.config ?? ({} as AgentWidgetConfig);
426
+ const plugins = opts?.plugins ?? [];
427
+ const maxItems =
428
+ typeof opts?.maxItems === "number"
429
+ ? Math.max(0, Math.floor(opts.maxItems))
430
+ : undefined;
431
+
432
+ // `data-overflow` is styled under the chip variant only; hint once so the
433
+ // no-op is discoverable without reading the CSS.
434
+ const surfaceKey = isFollowUp ? "followUps" : "starters";
435
+ if (
436
+ !hintedOverflowVariant.has(widgetConfig) &&
437
+ widgetConfig.debug === true &&
438
+ variant !== "chip" &&
439
+ widgetConfig.suggestions?.[surfaceKey]?.overflow !== undefined
440
+ ) {
441
+ hintedOverflowVariant.add(widgetConfig);
442
+ console.info(
443
+ `[persona] suggestions.${surfaceKey}.overflow applies to the "chip" variant only; the "${variant}" variant manages its own stacking.`
444
+ );
55
445
  }
56
446
 
57
- const fragment = document.createDocumentFragment();
58
- const streaming = session ? session.isStreaming() : false;
59
-
60
- // Get font family mapping function
61
- const getFontFamilyValue = (family: "sans-serif" | "serif" | "mono"): string => {
62
- switch (family) {
63
- case "serif":
64
- return 'Georgia, "Times New Roman", Times, serif';
65
- case "mono":
66
- return '"Courier New", Courier, "Lucida Console", Monaco, monospace';
67
- case "sans-serif":
68
- default:
69
- return '-apple-system, BlinkMacSystemFont, "Segoe UI", "Helvetica Neue", Arial, sans-serif';
70
- }
71
- };
447
+ // Normalize before transform: hooks see resolved items and may return the
448
+ // loose shape, so re-resolution only fills a missing per-item behavior.
449
+ const resolveAll = (
450
+ list: AgentWidgetSuggestion[]
451
+ ): AgentWidgetResolvedSuggestion[] =>
452
+ list
453
+ .map(normalizeSuggestion)
454
+ .filter((item): item is NormalizedSuggestion => item !== null)
455
+ .map((item) => ({ ...item, behavior: item.behavior ?? behavior }));
72
456
 
73
- chips.forEach((chip) => {
74
- const btn = createElement(
75
- "button",
76
- "persona-rounded-button persona-bg-persona-surface persona-px-3 persona-py-1.5 persona-text-xs persona-font-medium persona-text-persona-primary hover:persona-opacity-80 persona-cursor-pointer persona-border persona-border-persona-border"
77
- ) as HTMLButtonElement;
78
- btn.type = "button";
79
- btn.textContent = chip;
80
- btn.disabled = streaming;
457
+ let resolved = resolveAll(items ?? []);
458
+ plugins.forEach((plugin) => {
459
+ if (!plugin.transformSuggestions) return;
460
+ resolved = resolveAll(
461
+ plugin.transformSuggestions({
462
+ suggestions: resolved.map((item) => ({ ...item })),
463
+ surface,
464
+ source,
465
+ config: widgetConfig,
466
+ })
467
+ );
468
+ });
81
469
 
82
- // Apply typography settings
83
- if (chipsConfig?.fontFamily) {
84
- btn.style.fontFamily = getFontFamilyValue(chipsConfig.fontFamily);
85
- }
86
- if (chipsConfig?.fontWeight) {
87
- btn.style.fontWeight = chipsConfig.fontWeight;
88
- }
470
+ // The cap applies once, after the full transform chain.
471
+ const normalized =
472
+ maxItems === undefined ? resolved : resolved.slice(0, maxItems);
89
473
 
90
- // Apply padding settings
91
- if (chipsConfig?.paddingX) {
92
- btn.style.paddingLeft = chipsConfig.paddingX;
93
- btn.style.paddingRight = chipsConfig.paddingX;
94
- }
95
- if (chipsConfig?.paddingY) {
96
- btn.style.paddingTop = chipsConfig.paddingY;
97
- btn.style.paddingBottom = chipsConfig.paddingY;
474
+ if (!normalized.length) {
475
+ container.hidden = true;
476
+ lastShownKey = null;
477
+ clearOverflowAffordance();
478
+ return;
479
+ }
480
+
481
+ if (!isFollowUp) {
482
+ const messagesToCheck = messages ?? session.getMessages();
483
+ if (messagesToCheck.some((message) => message.role === "user")) {
484
+ container.hidden = true;
485
+ lastShownKey = null;
486
+ return;
98
487
  }
488
+ }
99
489
 
100
- btn.addEventListener("click", () => {
101
- if (!session || session.isStreaming()) return;
102
- textarea.value = "";
103
- if (agentPushed) {
104
- container.dispatchEvent(
105
- new CustomEvent("persona:suggestReplies:selected", {
106
- detail: { suggestion: chip },
107
- bubbles: true,
108
- composed: true,
109
- })
110
- );
490
+ container.hidden = false;
491
+ container.classList.add("persona-suggestions");
492
+ container.dataset.personaSuggestionSurface = suggestionSurfaceAttr(surface);
493
+ container.dataset.variant = variant;
494
+ container.dataset.overflow = overflow;
495
+
496
+ const fragment = document.createDocumentFragment();
497
+ const streaming = session.isStreaming();
498
+
499
+ normalized.forEach((item, index) => {
500
+ const element = createSuggestionElement({
501
+ item,
502
+ index,
503
+ surface,
504
+ source,
505
+ variant,
506
+ streaming,
507
+ config: widgetConfig,
508
+ plugins,
509
+ chipsConfig,
510
+ session,
511
+ getTextarea: () => textarea,
512
+ eventTarget: container,
513
+ });
514
+
515
+ fragment.appendChild(element);
516
+ suggestionElements.push(element);
517
+ const buttons = [
518
+ ...(element instanceof HTMLButtonElement ? [element] : []),
519
+ ...Array.from(element.querySelectorAll<HTMLButtonElement>("button")),
520
+ ];
521
+ buttons.forEach((button) => {
522
+ if (!suggestionButtons.includes(button)) {
523
+ button.disabled = streaming;
524
+ suggestionButtons.push(button);
111
525
  }
112
- session.sendMessage(chip);
113
526
  });
114
- fragment.appendChild(btn);
115
- suggestionButtons.push(btn);
116
527
  });
117
528
  container.appendChild(fragment);
118
- if (agentPushed) {
119
- const shownKey = JSON.stringify(chips);
120
- if (shownKey !== lastAgentShownKey) {
121
- lastAgentShownKey = shownKey;
529
+ syncOverflowAffordance();
530
+ scheduleOverflowAffordance();
531
+
532
+ const shownKey = JSON.stringify({
533
+ normalized,
534
+ surface,
535
+ variant,
536
+ source,
537
+ });
538
+ if (shownKey !== lastShownKey) {
539
+ lastShownKey = shownKey;
540
+ // Guarded by the same key as the shown event: render() re-runs on every
541
+ // UI update with identical items and must not re-animate.
542
+ animateEntrance(suggestionElements);
543
+ container.dispatchEvent(
544
+ new CustomEvent("persona:suggestion:shown", {
545
+ detail: {
546
+ suggestions: normalized.map((item) => ({ ...item })),
547
+ surface,
548
+ source,
549
+ variant,
550
+ },
551
+ bubbles: true,
552
+ composed: true,
553
+ })
554
+ );
555
+ if (isFollowUp) {
122
556
  container.dispatchEvent(
123
557
  new CustomEvent("persona:suggestReplies:shown", {
124
- detail: { suggestions: [...chips] },
558
+ detail: {
559
+ suggestions: normalized.map((item) => item.prompt),
560
+ },
125
561
  bubbles: true,
126
562
  composed: true,
127
563
  })
@@ -132,9 +568,15 @@ export const createSuggestions = (container: HTMLElement): SuggestionButtons =>
132
568
 
133
569
  return {
134
570
  buttons: suggestionButtons,
135
- render
571
+ elements: suggestionElements,
572
+ destroy: () => {
573
+ container.removeEventListener("scroll", scheduleOverflowAffordance);
574
+ resizeObserver?.disconnect();
575
+ if (overflowFrame !== null && typeof cancelAnimationFrame === "function") {
576
+ cancelAnimationFrame(overflowFrame);
577
+ }
578
+ overflowFrame = null;
579
+ },
580
+ render,
136
581
  };
137
582
  };
138
-
139
-
140
-