@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
@@ -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,463 @@ 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[];
25
64
  }
26
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
+
27
141
  export const createSuggestions = (container: HTMLElement): SuggestionButtons => {
28
142
  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;
143
+ const suggestionElements: HTMLElement[] = [];
144
+ let lastShownKey: string | null = null;
145
+ let overflowFrame: number | null = null;
146
+
147
+ const clearOverflowAffordance = () => {
148
+ container.removeAttribute("data-scroll-left");
149
+ container.removeAttribute("data-scroll-right");
150
+ };
151
+
152
+ const syncOverflowAffordance = () => {
153
+ overflowFrame = null;
154
+ if (
155
+ container.hidden ||
156
+ container.dataset.variant !== "chip" ||
157
+ container.dataset.overflow !== "scroll"
158
+ ) {
159
+ clearOverflowAffordance();
160
+ return;
161
+ }
162
+
163
+ const maxScroll = Math.max(0, container.scrollWidth - container.clientWidth);
164
+ if (maxScroll <= 1) {
165
+ clearOverflowAffordance();
166
+ return;
167
+ }
168
+
169
+ const isRtl = getComputedStyle(container).direction === "rtl";
170
+ const position = Math.min(
171
+ maxScroll,
172
+ Math.max(0, Math.abs(container.scrollLeft))
173
+ );
174
+ // Scroll snap and the scroller's 2px visual padding can settle a browser
175
+ // just off either boundary. Treat that subpixel-sized drift as the edge so
176
+ // a misleading opposite fade does not appear before the user scrolls.
177
+ const edgeTolerance = 4;
178
+ const atStart = position <= edgeTolerance;
179
+ const atEnd = position >= maxScroll - edgeTolerance;
180
+ const hasLeftOverflow = isRtl ? !atEnd : !atStart;
181
+ const hasRightOverflow = isRtl ? !atStart : !atEnd;
182
+
183
+ container.toggleAttribute("data-scroll-left", hasLeftOverflow);
184
+ container.toggleAttribute("data-scroll-right", hasRightOverflow);
185
+ };
186
+
187
+ const scheduleOverflowAffordance = () => {
188
+ if (overflowFrame !== null || typeof requestAnimationFrame !== "function") {
189
+ if (typeof requestAnimationFrame !== "function") {
190
+ syncOverflowAffordance();
191
+ }
192
+ return;
193
+ }
194
+ overflowFrame = requestAnimationFrame(syncOverflowAffordance);
195
+ };
196
+
197
+ container.addEventListener("scroll", scheduleOverflowAffordance, {
198
+ passive: true,
199
+ });
200
+ const resizeObserver =
201
+ typeof ResizeObserver === "function"
202
+ ? new ResizeObserver(scheduleOverflowAffordance)
203
+ : null;
204
+ resizeObserver?.observe(container);
32
205
 
33
206
  const render = (
34
- chips: string[] | undefined,
207
+ items: AgentWidgetSuggestion[] | undefined,
35
208
  session: AgentWidgetSession,
36
209
  textarea: HTMLTextAreaElement,
37
210
  messages?: AgentWidgetMessage[],
38
211
  chipsConfig?: AgentWidgetSuggestionChipsConfig,
39
212
  opts?: SuggestionRenderOptions
40
213
  ) => {
41
- container.innerHTML = "";
214
+ container.replaceChildren();
42
215
  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;
216
+ suggestionElements.length = 0;
217
+
218
+ const source: AgentWidgetSuggestionSource = opts?.source ?? "config";
219
+ const surface =
220
+ opts?.surface ?? (source === "config" ? "starter" : "followUp");
221
+ // Follow-ups render mid-conversation and keep the legacy event names.
222
+ const isFollowUp = surface === "followUp";
223
+ const variant = opts?.variant ?? "chip";
224
+ const behavior = opts?.behavior ?? "send";
225
+ const overflow = opts?.overflow ?? "wrap";
226
+ const widgetConfig = opts?.config ?? ({} as AgentWidgetConfig);
227
+ const plugins = opts?.plugins ?? [];
228
+ const maxItems =
229
+ typeof opts?.maxItems === "number"
230
+ ? Math.max(0, Math.floor(opts.maxItems))
231
+ : undefined;
232
+
233
+ // `data-overflow` is styled under the chip variant only; hint once so the
234
+ // no-op is discoverable without reading the CSS.
235
+ const surfaceKey = isFollowUp ? "followUps" : "starters";
236
+ if (
237
+ !hintedOverflowVariant.has(widgetConfig) &&
238
+ widgetConfig.debug === true &&
239
+ variant !== "chip" &&
240
+ widgetConfig.suggestions?.[surfaceKey]?.overflow !== undefined
241
+ ) {
242
+ hintedOverflowVariant.add(widgetConfig);
243
+ console.info(
244
+ `[persona] suggestions.${surfaceKey}.overflow applies to the "chip" variant only; the "${variant}" variant manages its own stacking.`
245
+ );
55
246
  }
56
247
 
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
- };
248
+ // Normalize before transform: hooks see resolved items and may return the
249
+ // loose shape, so re-resolution only fills a missing per-item behavior.
250
+ const resolveAll = (
251
+ list: AgentWidgetSuggestion[]
252
+ ): AgentWidgetResolvedSuggestion[] =>
253
+ list
254
+ .map(normalizeSuggestion)
255
+ .filter((item): item is NormalizedSuggestion => item !== null)
256
+ .map((item) => ({ ...item, behavior: item.behavior ?? behavior }));
72
257
 
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;
81
-
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
- }
258
+ let resolved = resolveAll(items ?? []);
259
+ plugins.forEach((plugin) => {
260
+ if (!plugin.transformSuggestions) return;
261
+ resolved = resolveAll(
262
+ plugin.transformSuggestions({
263
+ suggestions: resolved.map((item) => ({ ...item })),
264
+ surface,
265
+ source,
266
+ config: widgetConfig,
267
+ })
268
+ );
269
+ });
89
270
 
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;
271
+ // The cap applies once, after the full transform chain.
272
+ const normalized =
273
+ maxItems === undefined ? resolved : resolved.slice(0, maxItems);
274
+
275
+ if (!normalized.length) {
276
+ container.hidden = true;
277
+ lastShownKey = null;
278
+ clearOverflowAffordance();
279
+ return;
280
+ }
281
+
282
+ if (!isFollowUp) {
283
+ const messagesToCheck = messages ?? session.getMessages();
284
+ if (messagesToCheck.some((message) => message.role === "user")) {
285
+ container.hidden = true;
286
+ lastShownKey = null;
287
+ return;
98
288
  }
289
+ }
99
290
 
100
- btn.addEventListener("click", () => {
101
- if (!session || session.isStreaming()) return;
102
- textarea.value = "";
103
- if (agentPushed) {
291
+ container.hidden = false;
292
+ container.classList.add("persona-suggestions");
293
+ container.dataset.personaSuggestionSurface = suggestionSurfaceAttr(surface);
294
+ container.dataset.variant = variant;
295
+ container.dataset.overflow = overflow;
296
+
297
+ const fragment = document.createDocumentFragment();
298
+ const streaming = session.isStreaming();
299
+
300
+ normalized.forEach((item, index) => {
301
+ const itemBehavior = item.behavior;
302
+ const select = () => {
303
+ if (session.isStreaming()) return;
304
+ const detail = {
305
+ suggestion: { ...item },
306
+ surface,
307
+ source,
308
+ behavior: itemBehavior,
309
+ };
310
+ const shouldContinue = container.dispatchEvent(
311
+ new CustomEvent("persona:suggestion:selected", {
312
+ detail,
313
+ bubbles: true,
314
+ composed: true,
315
+ cancelable: true,
316
+ })
317
+ );
318
+ if (isFollowUp) {
104
319
  container.dispatchEvent(
105
320
  new CustomEvent("persona:suggestReplies:selected", {
106
- detail: { suggestion: chip },
321
+ detail: { suggestion: item.prompt },
107
322
  bubbles: true,
108
323
  composed: true,
109
324
  })
110
325
  );
111
326
  }
112
- session.sendMessage(chip);
327
+ if (!shouldContinue) return;
328
+
329
+ for (const plugin of plugins) {
330
+ if (
331
+ plugin.onSuggestionSelect?.({
332
+ suggestion: { ...item },
333
+ surface,
334
+ source,
335
+ variant,
336
+ config: widgetConfig,
337
+ }) === false
338
+ ) {
339
+ return;
340
+ }
341
+ }
342
+
343
+ if (itemBehavior === "fill") {
344
+ textarea.value = item.prompt;
345
+ textarea.dispatchEvent(
346
+ new Event("input", { bubbles: true, composed: true })
347
+ );
348
+ textarea.focus();
349
+ return;
350
+ }
351
+
352
+ textarea.value = "";
353
+ session.sendMessage(item.prompt);
354
+ };
355
+
356
+ const defaultRenderer = (): HTMLElement => {
357
+ const button = createElement(
358
+ "button",
359
+ `persona-suggestion persona-suggestion--${variant}`
360
+ ) as HTMLButtonElement;
361
+ button.type = "button";
362
+ button.disabled = streaming;
363
+ button.dataset.suggestionId = item.id;
364
+ button.dataset.emphasis = item.emphasis;
365
+ button.dataset.behavior = itemBehavior;
366
+
367
+ // Legacy `suggestionChipsConfig` compat is chip-only: its inline
368
+ // styles would otherwise override the card/list padding tokens.
369
+ if (variant === "chip") {
370
+ if (chipsConfig?.fontFamily) {
371
+ button.style.fontFamily = fontFamilyValue(chipsConfig.fontFamily);
372
+ }
373
+ if (chipsConfig?.fontWeight) {
374
+ button.style.fontWeight = chipsConfig.fontWeight;
375
+ }
376
+ if (chipsConfig?.paddingX) {
377
+ button.style.paddingLeft = chipsConfig.paddingX;
378
+ button.style.paddingRight = chipsConfig.paddingX;
379
+ }
380
+ if (chipsConfig?.paddingY) {
381
+ button.style.paddingTop = chipsConfig.paddingY;
382
+ button.style.paddingBottom = chipsConfig.paddingY;
383
+ }
384
+ }
385
+
386
+ if (item.icon) {
387
+ const icon = renderLucideIcon(
388
+ item.icon,
389
+ "var(--persona-suggestion-icon-size)",
390
+ "currentColor",
391
+ 1.8
392
+ );
393
+ if (icon) {
394
+ icon.classList.add("persona-suggestion__icon");
395
+ // The glyph strokes in currentColor, so this tints it alone; inline
396
+ // also beats the emphasis-primary icon rule.
397
+ if (item.iconColor) icon.style.color = item.iconColor;
398
+ button.appendChild(icon);
399
+ }
400
+ }
401
+
402
+ const copy = createElement("span", "persona-suggestion__copy");
403
+ copy.appendChild(
404
+ createNode("span", {
405
+ className: "persona-suggestion__label",
406
+ text: item.label,
407
+ })
408
+ );
409
+ if (item.description) {
410
+ copy.appendChild(
411
+ createNode("span", {
412
+ className: "persona-suggestion__description",
413
+ text: item.description,
414
+ })
415
+ );
416
+ }
417
+ button.appendChild(copy);
418
+
419
+ if (variant !== "chip") {
420
+ const arrow = renderLucideIcon("arrow-right", 16, "currentColor", 1.8);
421
+ if (arrow) {
422
+ arrow.classList.add("persona-suggestion__arrow");
423
+ button.appendChild(arrow);
424
+ }
425
+ }
426
+
427
+ button.addEventListener("click", select);
428
+ return button;
429
+ };
430
+
431
+ let element: HTMLElement | null = null;
432
+ for (const plugin of plugins) {
433
+ if (!plugin.renderSuggestion) continue;
434
+ element = plugin.renderSuggestion({
435
+ suggestion: { ...item },
436
+ index,
437
+ surface,
438
+ source,
439
+ variant,
440
+ streaming,
441
+ config: widgetConfig,
442
+ defaultRenderer,
443
+ select,
444
+ });
445
+ if (element) break;
446
+ }
447
+ element ??= defaultRenderer();
448
+ element.dataset.suggestionId ||= item.id;
449
+ element.dataset.behavior ||= itemBehavior;
450
+ element.dataset.emphasis ||= item.emphasis;
451
+ if (!(element instanceof HTMLButtonElement)) {
452
+ element.setAttribute("aria-disabled", streaming ? "true" : "false");
453
+ }
454
+
455
+ fragment.appendChild(element);
456
+ suggestionElements.push(element);
457
+ const buttons = [
458
+ ...(element instanceof HTMLButtonElement ? [element] : []),
459
+ ...Array.from(element.querySelectorAll<HTMLButtonElement>("button")),
460
+ ];
461
+ buttons.forEach((button) => {
462
+ if (!suggestionButtons.includes(button)) {
463
+ button.disabled = streaming;
464
+ suggestionButtons.push(button);
465
+ }
113
466
  });
114
- fragment.appendChild(btn);
115
- suggestionButtons.push(btn);
116
467
  });
117
468
  container.appendChild(fragment);
118
- if (agentPushed) {
119
- const shownKey = JSON.stringify(chips);
120
- if (shownKey !== lastAgentShownKey) {
121
- lastAgentShownKey = shownKey;
469
+ syncOverflowAffordance();
470
+ scheduleOverflowAffordance();
471
+
472
+ const shownKey = JSON.stringify({
473
+ normalized,
474
+ surface,
475
+ variant,
476
+ source,
477
+ });
478
+ if (shownKey !== lastShownKey) {
479
+ lastShownKey = shownKey;
480
+ // Guarded by the same key as the shown event: render() re-runs on every
481
+ // UI update with identical items and must not re-animate.
482
+ animateEntrance(suggestionElements);
483
+ container.dispatchEvent(
484
+ new CustomEvent("persona:suggestion:shown", {
485
+ detail: {
486
+ suggestions: normalized.map((item) => ({ ...item })),
487
+ surface,
488
+ source,
489
+ variant,
490
+ },
491
+ bubbles: true,
492
+ composed: true,
493
+ })
494
+ );
495
+ if (isFollowUp) {
122
496
  container.dispatchEvent(
123
497
  new CustomEvent("persona:suggestReplies:shown", {
124
- detail: { suggestions: [...chips] },
498
+ detail: {
499
+ suggestions: normalized.map((item) => item.prompt),
500
+ },
125
501
  bubbles: true,
126
502
  composed: true,
127
503
  })
@@ -132,9 +508,15 @@ export const createSuggestions = (container: HTMLElement): SuggestionButtons =>
132
508
 
133
509
  return {
134
510
  buttons: suggestionButtons,
135
- render
511
+ elements: suggestionElements,
512
+ destroy: () => {
513
+ container.removeEventListener("scroll", scheduleOverflowAffordance);
514
+ resizeObserver?.disconnect();
515
+ if (overflowFrame !== null && typeof cancelAnimationFrame === "function") {
516
+ cancelAnimationFrame(overflowFrame);
517
+ }
518
+ overflowFrame = null;
519
+ },
520
+ render,
136
521
  };
137
522
  };
138
-
139
-
140
-
@@ -30,6 +30,8 @@ export interface WidgetTranscriptRefs {
30
30
  container: HTMLElement;
31
31
  body: HTMLElement;
32
32
  messagesWrapper: HTMLElement;
33
+ starterSuggestions: HTMLElement;
34
+ transcriptSuggestions: HTMLElement;
33
35
  /** Absolute slot above the composer where interactive sheets mount. */
34
36
  composerOverlay: HTMLElement;
35
37
  introTitle: HTMLElement;
@@ -119,6 +121,8 @@ export const createWidgetView = ({
119
121
  container: panelElements.container,
120
122
  body: panelElements.body,
121
123
  messagesWrapper: panelElements.messagesWrapper,
124
+ starterSuggestions: panelElements.starterSuggestions,
125
+ transcriptSuggestions: panelElements.transcriptSuggestions,
122
126
  composerOverlay: panelElements.composerOverlay,
123
127
  introTitle: panelElements.introTitle,
124
128
  introSubtitle: panelElements.introSubtitle,
package/src/defaults.ts CHANGED
@@ -169,10 +169,12 @@ export const DEFAULT_WIDGET_CONFIG: Partial<AgentWidgetConfig> = {
169
169
  submitLabel: "Send",
170
170
  },
171
171
  },
172
+ // Placeholder copy that models the right shape: verb-first, user-voice,
173
+ // one line. Integrators should swap in domain prompts.
172
174
  suggestionChips: [
173
- "What can you help me with?",
174
- "Tell me about your features",
175
- "How does this work?",
175
+ "Show me what you can help with",
176
+ "Walk me through getting started",
177
+ "Answer a question about this page",
176
178
  ],
177
179
  suggestionChipsConfig: {
178
180
  fontFamily: "sans-serif",
@@ -260,7 +260,24 @@ export type RuntypeExecutionStreamEvent = ({
260
260
  type: "source";
261
261
  url?: string;
262
262
  [key: string]: unknown;
263
+ } | {
264
+ executionId: string;
265
+ iteration?: number;
266
+ seq: number;
267
+ state: Record<string, unknown>;
268
+ type: "state_snapshot";
263
269
  } | ({
270
+ executionId: string;
271
+ iteration?: number;
272
+ patch: Array<{
273
+ from?: string;
274
+ op: "add" | "remove" | "replace" | "move" | "copy" | "test";
275
+ path: string;
276
+ value?: unknown;
277
+ }>;
278
+ seq: number;
279
+ type: "state_delta";
280
+ }) | ({
264
281
  executionId: string;
265
282
  hiddenParameterNames?: Array<string>;
266
283
  iteration?: number;