@runtypelabs/persona 4.15.0 → 4.17.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 (91) hide show
  1. package/dist/codegen.cjs +1 -1
  2. package/dist/codegen.js +1 -1
  3. package/dist/context-mentions.d.cts +283 -9
  4. package/dist/context-mentions.d.ts +283 -9
  5. package/dist/index.cjs +68 -68
  6. package/dist/index.cjs.map +1 -1
  7. package/dist/index.d.cts +299 -10
  8. package/dist/index.d.ts +299 -10
  9. package/dist/index.global.js +57 -57
  10. package/dist/index.global.js.map +1 -1
  11. package/dist/index.js +69 -69
  12. package/dist/index.js.map +1 -1
  13. package/dist/install.global.js +1 -1
  14. package/dist/install.global.js.map +1 -1
  15. package/dist/launcher.global.js +2 -2
  16. package/dist/launcher.global.js.map +1 -1
  17. package/dist/markdown-parsers.js +36 -36
  18. package/dist/smart-dom-reader.d.cts +283 -9
  19. package/dist/smart-dom-reader.d.ts +283 -9
  20. package/dist/testing.cjs +3 -3
  21. package/dist/testing.d.cts +7 -0
  22. package/dist/testing.d.ts +7 -0
  23. package/dist/testing.js +3 -3
  24. package/dist/theme-editor-preview.cjs +64 -64
  25. package/dist/theme-editor-preview.d.cts +283 -9
  26. package/dist/theme-editor-preview.d.ts +283 -9
  27. package/dist/theme-editor-preview.js +56 -56
  28. package/dist/theme-editor.cjs +10 -10
  29. package/dist/theme-editor.d.cts +283 -10
  30. package/dist/theme-editor.d.ts +283 -10
  31. package/dist/theme-editor.js +10 -10
  32. package/dist/theme-reference.cjs +1 -1
  33. package/dist/theme-reference.d.cts +66 -3
  34. package/dist/theme-reference.d.ts +66 -3
  35. package/dist/theme-reference.js +1 -1
  36. package/dist/widget.css +1 -1
  37. package/package.json +2 -2
  38. package/src/components/artifact-pane.test.ts +27 -0
  39. package/src/components/artifact-pane.ts +7 -3
  40. package/src/components/header-builder.ts +27 -2
  41. package/src/components/header-layouts.test.ts +56 -0
  42. package/src/components/header-layouts.ts +29 -9
  43. package/src/components/launcher-surface.test.ts +403 -0
  44. package/src/components/launcher.ts +297 -2
  45. package/src/components/panel.ts +69 -74
  46. package/src/components/suggestions.test.ts +40 -0
  47. package/src/components/suggestions.ts +213 -153
  48. package/src/components/welcome.test.ts +121 -0
  49. package/src/components/welcome.ts +305 -0
  50. package/src/components/widget-view.test.ts +5 -3
  51. package/src/components/widget-view.ts +14 -7
  52. package/src/defaults.ts +2 -2
  53. package/src/index-core.ts +12 -1
  54. package/src/install.test.ts +35 -1
  55. package/src/install.ts +47 -11
  56. package/src/launcher-global.ts +10 -7
  57. package/src/plugins/types.ts +66 -1
  58. package/src/styles/widget-styles.test.ts +220 -0
  59. package/src/styles/widget.css +353 -9
  60. package/src/testing/mock-stream.test.ts +19 -0
  61. package/src/testing/mock-stream.ts +20 -0
  62. package/src/theme-editor/sections.ts +30 -1
  63. package/src/theme-reference.ts +26 -4
  64. package/src/types/theme.ts +40 -3
  65. package/src/types.ts +164 -6
  66. package/src/ui.body-top-slot.test.ts +95 -0
  67. package/src/ui.content-width.test.ts +91 -0
  68. package/src/ui.docked.test.ts +7 -4
  69. package/src/ui.launcher-teaser.test.ts +162 -0
  70. package/src/ui.rebuild-composer.test.ts +491 -0
  71. package/src/ui.render-welcome.test.ts +505 -0
  72. package/src/ui.scroll-additive.test.ts +91 -21
  73. package/src/ui.scroll.test.ts +302 -14
  74. package/src/ui.suggest-replies.test.ts +46 -0
  75. package/src/ui.ts +747 -136
  76. package/src/ui.welcome.test.ts +504 -0
  77. package/src/utils/artifact-gate.test.ts +27 -0
  78. package/src/utils/artifact-gate.ts +9 -0
  79. package/src/utils/attachment-manager.test.ts +37 -0
  80. package/src/utils/attachment-manager.ts +15 -0
  81. package/src/utils/auto-follow.test.ts +23 -0
  82. package/src/utils/auto-follow.ts +11 -1
  83. package/src/utils/config-merge.ts +2 -0
  84. package/src/utils/content-width.test.ts +37 -0
  85. package/src/utils/content-width.ts +17 -0
  86. package/src/utils/plugin-storage.test.ts +94 -0
  87. package/src/utils/plugin-storage.ts +98 -0
  88. package/src/utils/theme.test.ts +137 -0
  89. package/src/utils/tokens.ts +49 -3
  90. package/src/welcome.test.ts +213 -0
  91. package/src/welcome.ts +130 -0
@@ -1,5 +1,5 @@
1
- import { createElement } from "../utils/dom";
2
- import { AgentWidgetConfig } from "../types";
1
+ import { createElement, createNode, cx } from "../utils/dom";
2
+ import { AgentWidgetConfig, AgentWidgetLauncherTeaserConfig } from "../types";
3
3
  import { positionMap } from "../utils/positioning";
4
4
  import { isDockedMountMode } from "../utils/dock";
5
5
  import { renderLucideIcon } from "../utils/icons";
@@ -240,3 +240,298 @@ export const createLauncherButton = (
240
240
  destroy
241
241
  };
242
242
  };
243
+
244
+ /** Teaser bubble handle. `dismiss(true)` also writes the persisted flag. */
245
+ export interface LauncherTeaser {
246
+ element: HTMLElement;
247
+ show: () => void;
248
+ dismiss: (persist: boolean) => void;
249
+ }
250
+
251
+ /** "auto" covers restored/hook-driven opens: they never persist the teaser flag. */
252
+ export type LauncherOpenOrigin = "user" | "auto";
253
+
254
+ export interface LauncherSurface {
255
+ /** Positioned wrapper. Sibling teaser + launcher live inside it. */
256
+ element: HTMLElement;
257
+ launcher: LauncherButton;
258
+ /** Null when no teaser is configured for the current config. */
259
+ teaser: LauncherTeaser | null;
260
+ /** Opening the panel consumes the teaser; closing never resurrects it. */
261
+ setPanelOpen: (open: boolean, origin?: LauncherOpenOrigin) => void;
262
+ update: (config: AgentWidgetConfig) => void;
263
+ destroy: () => void;
264
+ }
265
+
266
+ const TEASER_KEY_SUFFIX = "teaser-dismissed";
267
+
268
+ /** Consumed teaser keys for this page load; also the blocked-storage fallback. */
269
+ const teaserMemoryDismissed = new Set<string>();
270
+
271
+ const teaserStorageKey = (config?: AgentWidgetConfig): string => {
272
+ const persist = config?.persistState;
273
+ const prefix =
274
+ (typeof persist === "object" ? persist?.keyPrefix : undefined) ?? "persona-";
275
+ return `${prefix}${TEASER_KEY_SUFFIX}`;
276
+ };
277
+
278
+ // localStorage access throws (not just fails) in Safari private mode and
279
+ // partitioned iframes, so every access falls back to the in-memory set.
280
+ const readTeaserDismissed = (key: string, useStorage: boolean): boolean => {
281
+ if (teaserMemoryDismissed.has(key)) return true;
282
+ if (!useStorage) return false;
283
+ try {
284
+ return window.localStorage.getItem(key) === "1";
285
+ } catch {
286
+ return false;
287
+ }
288
+ };
289
+
290
+ const writeTeaserDismissed = (key: string, useStorage: boolean): void => {
291
+ teaserMemoryDismissed.add(key);
292
+ if (!useStorage) return;
293
+ try {
294
+ window.localStorage.setItem(key, "1");
295
+ } catch {
296
+ /* blocked storage: the in-memory flag above is the fallback */
297
+ }
298
+ };
299
+
300
+ const prefersReducedMotion = (): boolean =>
301
+ typeof window !== "undefined" &&
302
+ typeof window.matchMedia === "function" &&
303
+ window.matchMedia("(prefers-reduced-motion: reduce)").matches;
304
+
305
+ const resolveTeaserConfig = (
306
+ config: AgentWidgetConfig | undefined
307
+ ): AgentWidgetLauncherTeaserConfig | null => {
308
+ const teaser = config?.launcher?.teaser;
309
+ if (!teaser || !teaser.text) return null;
310
+ if (config?.launcher?.enabled === false) return null;
311
+ if (isDockedMountMode(config ?? {})) return null;
312
+ return teaser;
313
+ };
314
+
315
+ /**
316
+ * Launcher wrapper owning the collapsed button and its optional teaser bubble.
317
+ * Both the critical `launcher.global.js` bundle and the full widget build the
318
+ * launcher through this, so teaser behavior and cleanup are identical on the
319
+ * eager and deferred paths.
320
+ */
321
+ export const createLauncherSurface = (
322
+ config: AgentWidgetConfig | undefined,
323
+ onToggle: () => void
324
+ ): LauncherSurface => {
325
+ const wrapper = createElement("div", "persona-launcher-surface");
326
+ wrapper.setAttribute("data-persona-launcher-surface", "true");
327
+
328
+ let currentConfig: AgentWidgetConfig = config ?? {};
329
+ let teaserConfig: AgentWidgetLauncherTeaserConfig | null = null;
330
+ let teaserElement: HTMLElement | null = null;
331
+ let dismissButton: HTMLButtonElement | null = null;
332
+ let teaserTextButton: HTMLButtonElement | null = null;
333
+ let timer: ReturnType<typeof setTimeout> | null = null;
334
+ let visible = false;
335
+ let shown = false;
336
+ let consumed = false;
337
+ let panelOpen = false;
338
+
339
+ const usesStorage = (): boolean =>
340
+ (teaserConfig?.frequency ?? "once") === "once" &&
341
+ currentConfig.persistState !== false;
342
+
343
+ const clearTimer = () => {
344
+ if (timer !== null) {
345
+ clearTimeout(timer);
346
+ timer = null;
347
+ }
348
+ };
349
+
350
+ const hideBubble = () => {
351
+ if (!teaserElement || !visible) return;
352
+ visible = false;
353
+ teaserElement.hidden = true;
354
+ };
355
+
356
+ const dismiss = (persist: boolean) => {
357
+ clearTimer();
358
+ consumed = true;
359
+ hideBubble();
360
+ writeTeaserDismissed(teaserStorageKey(currentConfig), persist && usesStorage());
361
+ };
362
+
363
+ /**
364
+ * Click-through and launcher clicks consume; only a seen teaser persists.
365
+ * An automatic open is never user intent, so it consumes in memory only.
366
+ */
367
+ const consumeForOpen = (origin: LauncherOpenOrigin) => {
368
+ if (!teaserConfig || consumed) return;
369
+ dismiss(origin === "user" && visible);
370
+ };
371
+
372
+ const show = () => {
373
+ timer = null;
374
+ if (!teaserElement || consumed || panelOpen) return;
375
+ shown = true;
376
+ visible = true;
377
+ teaserElement.hidden = false;
378
+ if (prefersReducedMotion() || typeof teaserElement.animate !== "function") return;
379
+ teaserElement.animate(
380
+ [
381
+ { opacity: "0", transform: "translateY(6px)" },
382
+ { opacity: "1", transform: "none" },
383
+ ],
384
+ { duration: 220, easing: "ease-out", fill: "backwards" }
385
+ );
386
+ };
387
+
388
+ const schedule = () => {
389
+ if (!teaserConfig || !teaserElement) return;
390
+ if (consumed || shown || panelOpen) return;
391
+ if (readTeaserDismissed(teaserStorageKey(currentConfig), usesStorage())) return;
392
+ clearTimer();
393
+ timer = setTimeout(show, Math.max(0, teaserConfig.delayMs ?? 0));
394
+ };
395
+
396
+ const handleTeaserClick = () => {
397
+ dismiss(usesStorage());
398
+ onToggle();
399
+ };
400
+
401
+ const handleDismissClick = (event: MouseEvent) => {
402
+ event.stopPropagation();
403
+ dismiss(usesStorage());
404
+ };
405
+
406
+ const handleLauncherToggle = () => {
407
+ consumeForOpen("user");
408
+ onToggle();
409
+ };
410
+
411
+ const launcher = createLauncherButton(config, handleLauncherToggle);
412
+
413
+ const removeTeaserElement = () => {
414
+ clearTimer();
415
+ teaserTextButton?.removeEventListener("click", handleTeaserClick);
416
+ dismissButton?.removeEventListener("click", handleDismissClick);
417
+ teaserElement?.remove();
418
+ teaserElement = null;
419
+ teaserTextButton = null;
420
+ dismissButton = null;
421
+ visible = false;
422
+ };
423
+
424
+ const buildTeaserElement = () => {
425
+ teaserTextButton = createNode("button", {
426
+ className: "persona-launcher-teaser-text",
427
+ attrs: { type: "button" },
428
+ });
429
+ teaserTextButton.addEventListener("click", handleTeaserClick);
430
+
431
+ dismissButton = createNode("button", {
432
+ className: "persona-launcher-teaser-dismiss",
433
+ attrs: { type: "button" },
434
+ text: "\u00d7",
435
+ });
436
+ dismissButton.addEventListener("click", handleDismissClick);
437
+
438
+ teaserElement = createNode(
439
+ "div",
440
+ {
441
+ className: "persona-launcher-teaser",
442
+ attrs: {
443
+ "data-persona-launcher-teaser": "true",
444
+ role: "status",
445
+ "aria-live": "polite",
446
+ hidden: "",
447
+ },
448
+ },
449
+ teaserTextButton,
450
+ dismissButton
451
+ );
452
+ wrapper.insertBefore(teaserElement, launcher.element);
453
+ };
454
+
455
+ /**
456
+ * The wrapper is `display: contents` until a teaser exists, so the launcher
457
+ * keeps its own fixed placement; with a teaser the wrapper takes over the
458
+ * corner and the button becomes static inside it.
459
+ */
460
+ const applyPlacement = () => {
461
+ const position = currentConfig.launcher?.position ?? "bottom-right";
462
+ const positionClass = positionMap[position] ?? positionMap["bottom-right"];
463
+
464
+ if (!teaserElement) {
465
+ wrapper.className = "persona-launcher-surface";
466
+ wrapper.removeAttribute("data-teaser");
467
+ wrapper.style.zIndex = "";
468
+ launcher.element.style.position = "";
469
+ return;
470
+ }
471
+
472
+ wrapper.className = cx("persona-launcher-surface", positionClass);
473
+ wrapper.setAttribute("data-teaser", position);
474
+ wrapper.style.zIndex = String(
475
+ currentConfig.launcher?.zIndex ?? DEFAULT_OVERLAY_Z_INDEX
476
+ );
477
+ launcher.element.style.position = "static";
478
+ };
479
+
480
+ const syncTeaser = (nextConfig: AgentWidgetConfig) => {
481
+ const next = resolveTeaserConfig(nextConfig);
482
+ teaserConfig = next;
483
+
484
+ if (!next) {
485
+ removeTeaserElement();
486
+ return;
487
+ }
488
+
489
+ // `shown` is deliberately not reset here: at most one appearance per load.
490
+ if (!teaserElement) buildTeaserElement();
491
+
492
+ teaserTextButton!.textContent = next.text;
493
+ dismissButton!.hidden = next.dismissible === false;
494
+ dismissButton!.setAttribute(
495
+ "aria-label",
496
+ next.dismissLabel ?? "Dismiss message"
497
+ );
498
+ schedule();
499
+ };
500
+
501
+ const update = (nextConfig: AgentWidgetConfig) => {
502
+ currentConfig = nextConfig;
503
+ launcher.update(nextConfig);
504
+ syncTeaser(nextConfig);
505
+ applyPlacement();
506
+ };
507
+
508
+ const setPanelOpen = (open: boolean, origin: LauncherOpenOrigin = "user") => {
509
+ panelOpen = open;
510
+ if (!open) return;
511
+ consumeForOpen(origin);
512
+ };
513
+
514
+ const destroy = () => {
515
+ removeTeaserElement();
516
+ launcher.destroy();
517
+ wrapper.remove();
518
+ };
519
+
520
+ wrapper.appendChild(launcher.element);
521
+ if (config) {
522
+ syncTeaser(config);
523
+ applyPlacement();
524
+ }
525
+
526
+ return {
527
+ element: wrapper,
528
+ launcher,
529
+ get teaser(): LauncherTeaser | null {
530
+ if (!teaserElement) return null;
531
+ return { element: teaserElement, show, dismiss };
532
+ },
533
+ setPanelOpen,
534
+ update,
535
+ destroy,
536
+ };
537
+ };
@@ -10,6 +10,16 @@ import { buildHeaderWithLayout } from "./header-layouts";
10
10
  import { createCloseButton, createClearChatButton } from "./header-parts";
11
11
  import { buildComposer, ComposerElements } from "./composer-builder";
12
12
  import { buildPillComposer, buildPillPeekBanner } from "./pill-composer-builder";
13
+ import {
14
+ applyWelcomeConfig,
15
+ applyWelcomeVisibility,
16
+ buildGreetingHost,
17
+ buildWelcomeHost,
18
+ renderWelcomeGreeting,
19
+ WelcomeHostElements,
20
+ } from "./welcome";
21
+ import { isWelcomeVisible, resolveWelcomeConfig } from "../welcome";
22
+ import { resolveContentMaxWidth } from "../utils/content-width";
13
23
 
14
24
  export interface PanelWrapper {
15
25
  wrapper: HTMLElement;
@@ -151,6 +161,11 @@ export interface PanelElements {
151
161
  micButtonWrapper: HTMLElement | null;
152
162
  composerForm: HTMLFormElement;
153
163
  statusText: HTMLElement;
164
+ /** Permanent welcome host: variant and visibility change on it in place. */
165
+ welcomeHost: HTMLElement;
166
+ welcomeIconHolder: HTMLElement;
167
+ /** Display-only greeting bubble host, pinned above the transcript. */
168
+ greetingHost: HTMLElement;
154
169
  introTitle: HTMLElement;
155
170
  introSubtitle: HTMLElement;
156
171
  closeButton: HTMLButtonElement;
@@ -194,6 +209,30 @@ const createSuggestionHost = (
194
209
  return host;
195
210
  };
196
211
 
212
+ /**
213
+ * Welcome surface for either panel builder: permanent host plus the greeting
214
+ * host that sits between it and the transcript.
215
+ */
216
+ const buildWelcomeSurface = (
217
+ config: AgentWidgetConfig | undefined,
218
+ body: HTMLElement
219
+ ): { welcome: WelcomeHostElements; greetingHost: HTMLElement } => {
220
+ const starterSuggestions = createSuggestionHost("starter");
221
+ const welcome = buildWelcomeHost(config, starterSuggestions, {
222
+ flatShadow: isDockedMountMode(config),
223
+ });
224
+ const resolved = resolveWelcomeConfig(config);
225
+ applyWelcomeConfig(welcome, resolved);
226
+ applyWelcomeVisibility(
227
+ body,
228
+ welcome.host,
229
+ isWelcomeVisible(resolved, config?.initialMessages)
230
+ );
231
+ const greetingHost = buildGreetingHost();
232
+ renderWelcomeGreeting(greetingHost, resolved.message, config);
233
+ return { welcome, greetingHost };
234
+ };
235
+
197
236
  /**
198
237
  * Composer-bar panel: minimal close-only header (× in the top-right of the
199
238
  * chat chrome). The pill (`footer`) and `peekBanner` are NOT children of
@@ -282,38 +321,17 @@ const buildComposerBarPanel = (
282
321
  // when streaming content first overflows and the scrollbar appears.
283
322
  body.style.setProperty("scrollbar-gutter", "stable");
284
323
 
285
- const introTitle = createNode("h2", {
286
- className: "persona-text-lg persona-font-semibold persona-text-persona-primary",
287
- text: config?.copy?.welcomeTitle ?? "Hello 👋",
288
- });
289
- const introSubtitle = createNode("p", {
290
- className: "persona-mt-2 persona-text-sm persona-text-persona-muted",
291
- text:
292
- config?.copy?.welcomeSubtitle ??
293
- "Ask anything about your account or products.",
294
- });
295
- const starterSuggestions = createSuggestionHost("starter");
296
- const introCard = createNode(
297
- "div",
298
- {
299
- className: "persona-rounded-2xl persona-p-6",
300
- attrs: { "data-persona-intro-card": "" },
301
- style: {
302
- background: "var(--persona-intro-card-bg, transparent)",
303
- boxShadow: "var(--persona-intro-card-shadow, none)",
304
- },
305
- },
306
- introTitle,
307
- introSubtitle,
308
- starterSuggestions
309
- );
324
+ const { welcome, greetingHost } = buildWelcomeSurface(config, body);
325
+ const { host: welcomeHost, title: introTitle, subtitle: introSubtitle } =
326
+ welcome;
327
+ const starterSuggestions = welcome.starterSuggestions;
310
328
 
311
329
  const messagesWrapper = createElement(
312
330
  "div",
313
- "persona-flex persona-flex-col persona-gap-3"
331
+ "persona-widget-messages persona-flex persona-flex-col persona-gap-3"
314
332
  );
315
333
  const transcriptSuggestions = createSuggestionHost("followUp");
316
- const contentMaxWidth = config?.layout?.contentMaxWidth;
334
+ const contentMaxWidth = resolveContentMaxWidth(config, true);
317
335
  if (contentMaxWidth) {
318
336
  messagesWrapper.style.maxWidth = contentMaxWidth;
319
337
  messagesWrapper.style.marginLeft = "auto";
@@ -321,13 +339,12 @@ const buildComposerBarPanel = (
321
339
  messagesWrapper.style.width = "100%";
322
340
  }
323
341
 
324
- const showWelcomeCard = config?.copy?.showWelcomeCard !== false;
325
- if (!showWelcomeCard) {
326
- introCard.style.display = "none";
327
- body.classList.remove("persona-gap-6");
328
- body.classList.add("persona-gap-3");
329
- }
330
- body.append(introCard, messagesWrapper, transcriptSuggestions);
342
+ body.append(
343
+ welcomeHost,
344
+ greetingHost,
345
+ messagesWrapper,
346
+ transcriptSuggestions
347
+ );
331
348
 
332
349
  // Composer overlay (interactive sheets like ask_user_question slide up here).
333
350
  // Anchored to the bottom of the container (which is `position: relative`),
@@ -368,6 +385,9 @@ const buildComposerBarPanel = (
368
385
  micButtonWrapper: composerElements.micButtonWrapper,
369
386
  composerForm: composerElements.composerForm,
370
387
  statusText: composerElements.statusText,
388
+ welcomeHost,
389
+ welcomeIconHolder: welcome.iconHolder,
390
+ greetingHost,
371
391
  introTitle,
372
392
  introSubtitle,
373
393
  closeButton,
@@ -424,45 +444,18 @@ export const buildPanel = (config?: AgentWidgetConfig, showClose = true): PanelE
424
444
  // when streaming content first overflows and the scrollbar appears.
425
445
  body.style.setProperty("scrollbar-gutter", "stable");
426
446
 
427
- const introTitle = createNode("h2", {
428
- className: "persona-text-lg persona-font-semibold persona-text-persona-primary",
429
- text: config?.copy?.welcomeTitle ?? "Hello 👋",
430
- });
431
- const introSubtitle = createNode("p", {
432
- className: "persona-mt-2 persona-text-sm persona-text-persona-muted",
433
- text:
434
- config?.copy?.welcomeSubtitle ??
435
- "Ask anything about your account or products.",
436
- });
437
- const starterSuggestions = createSuggestionHost("starter");
438
- // Background and box-shadow flow through the themable `components.introCard`
439
- // tokens (--persona-intro-card-bg / --persona-intro-card-shadow); both
440
- // default to flat (transparent / none). Docked mode always stays flat,
441
- // even when a theme sets an introCard shadow.
442
- const introCard = createNode(
443
- "div",
444
- {
445
- className: "persona-rounded-2xl persona-p-6",
446
- attrs: { "data-persona-intro-card": "" },
447
- style: {
448
- background: "var(--persona-intro-card-bg, transparent)",
449
- boxShadow: isDockedMountMode(config)
450
- ? "none"
451
- : "var(--persona-intro-card-shadow, none)",
452
- },
453
- },
454
- introTitle,
455
- introSubtitle,
456
- starterSuggestions
457
- );
447
+ const { welcome, greetingHost } = buildWelcomeSurface(config, body);
448
+ const { host: welcomeHost, title: introTitle, subtitle: introSubtitle } =
449
+ welcome;
450
+ const starterSuggestions = welcome.starterSuggestions;
458
451
 
459
452
  const messagesWrapper = createElement(
460
453
  "div",
461
- "persona-flex persona-flex-col persona-gap-3"
454
+ "persona-widget-messages persona-flex persona-flex-col persona-gap-3"
462
455
  );
463
456
  const transcriptSuggestions = createSuggestionHost("followUp");
464
457
 
465
- const contentMaxWidth = config?.layout?.contentMaxWidth;
458
+ const contentMaxWidth = resolveContentMaxWidth(config, false);
466
459
  if (contentMaxWidth) {
467
460
  messagesWrapper.style.maxWidth = contentMaxWidth;
468
461
  messagesWrapper.style.marginLeft = "auto";
@@ -470,13 +463,12 @@ export const buildPanel = (config?: AgentWidgetConfig, showClose = true): PanelE
470
463
  messagesWrapper.style.width = "100%";
471
464
  }
472
465
 
473
- const showWelcomeCard = config?.copy?.showWelcomeCard !== false;
474
- if (!showWelcomeCard) {
475
- introCard.style.display = "none";
476
- body.classList.remove("persona-gap-6");
477
- body.classList.add("persona-gap-3");
478
- }
479
- body.append(introCard, messagesWrapper, transcriptSuggestions);
466
+ body.append(
467
+ welcomeHost,
468
+ greetingHost,
469
+ messagesWrapper,
470
+ transcriptSuggestions
471
+ );
480
472
 
481
473
  // composer-bar mode early-returned above with its own pill builder; this
482
474
  // path always uses the standard column-stacked composer card.
@@ -535,6 +527,9 @@ export const buildPanel = (config?: AgentWidgetConfig, showClose = true): PanelE
535
527
  micButtonWrapper: composerElements.micButtonWrapper,
536
528
  composerForm: composerElements.composerForm,
537
529
  statusText: composerElements.statusText,
530
+ welcomeHost,
531
+ welcomeIconHolder: welcome.iconHolder,
532
+ greetingHost,
538
533
  introTitle,
539
534
  introSubtitle,
540
535
  closeButton: headerElements.closeButton,
@@ -198,6 +198,46 @@ describe("per-item icon color", () => {
198
198
  });
199
199
  });
200
200
 
201
+ describe("description rendering per variant", () => {
202
+ const items = [
203
+ {
204
+ label: "Track an order",
205
+ description: "Look up status by order number",
206
+ },
207
+ ];
208
+
209
+ it("keeps the chip variant to a single-line label", () => {
210
+ const { manager, session, textarea, container } = setup();
211
+
212
+ manager.render(items, session, textarea, [], undefined, {
213
+ variant: "chip",
214
+ });
215
+
216
+ expect(
217
+ container.querySelector(".persona-suggestion__description")
218
+ ).toBeNull();
219
+ expect(
220
+ (container.querySelector(".persona-suggestion__label") as HTMLElement)
221
+ .textContent
222
+ ).toBe("Track an order");
223
+ });
224
+
225
+ it("renders the description in the card and list variants", () => {
226
+ const { manager, session, textarea, container } = setup();
227
+
228
+ for (const variant of ["card", "list"] as const) {
229
+ manager.render(items, session, textarea, [], undefined, { variant });
230
+ expect(
231
+ (
232
+ container.querySelector(
233
+ ".persona-suggestion__description"
234
+ ) as HTMLElement
235
+ ).textContent
236
+ ).toBe("Look up status by order number");
237
+ }
238
+ });
239
+ });
240
+
201
241
  describe("legacy suggestionChipsConfig scoping", () => {
202
242
  const chipsConfig = {
203
243
  fontFamily: "sans-serif" as const,