@runtypelabs/persona 4.13.0 → 4.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (106) hide show
  1. package/README.md +4 -4
  2. package/dist/animations/glyph-cycle.d.cts +1 -1
  3. package/dist/animations/glyph-cycle.d.ts +1 -1
  4. package/dist/animations/{types-DveIaNx6.d.cts → types-BpWGm7_6.d.cts} +17 -0
  5. package/dist/animations/{types-DveIaNx6.d.ts → types-BpWGm7_6.d.ts} +17 -0
  6. package/dist/animations/wipe.d.cts +1 -1
  7. package/dist/animations/wipe.d.ts +1 -1
  8. package/dist/chunk-QJ4LV2KW.js +1 -0
  9. package/dist/codegen.cjs +12 -11
  10. package/dist/codegen.js +14 -13
  11. package/dist/{context-mentions-ONG7A7M6.js → context-mentions-BTSTIBFC.js} +1 -1
  12. package/dist/context-mentions.cjs +11 -8
  13. package/dist/context-mentions.d.cts +339 -22
  14. package/dist/context-mentions.d.ts +339 -22
  15. package/dist/context-mentions.js +11 -8
  16. package/dist/index.cjs +68 -67
  17. package/dist/index.cjs.map +1 -1
  18. package/dist/index.d.cts +483 -48
  19. package/dist/index.d.ts +483 -48
  20. package/dist/index.global.js +61 -58
  21. package/dist/index.global.js.map +1 -1
  22. package/dist/index.js +70 -69
  23. package/dist/index.js.map +1 -1
  24. package/dist/launcher.global.js +5 -2
  25. package/dist/launcher.global.js.map +1 -1
  26. package/dist/smart-dom-reader.d.cts +339 -22
  27. package/dist/smart-dom-reader.d.ts +339 -22
  28. package/dist/theme-editor-preview.cjs +67 -67
  29. package/dist/theme-editor-preview.d.cts +355 -23
  30. package/dist/theme-editor-preview.d.ts +355 -23
  31. package/dist/theme-editor-preview.js +75 -75
  32. package/dist/theme-editor.cjs +10 -10
  33. package/dist/theme-editor.d.cts +376 -24
  34. package/dist/theme-editor.d.ts +376 -24
  35. package/dist/theme-editor.js +10 -10
  36. package/dist/theme-reference.cjs +1 -1
  37. package/dist/theme-reference.d.cts +38 -0
  38. package/dist/theme-reference.d.ts +38 -0
  39. package/dist/theme-reference.js +1 -1
  40. package/dist/widget.css +1 -1
  41. package/package.json +1 -1
  42. package/src/artifacts-session.test.ts +106 -0
  43. package/src/ask-user-question-tool.ts +10 -7
  44. package/src/client.test.ts +2 -2
  45. package/src/client.ts +4 -1
  46. package/src/components/artifact-card.ts +13 -2
  47. package/src/components/artifact-inline.ts +11 -2
  48. package/src/components/artifact-preview.ts +1 -1
  49. package/src/components/composer-builder.test.ts +2 -0
  50. package/src/components/composer-builder.ts +1 -1
  51. package/src/components/composer-parts.test.ts +105 -4
  52. package/src/components/composer-parts.ts +54 -16
  53. package/src/components/message-bubble.test.ts +7 -1
  54. package/src/components/message-bubble.ts +11 -8
  55. package/src/components/messages.ts +5 -2
  56. package/src/components/panel.ts +31 -5
  57. package/src/components/pill-composer-builder.test.ts +18 -0
  58. package/src/components/suggestions.test.ts +233 -0
  59. package/src/components/suggestions.ts +463 -81
  60. package/src/components/widget-view.ts +4 -0
  61. package/src/defaults.ts +5 -3
  62. package/src/generated/runtype-openapi-contract.ts +17 -0
  63. package/src/index-core.ts +52 -2
  64. package/src/launcher-global.ts +1 -1
  65. package/src/plugins/types.ts +71 -3
  66. package/src/presets.ts +3 -3
  67. package/src/session.ts +55 -12
  68. package/src/styles/widget-styles.test.ts +162 -0
  69. package/src/styles/widget.css +440 -1
  70. package/src/suggest-replies-tool.test.ts +233 -2
  71. package/src/suggest-replies-tool.ts +115 -39
  72. package/src/theme-editor/index.ts +5 -0
  73. package/src/theme-editor/presets.test.ts +43 -0
  74. package/src/theme-editor/presets.ts +101 -87
  75. package/src/theme-editor/preview-utils.test.ts +34 -0
  76. package/src/theme-editor/preview-utils.ts +77 -2
  77. package/src/theme-editor/sections.test.ts +72 -7
  78. package/src/theme-editor/sections.ts +113 -13
  79. package/src/theme-editor/state-preferences.test.ts +15 -0
  80. package/src/theme-editor/state.ts +46 -0
  81. package/src/theme-editor/types.ts +15 -1
  82. package/src/theme-editor/webmcp/tools.test.ts +13 -0
  83. package/src/theme-editor/webmcp/tools.ts +5 -0
  84. package/src/theme-reference.ts +9 -2
  85. package/src/types/theme.ts +32 -0
  86. package/src/types.ts +291 -25
  87. package/src/ui.artifact-pane-gating.test.ts +5 -5
  88. package/src/ui.config-preferences.test.ts +166 -0
  89. package/src/ui.detached-panel.test.ts +1 -1
  90. package/src/ui.suggest-replies.test.ts +1066 -1
  91. package/src/ui.ts +311 -45
  92. package/src/ui.typing-indicator.test.ts +135 -0
  93. package/src/utils/artifact-display.test.ts +180 -16
  94. package/src/utils/artifact-display.ts +155 -14
  95. package/src/utils/artifact-file.test.ts +1 -1
  96. package/src/utils/code-generators.test.ts +40 -0
  97. package/src/utils/code-generators.ts +22 -0
  98. package/src/utils/config-merge.test.ts +84 -0
  99. package/src/utils/config-merge.ts +6 -1
  100. package/src/utils/feature-preferences.test.ts +326 -0
  101. package/src/utils/feature-preferences.ts +498 -0
  102. package/src/utils/icons.test.ts +26 -0
  103. package/src/utils/icons.ts +8 -0
  104. package/src/utils/theme.test.ts +171 -0
  105. package/src/utils/tokens.ts +71 -7
  106. package/dist/chunk-MMUPR2JW.js +0 -1
package/src/ui.ts CHANGED
@@ -34,7 +34,9 @@ import {
34
34
  PersonaArtifactFileMeta,
35
35
  PersonaArtifactActionContext,
36
36
  AgentWidgetContentSegment,
37
- AgentWidgetContextMentionRef
37
+ AgentWidgetContextMentionRef,
38
+ AgentWidgetSuggestion,
39
+ AgentWidgetSuggestionSource
38
40
  } from "./types";
39
41
  import { AttachmentManager } from "./utils/attachment-manager";
40
42
  import {
@@ -91,7 +93,7 @@ import { buildHeaderWithLayout } from "./components/header-layouts";
91
93
  import { positionMap } from "./utils/positioning";
92
94
  import type { HeaderElements as _HeaderElements, ComposerElements as _ComposerElements } from "./components/panel";
93
95
  import { MessageTransform, MessageActionCallbacks, LoadingIndicatorRenderer } from "./components/message-bubble";
94
- import { createStandardBubble, createTypingIndicator } from "./components/message-bubble";
96
+ import { createStandardBubble, createTypingIndicator, getBubbleClasses } from "./components/message-bubble";
95
97
  import { createReasoningBubble, reasoningExpansionState, updateReasoningBubbleUI } from "./components/reasoning-bubble";
96
98
  import { createToolBubble, toolExpansionState, updateToolBubbleUI } from "./components/tool-bubble";
97
99
  import {
@@ -111,6 +113,7 @@ import {
111
113
  import {
112
114
  isSuggestRepliesMessage,
113
115
  latestAgentSuggestions,
116
+ resolveFollowUpsFeature,
114
117
  } from "./suggest-replies-tool";
115
118
  import { formatElapsedMs } from "./utils/formatting";
116
119
  import { approvalDetailsExpansionState, createApprovalBubble, updateApprovalDetailsUI } from "./components/approval-bubble";
@@ -133,6 +136,7 @@ import {
133
136
  shouldExpandLauncherForArtifacts
134
137
  } from "./utils/artifact-gate";
135
138
  import { resolveArtifactDisplayMode } from "./utils/artifact-display";
139
+ import { resolveConfigPreferences } from "./utils/feature-preferences";
136
140
  import { readFlexGapPx, resolveArtifactPaneWidthPx } from "./utils/artifact-resize";
137
141
  import { enhanceWithForms } from "./components/forms";
138
142
  import { pluginRegistry } from "./plugins/registry";
@@ -358,6 +362,19 @@ type Controller = {
358
362
  * @deprecated Use injectMessage() instead.
359
363
  */
360
364
  injectTestMessage: (event: AgentWidgetEvent) => void;
365
+ /**
366
+ * Show follow-up suggestions from host code. An ephemeral UI overlay: the
367
+ * items never enter the transcript, the wire payload, or persisted state, so
368
+ * they do not survive a refresh. Cleared automatically when the next user
369
+ * message appends. Latest writer wins against the agent's `suggest_replies`
370
+ * payloads: this call overrides chips shown now, and a payload arriving
371
+ * afterwards overrides these. Renders through the `suggestions.followUps`
372
+ * presentation config and the full plugin and `persona:suggestReplies:*`
373
+ * event pipeline, with source `"host"`. An empty array clears.
374
+ */
375
+ setFollowUpSuggestions: (items: AgentWidgetSuggestion[]) => void;
376
+ /** Remove host-set follow-up suggestions. Agent-set chips are untouched. */
377
+ clearFollowUpSuggestions: () => void;
361
378
  getMessages: () => AgentWidgetMessage[];
362
379
  getStatus: () => AgentWidgetSessionStatus;
363
380
  getPersistentMetadata: () => Record<string, unknown>;
@@ -596,6 +613,11 @@ export const createAgentExperience = (
596
613
  }
597
614
 
598
615
  let config = mergeWithDefaults(initialConfig) as AgentWidgetConfig;
616
+ // `config.features` holds the preference-resolved view every read site uses;
617
+ // `baseFeatures` keeps the pre-preference base so update() re-resolves from
618
+ // it instead of stacking new preferences onto already-overlaid values.
619
+ let baseFeatures = config.features;
620
+ config = resolveConfigPreferences(config);
599
621
  // Note: applyThemeVariables is called after applyFullHeightStyles() below
600
622
  // because applyFullHeightStyles resets mount.style.cssText
601
623
 
@@ -866,6 +888,8 @@ export const createAgentExperience = (
866
888
  container,
867
889
  body,
868
890
  messagesWrapper,
891
+ starterSuggestions,
892
+ transcriptSuggestions,
869
893
  suggestions,
870
894
  textarea,
871
895
  sendButton,
@@ -1320,6 +1344,11 @@ export const createAgentExperience = (
1320
1344
  messagesWrapper.style.marginLeft = "auto";
1321
1345
  messagesWrapper.style.marginRight = "auto";
1322
1346
  messagesWrapper.style.width = "100%";
1347
+ starterSuggestions.style.maxWidth = contentMaxWidth;
1348
+ transcriptSuggestions.style.maxWidth = contentMaxWidth;
1349
+ transcriptSuggestions.style.marginLeft = "auto";
1350
+ transcriptSuggestions.style.marginRight = "auto";
1351
+ transcriptSuggestions.style.width = "100%";
1323
1352
  }
1324
1353
  // The pill IS the composer in composer-bar mode and should match the
1325
1354
  // wrapper's responsive width (50vw / 70vw / 90vw), not be capped by
@@ -1800,7 +1829,7 @@ export const createAgentExperience = (
1800
1829
  let artifactPaneExpandedPinned = false;
1801
1830
  // Whether the user explicitly opened the pane (card click, inline Expand,
1802
1831
  // showArtifacts(), programmatic upsert). Auto-open is otherwise reserved for
1803
- // artifacts whose resolved display mode is "panel": "card" keeps the card as
1832
+ // artifacts whose resolved display mode is "panel": "collapsed" keeps the card as
1804
1833
  // the only affordance and "inline" renders in the transcript. "inline" no
1805
1834
  // longer *auto*-opens the pane, but its Expand control is a deliberate,
1806
1835
  // user-driven open (setting artifactsPaneUserOpened, same as a card click);
@@ -1811,7 +1840,7 @@ export const createAgentExperience = (
1811
1840
  artifactsPaneUserOpened ||
1812
1841
  lastArtifactsState.artifacts.some(
1813
1842
  (a) =>
1814
- resolveArtifactDisplayMode(config.features?.artifacts, a.artifactType) === "panel"
1843
+ resolveArtifactDisplayMode(config.features?.artifacts, a) === "panel"
1815
1844
  );
1816
1845
  const artifactPaneVisible = () =>
1817
1846
  lastArtifactsState.artifacts.length > 0 &&
@@ -2010,7 +2039,7 @@ export const createAgentExperience = (
2010
2039
  event.preventDefault();
2011
2040
  event.stopPropagation();
2012
2041
  artifactsPaneUserHidden = false;
2013
- // Card click is an explicit open: it overrides the "card"/"inline"
2042
+ // Card click is an explicit open: it overrides the "collapsed"/"inline"
2014
2043
  // auto-open suppression for as long as artifacts exist.
2015
2044
  artifactsPaneUserOpened = true;
2016
2045
  session.selectArtifact(artifactId);
@@ -2518,7 +2547,7 @@ export const createAgentExperience = (
2518
2547
  } else if (lastArtifactsState.artifacts.length > 0 && artifactPaneCanShow()) {
2519
2548
  // User chose “show” again (e.g. programmatic showArtifacts): clear dismiss chrome
2520
2549
  // and force drawer open so narrow-host / mobile slide-out is not stuck off-screen.
2521
- // Artifacts whose display mode is "card" or "inline" don't auto-open the
2550
+ // Artifacts whose display mode is "collapsed" or "inline" don't auto-open the
2522
2551
  // pane (artifactPaneCanShow); it stays hidden until an explicit open or
2523
2552
  // until a "panel"-mode artifact arrives.
2524
2553
  artifactPaneApi.element.classList.remove("persona-hidden");
@@ -3440,7 +3469,17 @@ export const createAgentExperience = (
3440
3469
  }
3441
3470
  }
3442
3471
 
3443
- const suggestionsManager = createSuggestions(suggestions);
3472
+ const composerSuggestionsManager = createSuggestions(suggestions);
3473
+ const starterSuggestionsManager = createSuggestions(starterSuggestions);
3474
+ const transcriptSuggestionsManager = createSuggestions(transcriptSuggestions);
3475
+ const suggestionManagers = [
3476
+ composerSuggestionsManager,
3477
+ starterSuggestionsManager,
3478
+ transcriptSuggestionsManager,
3479
+ ];
3480
+ destroyCallbacks.push(() => {
3481
+ suggestionManagers.forEach((manager) => manager.destroy());
3482
+ });
3444
3483
  let closeHandler: (() => void) | null = null;
3445
3484
  let session: AgentWidgetSession;
3446
3485
 
@@ -3451,35 +3490,217 @@ export const createAgentExperience = (
3451
3490
  // keep their before-first-user-message behavior. Config updates MUST route
3452
3491
  // through here too: re-rendering with only `config.suggestionChips` would
3453
3492
  // drop a live agent chip row until the next message change.
3493
+ // Warn once per widget instance: renderSuggestions runs on every change.
3494
+ // Host-set follow-ups (`setFollowUpSuggestions`) are an ephemeral UI overlay:
3495
+ // never appended to the session, never sent on the wire, never persisted.
3496
+ let hostFollowUps: AgentWidgetSuggestion[] | null = null;
3497
+ // Latches: a host that drives the surface itself is a legitimate no-tool
3498
+ // integration, so the never-called hint must stay off even after the overlay
3499
+ // expires.
3500
+ let hostFollowUpsEverSet = false;
3501
+ // Latest-writer-wins bookkeeping captured at set time: the overlay is dropped
3502
+ // once another user message appends or a newer suggest_replies message lands.
3503
+ let hostFollowUpsUserCount = 0;
3504
+ let hostFollowUpsAgentId: string | null = null;
3505
+
3506
+ const countUserMessages = (messages: AgentWidgetMessage[]): number =>
3507
+ messages.reduce(
3508
+ (total, message) => (message.role === "user" ? total + 1 : total),
3509
+ 0
3510
+ );
3511
+
3512
+ /** Identity of the newest suggest_replies message, regardless of position. */
3513
+ const latestSuggestRepliesId = (
3514
+ messages: AgentWidgetMessage[]
3515
+ ): string | null => {
3516
+ for (let i = messages.length - 1; i >= 0; i--) {
3517
+ if (isSuggestRepliesMessage(messages[i])) return messages[i].id ?? null;
3518
+ }
3519
+ return null;
3520
+ };
3521
+
3522
+ let warnedPinnedStarterPlacement = false;
3523
+ const warnPinnedStarterPlacement = () => {
3524
+ if (warnedPinnedStarterPlacement || config.debug !== true) return;
3525
+ warnedPinnedStarterPlacement = true;
3526
+ console.warn(
3527
+ '[persona] suggestions.starters.placement is pinned to "welcome" but copy.showWelcomeCard is false, so no starters render. Use "auto" or "composer".',
3528
+ );
3529
+ };
3530
+ // Debug-only setup hint: a stream this session ended without the agent ever
3531
+ // calling suggest_replies, so the model never got the tool. Gated on a
3532
+ // COMPLETED stream, not on transcript shape: a restored transcript
3533
+ // (initialMessages/onStateLoaded) proves nothing about this session's wiring.
3534
+ let hintedFollowUpsNeverCalled = false;
3535
+ let completedStreamThisSession = false;
3536
+ const hintFollowUpsNeverCalled = (
3537
+ messages: AgentWidgetMessage[],
3538
+ feature: ReturnType<typeof resolveFollowUpsFeature>
3539
+ ) => {
3540
+ if (hintedFollowUpsNeverCalled || config.debug !== true) return;
3541
+ if (!feature.enabled || feature.expose) return;
3542
+ if (hostFollowUpsEverSet) return;
3543
+ if (!completedStreamThisSession) return;
3544
+ if (session.isStreaming()) return;
3545
+ if (messages.some(isSuggestRepliesMessage)) return;
3546
+ hintedFollowUpsNeverCalled = true;
3547
+ console.info(
3548
+ "[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."
3549
+ );
3550
+ };
3551
+
3454
3552
  const renderSuggestions = (messages?: AgentWidgetMessage[]) => {
3455
3553
  if (!session) return;
3456
3554
  const current = messages ?? session.getMessages();
3457
- const agentChips =
3458
- config.features?.suggestReplies?.enabled !== false
3459
- ? latestAgentSuggestions(current)
3460
- : null;
3461
- if (agentChips) {
3462
- suggestionsManager.render(
3463
- agentChips,
3555
+ const clearManager = (manager: ReturnType<typeof createSuggestions>) => {
3556
+ manager.render([], session, textarea, current);
3557
+ };
3558
+ const clearOthers = (active: ReturnType<typeof createSuggestions>) => {
3559
+ suggestionManagers.forEach((manager) => {
3560
+ if (manager !== active) clearManager(manager);
3561
+ });
3562
+ };
3563
+ // Same lifecycle as agent follow-ups: the overlay expires on the next user
3564
+ // message, and a suggest_replies message newer than the set call wins.
3565
+ if (
3566
+ hostFollowUps &&
3567
+ (countUserMessages(current) > hostFollowUpsUserCount ||
3568
+ latestSuggestRepliesId(current) !== hostFollowUpsAgentId)
3569
+ ) {
3570
+ hostFollowUps = null;
3571
+ }
3572
+
3573
+ // `enabled: false` disables the tool, not the surface: host-set follow-ups
3574
+ // still render.
3575
+ const followUpsFeature = resolveFollowUpsFeature(config);
3576
+ hintFollowUpsNeverCalled(current, followUpsFeature);
3577
+ const agentSuggestions = followUpsFeature.enabled
3578
+ ? latestAgentSuggestions(current)
3579
+ : null;
3580
+ const followUpSuggestions = hostFollowUps ?? agentSuggestions;
3581
+ const followUpSource: AgentWidgetSuggestionSource = hostFollowUps
3582
+ ? "host"
3583
+ : "agent";
3584
+
3585
+ if (followUpSuggestions) {
3586
+ const followUps = config.suggestions?.followUps;
3587
+ const requestedPlacement =
3588
+ followUps?.placement ?? (followUps ? "auto" : "composer");
3589
+ const placement =
3590
+ requestedPlacement === "auto"
3591
+ ? isComposerBar()
3592
+ ? "composer"
3593
+ : "after-message"
3594
+ : requestedPlacement;
3595
+ const manager =
3596
+ placement === "after-message"
3597
+ ? transcriptSuggestionsManager
3598
+ : composerSuggestionsManager;
3599
+ clearOthers(manager);
3600
+ manager.render(
3601
+ followUpSuggestions,
3464
3602
  session,
3465
3603
  textarea,
3466
3604
  current,
3467
3605
  config.suggestionChipsConfig,
3468
- { agentPushed: true }
3606
+ {
3607
+ source: followUpSource,
3608
+ surface: "followUp",
3609
+ variant: followUps?.variant ?? "chip",
3610
+ behavior: followUps?.behavior ?? "send",
3611
+ overflow: followUps?.overflow ?? "scroll",
3612
+ maxItems: followUps?.maxItems ?? 4,
3613
+ config,
3614
+ plugins,
3615
+ }
3469
3616
  );
3470
- } else if (current.some((msg) => msg.role === "user")) {
3471
- // Hide suggestions once a user message exists.
3472
- suggestionsManager.render([], session, textarea, current);
3473
- } else {
3474
- suggestionsManager.render(
3475
- config.suggestionChips,
3617
+ return;
3618
+ }
3619
+
3620
+ if (current.some((message) => message.role === "user")) {
3621
+ suggestionManagers.forEach(clearManager);
3622
+ return;
3623
+ }
3624
+
3625
+ const starters = config.suggestions?.starters;
3626
+ 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;
3630
+ const requestedPlacement = starters.placement ?? "auto";
3631
+ const placement =
3632
+ requestedPlacement === "auto"
3633
+ ? welcomeCardVisible
3634
+ ? "welcome"
3635
+ : "composer"
3636
+ : requestedPlacement;
3637
+ if (placement === "welcome" && !welcomeCardVisible) {
3638
+ warnPinnedStarterPlacement();
3639
+ suggestionManagers.forEach(clearManager);
3640
+ return;
3641
+ }
3642
+ const manager =
3643
+ placement === "welcome"
3644
+ ? starterSuggestionsManager
3645
+ : composerSuggestionsManager;
3646
+ clearOthers(manager);
3647
+ manager.render(
3648
+ starters.items ?? config.suggestionChips,
3476
3649
  session,
3477
3650
  textarea,
3478
3651
  current,
3479
- config.suggestionChipsConfig
3652
+ config.suggestionChipsConfig,
3653
+ {
3654
+ surface: "starter",
3655
+ variant: starters.variant ?? "card",
3656
+ behavior: starters.behavior ?? "send",
3657
+ overflow: starters.overflow ?? "wrap",
3658
+ maxItems: starters.maxItems ?? 4,
3659
+ config,
3660
+ plugins,
3661
+ }
3480
3662
  );
3663
+ return;
3481
3664
  }
3665
+
3666
+ clearOthers(composerSuggestionsManager);
3667
+ composerSuggestionsManager.render(
3668
+ config.suggestionChips,
3669
+ session,
3670
+ textarea,
3671
+ current,
3672
+ config.suggestionChipsConfig,
3673
+ {
3674
+ surface: "starter",
3675
+ variant: "chip",
3676
+ behavior: "send",
3677
+ overflow: "wrap",
3678
+ config,
3679
+ plugins,
3680
+ }
3681
+ );
3482
3682
  };
3683
+
3684
+ const clearHostFollowUps = () => {
3685
+ if (!hostFollowUps) return;
3686
+ hostFollowUps = null;
3687
+ renderSuggestions();
3688
+ };
3689
+
3690
+ const setHostFollowUps = (items: AgentWidgetSuggestion[]) => {
3691
+ const list = Array.isArray(items) ? [...items] : [];
3692
+ if (!list.length) {
3693
+ clearHostFollowUps();
3694
+ return;
3695
+ }
3696
+ const current = session ? session.getMessages() : [];
3697
+ hostFollowUps = list;
3698
+ hostFollowUpsEverSet = true;
3699
+ hostFollowUpsUserCount = countUserMessages(current);
3700
+ hostFollowUpsAgentId = latestSuggestRepliesId(current);
3701
+ renderSuggestions(current);
3702
+ };
3703
+
3483
3704
  let isStreaming = false;
3484
3705
  const messageCache = createMessageCache();
3485
3706
  // Tracks the last fingerprint we rendered a plugin-rendered ask_user_question
@@ -4505,7 +4726,7 @@ export const createAgentExperience = (
4505
4726
  // visible instead of silently swallowed.
4506
4727
  if (
4507
4728
  isSuggestRepliesMessage(message) &&
4508
- config.features?.suggestReplies?.enabled !== false
4729
+ resolveFollowUpsFeature(config).enabled
4509
4730
  ) {
4510
4731
  return;
4511
4732
  }
@@ -5045,28 +5266,30 @@ export const createAgentExperience = (
5045
5266
 
5046
5267
  // Only render if we have an indicator (allows hiding via returning null)
5047
5268
  if (typingIndicator) {
5048
- // Create a bubble wrapper for the typing indicator (similar to assistant messages)
5269
+ // Bubble wrapper for the typing indicator. It borrows the assistant
5270
+ // bubble's classes and background so the "thinking" bubble matches the
5271
+ // message bubbles that replace it under every layout preset.
5049
5272
  const typingBubble = document.createElement("div");
5050
5273
  const showBubble = config.loadingIndicator?.showBubble !== false; // default true
5274
+ const messageLayout = config.layout?.messages?.layout ?? "bubble";
5051
5275
  typingBubble.className = showBubble
5052
- ? [
5053
- "persona-rounded-2xl",
5054
- "persona-text-sm",
5055
- "persona-leading-relaxed",
5056
- "persona-shadow-sm",
5057
- "persona-bg-persona-surface",
5058
- "persona-border",
5059
- "persona-text-persona-primary",
5060
- "persona-px-5",
5061
- "persona-py-3"
5062
- ].join(" ")
5276
+ ? getBubbleClasses("assistant", messageLayout).join(" ")
5063
5277
  : [
5064
5278
  "persona-text-sm",
5065
5279
  "persona-leading-relaxed",
5066
- "persona-text-persona-primary"
5280
+ "persona-text-persona-text"
5067
5281
  ].join(" ");
5068
5282
  typingBubble.setAttribute("data-typing-indicator", "true");
5069
- typingBubble.style.borderColor = "var(--persona-message-assistant-border, var(--persona-border, #e5e7eb))";
5283
+ if (showBubble) {
5284
+ typingBubble.style.color =
5285
+ "var(--persona-message-assistant-text, var(--persona-text))";
5286
+ if (messageLayout !== "flat") {
5287
+ typingBubble.style.backgroundColor =
5288
+ "var(--persona-message-assistant-bg, var(--persona-container))";
5289
+ typingBubble.style.borderColor =
5290
+ "var(--persona-message-assistant-border, var(--persona-border, #e5e7eb))";
5291
+ }
5292
+ }
5070
5293
 
5071
5294
  typingBubble.appendChild(typingIndicator);
5072
5295
 
@@ -5876,8 +6099,16 @@ export const createAgentExperience = (
5876
6099
  if (micButton) {
5877
6100
  micButton.disabled = disabled;
5878
6101
  }
5879
- suggestionsManager.buttons.forEach((btn) => {
5880
- btn.disabled = disabled;
6102
+ suggestionManagers.forEach((manager) => {
6103
+ manager.elements.forEach((element) => {
6104
+ element.toggleAttribute("data-disabled", disabled);
6105
+ if (!(element instanceof HTMLButtonElement)) {
6106
+ element.setAttribute("aria-disabled", disabled ? "true" : "false");
6107
+ }
6108
+ });
6109
+ manager.buttons.forEach((button) => {
6110
+ button.disabled = disabled;
6111
+ });
5881
6112
  });
5882
6113
  footer.dataset.personaComposerStreaming = disabled ? "true" : "false";
5883
6114
  footer.querySelectorAll<HTMLElement>("[data-persona-composer-disable-when-streaming]").forEach((el) => {
@@ -6188,6 +6419,16 @@ export const createAgentExperience = (
6188
6419
  }
6189
6420
  if (!streaming) {
6190
6421
  scheduleAutoScroll(true);
6422
+ // setStreaming only fires on a change, so this is always a real stream
6423
+ // ending. Evaluate the never-called hint here: no later render is
6424
+ // guaranteed on a settled transcript.
6425
+ completedStreamThisSession = true;
6426
+ if (session) {
6427
+ hintFollowUpsNeverCalled(
6428
+ session.getMessages(),
6429
+ resolveFollowUpsFeature(config)
6430
+ );
6431
+ }
6191
6432
  }
6192
6433
  // Keep the "streaming below" hint and its announcement in sync with the
6193
6434
  // streaming lifecycle (Principles 8 + 15).
@@ -7846,8 +8087,11 @@ export const createAgentExperience = (
7846
8087
  const previousStreamAnimationType = config.features?.streamAnimation?.type;
7847
8088
  // One consistent recursive patch policy across the live controller and the
7848
8089
  // init handle. See utils/config-merge.ts for the replace-leaf list and
7849
- // explicit-undefined reset semantics.
7850
- config = mergeConfigUpdate(config, nextConfig);
8090
+ // explicit-undefined reset semantics. The patch merges over the
8091
+ // pre-preference base features, then preferences re-resolve on top.
8092
+ config = mergeConfigUpdate({ ...config, features: baseFeatures }, nextConfig);
8093
+ baseFeatures = config.features;
8094
+ config = resolveConfigPreferences(config);
7851
8095
  // applyFullHeightStyles resets mount.style.cssText, so call it before applyThemeVariables
7852
8096
  applyFullHeightStyles();
7853
8097
  applyThemeVariables(mount, config);
@@ -9282,13 +9526,20 @@ export const createAgentExperience = (
9282
9526
  if (!artifactsSidebarEnabled(config)) return null;
9283
9527
  // Programmatic upserts match the streamed UX: only "panel"-mode
9284
9528
  // artifacts auto-open the pane (overriding a previous Close), while
9285
- // "card"/"inline" stay calm — the injected transcript block is the
9529
+ // "collapsed"/"inline" stay calm — the injected transcript block is the
9286
9530
  // affordance. Independent of `transcript: false`: pane-only callers
9287
- // (e.g. the theme editor preview) rely on the panel-default surfacing;
9288
- // callers that want the pane in a non-panel mode call showArtifacts().
9531
+ // (e.g. the theme editor preview) rely on the auto-open default
9532
+ // surfacing; callers that want the pane in another mode call
9533
+ // showArtifacts().
9289
9534
  const mode = resolveArtifactDisplayMode(
9290
9535
  config.features?.artifacts,
9291
- manual.artifactType
9536
+ {
9537
+ artifactType: manual.artifactType,
9538
+ ...(manual.artifactType === "markdown" && manual.file
9539
+ ? { file: manual.file }
9540
+ : {}),
9541
+ ...(manual.presentation ? { presentation: manual.presentation } : {}),
9542
+ }
9292
9543
  );
9293
9544
  if (mode === "panel") {
9294
9545
  artifactsPaneUserHidden = false;
@@ -9340,6 +9591,21 @@ export const createAgentExperience = (
9340
9591
  }
9341
9592
  return session.resolveApproval(approvalMessage.approval, decision, options);
9342
9593
  },
9594
+ /**
9595
+ * Show follow-up suggestions from host code. Ephemeral UI state only: the
9596
+ * items never enter the transcript, the wire payload, or persistence, so
9597
+ * they do not survive a refresh. Cleared when the next user message
9598
+ * appends. Latest writer wins against agent `suggest_replies` payloads.
9599
+ * Renders through the `suggestions.followUps` presentation config and the
9600
+ * full plugin and DOM-event pipeline with source `"host"`.
9601
+ */
9602
+ setFollowUpSuggestions(items: AgentWidgetSuggestion[]): void {
9603
+ setHostFollowUps(items);
9604
+ },
9605
+ /** Remove host-set follow-ups. Agent-set chips are untouched. */
9606
+ clearFollowUpSuggestions(): void {
9607
+ clearHostFollowUps();
9608
+ },
9343
9609
  getMessages() {
9344
9610
  return session.getMessages();
9345
9611
  },
@@ -0,0 +1,135 @@
1
+ // @vitest-environment jsdom
2
+
3
+ import { afterEach, describe, expect, it, vi } from "vitest";
4
+
5
+ import { createAgentExperience } from "./ui";
6
+
7
+ const createMount = () => {
8
+ const mount = document.createElement("div");
9
+ document.body.appendChild(mount);
10
+ return mount;
11
+ };
12
+
13
+ /**
14
+ * Mount a widget with a never-resolving fetch, send a message, and return the
15
+ * standalone typing indicator bubble rendered during the silent gap.
16
+ */
17
+ const renderTypingBubble = async (
18
+ config: Record<string, unknown>
19
+ ): Promise<{ bubble: HTMLElement; destroy: () => void }> => {
20
+ global.fetch = vi.fn().mockImplementation(
21
+ () => new Promise(() => {})
22
+ ) as unknown as typeof fetch;
23
+
24
+ const mount = createMount();
25
+ const controller = createAgentExperience(mount, {
26
+ apiUrl: "https://api.example.com/chat",
27
+ launcher: { enabled: false },
28
+ ...config,
29
+ } as unknown as Parameters<typeof createAgentExperience>[1]);
30
+
31
+ controller.submitMessage("hello");
32
+ await Promise.resolve();
33
+ await Promise.resolve();
34
+
35
+ const bubble = mount.querySelector<HTMLElement>('[data-typing-indicator="true"]')!;
36
+ return { bubble, destroy: () => controller.destroy() };
37
+ };
38
+
39
+ describe("standalone typing indicator bubble", () => {
40
+ afterEach(() => {
41
+ document.body.innerHTML = "";
42
+ if (typeof localStorage !== "undefined") localStorage.clear();
43
+ vi.restoreAllMocks();
44
+ });
45
+
46
+ it("uses the assistant bubble classes under the default bubble layout", async () => {
47
+ const { bubble, destroy } = await renderTypingBubble({});
48
+
49
+ expect(bubble).not.toBeNull();
50
+ expect(bubble.classList.contains("persona-message-assistant-bubble")).toBe(true);
51
+ expect(bubble.classList.contains("persona-shadow-sm")).toBe(true);
52
+ expect(bubble.classList.contains("persona-border")).toBe(true);
53
+ expect(bubble.classList.contains("persona-px-5")).toBe(true);
54
+
55
+ destroy();
56
+ });
57
+
58
+ it("drops the shadow and border under the minimal layout", async () => {
59
+ const { bubble, destroy } = await renderTypingBubble({
60
+ layout: { messages: { layout: "minimal" } },
61
+ });
62
+
63
+ expect(bubble.classList.contains("persona-message-assistant-bubble")).toBe(true);
64
+ expect(bubble.classList.contains("persona-shadow-sm")).toBe(false);
65
+ expect(bubble.classList.contains("persona-border")).toBe(false);
66
+ expect(bubble.classList.contains("persona-px-3")).toBe(true);
67
+ // Minimal keeps the surface background, matching its message bubbles.
68
+ expect(bubble.style.backgroundColor).toContain("--persona-message-assistant-bg");
69
+
70
+ destroy();
71
+ });
72
+
73
+ it("drops the background entirely under the flat layout", async () => {
74
+ const { bubble, destroy } = await renderTypingBubble({
75
+ layout: { messages: { layout: "flat" } },
76
+ });
77
+
78
+ expect(bubble.classList.contains("persona-shadow-sm")).toBe(false);
79
+ expect(bubble.classList.contains("persona-border")).toBe(false);
80
+ expect(bubble.style.backgroundColor).toBe("");
81
+ expect(bubble.style.color).toBe(
82
+ "var(--persona-message-assistant-text, var(--persona-text))"
83
+ );
84
+
85
+ destroy();
86
+ });
87
+
88
+ it("renders bare text classes when loadingIndicator.showBubble is false", async () => {
89
+ const { bubble, destroy } = await renderTypingBubble({
90
+ loadingIndicator: { showBubble: false },
91
+ });
92
+
93
+ expect(bubble.classList.contains("persona-message-assistant-bubble")).toBe(false);
94
+ expect(bubble.classList.contains("persona-shadow-sm")).toBe(false);
95
+ expect(bubble.classList.contains("persona-border")).toBe(false);
96
+ expect(bubble.classList.contains("persona-text-persona-text")).toBe(true);
97
+ expect(bubble.classList.contains("persona-text-persona-primary")).toBe(false);
98
+ expect(bubble.style.backgroundColor).toBe("");
99
+
100
+ destroy();
101
+ });
102
+
103
+ it("honors the assistant bubble theme background token", async () => {
104
+ const { bubble, destroy } = await renderTypingBubble({
105
+ layout: { messages: { layout: "minimal" } },
106
+ theme: {
107
+ semantic: {
108
+ colors: {
109
+ container: "#112233",
110
+ text: "#ddeeff",
111
+ border: "#445566",
112
+ },
113
+ },
114
+ },
115
+ });
116
+
117
+ // The bubble reads the same variable the assistant message bubbles read,
118
+ // so `theme.components.message.assistant.background` covers both.
119
+ expect(bubble.style.backgroundColor).toBe(
120
+ "var(--persona-message-assistant-bg, var(--persona-container))"
121
+ );
122
+ const root = bubble.closest<HTMLElement>("[data-persona-root]");
123
+ expect(root?.style.getPropertyValue("--persona-message-assistant-bg")).toBe(
124
+ "#112233"
125
+ );
126
+ expect(root?.style.getPropertyValue("--persona-message-assistant-text")).toBe(
127
+ "#ddeeff"
128
+ );
129
+ expect(root?.style.getPropertyValue("--persona-message-assistant-border")).toBe(
130
+ "#445566"
131
+ );
132
+
133
+ destroy();
134
+ });
135
+ });