@runtypelabs/persona 4.15.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 (86) 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/launcher.global.js +2 -2
  14. package/dist/launcher.global.js.map +1 -1
  15. package/dist/smart-dom-reader.d.cts +283 -9
  16. package/dist/smart-dom-reader.d.ts +283 -9
  17. package/dist/testing.cjs +3 -3
  18. package/dist/testing.d.cts +7 -0
  19. package/dist/testing.d.ts +7 -0
  20. package/dist/testing.js +3 -3
  21. package/dist/theme-editor-preview.cjs +64 -64
  22. package/dist/theme-editor-preview.d.cts +283 -9
  23. package/dist/theme-editor-preview.d.ts +283 -9
  24. package/dist/theme-editor-preview.js +56 -56
  25. package/dist/theme-editor.cjs +10 -10
  26. package/dist/theme-editor.d.cts +283 -10
  27. package/dist/theme-editor.d.ts +283 -10
  28. package/dist/theme-editor.js +10 -10
  29. package/dist/theme-reference.cjs +1 -1
  30. package/dist/theme-reference.d.cts +66 -3
  31. package/dist/theme-reference.d.ts +66 -3
  32. package/dist/theme-reference.js +1 -1
  33. package/dist/widget.css +1 -1
  34. package/package.json +1 -1
  35. package/src/components/artifact-pane.test.ts +27 -0
  36. package/src/components/artifact-pane.ts +7 -3
  37. package/src/components/header-builder.ts +27 -2
  38. package/src/components/header-layouts.test.ts +56 -0
  39. package/src/components/header-layouts.ts +29 -9
  40. package/src/components/launcher-surface.test.ts +403 -0
  41. package/src/components/launcher.ts +297 -2
  42. package/src/components/panel.ts +69 -74
  43. package/src/components/suggestions.test.ts +40 -0
  44. package/src/components/suggestions.ts +213 -153
  45. package/src/components/welcome.test.ts +121 -0
  46. package/src/components/welcome.ts +305 -0
  47. package/src/components/widget-view.test.ts +5 -3
  48. package/src/components/widget-view.ts +14 -7
  49. package/src/defaults.ts +2 -2
  50. package/src/index-core.ts +12 -1
  51. package/src/launcher-global.ts +10 -7
  52. package/src/plugins/types.ts +66 -1
  53. package/src/styles/widget-styles.test.ts +220 -0
  54. package/src/styles/widget.css +353 -9
  55. package/src/testing/mock-stream.test.ts +19 -0
  56. package/src/testing/mock-stream.ts +20 -0
  57. package/src/theme-editor/sections.ts +30 -1
  58. package/src/theme-reference.ts +26 -4
  59. package/src/types/theme.ts +40 -3
  60. package/src/types.ts +164 -6
  61. package/src/ui.body-top-slot.test.ts +95 -0
  62. package/src/ui.content-width.test.ts +91 -0
  63. package/src/ui.docked.test.ts +7 -4
  64. package/src/ui.launcher-teaser.test.ts +162 -0
  65. package/src/ui.rebuild-composer.test.ts +491 -0
  66. package/src/ui.render-welcome.test.ts +505 -0
  67. package/src/ui.scroll-additive.test.ts +91 -21
  68. package/src/ui.scroll.test.ts +302 -14
  69. package/src/ui.suggest-replies.test.ts +46 -0
  70. package/src/ui.ts +747 -136
  71. package/src/ui.welcome.test.ts +504 -0
  72. package/src/utils/artifact-gate.test.ts +27 -0
  73. package/src/utils/artifact-gate.ts +9 -0
  74. package/src/utils/attachment-manager.test.ts +37 -0
  75. package/src/utils/attachment-manager.ts +15 -0
  76. package/src/utils/auto-follow.test.ts +23 -0
  77. package/src/utils/auto-follow.ts +11 -1
  78. package/src/utils/config-merge.ts +2 -0
  79. package/src/utils/content-width.test.ts +37 -0
  80. package/src/utils/content-width.ts +17 -0
  81. package/src/utils/plugin-storage.test.ts +94 -0
  82. package/src/utils/plugin-storage.ts +98 -0
  83. package/src/utils/theme.test.ts +137 -0
  84. package/src/utils/tokens.ts +49 -3
  85. package/src/welcome.test.ts +213 -0
  86. package/src/welcome.ts +130 -0
package/src/ui.ts CHANGED
@@ -36,7 +36,8 @@ import {
36
36
  AgentWidgetContentSegment,
37
37
  AgentWidgetContextMentionRef,
38
38
  AgentWidgetSuggestion,
39
- AgentWidgetSuggestionSource
39
+ AgentWidgetSuggestionSource,
40
+ AgentWidgetWelcomeIcon
40
41
  } from "./types";
41
42
  import { AttachmentManager } from "./utils/attachment-manager";
42
43
  import {
@@ -49,6 +50,7 @@ import { applyThemeVariables, createThemeObserver, getActiveTheme } from "./util
49
50
  import { resolveTokenValue } from "./utils/tokens";
50
51
  import { renderLucideIcon } from "./utils/icons";
51
52
  import { createElement } from "./utils/dom";
53
+ import { resolveContentMaxWidth } from "./utils/content-width";
52
54
  import { attachTooltip } from "./utils/tooltip";
53
55
  import { downloadInfoFor } from "./utils/artifact-file";
54
56
  import { artifactCopyText } from "./components/artifact-preview";
@@ -85,9 +87,20 @@ import {
85
87
  import { syncOverlayHostStacking } from "./utils/overlay-host-stacking";
86
88
  import { acquireScrollLock } from "./utils/scroll-lock";
87
89
  import { isComposerBarMountMode, isDockedMountMode, resolveDockConfig } from "./utils/dock";
88
- import { LauncherButton } from "./components/launcher";
90
+ import { LauncherSurface } from "./components/launcher";
89
91
  import { buildHeader, buildComposer, attachHeaderToContainer } from "./components/panel";
92
+ import { buildPillComposer } from "./components/pill-composer-builder";
90
93
  import { createWidgetView, resolveLauncher } from "./components/widget-view";
94
+ import {
95
+ animateWelcomeOut,
96
+ applyWelcomeConfig,
97
+ applyWelcomeVisibility,
98
+ clearWelcomePluginContent,
99
+ mountWelcomePluginContent,
100
+ renderWelcomeGreeting,
101
+ } from "./components/welcome";
102
+ import { createPluginStorageFactory } from "./utils/plugin-storage";
103
+ import { isWelcomeVisible, resolveWelcomeConfig } from "./welcome";
91
104
  import { HEADER_THEME_CSS } from "./components/header-builder";
92
105
  import { buildHeaderWithLayout } from "./components/header-layouts";
93
106
  import { positionMap } from "./utils/positioning";
@@ -118,7 +131,11 @@ import {
118
131
  import { formatElapsedMs } from "./utils/formatting";
119
132
  import { approvalDetailsExpansionState, createApprovalBubble, updateApprovalDetailsUI } from "./components/approval-bubble";
120
133
  import { createBuiltInApprovalPlugin } from "./components/approval-actions";
121
- import { createSuggestions } from "./components/suggestions";
134
+ import {
135
+ createSuggestionElement,
136
+ createSuggestions,
137
+ normalizeSuggestion,
138
+ } from "./components/suggestions";
122
139
  import { EventStreamBuffer } from "./utils/event-stream-buffer";
123
140
  import { EventStreamStore } from "./utils/event-stream-store";
124
141
  import { ThroughputTracker } from "./utils/throughput-tracker";
@@ -624,6 +641,14 @@ export const createAgentExperience = (
624
641
  // Get plugins for this instance
625
642
  const plugins = pluginRegistry.getForInstance(config.plugins);
626
643
 
644
+ // Guards the composer/welcome hooks, which can run before `session` exists
645
+ // (its `let` is declared far below and is in the temporal dead zone here).
646
+ let sessionInitialized = false;
647
+
648
+ // One store per widget instance, namespaced per plugin id. Shared by the
649
+ // `renderWelcome` and `renderComposer` contexts.
650
+ const pluginStorageFor = createPluginStorageFactory(() => config);
651
+
627
652
  // The built-in approval renderer, shaped as a plugin. Resolved as a FALLBACK
628
653
  // (not pushed into `plugins`) so a user `renderApproval` plugin always wins
629
654
  // and a later config-update plugin push can't reorder ahead of it.
@@ -896,6 +921,9 @@ export const createAgentExperience = (
896
921
  sendButtonWrapper,
897
922
  composerForm,
898
923
  statusText,
924
+ welcomeHost,
925
+ welcomeIconHolder,
926
+ greetingHost,
899
927
  introTitle,
900
928
  introSubtitle,
901
929
  closeButton,
@@ -951,6 +979,7 @@ export const createAgentExperience = (
951
979
  const isActivityWhilePinnedEnabled = () =>
952
980
  scrollBehaviorFeature.showActivityWhilePinned !== false;
953
981
  const isAnnounceEnabled = () => scrollBehaviorFeature.announce === true;
982
+ const getScrollbarPolicy = () => scrollBehaviorFeature.scrollbar ?? "on-scroll";
954
983
  const scrollToBottomButton = createElement(
955
984
  "button",
956
985
  "persona-scroll-to-bottom-indicator persona-absolute persona-bottom-3 persona-left-1/2 persona-z-10 persona-flex persona-items-center persona-gap-1 persona-text-xs persona-transform persona--translate-x-1/2 persona-cursor-pointer"
@@ -980,6 +1009,9 @@ export const createAgentExperience = (
980
1009
  anchorSpacer.style.flexShrink = "0";
981
1010
  anchorSpacer.style.pointerEvents = "none";
982
1011
  anchorSpacer.style.height = "0px";
1012
+ // Hidden while unsized: at 0px it still counts as a flex child, so the
1013
+ // body's gap would add phantom scrollable space below the transcript.
1014
+ anchorSpacer.style.display = "none";
983
1015
  body.appendChild(anchorSpacer);
984
1016
 
985
1017
  // Visually-hidden polite live region for screen-reader announcements
@@ -1213,16 +1245,25 @@ export const createAgentExperience = (
1213
1245
  }
1214
1246
  };
1215
1247
 
1216
- // Plugin hook: renderComposer - allow plugins to provide custom composer
1248
+ // Plugin hook: renderComposer - allow plugins to provide custom composer.
1249
+ // Arbitration is a function so `rebuildComposer()` can re-run it with a
1250
+ // fresh ctx and swap the footer in place.
1217
1251
  const composerPlugin = plugins.find(p => p.renderComposer);
1218
- if (composerPlugin?.renderComposer) {
1252
+ // True while the footer content came from a plugin (not composition via
1253
+ // defaultRenderer). Plugin composers own their copy: updateCopy must not
1254
+ // stamp inputPlaceholder/sendButtonLabel onto DOM the core did not build.
1255
+ let composerIsPluginOwned = false;
1256
+ const renderComposerFromPlugins = (
1257
+ defaultRenderer: () => HTMLElement
1258
+ ): HTMLElement | null => {
1259
+ if (!composerPlugin?.renderComposer) return null;
1219
1260
  const composerCfg = config.composer;
1220
- const customComposer = composerPlugin.renderComposer({
1261
+ const streaming = sessionInitialized ? session.isStreaming() : false;
1262
+ return composerPlugin.renderComposer({
1221
1263
  config,
1222
- defaultRenderer: () => {
1223
- const composerElements = buildComposer({ config });
1224
- return composerElements.footer;
1225
- },
1264
+ defaultRenderer,
1265
+ requestRender: () => rebuildComposer(),
1266
+ storage: pluginStorageFor(composerPlugin.id),
1226
1267
  onSubmit: (text: string) => {
1227
1268
  if (!session || session.isStreaming()) return;
1228
1269
  const value = text.trim();
@@ -1244,8 +1285,8 @@ export const createAgentExperience = (
1244
1285
  attachmentManager!.clearAttachments();
1245
1286
  }
1246
1287
  },
1247
- streaming: false,
1248
- disabled: false,
1288
+ streaming,
1289
+ disabled: streaming,
1249
1290
  openAttachmentPicker: () => {
1250
1291
  attachmentInput?.click();
1251
1292
  },
@@ -1265,11 +1306,16 @@ export const createAgentExperience = (
1265
1306
  }
1266
1307
  : undefined
1267
1308
  });
1268
- if (customComposer) {
1269
- // Replace the default footer with custom composer (keeps view.composer.footer in sync).
1270
- view.replaceComposer(customComposer);
1271
- footer = view.composer.footer;
1272
- }
1309
+ };
1310
+
1311
+ const customComposer = renderComposerFromPlugins(
1312
+ () => buildComposer({ config }).footer
1313
+ );
1314
+ if (customComposer) {
1315
+ // Replace the default footer with custom composer (keeps view.composer.footer in sync).
1316
+ view.replaceComposer(customComposer);
1317
+ footer = view.composer.footer;
1318
+ composerIsPluginOwned = true;
1273
1319
  }
1274
1320
 
1275
1321
  const bindComposerRefsFromFooter = (rootFooter: HTMLElement) => {
@@ -1331,19 +1377,20 @@ export const createAgentExperience = (
1331
1377
  ensureComposerAttachmentSurface(footer);
1332
1378
  bindComposerRefsFromFooter(footer);
1333
1379
 
1334
- // Apply contentMaxWidth to composer form, suggestions, and attachment
1335
- // previews if configured. In composer-bar mode, fall back to
1336
- // `composerBar.contentMaxWidth` (default `720px`) when no explicit
1337
- // `layout.contentMaxWidth` is set, so the expanded panel's content
1338
- // centers horizontally without the host having to wire it up.
1339
- const contentMaxWidth =
1340
- config.layout?.contentMaxWidth ??
1341
- (isComposerBar() ? config.launcher?.composerBar?.contentMaxWidth ?? "720px" : undefined);
1380
+ // Center the content column. Defaults: 768px panels, 720px composer-bar
1381
+ // (resolveContentMaxWidth); "none" opts out. Only engages on panels wider
1382
+ // than the cap, so launcher-width panels are unaffected.
1383
+ const contentMaxWidth = resolveContentMaxWidth(config, isComposerBar());
1342
1384
  if (contentMaxWidth) {
1343
1385
  messagesWrapper.style.maxWidth = contentMaxWidth;
1344
1386
  messagesWrapper.style.marginLeft = "auto";
1345
1387
  messagesWrapper.style.marginRight = "auto";
1346
1388
  messagesWrapper.style.width = "100%";
1389
+ // The greeting bubble is a transcript sibling: same column geometry.
1390
+ greetingHost.style.maxWidth = contentMaxWidth;
1391
+ greetingHost.style.marginLeft = "auto";
1392
+ greetingHost.style.marginRight = "auto";
1393
+ greetingHost.style.width = "100%";
1347
1394
  starterSuggestions.style.maxWidth = contentMaxWidth;
1348
1395
  transcriptSuggestions.style.maxWidth = contentMaxWidth;
1349
1396
  transcriptSuggestions.style.marginLeft = "auto";
@@ -1354,41 +1401,56 @@ export const createAgentExperience = (
1354
1401
  // wrapper's responsive width (50vw / 70vw / 90vw), not be capped by
1355
1402
  // contentMaxWidth (which is a centered-column convention for the
1356
1403
  // expanded panel's body, not the pill input itself).
1357
- if (contentMaxWidth && composerForm && !isComposerBar()) {
1358
- composerForm.style.maxWidth = contentMaxWidth;
1359
- composerForm.style.marginLeft = "auto";
1360
- composerForm.style.marginRight = "auto";
1361
- }
1362
- if (contentMaxWidth && suggestions && !isComposerBar()) {
1363
- suggestions.style.maxWidth = contentMaxWidth;
1364
- suggestions.style.marginLeft = "auto";
1365
- suggestions.style.marginRight = "auto";
1366
- }
1367
- if (contentMaxWidth && attachmentPreviewsContainer && !isComposerBar()) {
1368
- attachmentPreviewsContainer.style.maxWidth = contentMaxWidth;
1369
- attachmentPreviewsContainer.style.marginLeft = "auto";
1370
- attachmentPreviewsContainer.style.marginRight = "auto";
1371
- }
1404
+ // Re-run after a composer rebuild: the footer's elements are new.
1405
+ const applyComposerContentMaxWidth = () => {
1406
+ const max = resolveContentMaxWidth(config, isComposerBar());
1407
+ if (!max || isComposerBar()) return;
1408
+ for (const element of [composerForm, suggestions, attachmentPreviewsContainer, statusText]) {
1409
+ if (!element) continue;
1410
+ element.style.maxWidth = max;
1411
+ element.style.marginLeft = "auto";
1412
+ element.style.marginRight = "auto";
1413
+ }
1414
+ };
1415
+ applyComposerContentMaxWidth();
1372
1416
 
1373
- if (config.attachments?.enabled && attachmentInput && attachmentPreviewsContainer) {
1374
- attachmentManager = AttachmentManager.fromConfig(config.attachments);
1375
- attachmentManager.setPreviewsContainer(attachmentPreviewsContainer);
1417
+ /**
1418
+ * Bind the attachment surface of the CURRENT footer. On a rebuild the manager
1419
+ * survives and its pending previews are repainted into the new container.
1420
+ */
1421
+ const setupComposerAttachments = () => {
1422
+ if (!config.attachments?.enabled || !attachmentInput || !attachmentPreviewsContainer) {
1423
+ return;
1424
+ }
1425
+ if (!attachmentManager) {
1426
+ attachmentManager = AttachmentManager.fromConfig(config.attachments);
1427
+ attachmentManager.setPreviewsContainer(attachmentPreviewsContainer);
1428
+ } else {
1429
+ attachmentManager.remountPreviews(attachmentPreviewsContainer);
1430
+ }
1376
1431
  attachmentInput.addEventListener("change", (e) => {
1377
1432
  const target = e.target as HTMLInputElement;
1378
1433
  attachmentManager?.handleFileSelect(target.files);
1379
1434
  target.value = "";
1380
1435
  });
1381
1436
 
1382
- const dropCfg = config.attachments.dropOverlay;
1383
- const overlay = buildDropOverlay(dropCfg);
1384
- container.appendChild(overlay);
1385
- }
1437
+ if (!container.querySelector(".persona-attachment-drop-overlay")) {
1438
+ container.appendChild(buildDropOverlay(config.attachments.dropOverlay));
1439
+ }
1440
+ };
1441
+ setupComposerAttachments();
1386
1442
 
1387
1443
  // Context mentions: render the affordance button + chip row eagerly (so the
1388
1444
  // feature is discoverable) and lazy-load the heavy runtime on first use. The
1389
1445
  // orchestrator returns null when disabled or sourceless.
1390
1446
  const mentionPrefetch = () => mentionOrchestrator?.prefetch();
1391
- if (config.contextMentions?.enabled && textarea) {
1447
+ /**
1448
+ * Create the orchestrator for the CURRENT composer input. A rebuild destroys
1449
+ * the previous one (its bindings point at the detached textarea), so any
1450
+ * uncommitted mention chips are cleared with it.
1451
+ */
1452
+ const setupMentionOrchestrator = () => {
1453
+ if (!config.contextMentions?.enabled || !textarea) return;
1392
1454
  // Slash-command dispatch (prompt macros write text / submit; client actions
1393
1455
  // read/replace the value) and submission are owned by the composer input
1394
1456
  // surface itself — the mention runtime builds a textarea (chip) or
@@ -1425,7 +1487,8 @@ export const createAgentExperience = (
1425
1487
  // `@` is instant) is registered through the shared `composerListeners`
1426
1488
  // registry below, so it survives the inline contenteditable swap too.
1427
1489
  }
1428
- }
1490
+ };
1491
+ setupMentionOrchestrator();
1429
1492
 
1430
1493
  // Slot system: allow custom content injection into specific regions
1431
1494
  const renderSlots = () => {
@@ -1435,8 +1498,9 @@ export const createAgentExperience = (
1435
1498
  const getDefaultSlotContent = (slot: WidgetLayoutSlot): HTMLElement | null => {
1436
1499
  switch (slot) {
1437
1500
  case "body-top":
1438
- // Default: the intro card
1439
- return container.querySelector(".persona-rounded-2xl.persona-bg-persona-surface.persona-p-6") as HTMLElement || null;
1501
+ // Intro card is identified by its data attribute; its utility classes
1502
+ // are themable and must never be used as a selector.
1503
+ return body.querySelector("[data-persona-intro-card]") as HTMLElement | null;
1440
1504
  case "messages":
1441
1505
  return messagesWrapper;
1442
1506
  case "footer-top":
@@ -1473,7 +1537,7 @@ export const createAgentExperience = (
1473
1537
  break;
1474
1538
  case "body-top": {
1475
1539
  // Replace or prepend to body
1476
- const introCard = body.querySelector(".persona-rounded-2xl.persona-bg-persona-surface.persona-p-6");
1540
+ const introCard = body.querySelector("[data-persona-intro-card]");
1477
1541
  if (introCard) {
1478
1542
  introCard.replaceWith(element);
1479
1543
  } else {
@@ -3347,19 +3411,52 @@ export const createAgentExperience = (
3347
3411
 
3348
3412
  restoreBodyScrollTop();
3349
3413
  };
3414
+ // Column var for plugin-rendered content (renderWelcome overlay stacks,
3415
+ // custom composers): resolved config value, matched without config access.
3416
+ // Must land after applyFullHeightStyles, which wipes mount.style.cssText.
3417
+ const applyContentMaxWidthVar = () => {
3418
+ mount.style.setProperty(
3419
+ "--persona-content-max-width",
3420
+ resolveContentMaxWidth(config, isComposerBar())
3421
+ );
3422
+ };
3423
+ // Floating launcher panels get a fixed pixel height (recalcPanelHeight owns
3424
+ // it on resize), but applyFullHeightStyles wipes panel.style.cssText and only
3425
+ // restores width — so every chrome sync must re-stamp the height or the panel
3426
+ // grows to fit its content. Guards mirror recalcPanelHeight's path.
3427
+ const applyFloatingPanelFixedHeight = () => {
3428
+ if (isComposerBar()) return;
3429
+ const dockedMode = isDockedMountMode(config);
3430
+ const sidebarMode = config.launcher?.sidebarMode ?? false;
3431
+ const fullHeight = dockedMode || sidebarMode || (config.launcher?.fullHeight ?? false);
3432
+ if (fullHeight || !launcherEnabled) return;
3433
+ const ownerWindow = mount.ownerDocument.defaultView ?? window;
3434
+ const mobileFullscreen = config.launcher?.mobileFullscreen ?? true;
3435
+ const mobileBreakpoint = config.launcher?.mobileBreakpoint ?? 640;
3436
+ if (mobileFullscreen && ownerWindow.innerWidth <= mobileBreakpoint) return;
3437
+ const viewportHeight = ownerWindow.innerHeight;
3438
+ const verticalMargin = 64; // leave space for launcher's offset
3439
+ const heightOffset = config.launcher?.heightOffset ?? 0;
3440
+ const available = Math.max(200, viewportHeight - verticalMargin);
3441
+ const clamped = Math.min(640, available);
3442
+ panel.style.height = `${Math.max(200, clamped - heightOffset)}px`;
3443
+ };
3350
3444
  applyFullHeightStyles();
3351
3445
  // Apply theme variables after applyFullHeightStyles since it resets mount.style.cssText
3352
3446
  applyThemeVariables(mount, config);
3353
3447
  applyArtifactLayoutCssVars(mount, config);
3354
3448
  applyArtifactPaneAppearance(mount, config);
3449
+ applyContentMaxWidthVar();
3355
3450
 
3356
3451
  // applyFullHeightStyles wipes mount.style.cssText, so re-apply the theme +
3357
3452
  // artifact layout vars after it, mirroring the init sequence above.
3358
3453
  syncPanelChrome = () => {
3359
3454
  applyFullHeightStyles();
3455
+ applyFloatingPanelFixedHeight();
3360
3456
  applyThemeVariables(mount, config);
3361
3457
  applyArtifactLayoutCssVars(mount, config);
3362
3458
  applyArtifactPaneAppearance(mount, config);
3459
+ applyContentMaxWidthVar();
3363
3460
  // Owned here so it lands after applyArtifactPaneAppearance and derives from
3364
3461
  // the same resolved panel radius the chat card uses (see applyFullHeightStyles).
3365
3462
  if (weldedOuterRadius) {
@@ -3469,7 +3566,8 @@ export const createAgentExperience = (
3469
3566
  }
3470
3567
  }
3471
3568
 
3472
- const composerSuggestionsManager = createSuggestions(suggestions);
3569
+ // Reassignable: a composer rebuild swaps the row this manager renders into.
3570
+ let composerSuggestionsManager = createSuggestions(suggestions);
3473
3571
  const starterSuggestionsManager = createSuggestions(starterSuggestions);
3474
3572
  const transcriptSuggestionsManager = createSuggestions(transcriptSuggestions);
3475
3573
  const suggestionManagers = [
@@ -3524,7 +3622,7 @@ export const createAgentExperience = (
3524
3622
  if (warnedPinnedStarterPlacement || config.debug !== true) return;
3525
3623
  warnedPinnedStarterPlacement = true;
3526
3624
  console.warn(
3527
- '[persona] suggestions.starters.placement is pinned to "welcome" but copy.showWelcomeCard is false, so no starters render. Use "auto" or "composer".',
3625
+ '[persona] suggestions.starters.placement is pinned to "welcome" but the welcome surface is hidden (welcome.variant: "none" or copy.showWelcomeCard: false), so no starters render. Use "auto" or "composer".',
3528
3626
  );
3529
3627
  };
3530
3628
  // Debug-only setup hint: a stream this session ended without the agent ever
@@ -3608,7 +3706,10 @@ export const createAgentExperience = (
3608
3706
  surface: "followUp",
3609
3707
  variant: followUps?.variant ?? "chip",
3610
3708
  behavior: followUps?.behavior ?? "send",
3611
- overflow: followUps?.overflow ?? "scroll",
3709
+ // Wrap by default: 2-4 compact chips always fit at widget width,
3710
+ // and a scroll strip hides most of the set behind a fade. Hosts
3711
+ // with large sets opt back in with overflow: "scroll".
3712
+ overflow: followUps?.overflow ?? "wrap",
3612
3713
  maxItems: followUps?.maxItems ?? 4,
3613
3714
  config,
3614
3715
  plugins,
@@ -3624,9 +3725,12 @@ export const createAgentExperience = (
3624
3725
 
3625
3726
  const starters = config.suggestions?.starters;
3626
3727
  if (starters) {
3627
- // The starter host lives inside the welcome card, so a pinned "welcome"
3628
- // placement has nowhere to render when that card is hidden.
3629
- const welcomeCardVisible = config.copy?.showWelcomeCard !== false;
3728
+ // The starter host lives inside the welcome surface, so a pinned
3729
+ // "welcome" placement has nowhere to render when that surface is hidden.
3730
+ const welcomeCardVisible = isWelcomeVisible(
3731
+ resolveWelcomeConfig(config),
3732
+ current
3733
+ );
3630
3734
  const requestedPlacement = starters.placement ?? "auto";
3631
3735
  const placement =
3632
3736
  requestedPlacement === "auto"
@@ -3776,6 +3880,7 @@ export const createAgentExperience = (
3776
3880
  let readerEngagedThisTurn = false;
3777
3881
  const markReaderEngaged = () => {
3778
3882
  readerEngagedThisTurn = true;
3883
+ revealTranscriptScrollbar();
3779
3884
  };
3780
3885
  // Dedupes assistant-turn detection across token-by-token re-renders.
3781
3886
  let lastHandledAssistantId: string | null = null;
@@ -3787,6 +3892,40 @@ export const createAgentExperience = (
3787
3892
  const USER_SCROLL_THRESHOLD = 4;
3788
3893
  const BOTTOM_THRESHOLD = 24;
3789
3894
  const AUTO_SCROLL_SNAP_THRESHOLD = 80;
3895
+
3896
+ // Scrollbar visibility policy (features.scrollBehavior.scrollbar). The
3897
+ // resolved policy is stamped on the root once and CSS scopes every
3898
+ // scroller off it. Under "on-scroll" the transcript bar reveals here on
3899
+ // genuine reader input (markReaderEngaged is the funnel for wheel, touch,
3900
+ // pointer, and nav keys, so widget-driven scrolling never reveals it) and
3901
+ // hides again after an idle delay, but only at the resting position: away
3902
+ // from rest the bar is the positional affordance and stays. The scroll
3903
+ // back down re-enters through markReaderEngaged and re-arms the timer.
3904
+ const SCROLLBAR_IDLE_MS = 1000;
3905
+ let scrollbarIdleTimer: ReturnType<typeof setTimeout> | null = null;
3906
+ const applyScrollbarPolicy = () => {
3907
+ mount.setAttribute("data-persona-scrollbar", getScrollbarPolicy());
3908
+ if (getScrollbarPolicy() !== "on-scroll") {
3909
+ if (scrollbarIdleTimer !== null) clearTimeout(scrollbarIdleTimer);
3910
+ scrollbarIdleTimer = null;
3911
+ body.removeAttribute("data-persona-scrollbar-visible");
3912
+ }
3913
+ };
3914
+ const revealTranscriptScrollbar = () => {
3915
+ if (getScrollbarPolicy() !== "on-scroll") return;
3916
+ body.setAttribute("data-persona-scrollbar-visible", "");
3917
+ if (scrollbarIdleTimer !== null) clearTimeout(scrollbarIdleTimer);
3918
+ scrollbarIdleTimer = setTimeout(() => {
3919
+ scrollbarIdleTimer = null;
3920
+ if (isElementNearBottom(body, BOTTOM_THRESHOLD)) {
3921
+ body.removeAttribute("data-persona-scrollbar-visible");
3922
+ }
3923
+ }, SCROLLBAR_IDLE_MS);
3924
+ };
3925
+ applyScrollbarPolicy();
3926
+ destroyCallbacks.push(() => {
3927
+ if (scrollbarIdleTimer !== null) clearTimeout(scrollbarIdleTimer);
3928
+ });
3790
3929
  const messageState = new Map<
3791
3930
  string,
3792
3931
  { streaming?: boolean; role: AgentWidgetMessage["role"] }
@@ -3976,14 +4115,33 @@ export const createAgentExperience = (
3976
4115
  updateScrollToBottomCountBadge();
3977
4116
  };
3978
4117
 
4118
+ // "Near the latest content", ignoring anchor-spacer air: while a turn is
4119
+ // anchored, the spacer keeps scrollable space below the last message, so
4120
+ // container-bottom distance reads "away" even with the whole reply on
4121
+ // screen (and flickers as the spacer drains). Measure against the real
4122
+ // content bottom instead; the button only appears once content actually
4123
+ // extends below the viewport.
4124
+ const isNearLatestContent = () => {
4125
+ const spacerHeight = anchorState?.spacerHeight ?? 0;
4126
+ if (spacerHeight <= 0) return isElementNearBottom(body, BOTTOM_THRESHOLD);
4127
+ const contentHeight = measureContentHeight(spacerHeight);
4128
+ return (
4129
+ contentHeight - body.scrollTop - body.clientHeight <= BOTTOM_THRESHOLD
4130
+ );
4131
+ };
4132
+
3979
4133
  // Whether the user is currently away from the latest content: drives both
3980
4134
  // the scroll-to-bottom affordance and the new-messages badge. When following
3981
4135
  // the bottom (follow mode, or a no-anchor anchor-top fallback turn) that's
3982
4136
  // "auto-follow paused"; otherwise it's simply "not near the bottom".
3983
4137
  const isAwayFromLatest = () =>
3984
- isFollowEffective()
3985
- ? !autoFollow.isFollowing()
3986
- : !isElementNearBottom(body, BOTTOM_THRESHOLD);
4138
+ isFollowEffective() ? !autoFollow.isFollowing() : !isNearLatestContent();
4139
+
4140
+ // Empty transcript = welcome-only view: there is no "latest message" to
4141
+ // follow, jump to, or badge, and bottom-scrolling clips the greeting when
4142
+ // the welcome content slightly overflows the viewport.
4143
+ const transcriptHasMessages = () =>
4144
+ !!session && session.getMessages().length > 0;
3987
4145
 
3988
4146
  const syncScrollToBottomButton = () => {
3989
4147
  if (!isScrollToBottomEnabled() || eventStreamVisible) {
@@ -3998,7 +4156,7 @@ export const createAgentExperience = (
3998
4156
  }
3999
4157
  updateScrollToBottomButtonOffset();
4000
4158
  const hasOverflow = getScrollBottomOffset(body) > 0;
4001
- const show = hasOverflow && isAwayFromLatest();
4159
+ const show = hasOverflow && isAwayFromLatest() && transcriptHasMessages();
4002
4160
  if (!show) {
4003
4161
  resetNewMessagesCount();
4004
4162
  } else {
@@ -4025,6 +4183,7 @@ export const createAgentExperience = (
4025
4183
  // no-anchor fallback turn (see `isFollowEffective`). Anchored anchor-top
4026
4184
  // turns and "none" never chase the bottom during streaming.
4027
4185
  if (!isFollowEffective()) return;
4186
+ if (!transcriptHasMessages()) return;
4028
4187
 
4029
4188
  if (!autoFollow.isFollowing()) return;
4030
4189
 
@@ -4159,6 +4318,10 @@ export const createAgentExperience = (
4159
4318
  // Instant jump used for initial mount / panel open in non-follow scroll
4160
4319
  // modes (where scheduleAutoScroll is inert).
4161
4320
  const jumpToBottomInstant = () => {
4321
+ if (!transcriptHasMessages()) {
4322
+ syncScrollToBottomButton();
4323
+ return;
4324
+ }
4162
4325
  const element = getScrollableContainer();
4163
4326
  isAutoScrolling = true;
4164
4327
  element.scrollTop = getScrollBottomOffset(element);
@@ -4241,12 +4404,55 @@ export const createAgentExperience = (
4241
4404
  };
4242
4405
 
4243
4406
  const setAnchorSpacerHeight = (height: number) => {
4244
- anchorSpacer.style.height = `${Math.max(0, Math.round(height))}px`;
4407
+ const rounded = Math.max(0, Math.round(height));
4408
+ anchorSpacer.style.height = `${rounded}px`;
4409
+ anchorSpacer.style.display = rounded > 0 ? "" : "none";
4245
4410
  if (anchorState) {
4246
4411
  anchorState.spacerHeight = Math.max(0, height);
4247
4412
  }
4248
4413
  };
4249
4414
 
4415
+ // True scroll-content height, excluding the anchor spacer. Never derive
4416
+ // this from `scrollHeight - spacer`: scrollHeight floors at clientHeight,
4417
+ // so content shorter than the viewport reads as viewport-sized and the
4418
+ // spacer comes up short by exactly the empty space below the content — the
4419
+ // anchor target then sits past maxScrollTop, the ease clamps early, and
4420
+ // every transient content dip during streaming bounces the viewport.
4421
+ // Layoutless environments (jsdom) measure 0; fall back to the naive value.
4422
+ const measureContentHeight = (previousSpacerHeight: number): number => {
4423
+ const paddingBottom =
4424
+ parseFloat(getComputedStyle(body).paddingBottom) || 0;
4425
+ let bottom = 0;
4426
+ for (const child of Array.from(body.children) as HTMLElement[]) {
4427
+ if (child === anchorSpacer || child.offsetHeight === 0) continue;
4428
+ const childBottom = offsetTopWithinBody(child) + child.offsetHeight;
4429
+ if (childBottom > bottom) bottom = childBottom;
4430
+ }
4431
+ const naive = body.scrollHeight - previousSpacerHeight;
4432
+ return bottom > 0 ? Math.min(bottom + paddingBottom, naive) : naive;
4433
+ };
4434
+
4435
+ // Stream-end spacer trim: the ResizeObserver drain is growth-driven, so it
4436
+ // stops the moment content stops growing and can leave leftover reserve —
4437
+ // dead scrollable air below the last message, plus a scroll-to-bottom
4438
+ // arrow pointing at nothing. Trim to what the reader's current position
4439
+ // actually needs plus the anti-bounce slack (shrink-only, like the drain).
4440
+ const trimAnchorSpacerToRest = () => {
4441
+ if (!anchorState || anchorState.spacerHeight <= 0) return;
4442
+ const contentHeight = measureContentHeight(anchorState.spacerHeight);
4443
+ const required = Math.max(
4444
+ 0,
4445
+ Math.round(body.scrollTop) +
4446
+ body.clientHeight -
4447
+ contentHeight +
4448
+ BOTTOM_THRESHOLD
4449
+ );
4450
+ if (required < anchorState.spacerHeight) {
4451
+ setAnchorSpacerHeight(required);
4452
+ syncScrollToBottomButton();
4453
+ }
4454
+ };
4455
+
4250
4456
  const resetAnchorState = () => {
4251
4457
  if (anchorRAF !== null) {
4252
4458
  cancelAnimationFrame(anchorRAF);
@@ -4258,8 +4464,19 @@ export const createAgentExperience = (
4258
4464
  cancelSmoothScroll();
4259
4465
  anchorState = null;
4260
4466
  anchorSpacer.style.height = "0px";
4467
+ anchorSpacer.style.display = "none";
4261
4468
  };
4262
4469
 
4470
+ const escapeMessageId = (messageId: string): string =>
4471
+ typeof CSS !== "undefined" && typeof CSS.escape === "function"
4472
+ ? CSS.escape(messageId)
4473
+ : messageId.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
4474
+
4475
+ const queryMessageBubble = (messageId: string): HTMLElement | null =>
4476
+ body.querySelector<HTMLElement>(
4477
+ `[data-message-id="${escapeMessageId(messageId)}"]`
4478
+ );
4479
+
4263
4480
  // Anchor-top mode: scroll `messageId` to rest `anchorTopOffset` px below the
4264
4481
  // viewport top and hold it there while the response streams in beneath it.
4265
4482
  // Deferred one frame so the message bubble has been rendered and laid out.
@@ -4269,10 +4486,7 @@ export const createAgentExperience = (
4269
4486
  }
4270
4487
  anchorRAF = requestAnimationFrame(() => {
4271
4488
  anchorRAF = null;
4272
- const escapedId =
4273
- typeof CSS !== "undefined" && typeof CSS.escape === "function"
4274
- ? CSS.escape(messageId)
4275
- : messageId.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
4489
+ const escapedId = escapeMessageId(messageId);
4276
4490
  const bubble = body.querySelector<HTMLElement>(
4277
4491
  `[data-message-id="${escapedId}"]`
4278
4492
  );
@@ -4284,12 +4498,17 @@ export const createAgentExperience = (
4284
4498
  const anchorOffsetTop = offsetTopWithinBody(bubble);
4285
4499
 
4286
4500
  const previousSpacerHeight = anchorState?.spacerHeight ?? 0;
4287
- const contentHeight = body.scrollHeight - previousSpacerHeight;
4501
+ const contentHeight = measureContentHeight(previousSpacerHeight);
4288
4502
  const { targetScrollTop, spacerHeight } = computeAnchorScrollState({
4289
4503
  anchorOffsetTop,
4290
4504
  topOffset: getAnchorTopOffset(),
4291
4505
  viewportHeight: body.clientHeight,
4292
- contentHeight
4506
+ contentHeight,
4507
+ // Slack past the held position so transient content dips during
4508
+ // streaming (indicator swaps) never clamp scrollTop and bounce the
4509
+ // viewport. Capped at BOTTOM_THRESHOLD so the slack region still
4510
+ // counts as "near bottom" and never surfaces the jump affordance.
4511
+ slack: BOTTOM_THRESHOLD
4293
4512
  });
4294
4513
 
4295
4514
  anchorState = {
@@ -4298,10 +4517,56 @@ export const createAgentExperience = (
4298
4517
  spacerHeight
4299
4518
  };
4300
4519
  setAnchorSpacerHeight(spacerHeight);
4301
- animateScrollTo(body, () => targetScrollTop, 220);
4520
+ // Live target: layout above the bubble can shrink mid-ease (the welcome
4521
+ // starter row hides right after this measurement frame), and a frozen
4522
+ // snapshot would carry the bubble past the offset and under the header.
4523
+ // animateScrollTo re-resolves per frame, so re-derive the pin from the
4524
+ // bubble's current position.
4525
+ animateScrollTo(
4526
+ body,
4527
+ () =>
4528
+ bubble.isConnected
4529
+ ? Math.max(0, offsetTopWithinBody(bubble) - getAnchorTopOffset())
4530
+ : targetScrollTop,
4531
+ 220
4532
+ );
4302
4533
  });
4303
4534
  };
4304
4535
 
4536
+ // Welcome dismissal removes the host's layout (and the roomier body gap) in
4537
+ // a single frame after its fade-out — after the anchor ease already scrolled
4538
+ // against the pre-collapse layout, so the browser would clamp scrollTop and
4539
+ // visibly yank the pinned message. Recompute the anchor against the
4540
+ // collapsed layout and jump instead of easing: the bubble is already
4541
+ // resting at the offset, so the re-pin is visually a no-op.
4542
+ const repinAnchoredMessage = () => {
4543
+ if (getScrollMode() !== "anchor-top") return;
4544
+ if (!anchorState || !anchoredMessageId) return;
4545
+ const bubble = queryMessageBubble(anchoredMessageId);
4546
+ if (!bubble) return;
4547
+ cancelSmoothScroll();
4548
+ const previousSpacerHeight = anchorState.spacerHeight;
4549
+ const contentHeight = measureContentHeight(previousSpacerHeight);
4550
+ const { targetScrollTop, spacerHeight } = computeAnchorScrollState({
4551
+ anchorOffsetTop: offsetTopWithinBody(bubble),
4552
+ topOffset: getAnchorTopOffset(),
4553
+ viewportHeight: body.clientHeight,
4554
+ contentHeight,
4555
+ slack: BOTTOM_THRESHOLD
4556
+ });
4557
+ anchorState = {
4558
+ initialSpacerHeight: spacerHeight,
4559
+ contentHeightAtAnchor: contentHeight,
4560
+ spacerHeight
4561
+ };
4562
+ setAnchorSpacerHeight(spacerHeight);
4563
+ isAutoScrolling = true;
4564
+ body.scrollTop = targetScrollTop;
4565
+ lastScrollTop = body.scrollTop;
4566
+ isAutoScrolling = false;
4567
+ syncScrollToBottomButton();
4568
+ };
4569
+
4305
4570
  // Content growth handler (ResizeObserver-driven). In follow mode this is
4306
4571
  // what keeps the transcript pinned when content grows *without* a render
4307
4572
  // event: images/embeds finishing loading mid-stream, fonts swapping,
@@ -4388,6 +4653,33 @@ export const createAgentExperience = (
4388
4653
  // Anchoring to the first unread (not the newest) block means this fires at
4389
4654
  // most once per turn in practice, and later blocks stream in below the
4390
4655
  // anchor exactly as they do today.
4656
+
4657
+ // Minimum readable slice of the first unread block that must fit under the
4658
+ // pinned user message for the anchor to stay put. Below this, the block is
4659
+ // "stranded" and the anchor hands off to it.
4660
+ const ANCHOR_HANDOFF_MIN_VISIBLE = 96;
4661
+
4662
+ // Market pattern (ChatGPT/Claude/Gemini): the sent user message stays
4663
+ // pinned at the top as the visible header of the streaming reply. The
4664
+ // handoff to the first unread block exists for the stranded case — a long
4665
+ // user message or a tall tool-call prelude pushing the reply below the
4666
+ // fold — so it only fires when the block's start is not readably within
4667
+ // the viewport while the user message holds the anchor.
4668
+ const isFirstUnreadStranded = (messageId: string): boolean => {
4669
+ if (lastSentUserMessageId === null) return true;
4670
+ const userBubble = queryMessageBubble(lastSentUserMessageId);
4671
+ const blockBubble = queryMessageBubble(messageId);
4672
+ // Unmeasurable geometry: fall back to handing off (pre-conditional
4673
+ // behavior) rather than risking an answer parked below the fold.
4674
+ if (!userBubble || !blockBubble) return true;
4675
+ const delta =
4676
+ offsetTopWithinBody(blockBubble) - offsetTopWithinBody(userBubble);
4677
+ return (
4678
+ getAnchorTopOffset() + delta >
4679
+ body.clientHeight - ANCHOR_HANDOFF_MIN_VISIBLE
4680
+ );
4681
+ };
4682
+
4391
4683
  const maybeAnchorToFirstUnread = (messages: AgentWidgetMessage[]) => {
4392
4684
  if (getScrollMode() !== "anchor-top") return;
4393
4685
  if (!currentTurnAnchored) return;
@@ -4403,6 +4695,10 @@ export const createAgentExperience = (
4403
4695
  if (!isAttentionWorthyMessage(message)) continue;
4404
4696
  // Already parked on it: hold still while it streams.
4405
4697
  if (message.id === anchoredMessageId) return;
4698
+ // Block starts within view under the pinned user message: keep the
4699
+ // user message anchored. Re-evaluated every render, so a tool prelude
4700
+ // that later pushes the block below the fold still hands off.
4701
+ if (!isFirstUnreadStranded(message.id)) return;
4406
4702
  anchoredMessageId = message.id;
4407
4703
  scheduleAnchorToMessage(message.id);
4408
4704
  return;
@@ -5935,8 +6231,9 @@ export const createAgentExperience = (
5935
6231
 
5936
6232
  // Re-run chrome application now that data-state has flipped: collapsed
5937
6233
  // clears container chrome (pill stands alone), expanded paints it via
5938
- // the same theme.components.panel.* contract as floating mode.
5939
- applyFullHeightStyles();
6234
+ // the same theme.components.panel.* contract as floating mode. Full
6235
+ // sync: applyFullHeightStyles alone wipes the theme vars with cssText.
6236
+ syncPanelChrome();
5940
6237
 
5941
6238
  // Outside-click dismiss: while expanded, clicking anywhere outside the
5942
6239
  // wrapper (panel chrome + pill) collapses back to just the pill.
@@ -5972,8 +6269,9 @@ export const createAgentExperience = (
5972
6269
  panel.classList.remove("persona-scale-95", "persona-opacity-0");
5973
6270
  panel.classList.add("persona-scale-100", "persona-opacity-100");
5974
6271
  // Hide launcher button when widget is open
5975
- if (launcherButtonInstance) {
5976
- launcherButtonInstance.element.style.display = "none";
6272
+ if (launcherSurfaceInstance) {
6273
+ launcherSurfaceInstance.setPanelOpen(true);
6274
+ launcherSurfaceInstance.element.style.display = "none";
5977
6275
  } else if (customLauncherElement) {
5978
6276
  customLauncherElement.style.display = "none";
5979
6277
  }
@@ -5998,8 +6296,9 @@ export const createAgentExperience = (
5998
6296
  panel.classList.add("persona-scale-95", "persona-opacity-0");
5999
6297
  }
6000
6298
  // Show launcher when closed, except docked mode (0px column: use controller.open()).
6001
- if (launcherButtonInstance) {
6002
- launcherButtonInstance.element.style.display = dockedMode ? "none" : "";
6299
+ if (launcherSurfaceInstance) {
6300
+ launcherSurfaceInstance.setPanelOpen(false);
6301
+ launcherSurfaceInstance.element.style.display = dockedMode ? "none" : "";
6003
6302
  } else if (customLauncherElement) {
6004
6303
  customLauncherElement.style.display = dockedMode ? "none" : "";
6005
6304
  }
@@ -6110,6 +6409,19 @@ export const createAgentExperience = (
6110
6409
  button.disabled = disabled;
6111
6410
  });
6112
6411
  });
6412
+ // Plugin renderStarter output: created with the streaming flag of its
6413
+ // render moment, so a stream ending must re-enable it here.
6414
+ welcomeStarterElements.forEach((element) => {
6415
+ element.toggleAttribute("data-disabled", disabled);
6416
+ if (element instanceof HTMLButtonElement) {
6417
+ element.disabled = disabled;
6418
+ } else {
6419
+ element.setAttribute("aria-disabled", disabled ? "true" : "false");
6420
+ }
6421
+ element.querySelectorAll<HTMLButtonElement>("button").forEach((button) => {
6422
+ button.disabled = disabled;
6423
+ });
6424
+ });
6113
6425
  footer.dataset.personaComposerStreaming = disabled ? "true" : "false";
6114
6426
  footer.querySelectorAll<HTMLElement>("[data-persona-composer-disable-when-streaming]").forEach((el) => {
6115
6427
  if (
@@ -6133,32 +6445,229 @@ export const createAgentExperience = (
6133
6445
  if (config.autoFocusInput) setTimeout(() => maybeFocusInput(), 200);
6134
6446
  });
6135
6447
 
6136
- const updateCopy = () => {
6137
- introTitle.textContent = config.copy?.welcomeTitle ?? "Hello 👋";
6138
- introSubtitle.textContent =
6139
- config.copy?.welcomeSubtitle ??
6140
- "Ask anything about your account or products.";
6141
- textarea.placeholder = config.copy?.inputPlaceholder ?? "How can I help...";
6142
-
6143
- // Toggle welcome card visibility
6144
- const introCard = body.querySelector("[data-persona-intro-card]") as HTMLElement | null;
6145
- if (introCard) {
6146
- const showCard = config.copy?.showWelcomeCard !== false;
6147
- introCard.style.display = showCard ? "" : "none";
6148
- if (showCard) {
6149
- body.classList.remove("persona-gap-3");
6150
- body.classList.add("persona-gap-6");
6151
- } else {
6152
- body.classList.remove("persona-gap-6");
6153
- body.classList.add("persona-gap-3");
6448
+ // Welcome visibility is derived, never stored: `resolveWelcomeConfig` owns
6449
+ // the config and the session's user messages own the dismissal.
6450
+ let welcomeShown = !welcomeHost.hidden;
6451
+ // The dismiss runs across many renders (assistant placeholder, every
6452
+ // streaming chunk), so the in-flight animation owns the host until it settles.
6453
+ let welcomeDismissAnimation: Animation | null = null;
6454
+ // Restored history must hide the hero outright, not animate it out.
6455
+ let welcomeHydrated = !pendingStoredState;
6456
+ // Content is re-applied only when the resolved config changes: this runs on
6457
+ // every message change, including each streaming chunk.
6458
+ let lastWelcomeKey: string | null = null;
6459
+ let lastWelcomeIcon: AgentWidgetWelcomeIcon | undefined;
6460
+ let lastGreetingKey: string | null = null;
6461
+ // Element returned by a `renderWelcome` plugin, or null when the default
6462
+ // renderer owns the host. A plugin element ignores derived visibility.
6463
+ let welcomePluginContent: HTMLElement | null = null;
6464
+ const updateWelcome = (messages?: AgentWidgetMessage[]) => {
6465
+ const resolved = resolveWelcomeConfig(config);
6466
+ const welcomeKey = `${resolved.variant}|${resolved.dismiss}|${resolved.title}|${resolved.subtitle}`;
6467
+ if (welcomeKey !== lastWelcomeKey || resolved.icon !== lastWelcomeIcon) {
6468
+ lastWelcomeKey = welcomeKey;
6469
+ lastWelcomeIcon = resolved.icon;
6470
+ applyWelcomeConfig(
6471
+ {
6472
+ host: welcomeHost,
6473
+ iconHolder: welcomeIconHolder,
6474
+ title: introTitle,
6475
+ subtitle: introSubtitle,
6476
+ starterSuggestions,
6477
+ },
6478
+ resolved
6479
+ );
6480
+ }
6481
+ const greetingKey =
6482
+ resolved.message === undefined
6483
+ ? null
6484
+ : `${config.layout?.messages?.layout ?? "bubble"}|${resolved.message}`;
6485
+ if (greetingKey !== lastGreetingKey) {
6486
+ lastGreetingKey = greetingKey;
6487
+ renderWelcomeGreeting(greetingHost, resolved.message, config);
6488
+ }
6489
+
6490
+ const current =
6491
+ messages ?? (session ? session.getMessages() : config.initialMessages);
6492
+ const visible = isWelcomeVisible(resolved, current);
6493
+ const flipped = visible !== welcomeShown;
6494
+ welcomeShown = visible;
6495
+ // Plugin content renders regardless of derived visibility and owns the
6496
+ // host until the plugin returns null again.
6497
+ if (welcomePluginContent) {
6498
+ welcomeDismissAnimation?.cancel();
6499
+ welcomeDismissAnimation = null;
6500
+ applyWelcomeVisibility(body, welcomeHost, true);
6501
+ return;
6502
+ }
6503
+ if (
6504
+ flipped &&
6505
+ !visible &&
6506
+ resolved.dismiss === "on-first-message" &&
6507
+ welcomeHydrated
6508
+ ) {
6509
+ welcomeDismissAnimation = animateWelcomeOut(welcomeHost, (animation) => {
6510
+ if (welcomeDismissAnimation === animation) {
6511
+ welcomeDismissAnimation = null;
6512
+ }
6513
+ // A re-show (clearChat, or plugin content claiming the host
6514
+ // mid-animation) wins the race.
6515
+ if (!welcomeShown && !welcomePluginContent) {
6516
+ applyWelcomeVisibility(body, welcomeHost, false);
6517
+ repinAnchoredMessage();
6518
+ }
6519
+ // The forwards fill would pin opacity 0 across a later re-show.
6520
+ animation?.cancel();
6521
+ });
6522
+ return;
6523
+ }
6524
+ if (welcomeDismissAnimation) {
6525
+ // Later renders must not hide the host out from under the animation.
6526
+ if (!visible) return;
6527
+ welcomeDismissAnimation.cancel();
6528
+ welcomeDismissAnimation = null;
6529
+ }
6530
+ applyWelcomeVisibility(body, welcomeHost, visible);
6531
+ if (flipped && !visible) repinAnchoredMessage();
6532
+ };
6533
+
6534
+ // `renderWelcome` arbitration. The core owns the host; a re-render runs the
6535
+ // registered cleanups, drops the previous plugin element, and re-arbitrates
6536
+ // with a fresh ctx.
6537
+ const welcomeCleanups: Array<() => void> = [];
6538
+ // renderStarter output is one-shot DOM the suggestion managers never see;
6539
+ // tracked here so setComposerDisabled can sync it on streaming flips.
6540
+ const welcomeStarterElements: HTMLElement[] = [];
6541
+ let welcomeArbitrating = false;
6542
+ let lastWelcomeArbitrationKey: string | null = null;
6543
+ let lastWelcomeArbitrationIcon: AgentWidgetWelcomeIcon | undefined;
6544
+
6545
+ const runWelcomeCleanups = () => {
6546
+ welcomeStarterElements.length = 0;
6547
+ const callbacks = welcomeCleanups.splice(0, welcomeCleanups.length);
6548
+ for (const callback of callbacks) {
6549
+ try {
6550
+ callback();
6551
+ } catch (error) {
6552
+ console.warn("[persona] renderWelcome cleanup threw", error);
6154
6553
  }
6155
6554
  }
6555
+ };
6556
+ destroyCallbacks.push(runWelcomeCleanups);
6156
6557
 
6157
- // Only update send button text if NOT using icon mode. Skip while
6158
- // streaming so we don't stomp on the "Stop" label.
6159
- const useIcon = config.sendButton?.useIcon ?? false;
6160
- if (!useIcon && !session?.isStreaming()) {
6161
- sendButton.textContent = config.copy?.sendButtonLabel ?? "Send";
6558
+ /** Default starter renderer for plugins: full select pipeline, welcome geometry. */
6559
+ const renderWelcomeStarter = (
6560
+ suggestion: AgentWidgetSuggestion,
6561
+ index: number
6562
+ ): HTMLElement => {
6563
+ const normalized = normalizeSuggestion(suggestion, index);
6564
+ // An unlabeled suggestion has nothing to render or send.
6565
+ if (!normalized) {
6566
+ if (config.debug === true) {
6567
+ console.warn("[persona] renderStarter received a suggestion with no label");
6568
+ }
6569
+ return createElement("span");
6570
+ }
6571
+ const starters = config.suggestions?.starters;
6572
+ const element = createSuggestionElement({
6573
+ item: {
6574
+ ...normalized,
6575
+ behavior: normalized.behavior ?? starters?.behavior ?? "send",
6576
+ },
6577
+ index,
6578
+ surface: "starter",
6579
+ source: "config",
6580
+ variant: starters?.variant ?? "card",
6581
+ streaming: sessionInitialized ? session.isStreaming() : false,
6582
+ config,
6583
+ plugins,
6584
+ chipsConfig: config.suggestionChipsConfig,
6585
+ session,
6586
+ getTextarea: () => textarea,
6587
+ eventTarget: welcomeHost,
6588
+ });
6589
+ welcomeStarterElements.push(element);
6590
+ return element;
6591
+ };
6592
+
6593
+ const renderWelcomeSurface = () => {
6594
+ // A plugin calling requestRender() from inside its own render would recurse.
6595
+ if (welcomeArbitrating) return;
6596
+ welcomeArbitrating = true;
6597
+ const resolved = resolveWelcomeConfig(config);
6598
+ lastWelcomeArbitrationKey = `${resolved.variant}|${resolved.dismiss}|${resolved.title}|${resolved.subtitle}|${resolved.message ?? ""}`;
6599
+ lastWelcomeArbitrationIcon = resolved.icon;
6600
+ try {
6601
+ runWelcomeCleanups();
6602
+ clearWelcomePluginContent(body, welcomeHost, welcomePluginContent);
6603
+ welcomePluginContent = null;
6604
+
6605
+ const current = session ? session.getMessages() : config.initialMessages;
6606
+ const visible = isWelcomeVisible(resolved, current);
6607
+ let starterIndex = 0;
6608
+ let element: HTMLElement | null = null;
6609
+ for (const plugin of plugins) {
6610
+ if (!plugin.renderWelcome) continue;
6611
+ element = plugin.renderWelcome({
6612
+ config: resolved,
6613
+ variant: resolved.variant,
6614
+ visible,
6615
+ defaultRenderer: () => welcomeHost,
6616
+ sendMessage: (text: string) => {
6617
+ if (!session || session.isStreaming()) return;
6618
+ const value = text.trim();
6619
+ if (!value) return;
6620
+ session.sendMessage(value);
6621
+ },
6622
+ requestRender: () => renderWelcomeSurface(),
6623
+ renderStarter: (suggestion) =>
6624
+ renderWelcomeStarter(suggestion, starterIndex++),
6625
+ storage: pluginStorageFor(plugin.id),
6626
+ onCleanup: (fn) => {
6627
+ welcomeCleanups.push(fn);
6628
+ },
6629
+ });
6630
+ if (element) break;
6631
+ }
6632
+ // Returning `defaultRenderer()` is composition, not a takeover: the
6633
+ // default content stays and derived visibility still governs.
6634
+ if (element && element !== welcomeHost) {
6635
+ welcomePluginContent = element;
6636
+ mountWelcomePluginContent(body, welcomeHost, element);
6637
+ }
6638
+ } finally {
6639
+ welcomeArbitrating = false;
6640
+ }
6641
+ updateWelcome();
6642
+ };
6643
+
6644
+ /** Re-arbitrate only when the resolved welcome config actually changed. */
6645
+ const refreshWelcomePlugins = () => {
6646
+ const resolved = resolveWelcomeConfig(config);
6647
+ const key = `${resolved.variant}|${resolved.dismiss}|${resolved.title}|${resolved.subtitle}|${resolved.message ?? ""}`;
6648
+ if (
6649
+ key === lastWelcomeArbitrationKey &&
6650
+ resolved.icon === lastWelcomeArbitrationIcon
6651
+ ) {
6652
+ return;
6653
+ }
6654
+ renderWelcomeSurface();
6655
+ };
6656
+
6657
+ const updateCopy = () => {
6658
+ updateWelcome();
6659
+ // Plugin-owned composers set their own placeholder and send label (the
6660
+ // pre-chat gate keeps its lock reason in the placeholder, for example).
6661
+ if (!composerIsPluginOwned) {
6662
+ textarea.placeholder =
6663
+ config.copy?.inputPlaceholder ?? "How can I help...";
6664
+
6665
+ // Only update send button text if NOT using icon mode. Skip while
6666
+ // streaming so we don't stomp on the "Stop" label.
6667
+ const useIcon = config.sendButton?.useIcon ?? false;
6668
+ if (!useIcon && !session?.isStreaming()) {
6669
+ sendButton.textContent = config.copy?.sendButtonLabel ?? "Send";
6670
+ }
6162
6671
  }
6163
6672
 
6164
6673
  textarea.style.fontFamily =
@@ -6299,6 +6808,7 @@ export const createAgentExperience = (
6299
6808
  suppressTransition: isStreaming,
6300
6809
  });
6301
6810
  ensureToolElapsedTimer();
6811
+ updateWelcome(messages);
6302
6812
  renderSuggestions(messages);
6303
6813
  scheduleAutoScroll(!isStreaming);
6304
6814
  trackMessages(messages);
@@ -6419,6 +6929,7 @@ export const createAgentExperience = (
6419
6929
  }
6420
6930
  if (!streaming) {
6421
6931
  scheduleAutoScroll(true);
6932
+ trimAnchorSpacerToRest();
6422
6933
  // setStreaming only fires on a change, so this is always a real stream
6423
6934
  // ending. Evaluate the never-called hint here: no later render is
6424
6935
  // guaranteed on a settled transcript.
@@ -6546,6 +7057,8 @@ export const createAgentExperience = (
6546
7057
  if (state === "idle") lastReadAloudId = null;
6547
7058
  });
6548
7059
 
7060
+ sessionInitialized = true;
7061
+
6549
7062
  // The constructor only emits onMessagesChanged when it has initial
6550
7063
  // messages, so seed send-detection explicitly for the empty-session case: // otherwise the user's very first send would be mistaken for the seed.
6551
7064
  scrollSendSeeded = true;
@@ -6629,7 +7142,10 @@ export const createAgentExperience = (
6629
7142
  console.error("[AgentWidget] Failed to hydrate stored state:", error);
6630
7143
  }
6631
7144
  })
6632
- .finally(() => maybeBootResume());
7145
+ .finally(() => {
7146
+ welcomeHydrated = true;
7147
+ maybeBootResume();
7148
+ });
6633
7149
  } else {
6634
7150
  maybeBootResume();
6635
7151
  }
@@ -6649,7 +7165,7 @@ export const createAgentExperience = (
6649
7165
  // Combine `@`-chip mentions with an inline server-command's context bundle so
6650
7166
  // both reach the message in one `mentions` payload. Either side may be null.
6651
7167
  type SubmitMentions = NonNullable<
6652
- ReturnType<NonNullable<typeof mentionOrchestrator>["collectForSubmit"]>
7168
+ ReturnType<ContextMentionOrchestrator["collectForSubmit"]>
6653
7169
  >;
6654
7170
  const mergeSubmitMentions = (
6655
7171
  a: SubmitMentions | null,
@@ -7489,27 +8005,28 @@ export const createAgentExperience = (
7489
8005
  };
7490
8006
 
7491
8007
  // Plugin hook: renderLauncher - allow plugins to provide custom launcher
7492
- let launcherButtonInstance: LauncherButton | null = null;
8008
+ let launcherSurfaceInstance: LauncherSurface | null = null;
7493
8009
  let customLauncherElement: HTMLElement | null = null;
7494
8010
 
7495
8011
  // Composer-bar mode is launcher-less by design: the persistent pill IS the
7496
8012
  // entry point, so skip creating any launcher button (default or plugin).
7497
8013
  if (launcherEnabled && !isComposerBar()) {
7498
8014
  const { instance, element } = resolveLauncher({ config, plugins, onToggle: toggleOpen });
7499
- launcherButtonInstance = instance;
8015
+ launcherSurfaceInstance = instance;
7500
8016
  // A plugin-provided launcher returns no controller instance; track its
7501
8017
  // element separately so the update path can manage it.
7502
8018
  if (!instance) customLauncherElement = element;
7503
8019
  }
7504
8020
 
7505
- if (launcherButtonInstance) {
7506
- mount.appendChild(launcherButtonInstance.element);
8021
+ if (launcherSurfaceInstance) {
8022
+ mount.appendChild(launcherSurfaceInstance.element);
7507
8023
  } else if (customLauncherElement) {
7508
8024
  mount.appendChild(customLauncherElement);
7509
8025
  }
7510
8026
  updateOpenState();
7511
8027
  renderSuggestions();
7512
8028
  updateCopy();
8029
+ renderWelcomeSurface();
7513
8030
  setComposerDisabled(session.isStreaming());
7514
8031
  // Reopen-where-left-off takes precedence when opted in (Principle 11);
7515
8032
  // otherwise fall back to the historical per-mode positioning.
@@ -7545,7 +8062,6 @@ export const createAgentExperience = (
7545
8062
 
7546
8063
  const dockedMode = isDockedMountMode(config);
7547
8064
  const sidebarMode = config.launcher?.sidebarMode ?? false;
7548
- const fullHeight = dockedMode || sidebarMode || (config.launcher?.fullHeight ?? false);
7549
8065
 
7550
8066
  // Mobile fullscreen: re-apply fullscreen styles on resize (handles orientation changes)
7551
8067
  const ownerWindow = mount.ownerDocument.defaultView ?? window;
@@ -7602,15 +8118,7 @@ export const createAgentExperience = (
7602
8118
  applyLauncherArtifactPanelWidth();
7603
8119
 
7604
8120
  // In fullHeight mode, don't set a fixed height
7605
- if (!fullHeight) {
7606
- const viewportHeight = ownerWindow.innerHeight;
7607
- const verticalMargin = 64; // leave space for launcher's offset
7608
- const heightOffset = config.launcher?.heightOffset ?? 0;
7609
- const available = Math.max(200, viewportHeight - verticalMargin);
7610
- const clamped = Math.min(640, available);
7611
- const finalHeight = Math.max(200, clamped - heightOffset);
7612
- panel.style.height = `${finalHeight}px`;
7613
- }
8121
+ applyFloatingPanelFixedHeight();
7614
8122
  } finally {
7615
8123
  // applyFullHeightStyles() assigns wrapper.style.cssText (e.g. display:flex !important), which
7616
8124
  // overwrites updateOpenState()'s display:none when docked+closed. Re-sync after every recalc.
@@ -7958,11 +8466,91 @@ export const createAgentExperience = (
7958
8466
  // lazily). When that happens, move the composer listeners onto the new element
7959
8467
  // and repoint `textarea` (the swapped element shims the textarea API the rest of
7960
8468
  // the composer code relies on). Fires immediately if the swap already occurred.
7961
- mentionOrchestrator?.onComposerSwap((next, prev) => {
7962
- detachComposerListeners(prev);
7963
- textarea = next as unknown as HTMLTextAreaElement;
7964
- attachComposerListeners(next);
7965
- });
8469
+ const registerComposerSwapHandler = () => {
8470
+ mentionOrchestrator?.onComposerSwap((next, prev) => {
8471
+ detachComposerListeners(prev);
8472
+ textarea = next as unknown as HTMLTextAreaElement;
8473
+ attachComposerListeners(next);
8474
+ });
8475
+ };
8476
+ registerComposerSwapHandler();
8477
+
8478
+ /**
8479
+ * Re-run composer plugin arbitration and swap the footer in place. Exposed to
8480
+ * composer plugins as `requestRender()`. Everything bound to the old footer
8481
+ * (listeners, attachment previews, mention affordances, the composer
8482
+ * suggestion row) is torn down and re-bound to the new one.
8483
+ */
8484
+ let composerRebuilding = false;
8485
+ function rebuildComposer(): void {
8486
+ if (composerRebuilding) return;
8487
+ composerRebuilding = true;
8488
+ try {
8489
+ const previousValue = textarea?.value ?? "";
8490
+ const activeDoc = footer.ownerDocument ?? document;
8491
+ const hadFocus = textarea ? activeDoc.activeElement === textarea : false;
8492
+
8493
+ detachComposerListeners(textarea);
8494
+ composerForm?.removeEventListener("submit", handleSubmit);
8495
+ micButton?.removeEventListener("click", handleMicButtonClick);
8496
+ // Its bindings point at the outgoing textarea; the new footer gets a new one.
8497
+ mentionOrchestrator?.destroy();
8498
+ mentionOrchestrator = null;
8499
+
8500
+ let defaultElements: _ComposerElements | null = null;
8501
+ const defaultRenderer = () => {
8502
+ defaultElements = isComposerBar()
8503
+ ? buildPillComposer({ config })
8504
+ : buildComposer({ config });
8505
+ return defaultElements.footer;
8506
+ };
8507
+ const nextFooter =
8508
+ renderComposerFromPlugins(defaultRenderer) ?? defaultRenderer();
8509
+
8510
+ view.replaceComposer(nextFooter);
8511
+ footer = view.composer.footer;
8512
+ // A plugin composer owns its own send affordance, so the built-in
8513
+ // send/stop swap has nothing to drive.
8514
+ const isDefaultFooter =
8515
+ defaultElements !== null &&
8516
+ nextFooter === (defaultElements as _ComposerElements).footer;
8517
+ composerIsPluginOwned = !isDefaultFooter;
8518
+ setSendButtonMode = isDefaultFooter
8519
+ ? (defaultElements as unknown as _ComposerElements).setSendButtonMode
8520
+ : () => {};
8521
+
8522
+ ensureComposerAttachmentSurface(footer);
8523
+ bindComposerRefsFromFooter(footer);
8524
+ applyComposerContentMaxWidth();
8525
+ setupComposerAttachments();
8526
+ setupMentionOrchestrator();
8527
+ registerComposerSwapHandler();
8528
+
8529
+ if (composerForm) composerForm.addEventListener("submit", handleSubmit);
8530
+ attachComposerListeners(textarea);
8531
+ micButton?.addEventListener("click", handleMicButtonClick);
8532
+
8533
+ // The composer suggestion manager is bound to its container element, and
8534
+ // the new footer ships a new one.
8535
+ const managerIndex = suggestionManagers.indexOf(composerSuggestionsManager);
8536
+ composerSuggestionsManager.destroy();
8537
+ composerSuggestionsManager = createSuggestions(suggestions);
8538
+ if (managerIndex >= 0) suggestionManagers[managerIndex] = composerSuggestionsManager;
8539
+
8540
+ if (textarea) textarea.value = previousValue;
8541
+ updateCopy();
8542
+ renderSuggestions();
8543
+ setComposerDisabled(session.isStreaming());
8544
+ // The new footer carries none of the per-mode inline styles. Full
8545
+ // chrome sync, not bare applyFullHeightStyles: the latter resets
8546
+ // mount.style.cssText, which silently drops every theme variable.
8547
+ syncPanelChrome();
8548
+ updateScrollToBottomButtonOffset();
8549
+ if (hadFocus) textarea?.focus();
8550
+ } finally {
8551
+ composerRebuilding = false;
8552
+ }
8553
+ }
7966
8554
 
7967
8555
  const escStopDoc = mount.ownerDocument ?? document;
7968
8556
  escStopDoc.addEventListener("keydown", handleEscStop, true);
@@ -8062,9 +8650,9 @@ export const createAgentExperience = (
8062
8650
  session.cancel();
8063
8651
  });
8064
8652
 
8065
- if (launcherButtonInstance) {
8653
+ if (launcherSurfaceInstance) {
8066
8654
  destroyCallbacks.push(() => {
8067
- launcherButtonInstance?.destroy();
8655
+ launcherSurfaceInstance?.destroy();
8068
8656
  });
8069
8657
  } else if (customLauncherElement) {
8070
8658
  destroyCallbacks.push(() => {
@@ -8122,6 +8710,7 @@ export const createAgentExperience = (
8122
8710
  resetAnchorState();
8123
8711
  resumeAutoScroll();
8124
8712
  }
8713
+ applyScrollbarPolicy();
8125
8714
  renderScrollToBottomButton();
8126
8715
  syncScrollToBottomButton();
8127
8716
  const prevShowEventStreamToggle = showEventStreamToggle;
@@ -8191,25 +8780,25 @@ export const createAgentExperience = (
8191
8780
  throughputTracker = null;
8192
8781
  }
8193
8782
 
8194
- if (config.launcher?.enabled === false && launcherButtonInstance) {
8195
- launcherButtonInstance.destroy();
8196
- launcherButtonInstance = null;
8783
+ if (config.launcher?.enabled === false && launcherSurfaceInstance) {
8784
+ launcherSurfaceInstance.destroy();
8785
+ launcherSurfaceInstance = null;
8197
8786
  }
8198
8787
  if (config.launcher?.enabled === false && customLauncherElement) {
8199
8788
  customLauncherElement.remove();
8200
8789
  customLauncherElement = null;
8201
8790
  }
8202
8791
 
8203
- if (config.launcher?.enabled !== false && !launcherButtonInstance && !customLauncherElement) {
8792
+ if (config.launcher?.enabled !== false && !launcherSurfaceInstance && !customLauncherElement) {
8204
8793
  // Resolve the launcher again when re-enabling (honors renderLauncher plugin).
8205
8794
  const { instance, element } = resolveLauncher({ config, plugins, onToggle: toggleOpen });
8206
- launcherButtonInstance = instance;
8795
+ launcherSurfaceInstance = instance;
8207
8796
  if (!instance) customLauncherElement = element;
8208
8797
  mount.appendChild(element);
8209
8798
  }
8210
8799
 
8211
- if (launcherButtonInstance) {
8212
- launcherButtonInstance.update(config);
8800
+ if (launcherSurfaceInstance) {
8801
+ launcherSurfaceInstance.update(config);
8213
8802
  }
8214
8803
  // Note: Custom launcher updates are handled by the plugin's own logic
8215
8804
 
@@ -8787,8 +9376,11 @@ export const createAgentExperience = (
8787
9376
  );
8788
9377
  renderSuggestions();
8789
9378
  updateCopy();
9379
+ // Live `welcome` updates reach `renderWelcome` plugins; unrelated
9380
+ // update() calls leave plugin-owned content (and its state) alone.
9381
+ refreshWelcomePlugins();
8790
9382
  setComposerDisabled(session.isStreaming());
8791
-
9383
+
8792
9384
  // Update voice recognition mic button visibility
8793
9385
  const voiceRecognitionEnabled = config.voiceRecognition?.enabled === true;
8794
9386
  const hasSpeechRecognition =
@@ -9213,16 +9805,20 @@ export const createAgentExperience = (
9213
9805
 
9214
9806
  // Update contentMaxWidth on messages wrapper and composer. Same
9215
9807
  // composer-bar fallback as the initial read above.
9216
- const updatedContentMaxWidth =
9217
- config.layout?.contentMaxWidth ??
9218
- (isComposerBar()
9219
- ? config.launcher?.composerBar?.contentMaxWidth ?? "720px"
9220
- : undefined);
9808
+ const updatedContentMaxWidth = resolveContentMaxWidth(
9809
+ config,
9810
+ isComposerBar()
9811
+ );
9812
+ applyContentMaxWidthVar();
9221
9813
  if (updatedContentMaxWidth) {
9222
9814
  messagesWrapper.style.maxWidth = updatedContentMaxWidth;
9223
9815
  messagesWrapper.style.marginLeft = "auto";
9224
9816
  messagesWrapper.style.marginRight = "auto";
9225
9817
  messagesWrapper.style.width = "100%";
9818
+ greetingHost.style.maxWidth = updatedContentMaxWidth;
9819
+ greetingHost.style.marginLeft = "auto";
9820
+ greetingHost.style.marginRight = "auto";
9821
+ greetingHost.style.width = "100%";
9226
9822
  if (composerForm) {
9227
9823
  composerForm.style.maxWidth = updatedContentMaxWidth;
9228
9824
  composerForm.style.marginLeft = "auto";
@@ -9233,11 +9829,18 @@ export const createAgentExperience = (
9233
9829
  suggestions.style.marginLeft = "auto";
9234
9830
  suggestions.style.marginRight = "auto";
9235
9831
  }
9832
+ statusText.style.maxWidth = updatedContentMaxWidth;
9833
+ statusText.style.marginLeft = "auto";
9834
+ statusText.style.marginRight = "auto";
9236
9835
  } else {
9237
9836
  messagesWrapper.style.maxWidth = "";
9238
9837
  messagesWrapper.style.marginLeft = "";
9239
9838
  messagesWrapper.style.marginRight = "";
9240
9839
  messagesWrapper.style.width = "";
9840
+ greetingHost.style.maxWidth = "";
9841
+ greetingHost.style.marginLeft = "";
9842
+ greetingHost.style.marginRight = "";
9843
+ greetingHost.style.width = "";
9241
9844
  if (composerForm) {
9242
9845
  composerForm.style.maxWidth = "";
9243
9846
  composerForm.style.marginLeft = "";
@@ -9248,6 +9851,9 @@ export const createAgentExperience = (
9248
9851
  suggestions.style.marginLeft = "";
9249
9852
  suggestions.style.marginRight = "";
9250
9853
  }
9854
+ statusText.style.maxWidth = "";
9855
+ statusText.style.marginLeft = "";
9856
+ statusText.style.marginRight = "";
9251
9857
  }
9252
9858
 
9253
9859
  // Update status indicator visibility and text
@@ -9736,7 +10342,7 @@ export const createAgentExperience = (
9736
10342
  destroyCallbacks.forEach((cb) => cb());
9737
10343
  wrapper.remove();
9738
10344
  pillRoot?.remove();
9739
- launcherButtonInstance?.destroy();
10345
+ launcherSurfaceInstance?.destroy();
9740
10346
  customLauncherElement?.remove();
9741
10347
  if (closeHandler) {
9742
10348
  closeButton.removeEventListener("click", closeHandler);
@@ -9872,6 +10478,9 @@ export const createAgentExperience = (
9872
10478
  const wasInVoiceMode = persistConfig.persist?.voiceState && storage.getItem(voiceModeKey) === 'true';
9873
10479
 
9874
10480
  if (wasOpen) {
10481
+ // The teaser's own timer is already queued, so suppress it synchronously
10482
+ // here: the deferred open below would otherwise run one task too late.
10483
+ launcherSurfaceInstance?.setPanelOpen(true, "auto");
9875
10484
  // Use setTimeout to ensure DOM is ready
9876
10485
  setTimeout(() => {
9877
10486
  controller.open();
@@ -9938,6 +10547,8 @@ export const createAgentExperience = (
9938
10547
  // Mirrors the same setTimeout(0) pattern used by persistState restore so both
9939
10548
  // can fire independently without interfering with each other.
9940
10549
  if (shouldOpenAfterStateLoaded && isPanelToggleable()) {
10550
+ // Same one-task-early suppression as the persistState restore above.
10551
+ launcherSurfaceInstance?.setPanelOpen(true, "auto");
9941
10552
  setTimeout(() => { controller.open(); }, 0);
9942
10553
  }
9943
10554