@runtypelabs/persona 4.14.0 → 4.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (119) hide show
  1. package/README.md +4 -4
  2. package/dist/animations/glyph-cycle.d.cts +1 -1
  3. package/dist/animations/glyph-cycle.d.ts +1 -1
  4. package/dist/animations/{types-DveIaNx6.d.cts → types-BpWGm7_6.d.cts} +17 -0
  5. package/dist/animations/{types-DveIaNx6.d.ts → types-BpWGm7_6.d.ts} +17 -0
  6. package/dist/animations/wipe.d.cts +1 -1
  7. package/dist/animations/wipe.d.ts +1 -1
  8. package/dist/chunk-QJ4LV2KW.js +1 -0
  9. package/dist/codegen.cjs +12 -11
  10. package/dist/codegen.js +14 -13
  11. package/dist/{context-mentions-ONG7A7M6.js → context-mentions-BTSTIBFC.js} +1 -1
  12. package/dist/context-mentions.cjs +11 -8
  13. package/dist/context-mentions.d.cts +520 -21
  14. package/dist/context-mentions.d.ts +520 -21
  15. package/dist/context-mentions.js +11 -8
  16. package/dist/index.cjs +69 -68
  17. package/dist/index.cjs.map +1 -1
  18. package/dist/index.d.cts +595 -46
  19. package/dist/index.d.ts +595 -46
  20. package/dist/index.global.js +60 -57
  21. package/dist/index.global.js.map +1 -1
  22. package/dist/index.js +69 -68
  23. package/dist/index.js.map +1 -1
  24. package/dist/launcher.global.js +5 -2
  25. package/dist/launcher.global.js.map +1 -1
  26. package/dist/smart-dom-reader.d.cts +520 -21
  27. package/dist/smart-dom-reader.d.ts +520 -21
  28. package/dist/testing.cjs +3 -3
  29. package/dist/testing.d.cts +7 -0
  30. package/dist/testing.d.ts +7 -0
  31. package/dist/testing.js +3 -3
  32. package/dist/theme-editor-preview.cjs +67 -67
  33. package/dist/theme-editor-preview.d.cts +534 -22
  34. package/dist/theme-editor-preview.d.ts +534 -22
  35. package/dist/theme-editor-preview.js +57 -57
  36. package/dist/theme-editor.cjs +10 -10
  37. package/dist/theme-editor.d.cts +521 -23
  38. package/dist/theme-editor.d.ts +521 -23
  39. package/dist/theme-editor.js +10 -10
  40. package/dist/theme-reference.cjs +1 -1
  41. package/dist/theme-reference.d.cts +104 -3
  42. package/dist/theme-reference.d.ts +104 -3
  43. package/dist/theme-reference.js +1 -1
  44. package/dist/widget.css +1 -1
  45. package/package.json +1 -1
  46. package/src/ask-user-question-tool.ts +10 -7
  47. package/src/components/artifact-pane.test.ts +27 -0
  48. package/src/components/artifact-pane.ts +7 -3
  49. package/src/components/artifact-preview.ts +1 -1
  50. package/src/components/composer-parts.test.ts +103 -4
  51. package/src/components/composer-parts.ts +53 -15
  52. package/src/components/header-builder.ts +27 -2
  53. package/src/components/header-layouts.test.ts +56 -0
  54. package/src/components/header-layouts.ts +29 -9
  55. package/src/components/launcher-surface.test.ts +403 -0
  56. package/src/components/launcher.ts +297 -2
  57. package/src/components/messages.ts +5 -2
  58. package/src/components/panel.ts +92 -71
  59. package/src/components/pill-composer-builder.test.ts +18 -0
  60. package/src/components/suggestions.test.ts +273 -0
  61. package/src/components/suggestions.ts +528 -86
  62. package/src/components/welcome.test.ts +121 -0
  63. package/src/components/welcome.ts +305 -0
  64. package/src/components/widget-view.test.ts +5 -3
  65. package/src/components/widget-view.ts +18 -7
  66. package/src/defaults.ts +7 -5
  67. package/src/generated/runtype-openapi-contract.ts +17 -0
  68. package/src/index-core.ts +29 -2
  69. package/src/launcher-global.ts +11 -8
  70. package/src/plugins/types.ts +136 -3
  71. package/src/presets.ts +3 -3
  72. package/src/session.ts +10 -8
  73. package/src/styles/widget-styles.test.ts +382 -0
  74. package/src/styles/widget.css +778 -4
  75. package/src/suggest-replies-tool.test.ts +233 -2
  76. package/src/suggest-replies-tool.ts +115 -39
  77. package/src/testing/mock-stream.test.ts +19 -0
  78. package/src/testing/mock-stream.ts +20 -0
  79. package/src/theme-editor/preview-utils.test.ts +34 -0
  80. package/src/theme-editor/preview-utils.ts +77 -2
  81. package/src/theme-editor/sections.test.ts +33 -0
  82. package/src/theme-editor/sections.ts +83 -6
  83. package/src/theme-reference.ts +34 -5
  84. package/src/types/theme.ts +72 -3
  85. package/src/types.ts +312 -18
  86. package/src/ui.body-top-slot.test.ts +95 -0
  87. package/src/ui.content-width.test.ts +91 -0
  88. package/src/ui.detached-panel.test.ts +1 -1
  89. package/src/ui.docked.test.ts +7 -4
  90. package/src/ui.launcher-teaser.test.ts +162 -0
  91. package/src/ui.rebuild-composer.test.ts +491 -0
  92. package/src/ui.render-welcome.test.ts +505 -0
  93. package/src/ui.scroll-additive.test.ts +91 -21
  94. package/src/ui.scroll.test.ts +302 -14
  95. package/src/ui.suggest-replies.test.ts +1112 -1
  96. package/src/ui.ts +1008 -149
  97. package/src/ui.welcome.test.ts +504 -0
  98. package/src/utils/artifact-file.test.ts +1 -1
  99. package/src/utils/artifact-gate.test.ts +27 -0
  100. package/src/utils/artifact-gate.ts +9 -0
  101. package/src/utils/attachment-manager.test.ts +37 -0
  102. package/src/utils/attachment-manager.ts +15 -0
  103. package/src/utils/auto-follow.test.ts +23 -0
  104. package/src/utils/auto-follow.ts +11 -1
  105. package/src/utils/code-generators.test.ts +40 -0
  106. package/src/utils/code-generators.ts +22 -0
  107. package/src/utils/config-merge.test.ts +24 -0
  108. package/src/utils/config-merge.ts +2 -0
  109. package/src/utils/content-width.test.ts +37 -0
  110. package/src/utils/content-width.ts +17 -0
  111. package/src/utils/icons.test.ts +26 -0
  112. package/src/utils/icons.ts +8 -0
  113. package/src/utils/plugin-storage.test.ts +94 -0
  114. package/src/utils/plugin-storage.ts +98 -0
  115. package/src/utils/theme.test.ts +216 -0
  116. package/src/utils/tokens.ts +113 -3
  117. package/src/welcome.test.ts +213 -0
  118. package/src/welcome.ts +130 -0
  119. package/dist/chunk-MMUPR2JW.js +0 -1
package/src/ui.ts CHANGED
@@ -34,7 +34,10 @@ import {
34
34
  PersonaArtifactFileMeta,
35
35
  PersonaArtifactActionContext,
36
36
  AgentWidgetContentSegment,
37
- AgentWidgetContextMentionRef
37
+ AgentWidgetContextMentionRef,
38
+ AgentWidgetSuggestion,
39
+ AgentWidgetSuggestionSource,
40
+ AgentWidgetWelcomeIcon
38
41
  } from "./types";
39
42
  import { AttachmentManager } from "./utils/attachment-manager";
40
43
  import {
@@ -47,6 +50,7 @@ import { applyThemeVariables, createThemeObserver, getActiveTheme } from "./util
47
50
  import { resolveTokenValue } from "./utils/tokens";
48
51
  import { renderLucideIcon } from "./utils/icons";
49
52
  import { createElement } from "./utils/dom";
53
+ import { resolveContentMaxWidth } from "./utils/content-width";
50
54
  import { attachTooltip } from "./utils/tooltip";
51
55
  import { downloadInfoFor } from "./utils/artifact-file";
52
56
  import { artifactCopyText } from "./components/artifact-preview";
@@ -83,9 +87,20 @@ import {
83
87
  import { syncOverlayHostStacking } from "./utils/overlay-host-stacking";
84
88
  import { acquireScrollLock } from "./utils/scroll-lock";
85
89
  import { isComposerBarMountMode, isDockedMountMode, resolveDockConfig } from "./utils/dock";
86
- import { LauncherButton } from "./components/launcher";
90
+ import { LauncherSurface } from "./components/launcher";
87
91
  import { buildHeader, buildComposer, attachHeaderToContainer } from "./components/panel";
92
+ import { buildPillComposer } from "./components/pill-composer-builder";
88
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";
89
104
  import { HEADER_THEME_CSS } from "./components/header-builder";
90
105
  import { buildHeaderWithLayout } from "./components/header-layouts";
91
106
  import { positionMap } from "./utils/positioning";
@@ -111,11 +126,16 @@ import {
111
126
  import {
112
127
  isSuggestRepliesMessage,
113
128
  latestAgentSuggestions,
129
+ resolveFollowUpsFeature,
114
130
  } from "./suggest-replies-tool";
115
131
  import { formatElapsedMs } from "./utils/formatting";
116
132
  import { approvalDetailsExpansionState, createApprovalBubble, updateApprovalDetailsUI } from "./components/approval-bubble";
117
133
  import { createBuiltInApprovalPlugin } from "./components/approval-actions";
118
- import { createSuggestions } from "./components/suggestions";
134
+ import {
135
+ createSuggestionElement,
136
+ createSuggestions,
137
+ normalizeSuggestion,
138
+ } from "./components/suggestions";
119
139
  import { EventStreamBuffer } from "./utils/event-stream-buffer";
120
140
  import { EventStreamStore } from "./utils/event-stream-store";
121
141
  import { ThroughputTracker } from "./utils/throughput-tracker";
@@ -359,6 +379,19 @@ type Controller = {
359
379
  * @deprecated Use injectMessage() instead.
360
380
  */
361
381
  injectTestMessage: (event: AgentWidgetEvent) => void;
382
+ /**
383
+ * Show follow-up suggestions from host code. An ephemeral UI overlay: the
384
+ * items never enter the transcript, the wire payload, or persisted state, so
385
+ * they do not survive a refresh. Cleared automatically when the next user
386
+ * message appends. Latest writer wins against the agent's `suggest_replies`
387
+ * payloads: this call overrides chips shown now, and a payload arriving
388
+ * afterwards overrides these. Renders through the `suggestions.followUps`
389
+ * presentation config and the full plugin and `persona:suggestReplies:*`
390
+ * event pipeline, with source `"host"`. An empty array clears.
391
+ */
392
+ setFollowUpSuggestions: (items: AgentWidgetSuggestion[]) => void;
393
+ /** Remove host-set follow-up suggestions. Agent-set chips are untouched. */
394
+ clearFollowUpSuggestions: () => void;
362
395
  getMessages: () => AgentWidgetMessage[];
363
396
  getStatus: () => AgentWidgetSessionStatus;
364
397
  getPersistentMetadata: () => Record<string, unknown>;
@@ -608,6 +641,14 @@ export const createAgentExperience = (
608
641
  // Get plugins for this instance
609
642
  const plugins = pluginRegistry.getForInstance(config.plugins);
610
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
+
611
652
  // The built-in approval renderer, shaped as a plugin. Resolved as a FALLBACK
612
653
  // (not pushed into `plugins`) so a user `renderApproval` plugin always wins
613
654
  // and a later config-update plugin push can't reorder ahead of it.
@@ -872,12 +913,17 @@ export const createAgentExperience = (
872
913
  container,
873
914
  body,
874
915
  messagesWrapper,
916
+ starterSuggestions,
917
+ transcriptSuggestions,
875
918
  suggestions,
876
919
  textarea,
877
920
  sendButton,
878
921
  sendButtonWrapper,
879
922
  composerForm,
880
923
  statusText,
924
+ welcomeHost,
925
+ welcomeIconHolder,
926
+ greetingHost,
881
927
  introTitle,
882
928
  introSubtitle,
883
929
  closeButton,
@@ -933,6 +979,7 @@ export const createAgentExperience = (
933
979
  const isActivityWhilePinnedEnabled = () =>
934
980
  scrollBehaviorFeature.showActivityWhilePinned !== false;
935
981
  const isAnnounceEnabled = () => scrollBehaviorFeature.announce === true;
982
+ const getScrollbarPolicy = () => scrollBehaviorFeature.scrollbar ?? "on-scroll";
936
983
  const scrollToBottomButton = createElement(
937
984
  "button",
938
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"
@@ -962,6 +1009,9 @@ export const createAgentExperience = (
962
1009
  anchorSpacer.style.flexShrink = "0";
963
1010
  anchorSpacer.style.pointerEvents = "none";
964
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";
965
1015
  body.appendChild(anchorSpacer);
966
1016
 
967
1017
  // Visually-hidden polite live region for screen-reader announcements
@@ -1195,16 +1245,25 @@ export const createAgentExperience = (
1195
1245
  }
1196
1246
  };
1197
1247
 
1198
- // 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.
1199
1251
  const composerPlugin = plugins.find(p => p.renderComposer);
1200
- 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;
1201
1260
  const composerCfg = config.composer;
1202
- const customComposer = composerPlugin.renderComposer({
1261
+ const streaming = sessionInitialized ? session.isStreaming() : false;
1262
+ return composerPlugin.renderComposer({
1203
1263
  config,
1204
- defaultRenderer: () => {
1205
- const composerElements = buildComposer({ config });
1206
- return composerElements.footer;
1207
- },
1264
+ defaultRenderer,
1265
+ requestRender: () => rebuildComposer(),
1266
+ storage: pluginStorageFor(composerPlugin.id),
1208
1267
  onSubmit: (text: string) => {
1209
1268
  if (!session || session.isStreaming()) return;
1210
1269
  const value = text.trim();
@@ -1226,8 +1285,8 @@ export const createAgentExperience = (
1226
1285
  attachmentManager!.clearAttachments();
1227
1286
  }
1228
1287
  },
1229
- streaming: false,
1230
- disabled: false,
1288
+ streaming,
1289
+ disabled: streaming,
1231
1290
  openAttachmentPicker: () => {
1232
1291
  attachmentInput?.click();
1233
1292
  },
@@ -1247,11 +1306,16 @@ export const createAgentExperience = (
1247
1306
  }
1248
1307
  : undefined
1249
1308
  });
1250
- if (customComposer) {
1251
- // Replace the default footer with custom composer (keeps view.composer.footer in sync).
1252
- view.replaceComposer(customComposer);
1253
- footer = view.composer.footer;
1254
- }
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;
1255
1319
  }
1256
1320
 
1257
1321
  const bindComposerRefsFromFooter = (rootFooter: HTMLElement) => {
@@ -1313,59 +1377,80 @@ export const createAgentExperience = (
1313
1377
  ensureComposerAttachmentSurface(footer);
1314
1378
  bindComposerRefsFromFooter(footer);
1315
1379
 
1316
- // Apply contentMaxWidth to composer form, suggestions, and attachment
1317
- // previews if configured. In composer-bar mode, fall back to
1318
- // `composerBar.contentMaxWidth` (default `720px`) when no explicit
1319
- // `layout.contentMaxWidth` is set, so the expanded panel's content
1320
- // centers horizontally without the host having to wire it up.
1321
- const contentMaxWidth =
1322
- config.layout?.contentMaxWidth ??
1323
- (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());
1324
1384
  if (contentMaxWidth) {
1325
1385
  messagesWrapper.style.maxWidth = contentMaxWidth;
1326
1386
  messagesWrapper.style.marginLeft = "auto";
1327
1387
  messagesWrapper.style.marginRight = "auto";
1328
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%";
1394
+ starterSuggestions.style.maxWidth = contentMaxWidth;
1395
+ transcriptSuggestions.style.maxWidth = contentMaxWidth;
1396
+ transcriptSuggestions.style.marginLeft = "auto";
1397
+ transcriptSuggestions.style.marginRight = "auto";
1398
+ transcriptSuggestions.style.width = "100%";
1329
1399
  }
1330
1400
  // The pill IS the composer in composer-bar mode and should match the
1331
1401
  // wrapper's responsive width (50vw / 70vw / 90vw), not be capped by
1332
1402
  // contentMaxWidth (which is a centered-column convention for the
1333
1403
  // expanded panel's body, not the pill input itself).
1334
- if (contentMaxWidth && composerForm && !isComposerBar()) {
1335
- composerForm.style.maxWidth = contentMaxWidth;
1336
- composerForm.style.marginLeft = "auto";
1337
- composerForm.style.marginRight = "auto";
1338
- }
1339
- if (contentMaxWidth && suggestions && !isComposerBar()) {
1340
- suggestions.style.maxWidth = contentMaxWidth;
1341
- suggestions.style.marginLeft = "auto";
1342
- suggestions.style.marginRight = "auto";
1343
- }
1344
- if (contentMaxWidth && attachmentPreviewsContainer && !isComposerBar()) {
1345
- attachmentPreviewsContainer.style.maxWidth = contentMaxWidth;
1346
- attachmentPreviewsContainer.style.marginLeft = "auto";
1347
- attachmentPreviewsContainer.style.marginRight = "auto";
1348
- }
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();
1349
1416
 
1350
- if (config.attachments?.enabled && attachmentInput && attachmentPreviewsContainer) {
1351
- attachmentManager = AttachmentManager.fromConfig(config.attachments);
1352
- 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
+ }
1353
1431
  attachmentInput.addEventListener("change", (e) => {
1354
1432
  const target = e.target as HTMLInputElement;
1355
1433
  attachmentManager?.handleFileSelect(target.files);
1356
1434
  target.value = "";
1357
1435
  });
1358
1436
 
1359
- const dropCfg = config.attachments.dropOverlay;
1360
- const overlay = buildDropOverlay(dropCfg);
1361
- container.appendChild(overlay);
1362
- }
1437
+ if (!container.querySelector(".persona-attachment-drop-overlay")) {
1438
+ container.appendChild(buildDropOverlay(config.attachments.dropOverlay));
1439
+ }
1440
+ };
1441
+ setupComposerAttachments();
1363
1442
 
1364
1443
  // Context mentions: render the affordance button + chip row eagerly (so the
1365
1444
  // feature is discoverable) and lazy-load the heavy runtime on first use. The
1366
1445
  // orchestrator returns null when disabled or sourceless.
1367
1446
  const mentionPrefetch = () => mentionOrchestrator?.prefetch();
1368
- 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;
1369
1454
  // Slash-command dispatch (prompt macros write text / submit; client actions
1370
1455
  // read/replace the value) and submission are owned by the composer input
1371
1456
  // surface itself — the mention runtime builds a textarea (chip) or
@@ -1402,7 +1487,8 @@ export const createAgentExperience = (
1402
1487
  // `@` is instant) is registered through the shared `composerListeners`
1403
1488
  // registry below, so it survives the inline contenteditable swap too.
1404
1489
  }
1405
- }
1490
+ };
1491
+ setupMentionOrchestrator();
1406
1492
 
1407
1493
  // Slot system: allow custom content injection into specific regions
1408
1494
  const renderSlots = () => {
@@ -1412,8 +1498,9 @@ export const createAgentExperience = (
1412
1498
  const getDefaultSlotContent = (slot: WidgetLayoutSlot): HTMLElement | null => {
1413
1499
  switch (slot) {
1414
1500
  case "body-top":
1415
- // Default: the intro card
1416
- 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;
1417
1504
  case "messages":
1418
1505
  return messagesWrapper;
1419
1506
  case "footer-top":
@@ -1450,7 +1537,7 @@ export const createAgentExperience = (
1450
1537
  break;
1451
1538
  case "body-top": {
1452
1539
  // Replace or prepend to body
1453
- const introCard = body.querySelector(".persona-rounded-2xl.persona-bg-persona-surface.persona-p-6");
1540
+ const introCard = body.querySelector("[data-persona-intro-card]");
1454
1541
  if (introCard) {
1455
1542
  introCard.replaceWith(element);
1456
1543
  } else {
@@ -3324,19 +3411,52 @@ export const createAgentExperience = (
3324
3411
 
3325
3412
  restoreBodyScrollTop();
3326
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
+ };
3327
3444
  applyFullHeightStyles();
3328
3445
  // Apply theme variables after applyFullHeightStyles since it resets mount.style.cssText
3329
3446
  applyThemeVariables(mount, config);
3330
3447
  applyArtifactLayoutCssVars(mount, config);
3331
3448
  applyArtifactPaneAppearance(mount, config);
3449
+ applyContentMaxWidthVar();
3332
3450
 
3333
3451
  // applyFullHeightStyles wipes mount.style.cssText, so re-apply the theme +
3334
3452
  // artifact layout vars after it, mirroring the init sequence above.
3335
3453
  syncPanelChrome = () => {
3336
3454
  applyFullHeightStyles();
3455
+ applyFloatingPanelFixedHeight();
3337
3456
  applyThemeVariables(mount, config);
3338
3457
  applyArtifactLayoutCssVars(mount, config);
3339
3458
  applyArtifactPaneAppearance(mount, config);
3459
+ applyContentMaxWidthVar();
3340
3460
  // Owned here so it lands after applyArtifactPaneAppearance and derives from
3341
3461
  // the same resolved panel radius the chat card uses (see applyFullHeightStyles).
3342
3462
  if (weldedOuterRadius) {
@@ -3446,7 +3566,18 @@ export const createAgentExperience = (
3446
3566
  }
3447
3567
  }
3448
3568
 
3449
- const suggestionsManager = createSuggestions(suggestions);
3569
+ // Reassignable: a composer rebuild swaps the row this manager renders into.
3570
+ let composerSuggestionsManager = createSuggestions(suggestions);
3571
+ const starterSuggestionsManager = createSuggestions(starterSuggestions);
3572
+ const transcriptSuggestionsManager = createSuggestions(transcriptSuggestions);
3573
+ const suggestionManagers = [
3574
+ composerSuggestionsManager,
3575
+ starterSuggestionsManager,
3576
+ transcriptSuggestionsManager,
3577
+ ];
3578
+ destroyCallbacks.push(() => {
3579
+ suggestionManagers.forEach((manager) => manager.destroy());
3580
+ });
3450
3581
  let closeHandler: (() => void) | null = null;
3451
3582
  let session: AgentWidgetSession;
3452
3583
 
@@ -3457,35 +3588,223 @@ export const createAgentExperience = (
3457
3588
  // keep their before-first-user-message behavior. Config updates MUST route
3458
3589
  // through here too: re-rendering with only `config.suggestionChips` would
3459
3590
  // drop a live agent chip row until the next message change.
3591
+ // Warn once per widget instance: renderSuggestions runs on every change.
3592
+ // Host-set follow-ups (`setFollowUpSuggestions`) are an ephemeral UI overlay:
3593
+ // never appended to the session, never sent on the wire, never persisted.
3594
+ let hostFollowUps: AgentWidgetSuggestion[] | null = null;
3595
+ // Latches: a host that drives the surface itself is a legitimate no-tool
3596
+ // integration, so the never-called hint must stay off even after the overlay
3597
+ // expires.
3598
+ let hostFollowUpsEverSet = false;
3599
+ // Latest-writer-wins bookkeeping captured at set time: the overlay is dropped
3600
+ // once another user message appends or a newer suggest_replies message lands.
3601
+ let hostFollowUpsUserCount = 0;
3602
+ let hostFollowUpsAgentId: string | null = null;
3603
+
3604
+ const countUserMessages = (messages: AgentWidgetMessage[]): number =>
3605
+ messages.reduce(
3606
+ (total, message) => (message.role === "user" ? total + 1 : total),
3607
+ 0
3608
+ );
3609
+
3610
+ /** Identity of the newest suggest_replies message, regardless of position. */
3611
+ const latestSuggestRepliesId = (
3612
+ messages: AgentWidgetMessage[]
3613
+ ): string | null => {
3614
+ for (let i = messages.length - 1; i >= 0; i--) {
3615
+ if (isSuggestRepliesMessage(messages[i])) return messages[i].id ?? null;
3616
+ }
3617
+ return null;
3618
+ };
3619
+
3620
+ let warnedPinnedStarterPlacement = false;
3621
+ const warnPinnedStarterPlacement = () => {
3622
+ if (warnedPinnedStarterPlacement || config.debug !== true) return;
3623
+ warnedPinnedStarterPlacement = true;
3624
+ console.warn(
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".',
3626
+ );
3627
+ };
3628
+ // Debug-only setup hint: a stream this session ended without the agent ever
3629
+ // calling suggest_replies, so the model never got the tool. Gated on a
3630
+ // COMPLETED stream, not on transcript shape: a restored transcript
3631
+ // (initialMessages/onStateLoaded) proves nothing about this session's wiring.
3632
+ let hintedFollowUpsNeverCalled = false;
3633
+ let completedStreamThisSession = false;
3634
+ const hintFollowUpsNeverCalled = (
3635
+ messages: AgentWidgetMessage[],
3636
+ feature: ReturnType<typeof resolveFollowUpsFeature>
3637
+ ) => {
3638
+ if (hintedFollowUpsNeverCalled || config.debug !== true) return;
3639
+ if (!feature.enabled || feature.expose) return;
3640
+ if (hostFollowUpsEverSet) return;
3641
+ if (!completedStreamThisSession) return;
3642
+ if (session.isStreaming()) return;
3643
+ if (messages.some(isSuggestRepliesMessage)) return;
3644
+ hintedFollowUpsNeverCalled = true;
3645
+ console.info(
3646
+ "[persona] Follow-up suggestions are enabled but the agent has not called suggest_replies. The model needs the tool declared (runtimeTools server-side, suggestions.followUps.expose: true from the widget, or your own backend's tool definition) and benefits from instructions telling it when to offer follow-ups. See the integration paths table in UI-COMPONENTS.md."
3647
+ );
3648
+ };
3649
+
3460
3650
  const renderSuggestions = (messages?: AgentWidgetMessage[]) => {
3461
3651
  if (!session) return;
3462
3652
  const current = messages ?? session.getMessages();
3463
- const agentChips =
3464
- config.features?.suggestReplies?.enabled !== false
3465
- ? latestAgentSuggestions(current)
3466
- : null;
3467
- if (agentChips) {
3468
- suggestionsManager.render(
3469
- agentChips,
3653
+ const clearManager = (manager: ReturnType<typeof createSuggestions>) => {
3654
+ manager.render([], session, textarea, current);
3655
+ };
3656
+ const clearOthers = (active: ReturnType<typeof createSuggestions>) => {
3657
+ suggestionManagers.forEach((manager) => {
3658
+ if (manager !== active) clearManager(manager);
3659
+ });
3660
+ };
3661
+ // Same lifecycle as agent follow-ups: the overlay expires on the next user
3662
+ // message, and a suggest_replies message newer than the set call wins.
3663
+ if (
3664
+ hostFollowUps &&
3665
+ (countUserMessages(current) > hostFollowUpsUserCount ||
3666
+ latestSuggestRepliesId(current) !== hostFollowUpsAgentId)
3667
+ ) {
3668
+ hostFollowUps = null;
3669
+ }
3670
+
3671
+ // `enabled: false` disables the tool, not the surface: host-set follow-ups
3672
+ // still render.
3673
+ const followUpsFeature = resolveFollowUpsFeature(config);
3674
+ hintFollowUpsNeverCalled(current, followUpsFeature);
3675
+ const agentSuggestions = followUpsFeature.enabled
3676
+ ? latestAgentSuggestions(current)
3677
+ : null;
3678
+ const followUpSuggestions = hostFollowUps ?? agentSuggestions;
3679
+ const followUpSource: AgentWidgetSuggestionSource = hostFollowUps
3680
+ ? "host"
3681
+ : "agent";
3682
+
3683
+ if (followUpSuggestions) {
3684
+ const followUps = config.suggestions?.followUps;
3685
+ const requestedPlacement =
3686
+ followUps?.placement ?? (followUps ? "auto" : "composer");
3687
+ const placement =
3688
+ requestedPlacement === "auto"
3689
+ ? isComposerBar()
3690
+ ? "composer"
3691
+ : "after-message"
3692
+ : requestedPlacement;
3693
+ const manager =
3694
+ placement === "after-message"
3695
+ ? transcriptSuggestionsManager
3696
+ : composerSuggestionsManager;
3697
+ clearOthers(manager);
3698
+ manager.render(
3699
+ followUpSuggestions,
3470
3700
  session,
3471
3701
  textarea,
3472
3702
  current,
3473
3703
  config.suggestionChipsConfig,
3474
- { agentPushed: true }
3704
+ {
3705
+ source: followUpSource,
3706
+ surface: "followUp",
3707
+ variant: followUps?.variant ?? "chip",
3708
+ behavior: followUps?.behavior ?? "send",
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",
3713
+ maxItems: followUps?.maxItems ?? 4,
3714
+ config,
3715
+ plugins,
3716
+ }
3475
3717
  );
3476
- } else if (current.some((msg) => msg.role === "user")) {
3477
- // Hide suggestions once a user message exists.
3478
- suggestionsManager.render([], session, textarea, current);
3479
- } else {
3480
- suggestionsManager.render(
3481
- config.suggestionChips,
3718
+ return;
3719
+ }
3720
+
3721
+ if (current.some((message) => message.role === "user")) {
3722
+ suggestionManagers.forEach(clearManager);
3723
+ return;
3724
+ }
3725
+
3726
+ const starters = config.suggestions?.starters;
3727
+ if (starters) {
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
+ );
3734
+ const requestedPlacement = starters.placement ?? "auto";
3735
+ const placement =
3736
+ requestedPlacement === "auto"
3737
+ ? welcomeCardVisible
3738
+ ? "welcome"
3739
+ : "composer"
3740
+ : requestedPlacement;
3741
+ if (placement === "welcome" && !welcomeCardVisible) {
3742
+ warnPinnedStarterPlacement();
3743
+ suggestionManagers.forEach(clearManager);
3744
+ return;
3745
+ }
3746
+ const manager =
3747
+ placement === "welcome"
3748
+ ? starterSuggestionsManager
3749
+ : composerSuggestionsManager;
3750
+ clearOthers(manager);
3751
+ manager.render(
3752
+ starters.items ?? config.suggestionChips,
3482
3753
  session,
3483
3754
  textarea,
3484
3755
  current,
3485
- config.suggestionChipsConfig
3756
+ config.suggestionChipsConfig,
3757
+ {
3758
+ surface: "starter",
3759
+ variant: starters.variant ?? "card",
3760
+ behavior: starters.behavior ?? "send",
3761
+ overflow: starters.overflow ?? "wrap",
3762
+ maxItems: starters.maxItems ?? 4,
3763
+ config,
3764
+ plugins,
3765
+ }
3486
3766
  );
3767
+ return;
3487
3768
  }
3769
+
3770
+ clearOthers(composerSuggestionsManager);
3771
+ composerSuggestionsManager.render(
3772
+ config.suggestionChips,
3773
+ session,
3774
+ textarea,
3775
+ current,
3776
+ config.suggestionChipsConfig,
3777
+ {
3778
+ surface: "starter",
3779
+ variant: "chip",
3780
+ behavior: "send",
3781
+ overflow: "wrap",
3782
+ config,
3783
+ plugins,
3784
+ }
3785
+ );
3786
+ };
3787
+
3788
+ const clearHostFollowUps = () => {
3789
+ if (!hostFollowUps) return;
3790
+ hostFollowUps = null;
3791
+ renderSuggestions();
3488
3792
  };
3793
+
3794
+ const setHostFollowUps = (items: AgentWidgetSuggestion[]) => {
3795
+ const list = Array.isArray(items) ? [...items] : [];
3796
+ if (!list.length) {
3797
+ clearHostFollowUps();
3798
+ return;
3799
+ }
3800
+ const current = session ? session.getMessages() : [];
3801
+ hostFollowUps = list;
3802
+ hostFollowUpsEverSet = true;
3803
+ hostFollowUpsUserCount = countUserMessages(current);
3804
+ hostFollowUpsAgentId = latestSuggestRepliesId(current);
3805
+ renderSuggestions(current);
3806
+ };
3807
+
3489
3808
  let isStreaming = false;
3490
3809
  const messageCache = createMessageCache();
3491
3810
  // Tracks the last fingerprint we rendered a plugin-rendered ask_user_question
@@ -3561,6 +3880,7 @@ export const createAgentExperience = (
3561
3880
  let readerEngagedThisTurn = false;
3562
3881
  const markReaderEngaged = () => {
3563
3882
  readerEngagedThisTurn = true;
3883
+ revealTranscriptScrollbar();
3564
3884
  };
3565
3885
  // Dedupes assistant-turn detection across token-by-token re-renders.
3566
3886
  let lastHandledAssistantId: string | null = null;
@@ -3572,6 +3892,40 @@ export const createAgentExperience = (
3572
3892
  const USER_SCROLL_THRESHOLD = 4;
3573
3893
  const BOTTOM_THRESHOLD = 24;
3574
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
+ });
3575
3929
  const messageState = new Map<
3576
3930
  string,
3577
3931
  { streaming?: boolean; role: AgentWidgetMessage["role"] }
@@ -3761,14 +4115,33 @@ export const createAgentExperience = (
3761
4115
  updateScrollToBottomCountBadge();
3762
4116
  };
3763
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
+
3764
4133
  // Whether the user is currently away from the latest content: drives both
3765
4134
  // the scroll-to-bottom affordance and the new-messages badge. When following
3766
4135
  // the bottom (follow mode, or a no-anchor anchor-top fallback turn) that's
3767
4136
  // "auto-follow paused"; otherwise it's simply "not near the bottom".
3768
4137
  const isAwayFromLatest = () =>
3769
- isFollowEffective()
3770
- ? !autoFollow.isFollowing()
3771
- : !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;
3772
4145
 
3773
4146
  const syncScrollToBottomButton = () => {
3774
4147
  if (!isScrollToBottomEnabled() || eventStreamVisible) {
@@ -3783,7 +4156,7 @@ export const createAgentExperience = (
3783
4156
  }
3784
4157
  updateScrollToBottomButtonOffset();
3785
4158
  const hasOverflow = getScrollBottomOffset(body) > 0;
3786
- const show = hasOverflow && isAwayFromLatest();
4159
+ const show = hasOverflow && isAwayFromLatest() && transcriptHasMessages();
3787
4160
  if (!show) {
3788
4161
  resetNewMessagesCount();
3789
4162
  } else {
@@ -3810,6 +4183,7 @@ export const createAgentExperience = (
3810
4183
  // no-anchor fallback turn (see `isFollowEffective`). Anchored anchor-top
3811
4184
  // turns and "none" never chase the bottom during streaming.
3812
4185
  if (!isFollowEffective()) return;
4186
+ if (!transcriptHasMessages()) return;
3813
4187
 
3814
4188
  if (!autoFollow.isFollowing()) return;
3815
4189
 
@@ -3944,6 +4318,10 @@ export const createAgentExperience = (
3944
4318
  // Instant jump used for initial mount / panel open in non-follow scroll
3945
4319
  // modes (where scheduleAutoScroll is inert).
3946
4320
  const jumpToBottomInstant = () => {
4321
+ if (!transcriptHasMessages()) {
4322
+ syncScrollToBottomButton();
4323
+ return;
4324
+ }
3947
4325
  const element = getScrollableContainer();
3948
4326
  isAutoScrolling = true;
3949
4327
  element.scrollTop = getScrollBottomOffset(element);
@@ -4026,12 +4404,55 @@ export const createAgentExperience = (
4026
4404
  };
4027
4405
 
4028
4406
  const setAnchorSpacerHeight = (height: number) => {
4029
- 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";
4030
4410
  if (anchorState) {
4031
4411
  anchorState.spacerHeight = Math.max(0, height);
4032
4412
  }
4033
4413
  };
4034
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
+
4035
4456
  const resetAnchorState = () => {
4036
4457
  if (anchorRAF !== null) {
4037
4458
  cancelAnimationFrame(anchorRAF);
@@ -4043,8 +4464,19 @@ export const createAgentExperience = (
4043
4464
  cancelSmoothScroll();
4044
4465
  anchorState = null;
4045
4466
  anchorSpacer.style.height = "0px";
4467
+ anchorSpacer.style.display = "none";
4046
4468
  };
4047
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
+
4048
4480
  // Anchor-top mode: scroll `messageId` to rest `anchorTopOffset` px below the
4049
4481
  // viewport top and hold it there while the response streams in beneath it.
4050
4482
  // Deferred one frame so the message bubble has been rendered and laid out.
@@ -4054,10 +4486,7 @@ export const createAgentExperience = (
4054
4486
  }
4055
4487
  anchorRAF = requestAnimationFrame(() => {
4056
4488
  anchorRAF = null;
4057
- const escapedId =
4058
- typeof CSS !== "undefined" && typeof CSS.escape === "function"
4059
- ? CSS.escape(messageId)
4060
- : messageId.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
4489
+ const escapedId = escapeMessageId(messageId);
4061
4490
  const bubble = body.querySelector<HTMLElement>(
4062
4491
  `[data-message-id="${escapedId}"]`
4063
4492
  );
@@ -4069,12 +4498,17 @@ export const createAgentExperience = (
4069
4498
  const anchorOffsetTop = offsetTopWithinBody(bubble);
4070
4499
 
4071
4500
  const previousSpacerHeight = anchorState?.spacerHeight ?? 0;
4072
- const contentHeight = body.scrollHeight - previousSpacerHeight;
4501
+ const contentHeight = measureContentHeight(previousSpacerHeight);
4073
4502
  const { targetScrollTop, spacerHeight } = computeAnchorScrollState({
4074
4503
  anchorOffsetTop,
4075
4504
  topOffset: getAnchorTopOffset(),
4076
4505
  viewportHeight: body.clientHeight,
4077
- 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
4078
4512
  });
4079
4513
 
4080
4514
  anchorState = {
@@ -4083,10 +4517,56 @@ export const createAgentExperience = (
4083
4517
  spacerHeight
4084
4518
  };
4085
4519
  setAnchorSpacerHeight(spacerHeight);
4086
- 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
+ );
4087
4533
  });
4088
4534
  };
4089
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
+
4090
4570
  // Content growth handler (ResizeObserver-driven). In follow mode this is
4091
4571
  // what keeps the transcript pinned when content grows *without* a render
4092
4572
  // event: images/embeds finishing loading mid-stream, fonts swapping,
@@ -4173,6 +4653,33 @@ export const createAgentExperience = (
4173
4653
  // Anchoring to the first unread (not the newest) block means this fires at
4174
4654
  // most once per turn in practice, and later blocks stream in below the
4175
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
+
4176
4683
  const maybeAnchorToFirstUnread = (messages: AgentWidgetMessage[]) => {
4177
4684
  if (getScrollMode() !== "anchor-top") return;
4178
4685
  if (!currentTurnAnchored) return;
@@ -4188,6 +4695,10 @@ export const createAgentExperience = (
4188
4695
  if (!isAttentionWorthyMessage(message)) continue;
4189
4696
  // Already parked on it: hold still while it streams.
4190
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;
4191
4702
  anchoredMessageId = message.id;
4192
4703
  scheduleAnchorToMessage(message.id);
4193
4704
  return;
@@ -4511,7 +5022,7 @@ export const createAgentExperience = (
4511
5022
  // visible instead of silently swallowed.
4512
5023
  if (
4513
5024
  isSuggestRepliesMessage(message) &&
4514
- config.features?.suggestReplies?.enabled !== false
5025
+ resolveFollowUpsFeature(config).enabled
4515
5026
  ) {
4516
5027
  return;
4517
5028
  }
@@ -5720,8 +6231,9 @@ export const createAgentExperience = (
5720
6231
 
5721
6232
  // Re-run chrome application now that data-state has flipped: collapsed
5722
6233
  // clears container chrome (pill stands alone), expanded paints it via
5723
- // the same theme.components.panel.* contract as floating mode.
5724
- applyFullHeightStyles();
6234
+ // the same theme.components.panel.* contract as floating mode. Full
6235
+ // sync: applyFullHeightStyles alone wipes the theme vars with cssText.
6236
+ syncPanelChrome();
5725
6237
 
5726
6238
  // Outside-click dismiss: while expanded, clicking anywhere outside the
5727
6239
  // wrapper (panel chrome + pill) collapses back to just the pill.
@@ -5757,8 +6269,9 @@ export const createAgentExperience = (
5757
6269
  panel.classList.remove("persona-scale-95", "persona-opacity-0");
5758
6270
  panel.classList.add("persona-scale-100", "persona-opacity-100");
5759
6271
  // Hide launcher button when widget is open
5760
- if (launcherButtonInstance) {
5761
- launcherButtonInstance.element.style.display = "none";
6272
+ if (launcherSurfaceInstance) {
6273
+ launcherSurfaceInstance.setPanelOpen(true);
6274
+ launcherSurfaceInstance.element.style.display = "none";
5762
6275
  } else if (customLauncherElement) {
5763
6276
  customLauncherElement.style.display = "none";
5764
6277
  }
@@ -5783,8 +6296,9 @@ export const createAgentExperience = (
5783
6296
  panel.classList.add("persona-scale-95", "persona-opacity-0");
5784
6297
  }
5785
6298
  // Show launcher when closed, except docked mode (0px column: use controller.open()).
5786
- if (launcherButtonInstance) {
5787
- launcherButtonInstance.element.style.display = dockedMode ? "none" : "";
6299
+ if (launcherSurfaceInstance) {
6300
+ launcherSurfaceInstance.setPanelOpen(false);
6301
+ launcherSurfaceInstance.element.style.display = dockedMode ? "none" : "";
5788
6302
  } else if (customLauncherElement) {
5789
6303
  customLauncherElement.style.display = dockedMode ? "none" : "";
5790
6304
  }
@@ -5884,8 +6398,29 @@ export const createAgentExperience = (
5884
6398
  if (micButton) {
5885
6399
  micButton.disabled = disabled;
5886
6400
  }
5887
- suggestionsManager.buttons.forEach((btn) => {
5888
- btn.disabled = disabled;
6401
+ suggestionManagers.forEach((manager) => {
6402
+ manager.elements.forEach((element) => {
6403
+ element.toggleAttribute("data-disabled", disabled);
6404
+ if (!(element instanceof HTMLButtonElement)) {
6405
+ element.setAttribute("aria-disabled", disabled ? "true" : "false");
6406
+ }
6407
+ });
6408
+ manager.buttons.forEach((button) => {
6409
+ button.disabled = disabled;
6410
+ });
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
+ });
5889
6424
  });
5890
6425
  footer.dataset.personaComposerStreaming = disabled ? "true" : "false";
5891
6426
  footer.querySelectorAll<HTMLElement>("[data-persona-composer-disable-when-streaming]").forEach((el) => {
@@ -5910,32 +6445,229 @@ export const createAgentExperience = (
5910
6445
  if (config.autoFocusInput) setTimeout(() => maybeFocusInput(), 200);
5911
6446
  });
5912
6447
 
5913
- const updateCopy = () => {
5914
- introTitle.textContent = config.copy?.welcomeTitle ?? "Hello 👋";
5915
- introSubtitle.textContent =
5916
- config.copy?.welcomeSubtitle ??
5917
- "Ask anything about your account or products.";
5918
- textarea.placeholder = config.copy?.inputPlaceholder ?? "How can I help...";
5919
-
5920
- // Toggle welcome card visibility
5921
- const introCard = body.querySelector("[data-persona-intro-card]") as HTMLElement | null;
5922
- if (introCard) {
5923
- const showCard = config.copy?.showWelcomeCard !== false;
5924
- introCard.style.display = showCard ? "" : "none";
5925
- if (showCard) {
5926
- body.classList.remove("persona-gap-3");
5927
- body.classList.add("persona-gap-6");
5928
- } else {
5929
- body.classList.remove("persona-gap-6");
5930
- 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);
6553
+ }
6554
+ }
6555
+ };
6556
+ destroyCallbacks.push(runWelcomeCleanups);
6557
+
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);
5931
6637
  }
6638
+ } finally {
6639
+ welcomeArbitrating = false;
5932
6640
  }
6641
+ updateWelcome();
6642
+ };
5933
6643
 
5934
- // Only update send button text if NOT using icon mode. Skip while
5935
- // streaming so we don't stomp on the "Stop" label.
5936
- const useIcon = config.sendButton?.useIcon ?? false;
5937
- if (!useIcon && !session?.isStreaming()) {
5938
- sendButton.textContent = config.copy?.sendButtonLabel ?? "Send";
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
+ }
5939
6671
  }
5940
6672
 
5941
6673
  textarea.style.fontFamily =
@@ -6076,6 +6808,7 @@ export const createAgentExperience = (
6076
6808
  suppressTransition: isStreaming,
6077
6809
  });
6078
6810
  ensureToolElapsedTimer();
6811
+ updateWelcome(messages);
6079
6812
  renderSuggestions(messages);
6080
6813
  scheduleAutoScroll(!isStreaming);
6081
6814
  trackMessages(messages);
@@ -6196,6 +6929,17 @@ export const createAgentExperience = (
6196
6929
  }
6197
6930
  if (!streaming) {
6198
6931
  scheduleAutoScroll(true);
6932
+ trimAnchorSpacerToRest();
6933
+ // setStreaming only fires on a change, so this is always a real stream
6934
+ // ending. Evaluate the never-called hint here: no later render is
6935
+ // guaranteed on a settled transcript.
6936
+ completedStreamThisSession = true;
6937
+ if (session) {
6938
+ hintFollowUpsNeverCalled(
6939
+ session.getMessages(),
6940
+ resolveFollowUpsFeature(config)
6941
+ );
6942
+ }
6199
6943
  }
6200
6944
  // Keep the "streaming below" hint and its announcement in sync with the
6201
6945
  // streaming lifecycle (Principles 8 + 15).
@@ -6313,6 +7057,8 @@ export const createAgentExperience = (
6313
7057
  if (state === "idle") lastReadAloudId = null;
6314
7058
  });
6315
7059
 
7060
+ sessionInitialized = true;
7061
+
6316
7062
  // The constructor only emits onMessagesChanged when it has initial
6317
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.
6318
7064
  scrollSendSeeded = true;
@@ -6396,7 +7142,10 @@ export const createAgentExperience = (
6396
7142
  console.error("[AgentWidget] Failed to hydrate stored state:", error);
6397
7143
  }
6398
7144
  })
6399
- .finally(() => maybeBootResume());
7145
+ .finally(() => {
7146
+ welcomeHydrated = true;
7147
+ maybeBootResume();
7148
+ });
6400
7149
  } else {
6401
7150
  maybeBootResume();
6402
7151
  }
@@ -6416,7 +7165,7 @@ export const createAgentExperience = (
6416
7165
  // Combine `@`-chip mentions with an inline server-command's context bundle so
6417
7166
  // both reach the message in one `mentions` payload. Either side may be null.
6418
7167
  type SubmitMentions = NonNullable<
6419
- ReturnType<NonNullable<typeof mentionOrchestrator>["collectForSubmit"]>
7168
+ ReturnType<ContextMentionOrchestrator["collectForSubmit"]>
6420
7169
  >;
6421
7170
  const mergeSubmitMentions = (
6422
7171
  a: SubmitMentions | null,
@@ -7256,27 +8005,28 @@ export const createAgentExperience = (
7256
8005
  };
7257
8006
 
7258
8007
  // Plugin hook: renderLauncher - allow plugins to provide custom launcher
7259
- let launcherButtonInstance: LauncherButton | null = null;
8008
+ let launcherSurfaceInstance: LauncherSurface | null = null;
7260
8009
  let customLauncherElement: HTMLElement | null = null;
7261
8010
 
7262
8011
  // Composer-bar mode is launcher-less by design: the persistent pill IS the
7263
8012
  // entry point, so skip creating any launcher button (default or plugin).
7264
8013
  if (launcherEnabled && !isComposerBar()) {
7265
8014
  const { instance, element } = resolveLauncher({ config, plugins, onToggle: toggleOpen });
7266
- launcherButtonInstance = instance;
8015
+ launcherSurfaceInstance = instance;
7267
8016
  // A plugin-provided launcher returns no controller instance; track its
7268
8017
  // element separately so the update path can manage it.
7269
8018
  if (!instance) customLauncherElement = element;
7270
8019
  }
7271
8020
 
7272
- if (launcherButtonInstance) {
7273
- mount.appendChild(launcherButtonInstance.element);
8021
+ if (launcherSurfaceInstance) {
8022
+ mount.appendChild(launcherSurfaceInstance.element);
7274
8023
  } else if (customLauncherElement) {
7275
8024
  mount.appendChild(customLauncherElement);
7276
8025
  }
7277
8026
  updateOpenState();
7278
8027
  renderSuggestions();
7279
8028
  updateCopy();
8029
+ renderWelcomeSurface();
7280
8030
  setComposerDisabled(session.isStreaming());
7281
8031
  // Reopen-where-left-off takes precedence when opted in (Principle 11);
7282
8032
  // otherwise fall back to the historical per-mode positioning.
@@ -7312,7 +8062,6 @@ export const createAgentExperience = (
7312
8062
 
7313
8063
  const dockedMode = isDockedMountMode(config);
7314
8064
  const sidebarMode = config.launcher?.sidebarMode ?? false;
7315
- const fullHeight = dockedMode || sidebarMode || (config.launcher?.fullHeight ?? false);
7316
8065
 
7317
8066
  // Mobile fullscreen: re-apply fullscreen styles on resize (handles orientation changes)
7318
8067
  const ownerWindow = mount.ownerDocument.defaultView ?? window;
@@ -7369,15 +8118,7 @@ export const createAgentExperience = (
7369
8118
  applyLauncherArtifactPanelWidth();
7370
8119
 
7371
8120
  // In fullHeight mode, don't set a fixed height
7372
- if (!fullHeight) {
7373
- const viewportHeight = ownerWindow.innerHeight;
7374
- const verticalMargin = 64; // leave space for launcher's offset
7375
- const heightOffset = config.launcher?.heightOffset ?? 0;
7376
- const available = Math.max(200, viewportHeight - verticalMargin);
7377
- const clamped = Math.min(640, available);
7378
- const finalHeight = Math.max(200, clamped - heightOffset);
7379
- panel.style.height = `${finalHeight}px`;
7380
- }
8121
+ applyFloatingPanelFixedHeight();
7381
8122
  } finally {
7382
8123
  // applyFullHeightStyles() assigns wrapper.style.cssText (e.g. display:flex !important), which
7383
8124
  // overwrites updateOpenState()'s display:none when docked+closed. Re-sync after every recalc.
@@ -7725,11 +8466,91 @@ export const createAgentExperience = (
7725
8466
  // lazily). When that happens, move the composer listeners onto the new element
7726
8467
  // and repoint `textarea` (the swapped element shims the textarea API the rest of
7727
8468
  // the composer code relies on). Fires immediately if the swap already occurred.
7728
- mentionOrchestrator?.onComposerSwap((next, prev) => {
7729
- detachComposerListeners(prev);
7730
- textarea = next as unknown as HTMLTextAreaElement;
7731
- attachComposerListeners(next);
7732
- });
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
+ }
7733
8554
 
7734
8555
  const escStopDoc = mount.ownerDocument ?? document;
7735
8556
  escStopDoc.addEventListener("keydown", handleEscStop, true);
@@ -7829,9 +8650,9 @@ export const createAgentExperience = (
7829
8650
  session.cancel();
7830
8651
  });
7831
8652
 
7832
- if (launcherButtonInstance) {
8653
+ if (launcherSurfaceInstance) {
7833
8654
  destroyCallbacks.push(() => {
7834
- launcherButtonInstance?.destroy();
8655
+ launcherSurfaceInstance?.destroy();
7835
8656
  });
7836
8657
  } else if (customLauncherElement) {
7837
8658
  destroyCallbacks.push(() => {
@@ -7889,6 +8710,7 @@ export const createAgentExperience = (
7889
8710
  resetAnchorState();
7890
8711
  resumeAutoScroll();
7891
8712
  }
8713
+ applyScrollbarPolicy();
7892
8714
  renderScrollToBottomButton();
7893
8715
  syncScrollToBottomButton();
7894
8716
  const prevShowEventStreamToggle = showEventStreamToggle;
@@ -7958,25 +8780,25 @@ export const createAgentExperience = (
7958
8780
  throughputTracker = null;
7959
8781
  }
7960
8782
 
7961
- if (config.launcher?.enabled === false && launcherButtonInstance) {
7962
- launcherButtonInstance.destroy();
7963
- launcherButtonInstance = null;
8783
+ if (config.launcher?.enabled === false && launcherSurfaceInstance) {
8784
+ launcherSurfaceInstance.destroy();
8785
+ launcherSurfaceInstance = null;
7964
8786
  }
7965
8787
  if (config.launcher?.enabled === false && customLauncherElement) {
7966
8788
  customLauncherElement.remove();
7967
8789
  customLauncherElement = null;
7968
8790
  }
7969
8791
 
7970
- if (config.launcher?.enabled !== false && !launcherButtonInstance && !customLauncherElement) {
8792
+ if (config.launcher?.enabled !== false && !launcherSurfaceInstance && !customLauncherElement) {
7971
8793
  // Resolve the launcher again when re-enabling (honors renderLauncher plugin).
7972
8794
  const { instance, element } = resolveLauncher({ config, plugins, onToggle: toggleOpen });
7973
- launcherButtonInstance = instance;
8795
+ launcherSurfaceInstance = instance;
7974
8796
  if (!instance) customLauncherElement = element;
7975
8797
  mount.appendChild(element);
7976
8798
  }
7977
8799
 
7978
- if (launcherButtonInstance) {
7979
- launcherButtonInstance.update(config);
8800
+ if (launcherSurfaceInstance) {
8801
+ launcherSurfaceInstance.update(config);
7980
8802
  }
7981
8803
  // Note: Custom launcher updates are handled by the plugin's own logic
7982
8804
 
@@ -8554,8 +9376,11 @@ export const createAgentExperience = (
8554
9376
  );
8555
9377
  renderSuggestions();
8556
9378
  updateCopy();
9379
+ // Live `welcome` updates reach `renderWelcome` plugins; unrelated
9380
+ // update() calls leave plugin-owned content (and its state) alone.
9381
+ refreshWelcomePlugins();
8557
9382
  setComposerDisabled(session.isStreaming());
8558
-
9383
+
8559
9384
  // Update voice recognition mic button visibility
8560
9385
  const voiceRecognitionEnabled = config.voiceRecognition?.enabled === true;
8561
9386
  const hasSpeechRecognition =
@@ -8980,16 +9805,20 @@ export const createAgentExperience = (
8980
9805
 
8981
9806
  // Update contentMaxWidth on messages wrapper and composer. Same
8982
9807
  // composer-bar fallback as the initial read above.
8983
- const updatedContentMaxWidth =
8984
- config.layout?.contentMaxWidth ??
8985
- (isComposerBar()
8986
- ? config.launcher?.composerBar?.contentMaxWidth ?? "720px"
8987
- : undefined);
9808
+ const updatedContentMaxWidth = resolveContentMaxWidth(
9809
+ config,
9810
+ isComposerBar()
9811
+ );
9812
+ applyContentMaxWidthVar();
8988
9813
  if (updatedContentMaxWidth) {
8989
9814
  messagesWrapper.style.maxWidth = updatedContentMaxWidth;
8990
9815
  messagesWrapper.style.marginLeft = "auto";
8991
9816
  messagesWrapper.style.marginRight = "auto";
8992
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%";
8993
9822
  if (composerForm) {
8994
9823
  composerForm.style.maxWidth = updatedContentMaxWidth;
8995
9824
  composerForm.style.marginLeft = "auto";
@@ -9000,11 +9829,18 @@ export const createAgentExperience = (
9000
9829
  suggestions.style.marginLeft = "auto";
9001
9830
  suggestions.style.marginRight = "auto";
9002
9831
  }
9832
+ statusText.style.maxWidth = updatedContentMaxWidth;
9833
+ statusText.style.marginLeft = "auto";
9834
+ statusText.style.marginRight = "auto";
9003
9835
  } else {
9004
9836
  messagesWrapper.style.maxWidth = "";
9005
9837
  messagesWrapper.style.marginLeft = "";
9006
9838
  messagesWrapper.style.marginRight = "";
9007
9839
  messagesWrapper.style.width = "";
9840
+ greetingHost.style.maxWidth = "";
9841
+ greetingHost.style.marginLeft = "";
9842
+ greetingHost.style.marginRight = "";
9843
+ greetingHost.style.width = "";
9008
9844
  if (composerForm) {
9009
9845
  composerForm.style.maxWidth = "";
9010
9846
  composerForm.style.marginLeft = "";
@@ -9015,6 +9851,9 @@ export const createAgentExperience = (
9015
9851
  suggestions.style.marginLeft = "";
9016
9852
  suggestions.style.marginRight = "";
9017
9853
  }
9854
+ statusText.style.maxWidth = "";
9855
+ statusText.style.marginLeft = "";
9856
+ statusText.style.marginRight = "";
9018
9857
  }
9019
9858
 
9020
9859
  // Update status indicator visibility and text
@@ -9358,6 +10197,21 @@ export const createAgentExperience = (
9358
10197
  }
9359
10198
  return session.resolveApproval(approvalMessage.approval, decision, options);
9360
10199
  },
10200
+ /**
10201
+ * Show follow-up suggestions from host code. Ephemeral UI state only: the
10202
+ * items never enter the transcript, the wire payload, or persistence, so
10203
+ * they do not survive a refresh. Cleared when the next user message
10204
+ * appends. Latest writer wins against agent `suggest_replies` payloads.
10205
+ * Renders through the `suggestions.followUps` presentation config and the
10206
+ * full plugin and DOM-event pipeline with source `"host"`.
10207
+ */
10208
+ setFollowUpSuggestions(items: AgentWidgetSuggestion[]): void {
10209
+ setHostFollowUps(items);
10210
+ },
10211
+ /** Remove host-set follow-ups. Agent-set chips are untouched. */
10212
+ clearFollowUpSuggestions(): void {
10213
+ clearHostFollowUps();
10214
+ },
9361
10215
  getMessages() {
9362
10216
  return session.getMessages();
9363
10217
  },
@@ -9488,7 +10342,7 @@ export const createAgentExperience = (
9488
10342
  destroyCallbacks.forEach((cb) => cb());
9489
10343
  wrapper.remove();
9490
10344
  pillRoot?.remove();
9491
- launcherButtonInstance?.destroy();
10345
+ launcherSurfaceInstance?.destroy();
9492
10346
  customLauncherElement?.remove();
9493
10347
  if (closeHandler) {
9494
10348
  closeButton.removeEventListener("click", closeHandler);
@@ -9624,6 +10478,9 @@ export const createAgentExperience = (
9624
10478
  const wasInVoiceMode = persistConfig.persist?.voiceState && storage.getItem(voiceModeKey) === 'true';
9625
10479
 
9626
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");
9627
10484
  // Use setTimeout to ensure DOM is ready
9628
10485
  setTimeout(() => {
9629
10486
  controller.open();
@@ -9690,6 +10547,8 @@ export const createAgentExperience = (
9690
10547
  // Mirrors the same setTimeout(0) pattern used by persistState restore so both
9691
10548
  // can fire independently without interfering with each other.
9692
10549
  if (shouldOpenAfterStateLoaded && isPanelToggleable()) {
10550
+ // Same one-task-early suppression as the persistState restore above.
10551
+ launcherSurfaceInstance?.setPanelOpen(true, "auto");
9693
10552
  setTimeout(() => { controller.open(); }, 0);
9694
10553
  }
9695
10554