@runtypelabs/persona 3.18.0 → 3.20.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 (42) hide show
  1. package/README.md +45 -2
  2. package/dist/index.cjs +47 -47
  3. package/dist/index.cjs.map +1 -1
  4. package/dist/index.d.cts +383 -6
  5. package/dist/index.d.ts +383 -6
  6. package/dist/index.global.js +102 -1636
  7. package/dist/index.global.js.map +1 -1
  8. package/dist/index.js +47 -47
  9. package/dist/index.js.map +1 -1
  10. package/dist/theme-editor.cjs +1514 -626
  11. package/dist/theme-editor.d.cts +192 -1
  12. package/dist/theme-editor.d.ts +192 -1
  13. package/dist/theme-editor.js +1628 -626
  14. package/dist/widget.css +348 -0
  15. package/package.json +1 -1
  16. package/src/components/composer-builder.test.ts +52 -0
  17. package/src/components/composer-builder.ts +67 -490
  18. package/src/components/composer-parts.test.ts +152 -0
  19. package/src/components/composer-parts.ts +452 -0
  20. package/src/components/header-builder.ts +22 -299
  21. package/src/components/header-parts.ts +360 -0
  22. package/src/components/panel.test.ts +61 -0
  23. package/src/components/panel.ts +262 -5
  24. package/src/components/pill-composer-builder.test.ts +85 -0
  25. package/src/components/pill-composer-builder.ts +183 -0
  26. package/src/index.ts +5 -0
  27. package/src/runtime/init.ts +4 -2
  28. package/src/runtime/persist-state.test.ts +152 -0
  29. package/src/session.test.ts +123 -0
  30. package/src/session.ts +58 -4
  31. package/src/styles/widget.css +348 -0
  32. package/src/types.ts +196 -1
  33. package/src/ui.component-directive.test.ts +183 -0
  34. package/src/ui.composer-bar.test.ts +1009 -0
  35. package/src/ui.ts +827 -72
  36. package/src/utils/attachment-manager.ts +1 -1
  37. package/src/utils/component-middleware.test.ts +134 -0
  38. package/src/utils/component-middleware.ts +44 -13
  39. package/src/utils/dock.test.ts +45 -0
  40. package/src/utils/dock.ts +3 -0
  41. package/src/utils/icons.ts +314 -58
  42. package/src/utils/stream-animation.ts +7 -2
package/src/ui.ts CHANGED
@@ -19,6 +19,7 @@ import {
19
19
  InjectAssistantMessageOptions,
20
20
  InjectUserMessageOptions,
21
21
  InjectSystemMessageOptions,
22
+ InjectComponentDirectiveOptions,
22
23
  LoadingIndicatorRenderContext,
23
24
  IdleIndicatorRenderContext,
24
25
  VoiceStatus,
@@ -42,13 +43,18 @@ import {
42
43
  } from "./utils/auto-follow";
43
44
  import { statusCopy, DEFAULT_OVERLAY_Z_INDEX, PORTALED_OVERLAY_Z_INDEX } from "./utils/constants";
44
45
  import {
46
+ applyStreamBuffer,
47
+ createSkeletonPlaceholder,
48
+ createStreamCaret,
45
49
  detachAllPlugins,
46
50
  ensurePluginActive,
51
+ resolveStreamAnimation,
47
52
  resolveStreamAnimationPlugin,
53
+ wrapStreamAnimation,
48
54
  } from "./utils/stream-animation";
49
55
  import { syncOverlayHostStacking } from "./utils/overlay-host-stacking";
50
56
  import { acquireScrollLock } from "./utils/scroll-lock";
51
- import { isDockedMountMode, resolveDockConfig } from "./utils/dock";
57
+ import { isComposerBarMountMode, isDockedMountMode, resolveDockConfig } from "./utils/dock";
52
58
  import { createLauncherButton } from "./components/launcher";
53
59
  import { createWrapper, buildPanel, buildHeader, buildComposer, attachHeaderToContainer } from "./components/panel";
54
60
  import { HEADER_THEME_CSS } from "./components/header-builder";
@@ -291,6 +297,14 @@ type Controller = {
291
297
  * Inject multiple messages in a single batch with one sort and one render pass.
292
298
  */
293
299
  injectMessageBatch: (optionsList: InjectMessageOptions[]) => AgentWidgetMessage[];
300
+ /**
301
+ * Convenience method for injecting an assistant message that renders as a
302
+ * registered component — same shape Persona produces from a streamed
303
+ * `{ "text": "...", "component": "...", "props": {...} }` payload.
304
+ */
305
+ injectComponentDirective: (
306
+ options: InjectComponentDirectiveOptions
307
+ ) => AgentWidgetMessage;
294
308
  /**
295
309
  * @deprecated Use injectMessage() instead.
296
310
  */
@@ -485,8 +499,14 @@ export const createAgentExperience = (
485
499
  }
486
500
  const eventBus = createEventBus<AgentWidgetControllerEventMap>();
487
501
 
488
- const storageAdapter: AgentWidgetStorageAdapter =
489
- config.storageAdapter ?? createLocalStorageAdapter();
502
+ // When persistState is explicitly false, message-history persistence is
503
+ // disabled — including any user-supplied storageAdapter. This is the strict
504
+ // kill-switch semantic; pass `persistState: true` (or omit it) to opt in.
505
+ const messagePersistenceDisabled = config.persistState === false;
506
+ const storageAdapter: AgentWidgetStorageAdapter | null =
507
+ messagePersistenceDisabled
508
+ ? null
509
+ : (config.storageAdapter ?? createLocalStorageAdapter());
490
510
  let persistentMetadata: Record<string, unknown> = {};
491
511
  let pendingStoredState: Promise<AgentWidgetStoredState | null> | null = null;
492
512
 
@@ -600,7 +620,15 @@ export const createAgentExperience = (
600
620
  let prevLauncherEnabled = launcherEnabled;
601
621
  let prevHeaderLayout = config.layout?.header?.layout;
602
622
  let wasMobileFullscreen = false;
603
- let open = launcherEnabled ? autoExpand : true;
623
+ // Composer-bar mode behaves like a launcher-enabled panel for state/toggle
624
+ // purposes (open/close maps to expand/collapse) but does not render a
625
+ // launcher button. `isPanelToggleable()` covers both modes; checks that
626
+ // gate the launcher button itself stay on the raw `launcherEnabled` flag.
627
+ const isComposerBar = () => isComposerBarMountMode(config);
628
+ const isPanelToggleable = () => launcherEnabled || isComposerBar();
629
+ // Composer-bar starts collapsed (open=false). Inline embed (no launcher)
630
+ // is always open. Launcher mode honors `autoExpand`.
631
+ let open = isComposerBar() ? false : (launcherEnabled ? autoExpand : true);
604
632
 
605
633
  // Track pending resubmit state for injection-triggered resubmit
606
634
  // When a handler returns resubmit: true, we wait for injectAssistantMessage()
@@ -707,8 +735,8 @@ export const createAgentExperience = (
707
735
  }
708
736
  }
709
737
 
710
- const { wrapper, panel } = createWrapper(config);
711
- const panelElements = buildPanel(config, launcherEnabled);
738
+ const { wrapper, panel, pillRoot } = createWrapper(config);
739
+ const panelElements = buildPanel(config, isPanelToggleable());
712
740
  let {
713
741
  container,
714
742
  body,
@@ -798,7 +826,7 @@ export const createAgentExperience = (
798
826
  const customHeader = headerPlugin.renderHeader({
799
827
  config,
800
828
  defaultRenderer: () => {
801
- const headerElements = buildHeader({ config, showClose: launcherEnabled });
829
+ const headerElements = buildHeader({ config, showClose: isPanelToggleable() });
802
830
  attachHeaderToContainer(container, headerElements, config);
803
831
  return headerElements.header;
804
832
  },
@@ -951,6 +979,9 @@ export const createAgentExperience = (
951
979
  const value = text.trim();
952
980
  const hasAttachments = attachmentManager?.hasAttachments() ?? false;
953
981
  if (!value && !hasAttachments) return;
982
+ // Mirror the default composer's auto-expand behavior so plugin
983
+ // composers do not silently submit while the panel stays collapsed.
984
+ maybeExpandComposerBar();
954
985
  let contentParts: ContentPart[] | undefined;
955
986
  if (hasAttachments) {
956
987
  contentParts = [];
@@ -1023,19 +1054,35 @@ export const createAgentExperience = (
1023
1054
  ensureComposerAttachmentSurface(footer);
1024
1055
  bindComposerRefsFromFooter(footer);
1025
1056
 
1026
- // Apply contentMaxWidth to composer form, suggestions, and attachment previews if configured
1027
- const contentMaxWidth = config.layout?.contentMaxWidth;
1028
- if (contentMaxWidth && composerForm) {
1057
+ // Apply contentMaxWidth to composer form, suggestions, and attachment
1058
+ // previews if configured. In composer-bar mode, fall back to
1059
+ // `composerBar.contentMaxWidth` (default `720px`) when no explicit
1060
+ // `layout.contentMaxWidth` is set, so the expanded panel's content
1061
+ // centers horizontally without the host having to wire it up.
1062
+ const contentMaxWidth =
1063
+ config.layout?.contentMaxWidth ??
1064
+ (isComposerBar() ? config.launcher?.composerBar?.contentMaxWidth ?? "720px" : undefined);
1065
+ if (contentMaxWidth) {
1066
+ messagesWrapper.style.maxWidth = contentMaxWidth;
1067
+ messagesWrapper.style.marginLeft = "auto";
1068
+ messagesWrapper.style.marginRight = "auto";
1069
+ messagesWrapper.style.width = "100%";
1070
+ }
1071
+ // The pill IS the composer in composer-bar mode and should match the
1072
+ // wrapper's responsive width (50vw / 70vw / 90vw), not be capped by
1073
+ // contentMaxWidth (which is a centered-column convention for the
1074
+ // expanded panel's body, not the pill input itself).
1075
+ if (contentMaxWidth && composerForm && !isComposerBar()) {
1029
1076
  composerForm.style.maxWidth = contentMaxWidth;
1030
1077
  composerForm.style.marginLeft = "auto";
1031
1078
  composerForm.style.marginRight = "auto";
1032
1079
  }
1033
- if (contentMaxWidth && suggestions) {
1080
+ if (contentMaxWidth && suggestions && !isComposerBar()) {
1034
1081
  suggestions.style.maxWidth = contentMaxWidth;
1035
1082
  suggestions.style.marginLeft = "auto";
1036
1083
  suggestions.style.marginRight = "auto";
1037
1084
  }
1038
- if (contentMaxWidth && attachmentPreviewsContainer) {
1085
+ if (contentMaxWidth && attachmentPreviewsContainer && !isComposerBar()) {
1039
1086
  attachmentPreviewsContainer.style.maxWidth = contentMaxWidth;
1040
1087
  attachmentPreviewsContainer.style.marginLeft = "auto";
1041
1088
  attachmentPreviewsContainer.style.marginRight = "auto";
@@ -2029,12 +2076,74 @@ export const createAgentExperience = (
2029
2076
  }
2030
2077
  } else {
2031
2078
  panel.appendChild(container);
2079
+ // Composer-bar mode: the pill (footer) and peek banner live in a
2080
+ // viewport-fixed sibling of the wrapper (`pillRoot`) so they're
2081
+ // independent of the wrapper's geometry transitions. Critical for
2082
+ // modal mode — the wrapper there has `transform: translate(-50%, -50%)`
2083
+ // which would establish a containing block trapping any `position: fixed`
2084
+ // descendant. Order inside pillRoot: peekBanner (slim row above pill)
2085
+ // → footer (pill). pillRoot's `gap` spaces them; the peek is hidden by
2086
+ // default until ui.ts toggles `.persona-pill-peek--visible` based on
2087
+ // streaming/hover/open state via syncComposerBarPeek().
2088
+ if (isComposerBar() && pillRoot) {
2089
+ if (panelElements.peekBanner) {
2090
+ pillRoot.appendChild(panelElements.peekBanner);
2091
+ }
2092
+ pillRoot.appendChild(footer);
2093
+ }
2032
2094
  }
2033
2095
  mount.appendChild(wrapper);
2096
+ // pillRoot is mounted *after* wrapper so it naturally stacks on top
2097
+ // when both share the same z-index (e.g. fullscreen mode where the
2098
+ // pill should float above the chat panel chrome).
2099
+ if (pillRoot) {
2100
+ mount.appendChild(pillRoot);
2101
+ }
2034
2102
 
2035
2103
  // Apply full-height and sidebar styles if enabled
2036
2104
  // This ensures the widget fills its container height with proper flex layout
2037
2105
  const applyFullHeightStyles = () => {
2106
+ // Composer-bar mode owns its own sizing/chrome. Geometry comes from
2107
+ // `applyComposerBarGeometry()` (per-state inline on the wrapper), the
2108
+ // pill carries its own chrome via `.persona-pill-composer`, and the
2109
+ // expanded chat panel chrome (border + radius + shadow + bg) is painted
2110
+ // inline on the `container` (NOT the panel — the panel is a transparent
2111
+ // flex column with a gap so the pill renders as a sibling below the
2112
+ // chrome). Same theme contract as floating mode
2113
+ // (`theme.components.panel.{shadow,border,borderRadius}`); collapsed
2114
+ // clears it (container is hidden via display:none anyway), expanded
2115
+ // re-applies it, with the `fullscreen` variant intentionally chrome-less.
2116
+ if (isComposerBar()) {
2117
+ panel.style.width = "100%";
2118
+ panel.style.maxWidth = "100%";
2119
+ const cb = config.launcher?.composerBar ?? {};
2120
+ const isExpanded = wrapper.dataset.state === "expanded";
2121
+ const expandedSize = cb.expandedSize ?? "anchored";
2122
+ const wantsChrome = isExpanded && expandedSize !== "fullscreen";
2123
+ if (!wantsChrome) {
2124
+ container.style.background = "";
2125
+ container.style.border = "";
2126
+ container.style.borderRadius = "";
2127
+ container.style.overflow = "";
2128
+ container.style.boxShadow = "";
2129
+ return;
2130
+ }
2131
+ const panelPartial = config.theme?.components?.panel;
2132
+ const activeTheme = getActiveTheme(config);
2133
+ const resolveCb = (raw: string | undefined, fallback: string): string => {
2134
+ if (raw == null || raw === "") return fallback;
2135
+ return resolveTokenValue(activeTheme, raw) ?? raw;
2136
+ };
2137
+ const defaultBorder = "1px solid var(--persona-border)";
2138
+ const defaultShadow = "var(--persona-palette-shadows-xl, 0 25px 50px -12px rgba(0, 0, 0, 0.25))";
2139
+ const defaultRadius = "var(--persona-panel-radius, var(--persona-radius-xl, 0.75rem))";
2140
+ container.style.background = "var(--persona-surface, #ffffff)";
2141
+ container.style.border = resolveCb(panelPartial?.border, defaultBorder);
2142
+ container.style.borderRadius = resolveCb(panelPartial?.borderRadius, defaultRadius);
2143
+ container.style.boxShadow = resolveCb(panelPartial?.shadow, defaultShadow);
2144
+ container.style.overflow = "hidden";
2145
+ return;
2146
+ }
2038
2147
  const dockedMode = isDockedMountMode(config);
2039
2148
  const sidebarMode = config.launcher?.sidebarMode ?? false;
2040
2149
  const fullHeight = dockedMode || sidebarMode || (config.launcher?.fullHeight ?? false);
@@ -2468,6 +2577,12 @@ export const createAgentExperience = (
2468
2577
  // bubble for, per message id. Lets us skip unnecessary rebuilds across
2469
2578
  // re-renders so user state inside the plugin (typed text, focus) survives.
2470
2579
  const lastAskBubbleFingerprint = new Map<string, string>();
2580
+ // Same idea for component-directive bubbles (registered custom components
2581
+ // rendered from JSON directives). The renderer's element is injected into the
2582
+ // live DOM post-morph so its event listeners survive; this map gates the
2583
+ // expensive rebuild on fingerprint change so user state inside the rendered
2584
+ // component (e.g. partially-filled form inputs) is not wiped on every pass.
2585
+ const lastComponentDirectiveFingerprint = new Map<string, string>();
2471
2586
  let configVersion = 0;
2472
2587
  const autoFollow = createFollowStateController();
2473
2588
  let lastScrollTop = 0;
@@ -2832,10 +2947,39 @@ export const createAgentExperience = (
2832
2947
  };
2833
2948
  const askPluginHydrate: AskPluginHydrate[] = [];
2834
2949
 
2950
+ // Component-directive bubbles use the same stub-and-hydrate pattern as
2951
+ // ask_user_question plugins: the renderer's HTMLElement is built live and
2952
+ // injected into the morphed wrapper afterward, so listeners attached via
2953
+ // `addEventListener` (e.g. form `submit` handlers) survive transcript
2954
+ // morphs. `bubble: null` means the fingerprint matched a previous pass and
2955
+ // the live wrapper is reused as-is.
2956
+ type ComponentDirectiveHydrate = {
2957
+ messageId: string;
2958
+ fingerprint: string;
2959
+ bubble: HTMLElement | null;
2960
+ };
2961
+ const componentDirectiveHydrate: ComponentDirectiveHydrate[] = [];
2962
+ const componentStreamingEnabled = config.enableComponentStreaming !== false;
2963
+
2835
2964
  messages.forEach((message) => {
2836
2965
  activeMessageIds.add(message.id);
2837
2966
 
2838
2967
  const askWithPlugin = hasAskPlugin && isAskUserQuestionMessage(message);
2968
+ const hasDirectiveBubble =
2969
+ !askWithPlugin &&
2970
+ message.role === "assistant" &&
2971
+ !message.variant &&
2972
+ componentStreamingEnabled &&
2973
+ hasComponentDirective(message);
2974
+
2975
+ // If a message previously rendered as a directive bubble but no longer
2976
+ // does (e.g. content was rewritten), strip `data-preserve-runtime` from
2977
+ // the live wrapper so the next morph can replace it.
2978
+ if (!hasDirectiveBubble && lastComponentDirectiveFingerprint.has(message.id)) {
2979
+ const existing = container.querySelector<HTMLElement>(`#wrapper-${message.id}`);
2980
+ existing?.removeAttribute("data-preserve-runtime");
2981
+ lastComponentDirectiveFingerprint.delete(message.id);
2982
+ }
2839
2983
 
2840
2984
  // Fingerprint cache: skip re-rendering unchanged messages. Append the
2841
2985
  // ask-user-question answered/answers state so flipping `askUserQuestionAnswered`
@@ -2849,7 +2993,7 @@ export const createAgentExperience = (
2849
2993
  }`
2850
2994
  : "";
2851
2995
  const fingerprint = computeMessageFingerprint(message, configVersion) + askMeta;
2852
- const cachedWrapper = askWithPlugin
2996
+ const cachedWrapper = (askWithPlugin || hasDirectiveBubble)
2853
2997
  ? null
2854
2998
  : getCachedWrapper(messageCache, message.id, fingerprint);
2855
2999
  if (cachedWrapper) {
@@ -3046,19 +3190,26 @@ export const createAgentExperience = (
3046
3190
  }
3047
3191
  }
3048
3192
 
3049
- // Check for component directive if no plugin handled it
3050
- if (!bubble && message.role === "assistant" && !message.variant) {
3051
- const enableComponentStreaming = config.enableComponentStreaming !== false; // Default to true
3052
- if (enableComponentStreaming && hasComponentDirective(message)) {
3053
- const directive = extractComponentDirectiveFromMessage(message);
3054
- if (directive) {
3193
+ // Check for component directive if no plugin handled it. We use the
3194
+ // same stub-and-hydrate trick as ask_user_question plugins (see comment
3195
+ // above `componentDirectiveHydrate`): build the live element with its
3196
+ // listeners, append a stub for the morph pass, then inject the live
3197
+ // element into the morphed wrapper afterward.
3198
+ if (!bubble && hasDirectiveBubble) {
3199
+ const directive = extractComponentDirectiveFromMessage(message);
3200
+ if (directive) {
3201
+ const lastFp = lastComponentDirectiveFingerprint.get(message.id);
3202
+ const needsRebuild = lastFp !== fingerprint;
3203
+ const wrapChrome = config.wrapComponentDirectiveInBubble !== false;
3204
+ let liveBubble: HTMLElement | null = null;
3205
+
3206
+ if (needsRebuild) {
3055
3207
  const componentBubble = renderComponentDirective(directive, {
3056
3208
  config,
3057
3209
  message,
3058
3210
  transform
3059
3211
  });
3060
3212
  if (componentBubble) {
3061
- const wrapChrome = config.wrapComponentDirectiveInBubble !== false;
3062
3213
  if (wrapChrome) {
3063
3214
  const componentWrapper = document.createElement("div");
3064
3215
  componentWrapper.className = [
@@ -3086,7 +3237,7 @@ export const createAgentExperience = (
3086
3237
  }
3087
3238
 
3088
3239
  componentWrapper.appendChild(componentBubble);
3089
- bubble = componentWrapper;
3240
+ liveBubble = componentWrapper;
3090
3241
  } else {
3091
3242
  const stack = document.createElement("div");
3092
3243
  stack.className =
@@ -3109,10 +3260,33 @@ export const createAgentExperience = (
3109
3260
  }
3110
3261
 
3111
3262
  stack.appendChild(componentBubble);
3112
- bubble = stack;
3263
+ liveBubble = stack;
3113
3264
  }
3114
3265
  }
3115
3266
  }
3267
+
3268
+ // If the directive is registered (live bubble built or already
3269
+ // mounted from a previous pass), use the stub-and-hydrate path.
3270
+ // Otherwise fall through to the standard render path so the message
3271
+ // text is at least visible.
3272
+ if (liveBubble || lastFp != null) {
3273
+ const stub = document.createElement("div");
3274
+ stub.className = "persona-flex";
3275
+ stub.id = `wrapper-${message.id}`;
3276
+ stub.setAttribute("data-wrapper-id", message.id);
3277
+ stub.setAttribute("data-component-directive-stub", "true");
3278
+ stub.setAttribute("data-preserve-runtime", "true");
3279
+ if (!wrapChrome) {
3280
+ stub.classList.add("persona-w-full");
3281
+ }
3282
+ tempContainer.appendChild(stub);
3283
+ componentDirectiveHydrate.push({
3284
+ messageId: message.id,
3285
+ fingerprint,
3286
+ bubble: liveBubble
3287
+ });
3288
+ return;
3289
+ }
3116
3290
  }
3117
3291
  }
3118
3292
 
@@ -3449,13 +3623,515 @@ export const createAgentExperience = (
3449
3623
  if (!activeMessageIds.has(id)) lastAskBubbleFingerprint.delete(id);
3450
3624
  }
3451
3625
  }
3626
+
3627
+ // Hydrate component-directive bubbles into their stub wrappers, mirroring
3628
+ // the ask-question hydration above.
3629
+ if (componentDirectiveHydrate.length > 0) {
3630
+ for (const { messageId, fingerprint, bubble } of componentDirectiveHydrate) {
3631
+ const wrapper = container.querySelector(`#wrapper-${messageId}`);
3632
+ if (!wrapper) continue;
3633
+ if (bubble === null) {
3634
+ // Fingerprint matched the previous pass — the live wrapper (kept
3635
+ // alive by `data-preserve-runtime`) still holds the listener-bearing
3636
+ // bubble from a prior render. Leave it untouched.
3637
+ continue;
3638
+ }
3639
+ wrapper.replaceChildren(bubble);
3640
+ wrapper.setAttribute("data-bubble-fp", fingerprint);
3641
+ lastComponentDirectiveFingerprint.set(messageId, fingerprint);
3642
+ }
3643
+ }
3644
+
3645
+ if (lastComponentDirectiveFingerprint.size > 0) {
3646
+ for (const id of lastComponentDirectiveFingerprint.keys()) {
3647
+ if (!activeMessageIds.has(id)) lastComponentDirectiveFingerprint.delete(id);
3648
+ }
3649
+ }
3452
3650
  };
3453
3651
 
3454
3652
  // Alias for clarity - the implementation handles flicker prevention via typing indicator logic
3455
3653
  const renderMessagesWithPlugins = renderMessagesWithPluginsImpl;
3456
3654
 
3655
+ /**
3656
+ * Composer-bar outside-click dismiss. While the chat is expanded, clicking
3657
+ * anywhere outside the wrapper (i.e. NOT inside the chat panel chrome and
3658
+ * NOT inside the pill) collapses back to just the pill. Uses `pointerdown`
3659
+ * + capture so we run before host-page click handlers (and before any
3660
+ * stop-propagation upstream); composedPath() includes the shadow DOM
3661
+ * subtree, so clicks inside the wrapper (which lives in the shadow root)
3662
+ * are correctly identified as inside.
3663
+ */
3664
+ let composerBarOutsideClickListener: ((e: PointerEvent) => void) | null = null;
3665
+
3666
+ const attachComposerBarOutsideClickDismiss = () => {
3667
+ if (composerBarOutsideClickListener) return;
3668
+ const listener: (e: PointerEvent) => void = (event) => {
3669
+ const path = event.composedPath();
3670
+ // pillRoot is a viewport-fixed sibling of the wrapper, so a click on
3671
+ // the pill or peek wouldn't be in `wrapper`'s composedPath even
3672
+ // though it's logically "inside" the widget.
3673
+ if (path.includes(wrapper)) return;
3674
+ if (pillRoot && path.includes(pillRoot)) return;
3675
+ setOpenState(false, "user");
3676
+ };
3677
+ composerBarOutsideClickListener = listener;
3678
+ const targetDoc = mount.ownerDocument ?? document;
3679
+ targetDoc.addEventListener("pointerdown", listener, true);
3680
+ };
3681
+
3682
+ const detachComposerBarOutsideClickDismiss = () => {
3683
+ if (!composerBarOutsideClickListener) return;
3684
+ const targetDoc = mount.ownerDocument ?? document;
3685
+ targetDoc.removeEventListener(
3686
+ "pointerdown",
3687
+ composerBarOutsideClickListener,
3688
+ true
3689
+ );
3690
+ composerBarOutsideClickListener = null;
3691
+ };
3692
+
3693
+ destroyCallbacks.push(() => detachComposerBarOutsideClickDismiss());
3694
+
3695
+ /**
3696
+ * Composer-bar ESC dismiss. While the chat is expanded, pressing Escape
3697
+ * collapses back to just the pill — same end state as outside-click.
3698
+ * Matches the WAI-ARIA dialog pattern (modal mode is literally a dialog)
3699
+ * and the dominant chat-widget convention (Intercom, Drift, Crisp).
3700
+ * Guards on `event.isComposing` so dismissing an IME suggestion doesn't
3701
+ * also collapse the panel.
3702
+ */
3703
+ let composerBarEscapeListener: ((e: KeyboardEvent) => void) | null = null;
3704
+
3705
+ const attachComposerBarEscapeDismiss = () => {
3706
+ if (composerBarEscapeListener) return;
3707
+ const listener: (e: KeyboardEvent) => void = (event) => {
3708
+ if (event.key !== "Escape") return;
3709
+ if (event.isComposing) return;
3710
+ setOpenState(false, "user");
3711
+ };
3712
+ composerBarEscapeListener = listener;
3713
+ const targetDoc = mount.ownerDocument ?? document;
3714
+ targetDoc.addEventListener("keydown", listener, true);
3715
+ };
3716
+
3717
+ const detachComposerBarEscapeDismiss = () => {
3718
+ if (!composerBarEscapeListener) return;
3719
+ const targetDoc = mount.ownerDocument ?? document;
3720
+ targetDoc.removeEventListener(
3721
+ "keydown",
3722
+ composerBarEscapeListener,
3723
+ true
3724
+ );
3725
+ composerBarEscapeListener = null;
3726
+ };
3727
+
3728
+ destroyCallbacks.push(() => detachComposerBarEscapeDismiss());
3729
+
3730
+ /**
3731
+ * Composer-bar "peek" affordance — a chrome-less row above the pill that
3732
+ * shows a chat-bubble icon, the trailing 100 chars of the most recent
3733
+ * assistant message, and a chevron-up. It is the user's path back into the
3734
+ * expanded chat from the collapsed pill.
3735
+ *
3736
+ * Visible when (collapsed) AND (there is an assistant message with content)
3737
+ * AND (`isStreaming` OR `composerHovered`). Otherwise hidden. The hover
3738
+ * zone is the whole `panel` (not just the pill) so the cursor moving
3739
+ * between the pill and the peek doesn't trigger fade-out.
3740
+ *
3741
+ * Driven from a single `syncComposerBarPeek()` invoked from
3742
+ * `onMessagesChanged`, `onStreamingChanged`, `updateOpenState`, the
3743
+ * pointerenter/pointerleave on `panel`, and once at end-of-init.
3744
+ */
3745
+ let composerHovered = false;
3746
+ // Track which peek-plugins we've already attached for this widget root.
3747
+ // `ensurePluginActive` is idempotent, but the call is guarded behind a flag
3748
+ // so we don't pay the lookup cost on every chunk.
3749
+ const peekActivatedPlugins = new Set<string>();
3750
+
3751
+ /**
3752
+ * Resolve the effective stream animation feature for the peek surface.
3753
+ * `composerBar.peek.streamAnimation` overrides; otherwise the peek inherits
3754
+ * `features.streamAnimation` so the surface for devs is consistent across
3755
+ * the main bubble and the peek banner.
3756
+ */
3757
+ const resolvePeekStreamAnimationFeature = () => {
3758
+ const peekFeature = config.launcher?.composerBar?.peek?.streamAnimation;
3759
+ if (peekFeature) return peekFeature;
3760
+ return config.features?.streamAnimation;
3761
+ };
3762
+
3763
+ const syncComposerBarPeek = () => {
3764
+ if (!isComposerBar()) return;
3765
+ const peekBanner = panelElements.peekBanner;
3766
+ const peekTextNode = panelElements.peekTextNode;
3767
+ if (!peekBanner || !peekTextNode) return;
3768
+
3769
+ if (open) {
3770
+ peekBanner.classList.remove("persona-pill-peek--visible");
3771
+ return;
3772
+ }
3773
+
3774
+ const messages = session?.getMessages() ?? [];
3775
+ let lastAssistant: AgentWidgetMessage | undefined;
3776
+ for (let i = messages.length - 1; i >= 0; i--) {
3777
+ const m = messages[i];
3778
+ if (m.role === "assistant" && m.content) {
3779
+ lastAssistant = m;
3780
+ break;
3781
+ }
3782
+ }
3783
+ if (!lastAssistant) {
3784
+ peekBanner.classList.remove("persona-pill-peek--visible");
3785
+ return;
3786
+ }
3787
+
3788
+ const text = lastAssistant.content;
3789
+ const streaming = Boolean(lastAssistant.streaming);
3790
+
3791
+ // Resolve the same animation surface used by the main bubble. The peek
3792
+ // ignores `bubbleClass` (carve-out: peek has no bubble) but honors
3793
+ // `containerClass`, `wrap`, `useCaret`, `buffer`, `placeholder`,
3794
+ // `speed`/`duration`, and custom plugins.
3795
+ const feature = resolvePeekStreamAnimationFeature();
3796
+ const streamAnimation = resolveStreamAnimation(feature);
3797
+ const plugin =
3798
+ streamAnimation.type !== "none"
3799
+ ? resolveStreamAnimationPlugin(streamAnimation.type, feature?.plugins)
3800
+ : null;
3801
+ const pluginStillAnimating =
3802
+ plugin?.isAnimating?.(lastAssistant) === true;
3803
+ const animationActive =
3804
+ plugin !== null && (streaming || pluginStillAnimating);
3805
+
3806
+ if (animationActive && plugin && !peekActivatedPlugins.has(plugin.name)) {
3807
+ ensurePluginActive(plugin, mount);
3808
+ peekActivatedPlugins.add(plugin.name);
3809
+ }
3810
+
3811
+ // Manage `containerClass` on the peek text node. We track which class is
3812
+ // currently applied so a config swap (or animation deactivating after
3813
+ // stream completion) cleans up the previous class instead of stacking.
3814
+ const desiredContainerClass =
3815
+ animationActive && plugin?.containerClass ? plugin.containerClass : null;
3816
+ const currentContainerClass =
3817
+ peekTextNode.dataset.personaPeekStreamClass ?? null;
3818
+ if (currentContainerClass && currentContainerClass !== desiredContainerClass) {
3819
+ peekTextNode.classList.remove(currentContainerClass);
3820
+ delete peekTextNode.dataset.personaPeekStreamClass;
3821
+ }
3822
+ if (desiredContainerClass && currentContainerClass !== desiredContainerClass) {
3823
+ peekTextNode.classList.add(desiredContainerClass);
3824
+ peekTextNode.dataset.personaPeekStreamClass = desiredContainerClass;
3825
+ }
3826
+
3827
+ if (animationActive) {
3828
+ peekTextNode.style.setProperty(
3829
+ "--persona-stream-step",
3830
+ `${streamAnimation.speed}ms`
3831
+ );
3832
+ peekTextNode.style.setProperty(
3833
+ "--persona-stream-duration",
3834
+ `${streamAnimation.duration}ms`
3835
+ );
3836
+ } else {
3837
+ peekTextNode.style.removeProperty("--persona-stream-step");
3838
+ peekTextNode.style.removeProperty("--persona-stream-duration");
3839
+ }
3840
+
3841
+ // Apply buffering (word/line/plugin custom). If the buffer trims content
3842
+ // to empty AND the placeholder is "skeleton", show the skeleton — that's
3843
+ // the "line buffer between completions" affordance. Otherwise no
3844
+ // pre-content placeholder on the peek (a typing-dots indicator inside a
3845
+ // 1-line ticker would feel cramped).
3846
+ const buffered = animationActive
3847
+ ? applyStreamBuffer(text, streamAnimation.buffer, plugin, lastAssistant, streaming)
3848
+ : text;
3849
+
3850
+ const skeletonEnabled =
3851
+ animationActive && streamAnimation.placeholder === "skeleton";
3852
+ const showSkeletonOnly =
3853
+ skeletonEnabled && streaming && (!buffered || !buffered.trim());
3854
+
3855
+ if (showSkeletonOnly) {
3856
+ // Replace text node contents with just a peek-sized skeleton bar. The
3857
+ // bar carries `data-preserve-animation` so idiomorph keeps its shimmer
3858
+ // running across morph passes.
3859
+ const tempContainer = document.createElement("div");
3860
+ const skeleton = createSkeletonPlaceholder();
3861
+ skeleton.classList.add("persona-pill-peek__skeleton");
3862
+ tempContainer.appendChild(skeleton);
3863
+ morphMessages(peekTextNode, tempContainer);
3864
+ } else {
3865
+ // Trailing 100 chars; for animated modes we keep the slice but use
3866
+ // ABSOLUTE indices so per-char/per-word span IDs stay stable as the
3867
+ // window shifts each chunk — idiomorph then preserves animations on
3868
+ // already-revealed units instead of restarting them. Plain "none" mode
3869
+ // keeps the legacy `…` ellipsis prefix for visual continuity with the
3870
+ // pre-animation behavior.
3871
+ const sliceStart = Math.max(0, buffered.length - 100);
3872
+ const slice = buffered.length > 100 ? buffered.slice(-100) : buffered;
3873
+ const escaped = escapeHtml(slice);
3874
+
3875
+ if (!animationActive || !plugin) {
3876
+ const preview = buffered.length > 100 ? `…${slice}` : slice;
3877
+ if (peekTextNode.textContent !== preview) {
3878
+ peekTextNode.textContent = preview;
3879
+ }
3880
+ } else {
3881
+ let html = escaped;
3882
+ if (plugin.wrap === "char" || plugin.wrap === "word") {
3883
+ html = wrapStreamAnimation(
3884
+ escaped,
3885
+ plugin.wrap,
3886
+ // Namespace span IDs to the peek surface so they don't collide
3887
+ // with the main bubble's spans for the same message id.
3888
+ `peek-${lastAssistant.id}`,
3889
+ { skipTags: plugin.skipTags, startIndex: sliceStart }
3890
+ );
3891
+ }
3892
+
3893
+ const tempContainer = document.createElement("div");
3894
+ tempContainer.innerHTML = html;
3895
+
3896
+ if (plugin.useCaret && slice.length > 0) {
3897
+ const caret = createStreamCaret();
3898
+ const spans = tempContainer.querySelectorAll(
3899
+ ".persona-stream-char, .persona-stream-word"
3900
+ );
3901
+ const lastSpan = spans[spans.length - 1];
3902
+ if (lastSpan?.parentNode) {
3903
+ lastSpan.parentNode.insertBefore(caret, lastSpan.nextSibling);
3904
+ } else {
3905
+ tempContainer.appendChild(caret);
3906
+ }
3907
+ }
3908
+
3909
+ morphMessages(peekTextNode, tempContainer);
3910
+
3911
+ // Fire the plugin's per-render hook so glyph-cycle / wipe / custom
3912
+ // plugins get a chance to mutate the peek's spans the same way they
3913
+ // mutate the main bubble's. The carve-out: `bubble` here is the peek
3914
+ // banner root, not a message bubble — plugins that target
3915
+ // `bubbleClass` should no-op on that surface.
3916
+ plugin.onAfterRender?.({
3917
+ container: peekTextNode,
3918
+ bubble: peekBanner,
3919
+ messageId: lastAssistant.id,
3920
+ message: lastAssistant,
3921
+ speed: streamAnimation.speed,
3922
+ duration: streamAnimation.duration,
3923
+ });
3924
+ }
3925
+ }
3926
+
3927
+ const shouldShow = isStreaming || composerHovered;
3928
+ peekBanner.classList.toggle("persona-pill-peek--visible", shouldShow);
3929
+ };
3930
+
3931
+ if (isComposerBar()) {
3932
+ const peekBanner = panelElements.peekBanner;
3933
+ if (peekBanner) {
3934
+ // pointerdown (not click) so this competes correctly with the
3935
+ // outside-click listener (also pointerdown, capture phase). The
3936
+ // outside-click composedPath check passes for events inside `wrapper`
3937
+ // or `pillRoot` (peek's parent), so the peek can stop propagation
3938
+ // here without breaking dismissal.
3939
+ const onPeekPointerDown = (e: PointerEvent) => {
3940
+ e.preventDefault();
3941
+ e.stopPropagation();
3942
+ setOpenState(true, "user");
3943
+ };
3944
+ peekBanner.addEventListener("pointerdown", onPeekPointerDown);
3945
+ destroyCallbacks.push(() => {
3946
+ peekBanner.removeEventListener("pointerdown", onPeekPointerDown);
3947
+ });
3948
+ }
3949
+
3950
+ const onPanelPointerEnter = () => {
3951
+ if (composerHovered) return;
3952
+ composerHovered = true;
3953
+ syncComposerBarPeek();
3954
+ };
3955
+ const onPanelPointerLeave = () => {
3956
+ if (!composerHovered) return;
3957
+ composerHovered = false;
3958
+ syncComposerBarPeek();
3959
+ };
3960
+ panel.addEventListener("pointerenter", onPanelPointerEnter);
3961
+ panel.addEventListener("pointerleave", onPanelPointerLeave);
3962
+ destroyCallbacks.push(() => {
3963
+ panel.removeEventListener("pointerenter", onPanelPointerEnter);
3964
+ panel.removeEventListener("pointerleave", onPanelPointerLeave);
3965
+ });
3966
+
3967
+ // pillRoot now hosts the pill + peek as viewport-level siblings, so the
3968
+ // panel's pointerenter/leave above no longer fires when the cursor is
3969
+ // over the pill area. Mirror the handlers onto pillRoot so hovering
3970
+ // either surface still drives `composerHovered`. Both handlers are
3971
+ // idempotent against the shared flag, so cross-traffic between panel
3972
+ // and pillRoot doesn't cause spurious flips.
3973
+ if (pillRoot) {
3974
+ pillRoot.addEventListener("pointerenter", onPanelPointerEnter);
3975
+ pillRoot.addEventListener("pointerleave", onPanelPointerLeave);
3976
+ destroyCallbacks.push(() => {
3977
+ pillRoot.removeEventListener("pointerenter", onPanelPointerEnter);
3978
+ pillRoot.removeEventListener("pointerleave", onPanelPointerLeave);
3979
+ });
3980
+ }
3981
+ }
3982
+
3983
+ /**
3984
+ * Composer-bar geometry, owned in one place so collapsed → expanded (and
3985
+ * back) transitions don't leave stale inline styles from a previous state.
3986
+ * `createWrapper` no longer sets any geometry; everything flows through
3987
+ * here.
3988
+ *
3989
+ * Width is expressed as `width: <configured>; max-width: calc(100vw -
3990
+ * 32px)`. The two combine such that `width` wins on wide viewports and
3991
+ * `max-width` clamps on narrow ones — same effect as `min(...)` but
3992
+ * jsdom-compatible. `100vw` is always the viewport, so the containing-
3993
+ * block edge case (host with `transform`/`filter` causing `100%` to
3994
+ * resolve against the host instead of the viewport) is neutralized.
3995
+ */
3996
+ const applyComposerBarGeometry = (isOpen: boolean) => {
3997
+ const cb = config.launcher?.composerBar ?? {};
3998
+ const expandedSize = cb.expandedSize ?? "anchored";
3999
+ const bottomOffset = cb.bottomOffset ?? "16px";
4000
+ // No hardcoded default — when undefined, CSS media queries provide the
4001
+ // responsive width (90vw / 70vw / 50vw at <640 / <1024 / >=1024) on
4002
+ // pillRoot.
4003
+ const collapsedMaxWidth = cb.collapsedMaxWidth;
4004
+ const expandedMaxWidth = cb.expandedMaxWidth ?? "880px";
4005
+ const expandedTopOffset = cb.expandedTopOffset ?? "5vh";
4006
+ const modalMaxWidth = cb.modalMaxWidth ?? "880px";
4007
+ const modalMaxHeight = cb.modalMaxHeight ?? "min(90vh, 800px)";
4008
+ const viewportClamp = "calc(100vw - 32px)";
4009
+ // Static fallback for the pill area's height (pill + 8px gap + peek
4010
+ // slack). Anchored mode uses this to compute the wrapper's bottom edge
4011
+ // so the chat panel chrome doesn't overlap the pill below. Defer
4012
+ // ResizeObserver-based dynamic sizing until we see a real misalignment.
4013
+ const pillAreaClearance = "var(--persona-pill-area-height, 80px)";
4014
+
4015
+ // Reset everything geometry-related so each branch sets exactly what it
4016
+ // needs. Using empty strings drops the inline declaration entirely so
4017
+ // CSS rules can take over (relevant for fullscreen).
4018
+ const s = wrapper.style;
4019
+ s.left = "";
4020
+ s.right = "";
4021
+ s.top = "";
4022
+ s.bottom = "";
4023
+ s.transform = "";
4024
+ s.width = "";
4025
+ s.maxWidth = "";
4026
+ s.height = "";
4027
+ s.maxHeight = "";
4028
+
4029
+ // pillRoot owns its own geometry (bottom offset + collapsed width
4030
+ // override). Reset and re-apply per-config every call so config edits
4031
+ // (e.g. via the demo's mode-switch) propagate cleanly.
4032
+ if (pillRoot) {
4033
+ const ps = pillRoot.style;
4034
+ ps.bottom = bottomOffset;
4035
+ // CSS media queries handle responsive width when no override is set.
4036
+ ps.width = collapsedMaxWidth ?? "";
4037
+ }
4038
+
4039
+ if (!isOpen) {
4040
+ // Collapsed: wrapper has nothing visible to render — the container
4041
+ // inside is `display: none` (via CSS keyed on `[data-state="collapsed"]`)
4042
+ // and the pill lives in pillRoot. Leave wrapper geometry empty so it
4043
+ // collapses to a zero-size positioning frame at the default fixed
4044
+ // origin. The container's fade-in keyframe handles the perceptible
4045
+ // expand animation, so there's no chrome to lose during this state.
4046
+ return;
4047
+ }
4048
+
4049
+ if (expandedSize === "fullscreen") {
4050
+ // Leave inline styles cleared so the CSS rule for fullscreen takes over.
4051
+ return;
4052
+ }
4053
+
4054
+ if (expandedSize === "modal") {
4055
+ s.top = "50%";
4056
+ s.left = "50%";
4057
+ s.transform = "translate(-50%, -50%)";
4058
+ s.bottom = "auto";
4059
+ s.right = "auto";
4060
+ s.width = modalMaxWidth;
4061
+ s.maxWidth = viewportClamp;
4062
+ s.maxHeight = modalMaxHeight;
4063
+ s.height = modalMaxHeight;
4064
+ return;
4065
+ }
4066
+
4067
+ // Default: anchored — pill stays at the viewport bottom (in pillRoot);
4068
+ // wrapper's bottom edge clears the pill area so the chrome doesn't
4069
+ // overlap it.
4070
+ s.left = "50%";
4071
+ s.transform = "translateX(-50%)";
4072
+ s.bottom = `calc(${bottomOffset} + ${pillAreaClearance})`;
4073
+ s.top = expandedTopOffset;
4074
+ s.width = expandedMaxWidth;
4075
+ s.maxWidth = viewportClamp;
4076
+ };
4077
+
3457
4078
  const updateOpenState = () => {
3458
- if (!launcherEnabled) return;
4079
+ if (!isPanelToggleable()) return;
4080
+
4081
+ // Composer-bar mode morphs the wrapper between collapsed pill and
4082
+ // expanded panel via data-attrs + per-state inline geometry. The chat
4083
+ // body and header are hidden in the collapsed state so only the
4084
+ // composer footer remains visible in the pill.
4085
+ if (isComposerBar()) {
4086
+ const cb = config.launcher?.composerBar ?? {};
4087
+ const expandedSize = cb.expandedSize ?? "anchored";
4088
+ const nextState = open ? "expanded" : "collapsed";
4089
+ wrapper.dataset.state = nextState;
4090
+ wrapper.dataset.expandedSize = expandedSize;
4091
+ // pillRoot mirrors wrapper's state attributes so CSS rules keyed off
4092
+ // [data-state] / [data-expanded-size] cascade to pill + peek even
4093
+ // though they live outside the wrapper subtree.
4094
+ if (pillRoot) {
4095
+ pillRoot.dataset.state = nextState;
4096
+ pillRoot.dataset.expandedSize = expandedSize;
4097
+ }
4098
+ wrapper.style.removeProperty("display");
4099
+ wrapper.classList.remove("persona-pointer-events-none", "persona-opacity-0");
4100
+ panel.classList.remove(
4101
+ "persona-scale-95",
4102
+ "persona-opacity-0",
4103
+ "persona-scale-100",
4104
+ "persona-opacity-100"
4105
+ );
4106
+
4107
+ applyComposerBarGeometry(open);
4108
+
4109
+ // Toggle the entire container (chat chrome + body + close button) so
4110
+ // the collapsed pill only shows the footer (which lives as a SIBLING
4111
+ // of the container in the panel — see panel.appendChild(footer) above).
4112
+ // The footer is always visible / interactive.
4113
+ container.style.display = open ? "flex" : "none";
4114
+
4115
+ // Re-run chrome application now that data-state has flipped: collapsed
4116
+ // clears container chrome (pill stands alone), expanded paints it via
4117
+ // the same theme.components.panel.* contract as floating mode.
4118
+ applyFullHeightStyles();
4119
+
4120
+ // Outside-click dismiss: while expanded, clicking anywhere outside the
4121
+ // wrapper (panel chrome + pill) collapses back to just the pill.
4122
+ if (open) {
4123
+ attachComposerBarOutsideClickDismiss();
4124
+ attachComposerBarEscapeDismiss();
4125
+ } else {
4126
+ detachComposerBarOutsideClickDismiss();
4127
+ detachComposerBarEscapeDismiss();
4128
+ }
4129
+ // Peek banner is hidden when expanded (`open === true` short-circuits
4130
+ // visibility); re-sync so collapsing back re-evaluates immediately.
4131
+ syncComposerBarPeek();
4132
+ return;
4133
+ }
4134
+
3459
4135
  const dockedMode = isDockedMountMode(config);
3460
4136
  const ownerWindow = mount.ownerDocument.defaultView ?? window;
3461
4137
  const mobileBreakpoint = config.launcher?.mobileBreakpoint ?? 640;
@@ -3510,7 +4186,7 @@ export const createAgentExperience = (
3510
4186
  };
3511
4187
 
3512
4188
  const setOpenState = (nextOpen: boolean, source: "user" | "auto" | "api" | "system" = "user") => {
3513
- if (!launcherEnabled) return;
4189
+ if (!isPanelToggleable()) return;
3514
4190
  if (open === nextOpen) return;
3515
4191
 
3516
4192
  const prevOpen = open;
@@ -3525,7 +4201,13 @@ export const createAgentExperience = (
3525
4201
  const mb = config.launcher?.mobileBreakpoint ?? 640;
3526
4202
  const isMobile = ow.innerWidth <= mb;
3527
4203
  const dockedMF = isDockedMountMode(config) && mf && isMobile;
3528
- return sm || (mf && isMobile && launcherEnabled) || dockedMF;
4204
+ // Composer-bar in expanded fullscreen mode covers the viewport — lock
4205
+ // background scroll and elevate host stacking to match other
4206
+ // viewport-covering modes (mobile fullscreen, sidebar).
4207
+ const composerBarFS =
4208
+ isComposerBar() &&
4209
+ (config.launcher?.composerBar?.expandedSize ?? "fullscreen") === "fullscreen";
4210
+ return sm || (mf && isMobile && launcherEnabled) || dockedMF || composerBarFS;
3529
4211
  })();
3530
4212
 
3531
4213
  if (open && isViewportCovering) {
@@ -3718,6 +4400,10 @@ export const createAgentExperience = (
3718
4400
 
3719
4401
  voiceState.lastUserMessageWasVoice = Boolean(lastUserMessage?.viaVoice);
3720
4402
  persistState(messages);
4403
+ // Composer-bar peek: re-render the trailing-100-char preview and
4404
+ // re-evaluate visibility (a new message may make it eligible to show
4405
+ // during streaming, or update the preview text on each token).
4406
+ syncComposerBarPeek();
3721
4407
  },
3722
4408
  onStatusChanged(status) {
3723
4409
  const currentStatusConfig = config.statusIndicator ?? {};
@@ -3740,6 +4426,9 @@ export const createAgentExperience = (
3740
4426
  if (!streaming) {
3741
4427
  scheduleAutoScroll(true);
3742
4428
  }
4429
+ // Composer-bar peek: streaming state is one of the two visibility
4430
+ // triggers (the other is composer hover), so re-evaluate now.
4431
+ syncComposerBarPeek();
3743
4432
  },
3744
4433
  onVoiceStatusChanged(status: VoiceStatus) {
3745
4434
  if (config.voiceRecognition?.provider?.type !== 'runtype') return;
@@ -3843,6 +4532,18 @@ export const createAgentExperience = (
3843
4532
  });
3844
4533
  }
3845
4534
 
4535
+ // Centralized so both the default composer (`handleSubmit`) and the plugin
4536
+ // composer (`renderComposer.onSubmit`) auto-expand the composer-bar wrapper
4537
+ // when a message is sent while the panel is collapsed. Without a single
4538
+ // helper the two submit paths drift over time.
4539
+ const maybeExpandComposerBar = () => {
4540
+ if (!isComposerBar()) return;
4541
+ if (open) return;
4542
+ const expandOnSubmit = config.launcher?.composerBar?.expandOnSubmit ?? true;
4543
+ if (!expandOnSubmit) return;
4544
+ setOpenState(true, "auto");
4545
+ };
4546
+
3846
4547
  const handleSubmit = (event: Event) => {
3847
4548
  event.preventDefault();
3848
4549
 
@@ -3860,6 +4561,8 @@ export const createAgentExperience = (
3860
4561
  // Must have text or attachments to send
3861
4562
  if (!value && !hasAttachments) return;
3862
4563
 
4564
+ maybeExpandComposerBar();
4565
+
3863
4566
  // Build content parts if there are attachments
3864
4567
  let contentParts: ContentPart[] | undefined;
3865
4568
  if (hasAttachments) {
@@ -4447,7 +5150,9 @@ export const createAgentExperience = (
4447
5150
  let launcherButtonInstance: ReturnType<typeof createLauncherButton> | null = null;
4448
5151
  let customLauncherElement: HTMLElement | null = null;
4449
5152
 
4450
- if (launcherEnabled) {
5153
+ // Composer-bar mode is launcher-less by design: the persistent pill IS the
5154
+ // entry point, so skip creating any launcher button (default or plugin).
5155
+ if (launcherEnabled && !isComposerBar()) {
4451
5156
  const launcherPlugin = plugins.find(p => p.renderLauncher);
4452
5157
  if (launcherPlugin?.renderLauncher) {
4453
5158
  const customLauncher = launcherPlugin.renderLauncher({
@@ -4462,7 +5167,7 @@ export const createAgentExperience = (
4462
5167
  customLauncherElement = customLauncher;
4463
5168
  }
4464
5169
  }
4465
-
5170
+
4466
5171
  // Use custom launcher if provided, otherwise use default
4467
5172
  if (!customLauncherElement) {
4468
5173
  launcherButtonInstance = createLauncherButton(config, toggleOpen);
@@ -4482,7 +5187,9 @@ export const createAgentExperience = (
4482
5187
  maybeRestoreVoiceFromMetadata();
4483
5188
 
4484
5189
  if (autoFocusInput) {
4485
- if (!launcherEnabled) {
5190
+ // Composer-bar's pill exposes the textarea immediately, so focus it on
5191
+ // init like the inline embed does — even though the panel is collapsed.
5192
+ if (!launcherEnabled || isComposerBar()) {
4486
5193
  setTimeout(() => maybeFocusInput(), 0);
4487
5194
  } else if (open) {
4488
5195
  setTimeout(() => maybeFocusInput(), 200);
@@ -4490,6 +5197,16 @@ export const createAgentExperience = (
4490
5197
  }
4491
5198
 
4492
5199
  const recalcPanelHeight = () => {
5200
+ // Composer-bar mode lets CSS own all sizing — collapsed pill is auto-sized
5201
+ // by the footer; expanded fullscreen/modal are driven by CSS attribute
5202
+ // selectors plus inline maxWidth/maxHeight set in updateOpenState. JS
5203
+ // sizing here would fight the morph transitions.
5204
+ if (isComposerBar()) {
5205
+ updateScrollToBottomButtonOffset();
5206
+ updateOpenState();
5207
+ return;
5208
+ }
5209
+
4493
5210
  const dockedMode = isDockedMountMode(config);
4494
5211
  const sidebarMode = config.launcher?.sidebarMode ?? false;
4495
5212
  const fullHeight = dockedMode || sidebarMode || (config.launcher?.fullHeight ?? false);
@@ -4661,7 +5378,7 @@ export const createAgentExperience = (
4661
5378
  closeButton.removeEventListener("click", closeHandler);
4662
5379
  closeHandler = null;
4663
5380
  }
4664
- if (launcherEnabled) {
5381
+ if (isPanelToggleable()) {
4665
5382
  closeButton.style.display = "";
4666
5383
  closeHandler = () => {
4667
5384
  setOpenState(false, "user");
@@ -5008,12 +5725,12 @@ export const createAgentExperience = (
5008
5725
  // Rebuild header with new layout
5009
5726
  const newHeaderElements = headerLayoutConfig
5010
5727
  ? buildHeaderWithLayout(config, headerLayoutConfig, {
5011
- showClose: launcherEnabled,
5728
+ showClose: isPanelToggleable(),
5012
5729
  onClose: () => setOpenState(false, "user")
5013
5730
  })
5014
5731
  : buildHeader({
5015
5732
  config,
5016
- showClose: launcherEnabled,
5733
+ showClose: isPanelToggleable(),
5017
5734
  onClose: () => setOpenState(false, "user")
5018
5735
  });
5019
5736
 
@@ -5414,9 +6131,15 @@ export const createAgentExperience = (
5414
6131
  if (clearChatButtonWrapper) {
5415
6132
  clearChatButtonWrapper.style.display = shouldShowClearChat ? "" : "none";
5416
6133
 
5417
- // When clear chat is hidden, close button needs ml-auto to stay right-aligned
6134
+ // When clear chat is hidden, close button needs ml-auto to stay right-aligned.
6135
+ // Composer-bar mode positions the close button absolutely, so the
6136
+ // ml-auto layout shim doesn't apply and is skipped below.
5418
6137
  const { closeButtonWrapper } = panelElements;
5419
- if (closeButtonWrapper && !closeButtonWrapper.classList.contains("persona-absolute")) {
6138
+ if (
6139
+ !isComposerBar() &&
6140
+ closeButtonWrapper &&
6141
+ !closeButtonWrapper.classList.contains("persona-absolute")
6142
+ ) {
5420
6143
  if (shouldShowClearChat) {
5421
6144
  closeButtonWrapper.classList.remove("persona-ml-auto");
5422
6145
  } else {
@@ -5424,11 +6147,14 @@ export const createAgentExperience = (
5424
6147
  }
5425
6148
  }
5426
6149
 
5427
- // Update placement if changed
6150
+ // Update placement if changed. Composer-bar mode owns the clear
6151
+ // button's position via panel.ts (absolute, top-right next to ×)
6152
+ // and must not get reshuffled into the floating launcher's
6153
+ // header strip.
5428
6154
  const isTopRight = clearChatPlacement === "top-right";
5429
6155
  const currentlyTopRight = clearChatButtonWrapper.classList.contains("persona-absolute");
5430
6156
 
5431
- if (isTopRight !== currentlyTopRight && shouldShowClearChat) {
6157
+ if (!isComposerBar() && isTopRight !== currentlyTopRight && shouldShowClearChat) {
5432
6158
  clearChatButtonWrapper.remove();
5433
6159
 
5434
6160
  if (isTopRight) {
@@ -5469,10 +6195,14 @@ export const createAgentExperience = (
5469
6195
  }
5470
6196
 
5471
6197
  if (shouldShowClearChat) {
5472
- // Update size
5473
- const clearChatSize = clearChatConfig.size ?? "32px";
5474
- clearChatButton.style.height = clearChatSize;
5475
- clearChatButton.style.width = clearChatSize;
6198
+ // Update size — composer-bar mode owns its sizing (16px to match
6199
+ // the close icon), so leave size alone there. Floating-launcher
6200
+ // and other modes still honor `launcher.clearChat.size`.
6201
+ if (!isComposerBar()) {
6202
+ const clearChatSize = clearChatConfig.size ?? "32px";
6203
+ clearChatButton.style.height = clearChatSize;
6204
+ clearChatButton.style.width = clearChatSize;
6205
+ }
5476
6206
 
5477
6207
  // Update icon
5478
6208
  const clearChatIconName = clearChatConfig.iconName ?? "refresh-cw";
@@ -5481,9 +6211,11 @@ export const createAgentExperience = (
5481
6211
  clearChatButton.style.color =
5482
6212
  clearChatIconColor || HEADER_THEME_CSS.actionIconColor;
5483
6213
 
5484
- // Clear existing icon and render new one
6214
+ // Clear existing icon and render new one. Composer-bar shrinks
6215
+ // the icon to match its 16px button.
5485
6216
  clearChatButton.innerHTML = "";
5486
- const iconSvg = renderLucideIcon(clearChatIconName, "20px", "currentColor", 2);
6217
+ const clearChatIconSize = isComposerBar() ? "14px" : "20px";
6218
+ const iconSvg = renderLucideIcon(clearChatIconName, clearChatIconSize, "currentColor", 2);
5487
6219
  if (iconSvg) {
5488
6220
  clearChatButton.appendChild(iconSvg);
5489
6221
  }
@@ -6046,8 +6778,13 @@ export const createAgentExperience = (
6046
6778
  tooltip.style.display = "none";
6047
6779
  }
6048
6780
 
6049
- // Update contentMaxWidth on messages wrapper and composer
6050
- const updatedContentMaxWidth = config.layout?.contentMaxWidth;
6781
+ // Update contentMaxWidth on messages wrapper and composer. Same
6782
+ // composer-bar fallback as the initial read above.
6783
+ const updatedContentMaxWidth =
6784
+ config.layout?.contentMaxWidth ??
6785
+ (isComposerBar()
6786
+ ? config.launcher?.composerBar?.contentMaxWidth ?? "720px"
6787
+ : undefined);
6051
6788
  if (updatedContentMaxWidth) {
6052
6789
  messagesWrapper.style.maxWidth = updatedContentMaxWidth;
6053
6790
  messagesWrapper.style.marginLeft = "auto";
@@ -6106,15 +6843,15 @@ export const createAgentExperience = (
6106
6843
  statusText.classList.add(alignClass);
6107
6844
  },
6108
6845
  open() {
6109
- if (!launcherEnabled) return;
6846
+ if (!isPanelToggleable()) return;
6110
6847
  setOpenState(true, "api");
6111
6848
  },
6112
6849
  close() {
6113
- if (!launcherEnabled) return;
6850
+ if (!isPanelToggleable()) return;
6114
6851
  setOpenState(false, "api");
6115
6852
  },
6116
6853
  toggle() {
6117
- if (!launcherEnabled) return;
6854
+ if (!isPanelToggleable()) return;
6118
6855
  setOpenState(!open, "api");
6119
6856
  },
6120
6857
  clearChat() {
@@ -6181,8 +6918,8 @@ export const createAgentExperience = (
6181
6918
  if (!textarea) return false;
6182
6919
  if (session.isStreaming()) return false;
6183
6920
 
6184
- // Auto-open widget if closed and launcher is enabled
6185
- if (!open && launcherEnabled) {
6921
+ // Auto-open widget if closed and the panel is toggleable
6922
+ if (!open && isPanelToggleable()) {
6186
6923
  setOpenState(true, "system");
6187
6924
  }
6188
6925
 
@@ -6197,8 +6934,8 @@ export const createAgentExperience = (
6197
6934
  const valueToSubmit = message?.trim() || textarea.value.trim();
6198
6935
  if (!valueToSubmit) return false;
6199
6936
 
6200
- // Auto-open widget if closed and launcher is enabled
6201
- if (!open && launcherEnabled) {
6937
+ // Auto-open widget if closed and the panel is toggleable
6938
+ if (!open && isPanelToggleable()) {
6202
6939
  setOpenState(true, "system");
6203
6940
  }
6204
6941
 
@@ -6211,7 +6948,7 @@ export const createAgentExperience = (
6211
6948
  if (session.isStreaming()) return false;
6212
6949
  if (config.voiceRecognition?.provider?.type === 'runtype') {
6213
6950
  if (session.isVoiceActive()) return true;
6214
- if (!open && launcherEnabled) setOpenState(true, "system");
6951
+ if (!open && isPanelToggleable()) setOpenState(true, "system");
6215
6952
  voiceState.manuallyDeactivated = false;
6216
6953
  persistVoiceMetadata();
6217
6954
  session.toggleVoice().then(() => {
@@ -6224,7 +6961,7 @@ export const createAgentExperience = (
6224
6961
  if (isRecording) return true;
6225
6962
  const SpeechRecognitionClass = getSpeechRecognitionClass();
6226
6963
  if (!SpeechRecognitionClass) return false;
6227
- if (!open && launcherEnabled) setOpenState(true, "system");
6964
+ if (!open && isPanelToggleable()) setOpenState(true, "system");
6228
6965
  voiceState.manuallyDeactivated = false;
6229
6966
  persistVoiceMetadata();
6230
6967
  startVoiceRecognition("user");
@@ -6250,15 +6987,15 @@ export const createAgentExperience = (
6250
6987
  return true;
6251
6988
  },
6252
6989
  injectMessage(options: InjectMessageOptions): AgentWidgetMessage {
6253
- // Auto-open widget if closed and launcher is enabled
6254
- if (!open && launcherEnabled) {
6990
+ // Auto-open widget if closed and the panel is toggleable
6991
+ if (!open && isPanelToggleable()) {
6255
6992
  setOpenState(true, "system");
6256
6993
  }
6257
6994
  return session.injectMessage(options);
6258
6995
  },
6259
6996
  injectAssistantMessage(options: InjectAssistantMessageOptions): AgentWidgetMessage {
6260
- // Auto-open widget if closed and launcher is enabled
6261
- if (!open && launcherEnabled) {
6997
+ // Auto-open widget if closed and the panel is toggleable
6998
+ if (!open && isPanelToggleable()) {
6262
6999
  setOpenState(true, "system");
6263
7000
  }
6264
7001
  const result = session.injectAssistantMessage(options);
@@ -6283,29 +7020,38 @@ export const createAgentExperience = (
6283
7020
  return result;
6284
7021
  },
6285
7022
  injectUserMessage(options: InjectUserMessageOptions): AgentWidgetMessage {
6286
- // Auto-open widget if closed and launcher is enabled
6287
- if (!open && launcherEnabled) {
7023
+ // Auto-open widget if closed and the panel is toggleable
7024
+ if (!open && isPanelToggleable()) {
6288
7025
  setOpenState(true, "system");
6289
7026
  }
6290
7027
  return session.injectUserMessage(options);
6291
7028
  },
6292
7029
  injectSystemMessage(options: InjectSystemMessageOptions): AgentWidgetMessage {
6293
- // Auto-open widget if closed and launcher is enabled
6294
- if (!open && launcherEnabled) {
7030
+ // Auto-open widget if closed and the panel is toggleable
7031
+ if (!open && isPanelToggleable()) {
6295
7032
  setOpenState(true, "system");
6296
7033
  }
6297
7034
  return session.injectSystemMessage(options);
6298
7035
  },
6299
7036
  injectMessageBatch(optionsList: InjectMessageOptions[]): AgentWidgetMessage[] {
6300
- if (!open && launcherEnabled) {
7037
+ if (!open && isPanelToggleable()) {
6301
7038
  setOpenState(true, "system");
6302
7039
  }
6303
7040
  return session.injectMessageBatch(optionsList);
6304
7041
  },
7042
+ injectComponentDirective(
7043
+ options: InjectComponentDirectiveOptions
7044
+ ): AgentWidgetMessage {
7045
+ // Auto-open widget if closed and the panel is toggleable
7046
+ if (!open && isPanelToggleable()) {
7047
+ setOpenState(true, "system");
7048
+ }
7049
+ return session.injectComponentDirective(options);
7050
+ },
6305
7051
  /** @deprecated Use injectMessage() instead */
6306
7052
  injectTestMessage(event: AgentWidgetEvent) {
6307
- // Auto-open widget if closed and launcher is enabled
6308
- if (!open && launcherEnabled) {
7053
+ // Auto-open widget if closed and the panel is toggleable
7054
+ if (!open && isPanelToggleable()) {
6309
7055
  setOpenState(true, "system");
6310
7056
  }
6311
7057
  session.injectTestEvent(event);
@@ -6370,7 +7116,9 @@ export const createAgentExperience = (
6370
7116
  return session?.getSelectedArtifactId() ?? null;
6371
7117
  },
6372
7118
  focusInput(): boolean {
6373
- if (launcherEnabled && !open) return false;
7119
+ // Composer-bar's textarea is always reachable in the collapsed pill,
7120
+ // so don't gate focus behind `open` for that mode.
7121
+ if (launcherEnabled && !open && !isComposerBar()) return false;
6374
7122
  if (!textarea) return false;
6375
7123
  textarea.focus();
6376
7124
  return true;
@@ -6407,14 +7155,14 @@ export const createAgentExperience = (
6407
7155
  },
6408
7156
  // State query methods
6409
7157
  isOpen(): boolean {
6410
- return launcherEnabled && open;
7158
+ return isPanelToggleable() && open;
6411
7159
  },
6412
7160
  isVoiceActive(): boolean {
6413
7161
  return voiceState.active;
6414
7162
  },
6415
7163
  getState(): AgentWidgetStateSnapshot {
6416
7164
  return {
6417
- open: launcherEnabled && open,
7165
+ open: isPanelToggleable() && open,
6418
7166
  launcherEnabled,
6419
7167
  voiceActive: voiceState.active,
6420
7168
  streaming: session.isStreaming()
@@ -6422,8 +7170,8 @@ export const createAgentExperience = (
6422
7170
  },
6423
7171
  // Feedback methods (CSAT/NPS)
6424
7172
  showCSATFeedback(options?: Partial<CSATFeedbackOptions>) {
6425
- // Auto-open widget if closed and launcher is enabled
6426
- if (!open && launcherEnabled) {
7173
+ // Auto-open widget if closed and the panel is toggleable
7174
+ if (!open && isPanelToggleable()) {
6427
7175
  setOpenState(true, "system");
6428
7176
  }
6429
7177
 
@@ -6449,8 +7197,8 @@ export const createAgentExperience = (
6449
7197
  feedbackEl.scrollIntoView({ behavior: 'smooth', block: 'end' });
6450
7198
  },
6451
7199
  showNPSFeedback(options?: Partial<NPSFeedbackOptions>) {
6452
- // Auto-open widget if closed and launcher is enabled
6453
- if (!open && launcherEnabled) {
7200
+ // Auto-open widget if closed and the panel is toggleable
7201
+ if (!open && isPanelToggleable()) {
6454
7202
  setOpenState(true, "system");
6455
7203
  }
6456
7204
 
@@ -6488,6 +7236,7 @@ export const createAgentExperience = (
6488
7236
  }
6489
7237
  destroyCallbacks.forEach((cb) => cb());
6490
7238
  wrapper.remove();
7239
+ pillRoot?.remove();
6491
7240
  launcherButtonInstance?.destroy();
6492
7241
  customLauncherElement?.remove();
6493
7242
  if (closeHandler) {
@@ -6610,7 +7359,7 @@ export const createAgentExperience = (
6610
7359
  // ============================================================================
6611
7360
  const persistConfig = normalizePersistStateConfig(config.persistState);
6612
7361
 
6613
- if (persistConfig && launcherEnabled) {
7362
+ if (persistConfig && isPanelToggleable()) {
6614
7363
  const storage = getPersistStorage(persistConfig.storage!);
6615
7364
  const openKey = `${persistConfig.keyPrefix}widget-open`;
6616
7365
  const voiceKey = `${persistConfig.keyPrefix}widget-voice`;
@@ -6689,10 +7438,16 @@ export const createAgentExperience = (
6689
7438
  // If onStateLoaded signalled open: true, open the panel after init.
6690
7439
  // Mirrors the same setTimeout(0) pattern used by persistState restore so both
6691
7440
  // can fire independently without interfering with each other.
6692
- if (shouldOpenAfterStateLoaded && launcherEnabled) {
7441
+ if (shouldOpenAfterStateLoaded && isPanelToggleable()) {
6693
7442
  setTimeout(() => { controller.open(); }, 0);
6694
7443
  }
6695
7444
 
7445
+ // Initial sync of the composer-bar peek banner so it reflects any
7446
+ // restored history. Subsequent updates flow through `onMessagesChanged`,
7447
+ // `onStreamingChanged`, `updateOpenState`, and pointerenter/leave on
7448
+ // the panel.
7449
+ syncComposerBarPeek();
7450
+
6696
7451
  return controller;
6697
7452
  };
6698
7453