@runtypelabs/persona 4.12.0 → 4.14.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 (68) hide show
  1. package/dist/codegen.cjs +1 -1
  2. package/dist/codegen.js +1 -1
  3. package/dist/context-mentions.d.cts +98 -6
  4. package/dist/context-mentions.d.ts +98 -6
  5. package/dist/index.cjs +64 -64
  6. package/dist/index.cjs.map +1 -1
  7. package/dist/index.d.cts +183 -8
  8. package/dist/index.d.ts +183 -8
  9. package/dist/index.global.js +54 -54
  10. package/dist/index.global.js.map +1 -1
  11. package/dist/index.js +67 -67
  12. package/dist/index.js.map +1 -1
  13. package/dist/launcher.global.js +1 -1
  14. package/dist/launcher.global.js.map +1 -1
  15. package/dist/smart-dom-reader.d.cts +98 -6
  16. package/dist/smart-dom-reader.d.ts +98 -6
  17. package/dist/theme-editor-preview.cjs +66 -66
  18. package/dist/theme-editor-preview.d.cts +100 -6
  19. package/dist/theme-editor-preview.d.ts +100 -6
  20. package/dist/theme-editor-preview.js +75 -75
  21. package/dist/theme-editor.cjs +10 -10
  22. package/dist/theme-editor.d.cts +134 -7
  23. package/dist/theme-editor.d.ts +134 -7
  24. package/dist/theme-editor.js +10 -10
  25. package/dist/theme-reference.cjs +1 -1
  26. package/dist/widget.css +1 -1
  27. package/package.json +1 -1
  28. package/src/artifacts-session.test.ts +106 -0
  29. package/src/client.test.ts +2 -2
  30. package/src/client.ts +4 -1
  31. package/src/components/artifact-card.ts +13 -2
  32. package/src/components/artifact-inline.ts +11 -2
  33. package/src/components/composer-builder.test.ts +2 -0
  34. package/src/components/composer-builder.ts +1 -1
  35. package/src/components/composer-parts.test.ts +2 -0
  36. package/src/components/composer-parts.ts +1 -1
  37. package/src/components/message-bubble.test.ts +7 -1
  38. package/src/components/message-bubble.ts +11 -8
  39. package/src/index-core.ts +34 -0
  40. package/src/session.ts +45 -4
  41. package/src/styles/widget.css +10 -1
  42. package/src/theme-editor/index.ts +5 -0
  43. package/src/theme-editor/presets.test.ts +43 -0
  44. package/src/theme-editor/presets.ts +101 -87
  45. package/src/theme-editor/sections.test.ts +39 -7
  46. package/src/theme-editor/sections.ts +60 -8
  47. package/src/theme-editor/state-preferences.test.ts +15 -0
  48. package/src/theme-editor/state.ts +46 -0
  49. package/src/theme-editor/types.ts +15 -1
  50. package/src/theme-editor/webmcp/tools.test.ts +13 -0
  51. package/src/theme-editor/webmcp/tools.ts +5 -0
  52. package/src/types.ts +139 -9
  53. package/src/ui.artifact-pane-gating.test.ts +5 -5
  54. package/src/ui.config-preferences.test.ts +166 -0
  55. package/src/ui.scroll-additive.test.ts +365 -0
  56. package/src/ui.scroll.test.ts +19 -6
  57. package/src/ui.ts +158 -53
  58. package/src/ui.typing-indicator.test.ts +135 -0
  59. package/src/utils/artifact-display.test.ts +180 -16
  60. package/src/utils/artifact-display.ts +155 -14
  61. package/src/utils/auto-follow.test.ts +38 -0
  62. package/src/utils/auto-follow.ts +35 -0
  63. package/src/utils/config-merge.test.ts +60 -0
  64. package/src/utils/config-merge.ts +6 -1
  65. package/src/utils/feature-preferences.test.ts +326 -0
  66. package/src/utils/feature-preferences.ts +498 -0
  67. package/src/utils/theme.test.ts +92 -0
  68. package/src/utils/tokens.ts +7 -7
package/src/ui.ts CHANGED
@@ -64,6 +64,7 @@ import {
64
64
  createFollowStateController,
65
65
  getScrollBottomOffset,
66
66
  hasSelectionWithin,
67
+ isAttentionWorthyMessage,
67
68
  isElementNearBottom,
68
69
  resolveFollowStateFromScroll,
69
70
  resolveFollowStateFromWheel
@@ -90,7 +91,7 @@ import { buildHeaderWithLayout } from "./components/header-layouts";
90
91
  import { positionMap } from "./utils/positioning";
91
92
  import type { HeaderElements as _HeaderElements, ComposerElements as _ComposerElements } from "./components/panel";
92
93
  import { MessageTransform, MessageActionCallbacks, LoadingIndicatorRenderer } from "./components/message-bubble";
93
- import { createStandardBubble, createTypingIndicator } from "./components/message-bubble";
94
+ import { createStandardBubble, createTypingIndicator, getBubbleClasses } from "./components/message-bubble";
94
95
  import { createReasoningBubble, reasoningExpansionState, updateReasoningBubbleUI } from "./components/reasoning-bubble";
95
96
  import { createToolBubble, toolExpansionState, updateToolBubbleUI } from "./components/tool-bubble";
96
97
  import {
@@ -132,6 +133,7 @@ import {
132
133
  shouldExpandLauncherForArtifacts
133
134
  } from "./utils/artifact-gate";
134
135
  import { resolveArtifactDisplayMode } from "./utils/artifact-display";
136
+ import { resolveConfigPreferences } from "./utils/feature-preferences";
135
137
  import { readFlexGapPx, resolveArtifactPaneWidthPx } from "./utils/artifact-resize";
136
138
  import { enhanceWithForms } from "./components/forms";
137
139
  import { pluginRegistry } from "./plugins/registry";
@@ -595,6 +597,11 @@ export const createAgentExperience = (
595
597
  }
596
598
 
597
599
  let config = mergeWithDefaults(initialConfig) as AgentWidgetConfig;
600
+ // `config.features` holds the preference-resolved view every read site uses;
601
+ // `baseFeatures` keeps the pre-preference base so update() re-resolves from
602
+ // it instead of stacking new preferences onto already-overlaid values.
603
+ let baseFeatures = config.features;
604
+ config = resolveConfigPreferences(config);
598
605
  // Note: applyThemeVariables is called after applyFullHeightStyles() below
599
606
  // because applyFullHeightStyles resets mount.style.cssText
600
607
 
@@ -1799,7 +1806,7 @@ export const createAgentExperience = (
1799
1806
  let artifactPaneExpandedPinned = false;
1800
1807
  // Whether the user explicitly opened the pane (card click, inline Expand,
1801
1808
  // showArtifacts(), programmatic upsert). Auto-open is otherwise reserved for
1802
- // artifacts whose resolved display mode is "panel": "card" keeps the card as
1809
+ // artifacts whose resolved display mode is "panel": "collapsed" keeps the card as
1803
1810
  // the only affordance and "inline" renders in the transcript. "inline" no
1804
1811
  // longer *auto*-opens the pane, but its Expand control is a deliberate,
1805
1812
  // user-driven open (setting artifactsPaneUserOpened, same as a card click);
@@ -1810,7 +1817,7 @@ export const createAgentExperience = (
1810
1817
  artifactsPaneUserOpened ||
1811
1818
  lastArtifactsState.artifacts.some(
1812
1819
  (a) =>
1813
- resolveArtifactDisplayMode(config.features?.artifacts, a.artifactType) === "panel"
1820
+ resolveArtifactDisplayMode(config.features?.artifacts, a) === "panel"
1814
1821
  );
1815
1822
  const artifactPaneVisible = () =>
1816
1823
  lastArtifactsState.artifacts.length > 0 &&
@@ -2009,7 +2016,7 @@ export const createAgentExperience = (
2009
2016
  event.preventDefault();
2010
2017
  event.stopPropagation();
2011
2018
  artifactsPaneUserHidden = false;
2012
- // Card click is an explicit open: it overrides the "card"/"inline"
2019
+ // Card click is an explicit open: it overrides the "collapsed"/"inline"
2013
2020
  // auto-open suppression for as long as artifacts exist.
2014
2021
  artifactsPaneUserOpened = true;
2015
2022
  session.selectArtifact(artifactId);
@@ -2517,7 +2524,7 @@ export const createAgentExperience = (
2517
2524
  } else if (lastArtifactsState.artifacts.length > 0 && artifactPaneCanShow()) {
2518
2525
  // User chose “show” again (e.g. programmatic showArtifacts): clear dismiss chrome
2519
2526
  // and force drawer open so narrow-host / mobile slide-out is not stuck off-screen.
2520
- // Artifacts whose display mode is "card" or "inline" don't auto-open the
2527
+ // Artifacts whose display mode is "collapsed" or "inline" don't auto-open the
2521
2528
  // pane (artifactPaneCanShow); it stays hidden until an explicit open or
2522
2529
  // until a "panel"-mode artifact arrives.
2523
2530
  artifactPaneApi.element.classList.remove("persona-hidden");
@@ -3537,6 +3544,24 @@ export const createAgentExperience = (
3537
3544
  // pinned and never re-arms the fallback, so a late-loading embed can't yank
3538
3545
  // the viewport down to the bottom.
3539
3546
  let currentTurnAnchored = false;
3547
+ // Which message the anchor is currently parked on. Starts as the sent user
3548
+ // message and moves once to the turn's first attention-worthy block (see
3549
+ // `maybeAnchorToFirstUnread`).
3550
+ let anchoredMessageId: string | null = null;
3551
+ // Has the reader taken over positioning this turn? First-unread anchoring is
3552
+ // a guess about where someone who ISN'T watching wants to land; the moment
3553
+ // they demonstrably are watching, the guess stands down and never moves the
3554
+ // viewport under them. Reset on each user send (an explicit "take me along").
3555
+ //
3556
+ // Driven by real INPUT (wheel, touch drag, pointer press, transcript
3557
+ // keyboard nav, focus, selection) rather than by scroll deltas: streaming
3558
+ // content under a pinned anchor makes the browser clamp and then restore
3559
+ // scrollTop, which is indistinguishable from a deliberate scroll if you only
3560
+ // look at the scroll event.
3561
+ let readerEngagedThisTurn = false;
3562
+ const markReaderEngaged = () => {
3563
+ readerEngagedThisTurn = true;
3564
+ };
3540
3565
  // Dedupes assistant-turn detection across token-by-token re-renders.
3541
3566
  let lastHandledAssistantId: string | null = null;
3542
3567
 
@@ -3940,6 +3965,24 @@ export const createAgentExperience = (
3940
3965
  return top;
3941
3966
  };
3942
3967
 
3968
+ // Defined here (rather than beside the scroll listeners) because
3969
+ // `maybeAnchorToFirstUnread` runs on every render and would otherwise hit
3970
+ // these consts in their temporal dead zone on a synchronous first paint.
3971
+ const getTranscriptSelection = (): Selection | null => {
3972
+ // Selections inside a shadow root are not always reflected by
3973
+ // document.getSelection(); prefer the shadow root's view when available
3974
+ // (non-standard but supported where it matters).
3975
+ const root = body.getRootNode();
3976
+ const shadowSelection =
3977
+ typeof (root as ShadowRoot & { getSelection?: () => Selection | null })
3978
+ .getSelection === "function"
3979
+ ? (root as ShadowRoot & { getSelection: () => Selection | null }).getSelection()
3980
+ : null;
3981
+ return shadowSelection ?? body.ownerDocument.getSelection();
3982
+ };
3983
+ const hasActiveTranscriptSelection = () =>
3984
+ hasSelectionWithin(getTranscriptSelection(), body);
3985
+
3943
3986
  // Principle 11: reopen where the reader left off. When `restorePosition` is
3944
3987
  // "last-user-turn" and there is pre-existing history, land with the last user
3945
3988
  // message pinned near the top of the viewport instead of jumping to the
@@ -4002,11 +4045,10 @@ export const createAgentExperience = (
4002
4045
  anchorSpacer.style.height = "0px";
4003
4046
  };
4004
4047
 
4005
- // Anchor-top mode: scroll the just-sent user message to rest
4006
- // `anchorTopOffset` px below the viewport top and hold it there while the
4007
- // response streams in beneath it. Deferred one frame so the message bubble
4008
- // has been rendered and laid out.
4009
- const scheduleAnchorToUserMessage = (messageId: string) => {
4048
+ // Anchor-top mode: scroll `messageId` to rest `anchorTopOffset` px below the
4049
+ // viewport top and hold it there while the response streams in beneath it.
4050
+ // Deferred one frame so the message bubble has been rendered and laid out.
4051
+ const scheduleAnchorToMessage = (messageId: string) => {
4010
4052
  if (anchorRAF !== null) {
4011
4053
  cancelAnimationFrame(anchorRAF);
4012
4054
  }
@@ -4087,7 +4129,11 @@ export const createAgentExperience = (
4087
4129
  // next user send.
4088
4130
  followFallbackActive = false;
4089
4131
  currentTurnAnchored = true;
4090
- scheduleAnchorToUserMessage(messageId);
4132
+ // A send is an explicit "take me along": it re-arms first-unread
4133
+ // positioning even if the reader had scrolled away during the last turn.
4134
+ readerEngagedThisTurn = false;
4135
+ anchoredMessageId = messageId;
4136
+ scheduleAnchorToMessage(messageId);
4091
4137
  }
4092
4138
  };
4093
4139
 
@@ -4110,6 +4156,44 @@ export const createAgentExperience = (
4110
4156
  scheduleAutoScroll(true);
4111
4157
  };
4112
4158
 
4159
+ // Anchor-top positioning: park on the turn's FIRST UNREAD attention-worthy
4160
+ // block.
4161
+ //
4162
+ // The read boundary is the last user send — the reader wrote it, so it (and
4163
+ // everything above) is read; every attention-worthy block after it is not.
4164
+ // Tool calls and reasoning are chrome and never targeted, so they scroll
4165
+ // past underneath rather than parking the reader on progress output.
4166
+ //
4167
+ // The policy assumes the reader is NOT watching the stream, and picks the
4168
+ // best place for them to land when they look back: the start of what they
4169
+ // haven't seen. `readerEngagedThisTurn` is the escape hatch — once someone
4170
+ // is demonstrably driving, their position wins and this stands down until
4171
+ // the next send.
4172
+ //
4173
+ // Anchoring to the first unread (not the newest) block means this fires at
4174
+ // most once per turn in practice, and later blocks stream in below the
4175
+ // anchor exactly as they do today.
4176
+ const maybeAnchorToFirstUnread = (messages: AgentWidgetMessage[]) => {
4177
+ if (getScrollMode() !== "anchor-top") return;
4178
+ if (!currentTurnAnchored) return;
4179
+ if (readerEngagedThisTurn) return;
4180
+ if (hasActiveTranscriptSelection()) return;
4181
+
4182
+ let pastReadBoundary = lastSentUserMessageId === null;
4183
+ for (const message of messages) {
4184
+ if (!pastReadBoundary) {
4185
+ if (message.id === lastSentUserMessageId) pastReadBoundary = true;
4186
+ continue;
4187
+ }
4188
+ if (!isAttentionWorthyMessage(message)) continue;
4189
+ // Already parked on it: hold still while it streams.
4190
+ if (message.id === anchoredMessageId) return;
4191
+ anchoredMessageId = message.id;
4192
+ scheduleAnchorToMessage(message.id);
4193
+ return;
4194
+ }
4195
+ };
4196
+
4113
4197
  const trackMessages = (messages: AgentWidgetMessage[]) => {
4114
4198
  const nextState = new Map<
4115
4199
  string,
@@ -4967,28 +5051,30 @@ export const createAgentExperience = (
4967
5051
 
4968
5052
  // Only render if we have an indicator (allows hiding via returning null)
4969
5053
  if (typingIndicator) {
4970
- // Create a bubble wrapper for the typing indicator (similar to assistant messages)
5054
+ // Bubble wrapper for the typing indicator. It borrows the assistant
5055
+ // bubble's classes and background so the "thinking" bubble matches the
5056
+ // message bubbles that replace it under every layout preset.
4971
5057
  const typingBubble = document.createElement("div");
4972
5058
  const showBubble = config.loadingIndicator?.showBubble !== false; // default true
5059
+ const messageLayout = config.layout?.messages?.layout ?? "bubble";
4973
5060
  typingBubble.className = showBubble
4974
- ? [
4975
- "persona-rounded-2xl",
4976
- "persona-text-sm",
4977
- "persona-leading-relaxed",
4978
- "persona-shadow-sm",
4979
- "persona-bg-persona-surface",
4980
- "persona-border",
4981
- "persona-text-persona-primary",
4982
- "persona-px-5",
4983
- "persona-py-3"
4984
- ].join(" ")
5061
+ ? getBubbleClasses("assistant", messageLayout).join(" ")
4985
5062
  : [
4986
5063
  "persona-text-sm",
4987
5064
  "persona-leading-relaxed",
4988
- "persona-text-persona-primary"
5065
+ "persona-text-persona-text"
4989
5066
  ].join(" ");
4990
5067
  typingBubble.setAttribute("data-typing-indicator", "true");
4991
- typingBubble.style.borderColor = "var(--persona-message-assistant-border, var(--persona-border, #e5e7eb))";
5068
+ if (showBubble) {
5069
+ typingBubble.style.color =
5070
+ "var(--persona-message-assistant-text, var(--persona-text))";
5071
+ if (messageLayout !== "flat") {
5072
+ typingBubble.style.backgroundColor =
5073
+ "var(--persona-message-assistant-bg, var(--persona-container))";
5074
+ typingBubble.style.borderColor =
5075
+ "var(--persona-message-assistant-border, var(--persona-border, #e5e7eb))";
5076
+ }
5077
+ }
4992
5078
 
4993
5079
  typingBubble.appendChild(typingIndicator);
4994
5080
 
@@ -5998,6 +6084,8 @@ export const createAgentExperience = (
5998
6084
  resetAnchorState();
5999
6085
  followFallbackActive = true;
6000
6086
  currentTurnAnchored = false;
6087
+ anchoredMessageId = null;
6088
+ readerEngagedThisTurn = false;
6001
6089
  }
6002
6090
  if (!scrollSendSeeded || suppressScrollSend) {
6003
6091
  scrollSendSeeded = true;
@@ -6013,6 +6101,7 @@ export const createAgentExperience = (
6013
6101
  handleAssistantTurnStarted();
6014
6102
  }
6015
6103
  if (lastAssistantMessage) lastHandledAssistantId = lastAssistantMessage.id;
6104
+ maybeAnchorToFirstUnread(messages);
6016
6105
 
6017
6106
  const prevLastUserMessageId = voiceState.lastUserMessageId;
6018
6107
  if (lastUserMessage && lastUserMessage.id !== prevLastUserMessageId) {
@@ -7341,21 +7430,6 @@ export const createAgentExperience = (
7341
7430
  lastScrollTop = body.scrollTop;
7342
7431
  let lastBottomOffset = getScrollBottomOffset(body);
7343
7432
 
7344
- const getTranscriptSelection = (): Selection | null => {
7345
- // Selections inside a shadow root are not always reflected by
7346
- // document.getSelection(); prefer the shadow root's view when available
7347
- // (non-standard but supported where it matters).
7348
- const root = body.getRootNode();
7349
- const shadowSelection =
7350
- typeof (root as ShadowRoot & { getSelection?: () => Selection | null })
7351
- .getSelection === "function"
7352
- ? (root as ShadowRoot & { getSelection: () => Selection | null }).getSelection()
7353
- : null;
7354
- return shadowSelection ?? body.ownerDocument.getSelection();
7355
- };
7356
- const hasActiveTranscriptSelection = () =>
7357
- hasSelectionWithin(getTranscriptSelection(), body);
7358
-
7359
7433
  const handleScroll = () => {
7360
7434
  const scrollTop = body.scrollTop;
7361
7435
  // When content mutates (e.g. stream-animation plugins re-rendering text)
@@ -7373,6 +7447,14 @@ export const createAgentExperience = (
7373
7447
  if (!isFollowEffective()) {
7374
7448
  // No follow state to manage (anchored anchor-top / none): just keep the
7375
7449
  // scroll-to-bottom affordance in sync with the user's position.
7450
+ //
7451
+ // Reader intent is deliberately NOT inferred from scroll deltas here.
7452
+ // Growing the transcript under a pinned anchor makes the browser clamp
7453
+ // scrollTop and then restore it, which lands as a matched pair of real
7454
+ // scroll events (e.g. -12 then +12) with no user involved. The restore
7455
+ // arrives while the bottom offset is GROWING, so the shrink mask above
7456
+ // doesn't cover it, and it reads as a deliberate scroll. Engagement is
7457
+ // tracked from actual input instead (see `markReaderEngaged` callers).
7376
7458
  lastScrollTop = scrollTop;
7377
7459
  syncScrollToBottomButton();
7378
7460
  return;
@@ -7459,21 +7541,23 @@ export const createAgentExperience = (
7459
7541
  "ArrowDown",
7460
7542
  ]);
7461
7543
  const handleTranscriptKeydown = (event: KeyboardEvent) => {
7544
+ if (!NAV_KEYS.has(event.key)) return;
7545
+ // Reader intent for first-unread anchoring is tracked unconditionally;
7546
+ // only the follow-mode *pause* below stays behind the opt-in flag.
7547
+ markReaderEngaged();
7462
7548
  if (!isPauseOnInteractionEnabled()) return;
7463
7549
  if (!isFollowEffective()) return;
7464
7550
  if (!autoFollow.isFollowing()) return;
7465
- if (NAV_KEYS.has(event.key)) {
7466
- pauseAutoScroll();
7467
- }
7551
+ pauseAutoScroll();
7468
7552
  };
7469
7553
  const handleTranscriptFocusIn = (event: FocusEvent) => {
7554
+ const target = event.target as Element | null;
7555
+ if (!target?.closest("a, button, [tabindex], input, textarea, select")) return;
7556
+ markReaderEngaged();
7470
7557
  if (!isPauseOnInteractionEnabled()) return;
7471
7558
  if (!isFollowEffective()) return;
7472
7559
  if (!autoFollow.isFollowing()) return;
7473
- const target = event.target as Element | null;
7474
- if (target && target.closest("a, button, [tabindex], input, textarea, select")) {
7475
- pauseAutoScroll();
7476
- }
7560
+ pauseAutoScroll();
7477
7561
  };
7478
7562
  body.addEventListener("keydown", handleTranscriptKeydown);
7479
7563
  body.addEventListener("focusin", handleTranscriptFocusIn);
@@ -7483,6 +7567,9 @@ export const createAgentExperience = (
7483
7567
  });
7484
7568
 
7485
7569
  const handleWheel = (event: WheelEvent) => {
7570
+ // A wheel tick is unambiguous reader intent in every mode — including
7571
+ // anchored anchor-top, where there is no follow state to pause.
7572
+ if (event.deltaY !== 0) markReaderEngaged();
7486
7573
  if (!isFollowEffective()) return;
7487
7574
  const action = resolveFollowStateFromWheel({
7488
7575
  following: autoFollow.isFollowing(),
@@ -7498,6 +7585,14 @@ export const createAgentExperience = (
7498
7585
  }
7499
7586
  };
7500
7587
  body.addEventListener("wheel", handleWheel, { passive: true });
7588
+ // Touch drag and pointer press (scrollbar drags, tapping a tool bubble open)
7589
+ // are the remaining ways a reader takes over without a wheel or key event.
7590
+ body.addEventListener("touchmove", markReaderEngaged, { passive: true });
7591
+ body.addEventListener("pointerdown", markReaderEngaged, { passive: true });
7592
+ destroyCallbacks.push(() => {
7593
+ body.removeEventListener("touchmove", markReaderEngaged);
7594
+ body.removeEventListener("pointerdown", markReaderEngaged);
7595
+ });
7501
7596
  destroyCallbacks.push(() => body.removeEventListener("wheel", handleWheel));
7502
7597
  scrollToBottomButton.addEventListener("click", () => {
7503
7598
  // Jumping to the latest abandons the current anchor: drop the spacer
@@ -7759,8 +7854,11 @@ export const createAgentExperience = (
7759
7854
  const previousStreamAnimationType = config.features?.streamAnimation?.type;
7760
7855
  // One consistent recursive patch policy across the live controller and the
7761
7856
  // init handle. See utils/config-merge.ts for the replace-leaf list and
7762
- // explicit-undefined reset semantics.
7763
- config = mergeConfigUpdate(config, nextConfig);
7857
+ // explicit-undefined reset semantics. The patch merges over the
7858
+ // pre-preference base features, then preferences re-resolve on top.
7859
+ config = mergeConfigUpdate({ ...config, features: baseFeatures }, nextConfig);
7860
+ baseFeatures = config.features;
7861
+ config = resolveConfigPreferences(config);
7764
7862
  // applyFullHeightStyles resets mount.style.cssText, so call it before applyThemeVariables
7765
7863
  applyFullHeightStyles();
7766
7864
  applyThemeVariables(mount, config);
@@ -9195,13 +9293,20 @@ export const createAgentExperience = (
9195
9293
  if (!artifactsSidebarEnabled(config)) return null;
9196
9294
  // Programmatic upserts match the streamed UX: only "panel"-mode
9197
9295
  // artifacts auto-open the pane (overriding a previous Close), while
9198
- // "card"/"inline" stay calm — the injected transcript block is the
9296
+ // "collapsed"/"inline" stay calm — the injected transcript block is the
9199
9297
  // affordance. Independent of `transcript: false`: pane-only callers
9200
- // (e.g. the theme editor preview) rely on the panel-default surfacing;
9201
- // callers that want the pane in a non-panel mode call showArtifacts().
9298
+ // (e.g. the theme editor preview) rely on the auto-open default
9299
+ // surfacing; callers that want the pane in another mode call
9300
+ // showArtifacts().
9202
9301
  const mode = resolveArtifactDisplayMode(
9203
9302
  config.features?.artifacts,
9204
- manual.artifactType
9303
+ {
9304
+ artifactType: manual.artifactType,
9305
+ ...(manual.artifactType === "markdown" && manual.file
9306
+ ? { file: manual.file }
9307
+ : {}),
9308
+ ...(manual.presentation ? { presentation: manual.presentation } : {}),
9309
+ }
9205
9310
  );
9206
9311
  if (mode === "panel") {
9207
9312
  artifactsPaneUserHidden = false;
@@ -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
+ });