@runtypelabs/persona 3.18.0 → 3.19.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 (38) hide show
  1. package/README.md +1 -1
  2. package/dist/index.cjs +47 -47
  3. package/dist/index.cjs.map +1 -1
  4. package/dist/index.d.cts +281 -4
  5. package/dist/index.d.ts +281 -4
  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 +1438 -619
  11. package/dist/theme-editor.d.cts +119 -1
  12. package/dist/theme-editor.d.ts +119 -1
  13. package/dist/theme-editor.js +1552 -619
  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 +4 -0
  27. package/src/runtime/init.ts +4 -2
  28. package/src/runtime/persist-state.test.ts +152 -0
  29. package/src/styles/widget.css +348 -0
  30. package/src/types.ts +121 -1
  31. package/src/ui.component-directive.test.ts +183 -0
  32. package/src/ui.composer-bar.test.ts +1009 -0
  33. package/src/ui.ts +809 -72
  34. package/src/utils/attachment-manager.ts +1 -1
  35. package/src/utils/dock.test.ts +45 -0
  36. package/src/utils/dock.ts +3 -0
  37. package/src/utils/icons.ts +314 -58
  38. package/src/utils/stream-animation.ts +7 -2
package/src/ui.ts CHANGED
@@ -42,13 +42,18 @@ import {
42
42
  } from "./utils/auto-follow";
43
43
  import { statusCopy, DEFAULT_OVERLAY_Z_INDEX, PORTALED_OVERLAY_Z_INDEX } from "./utils/constants";
44
44
  import {
45
+ applyStreamBuffer,
46
+ createSkeletonPlaceholder,
47
+ createStreamCaret,
45
48
  detachAllPlugins,
46
49
  ensurePluginActive,
50
+ resolveStreamAnimation,
47
51
  resolveStreamAnimationPlugin,
52
+ wrapStreamAnimation,
48
53
  } from "./utils/stream-animation";
49
54
  import { syncOverlayHostStacking } from "./utils/overlay-host-stacking";
50
55
  import { acquireScrollLock } from "./utils/scroll-lock";
51
- import { isDockedMountMode, resolveDockConfig } from "./utils/dock";
56
+ import { isComposerBarMountMode, isDockedMountMode, resolveDockConfig } from "./utils/dock";
52
57
  import { createLauncherButton } from "./components/launcher";
53
58
  import { createWrapper, buildPanel, buildHeader, buildComposer, attachHeaderToContainer } from "./components/panel";
54
59
  import { HEADER_THEME_CSS } from "./components/header-builder";
@@ -485,8 +490,14 @@ export const createAgentExperience = (
485
490
  }
486
491
  const eventBus = createEventBus<AgentWidgetControllerEventMap>();
487
492
 
488
- const storageAdapter: AgentWidgetStorageAdapter =
489
- config.storageAdapter ?? createLocalStorageAdapter();
493
+ // When persistState is explicitly false, message-history persistence is
494
+ // disabled — including any user-supplied storageAdapter. This is the strict
495
+ // kill-switch semantic; pass `persistState: true` (or omit it) to opt in.
496
+ const messagePersistenceDisabled = config.persistState === false;
497
+ const storageAdapter: AgentWidgetStorageAdapter | null =
498
+ messagePersistenceDisabled
499
+ ? null
500
+ : (config.storageAdapter ?? createLocalStorageAdapter());
490
501
  let persistentMetadata: Record<string, unknown> = {};
491
502
  let pendingStoredState: Promise<AgentWidgetStoredState | null> | null = null;
492
503
 
@@ -600,7 +611,15 @@ export const createAgentExperience = (
600
611
  let prevLauncherEnabled = launcherEnabled;
601
612
  let prevHeaderLayout = config.layout?.header?.layout;
602
613
  let wasMobileFullscreen = false;
603
- let open = launcherEnabled ? autoExpand : true;
614
+ // Composer-bar mode behaves like a launcher-enabled panel for state/toggle
615
+ // purposes (open/close maps to expand/collapse) but does not render a
616
+ // launcher button. `isPanelToggleable()` covers both modes; checks that
617
+ // gate the launcher button itself stay on the raw `launcherEnabled` flag.
618
+ const isComposerBar = () => isComposerBarMountMode(config);
619
+ const isPanelToggleable = () => launcherEnabled || isComposerBar();
620
+ // Composer-bar starts collapsed (open=false). Inline embed (no launcher)
621
+ // is always open. Launcher mode honors `autoExpand`.
622
+ let open = isComposerBar() ? false : (launcherEnabled ? autoExpand : true);
604
623
 
605
624
  // Track pending resubmit state for injection-triggered resubmit
606
625
  // When a handler returns resubmit: true, we wait for injectAssistantMessage()
@@ -707,8 +726,8 @@ export const createAgentExperience = (
707
726
  }
708
727
  }
709
728
 
710
- const { wrapper, panel } = createWrapper(config);
711
- const panelElements = buildPanel(config, launcherEnabled);
729
+ const { wrapper, panel, pillRoot } = createWrapper(config);
730
+ const panelElements = buildPanel(config, isPanelToggleable());
712
731
  let {
713
732
  container,
714
733
  body,
@@ -798,7 +817,7 @@ export const createAgentExperience = (
798
817
  const customHeader = headerPlugin.renderHeader({
799
818
  config,
800
819
  defaultRenderer: () => {
801
- const headerElements = buildHeader({ config, showClose: launcherEnabled });
820
+ const headerElements = buildHeader({ config, showClose: isPanelToggleable() });
802
821
  attachHeaderToContainer(container, headerElements, config);
803
822
  return headerElements.header;
804
823
  },
@@ -951,6 +970,9 @@ export const createAgentExperience = (
951
970
  const value = text.trim();
952
971
  const hasAttachments = attachmentManager?.hasAttachments() ?? false;
953
972
  if (!value && !hasAttachments) return;
973
+ // Mirror the default composer's auto-expand behavior so plugin
974
+ // composers do not silently submit while the panel stays collapsed.
975
+ maybeExpandComposerBar();
954
976
  let contentParts: ContentPart[] | undefined;
955
977
  if (hasAttachments) {
956
978
  contentParts = [];
@@ -1023,19 +1045,35 @@ export const createAgentExperience = (
1023
1045
  ensureComposerAttachmentSurface(footer);
1024
1046
  bindComposerRefsFromFooter(footer);
1025
1047
 
1026
- // Apply contentMaxWidth to composer form, suggestions, and attachment previews if configured
1027
- const contentMaxWidth = config.layout?.contentMaxWidth;
1028
- if (contentMaxWidth && composerForm) {
1048
+ // Apply contentMaxWidth to composer form, suggestions, and attachment
1049
+ // previews if configured. In composer-bar mode, fall back to
1050
+ // `composerBar.contentMaxWidth` (default `720px`) when no explicit
1051
+ // `layout.contentMaxWidth` is set, so the expanded panel's content
1052
+ // centers horizontally without the host having to wire it up.
1053
+ const contentMaxWidth =
1054
+ config.layout?.contentMaxWidth ??
1055
+ (isComposerBar() ? config.launcher?.composerBar?.contentMaxWidth ?? "720px" : undefined);
1056
+ if (contentMaxWidth) {
1057
+ messagesWrapper.style.maxWidth = contentMaxWidth;
1058
+ messagesWrapper.style.marginLeft = "auto";
1059
+ messagesWrapper.style.marginRight = "auto";
1060
+ messagesWrapper.style.width = "100%";
1061
+ }
1062
+ // The pill IS the composer in composer-bar mode and should match the
1063
+ // wrapper's responsive width (50vw / 70vw / 90vw), not be capped by
1064
+ // contentMaxWidth (which is a centered-column convention for the
1065
+ // expanded panel's body, not the pill input itself).
1066
+ if (contentMaxWidth && composerForm && !isComposerBar()) {
1029
1067
  composerForm.style.maxWidth = contentMaxWidth;
1030
1068
  composerForm.style.marginLeft = "auto";
1031
1069
  composerForm.style.marginRight = "auto";
1032
1070
  }
1033
- if (contentMaxWidth && suggestions) {
1071
+ if (contentMaxWidth && suggestions && !isComposerBar()) {
1034
1072
  suggestions.style.maxWidth = contentMaxWidth;
1035
1073
  suggestions.style.marginLeft = "auto";
1036
1074
  suggestions.style.marginRight = "auto";
1037
1075
  }
1038
- if (contentMaxWidth && attachmentPreviewsContainer) {
1076
+ if (contentMaxWidth && attachmentPreviewsContainer && !isComposerBar()) {
1039
1077
  attachmentPreviewsContainer.style.maxWidth = contentMaxWidth;
1040
1078
  attachmentPreviewsContainer.style.marginLeft = "auto";
1041
1079
  attachmentPreviewsContainer.style.marginRight = "auto";
@@ -2029,12 +2067,74 @@ export const createAgentExperience = (
2029
2067
  }
2030
2068
  } else {
2031
2069
  panel.appendChild(container);
2070
+ // Composer-bar mode: the pill (footer) and peek banner live in a
2071
+ // viewport-fixed sibling of the wrapper (`pillRoot`) so they're
2072
+ // independent of the wrapper's geometry transitions. Critical for
2073
+ // modal mode — the wrapper there has `transform: translate(-50%, -50%)`
2074
+ // which would establish a containing block trapping any `position: fixed`
2075
+ // descendant. Order inside pillRoot: peekBanner (slim row above pill)
2076
+ // → footer (pill). pillRoot's `gap` spaces them; the peek is hidden by
2077
+ // default until ui.ts toggles `.persona-pill-peek--visible` based on
2078
+ // streaming/hover/open state via syncComposerBarPeek().
2079
+ if (isComposerBar() && pillRoot) {
2080
+ if (panelElements.peekBanner) {
2081
+ pillRoot.appendChild(panelElements.peekBanner);
2082
+ }
2083
+ pillRoot.appendChild(footer);
2084
+ }
2032
2085
  }
2033
2086
  mount.appendChild(wrapper);
2087
+ // pillRoot is mounted *after* wrapper so it naturally stacks on top
2088
+ // when both share the same z-index (e.g. fullscreen mode where the
2089
+ // pill should float above the chat panel chrome).
2090
+ if (pillRoot) {
2091
+ mount.appendChild(pillRoot);
2092
+ }
2034
2093
 
2035
2094
  // Apply full-height and sidebar styles if enabled
2036
2095
  // This ensures the widget fills its container height with proper flex layout
2037
2096
  const applyFullHeightStyles = () => {
2097
+ // Composer-bar mode owns its own sizing/chrome. Geometry comes from
2098
+ // `applyComposerBarGeometry()` (per-state inline on the wrapper), the
2099
+ // pill carries its own chrome via `.persona-pill-composer`, and the
2100
+ // expanded chat panel chrome (border + radius + shadow + bg) is painted
2101
+ // inline on the `container` (NOT the panel — the panel is a transparent
2102
+ // flex column with a gap so the pill renders as a sibling below the
2103
+ // chrome). Same theme contract as floating mode
2104
+ // (`theme.components.panel.{shadow,border,borderRadius}`); collapsed
2105
+ // clears it (container is hidden via display:none anyway), expanded
2106
+ // re-applies it, with the `fullscreen` variant intentionally chrome-less.
2107
+ if (isComposerBar()) {
2108
+ panel.style.width = "100%";
2109
+ panel.style.maxWidth = "100%";
2110
+ const cb = config.launcher?.composerBar ?? {};
2111
+ const isExpanded = wrapper.dataset.state === "expanded";
2112
+ const expandedSize = cb.expandedSize ?? "anchored";
2113
+ const wantsChrome = isExpanded && expandedSize !== "fullscreen";
2114
+ if (!wantsChrome) {
2115
+ container.style.background = "";
2116
+ container.style.border = "";
2117
+ container.style.borderRadius = "";
2118
+ container.style.overflow = "";
2119
+ container.style.boxShadow = "";
2120
+ return;
2121
+ }
2122
+ const panelPartial = config.theme?.components?.panel;
2123
+ const activeTheme = getActiveTheme(config);
2124
+ const resolveCb = (raw: string | undefined, fallback: string): string => {
2125
+ if (raw == null || raw === "") return fallback;
2126
+ return resolveTokenValue(activeTheme, raw) ?? raw;
2127
+ };
2128
+ const defaultBorder = "1px solid var(--persona-border)";
2129
+ const defaultShadow = "var(--persona-palette-shadows-xl, 0 25px 50px -12px rgba(0, 0, 0, 0.25))";
2130
+ const defaultRadius = "var(--persona-panel-radius, var(--persona-radius-xl, 0.75rem))";
2131
+ container.style.background = "var(--persona-surface, #ffffff)";
2132
+ container.style.border = resolveCb(panelPartial?.border, defaultBorder);
2133
+ container.style.borderRadius = resolveCb(panelPartial?.borderRadius, defaultRadius);
2134
+ container.style.boxShadow = resolveCb(panelPartial?.shadow, defaultShadow);
2135
+ container.style.overflow = "hidden";
2136
+ return;
2137
+ }
2038
2138
  const dockedMode = isDockedMountMode(config);
2039
2139
  const sidebarMode = config.launcher?.sidebarMode ?? false;
2040
2140
  const fullHeight = dockedMode || sidebarMode || (config.launcher?.fullHeight ?? false);
@@ -2468,6 +2568,12 @@ export const createAgentExperience = (
2468
2568
  // bubble for, per message id. Lets us skip unnecessary rebuilds across
2469
2569
  // re-renders so user state inside the plugin (typed text, focus) survives.
2470
2570
  const lastAskBubbleFingerprint = new Map<string, string>();
2571
+ // Same idea for component-directive bubbles (registered custom components
2572
+ // rendered from JSON directives). The renderer's element is injected into the
2573
+ // live DOM post-morph so its event listeners survive; this map gates the
2574
+ // expensive rebuild on fingerprint change so user state inside the rendered
2575
+ // component (e.g. partially-filled form inputs) is not wiped on every pass.
2576
+ const lastComponentDirectiveFingerprint = new Map<string, string>();
2471
2577
  let configVersion = 0;
2472
2578
  const autoFollow = createFollowStateController();
2473
2579
  let lastScrollTop = 0;
@@ -2832,10 +2938,39 @@ export const createAgentExperience = (
2832
2938
  };
2833
2939
  const askPluginHydrate: AskPluginHydrate[] = [];
2834
2940
 
2941
+ // Component-directive bubbles use the same stub-and-hydrate pattern as
2942
+ // ask_user_question plugins: the renderer's HTMLElement is built live and
2943
+ // injected into the morphed wrapper afterward, so listeners attached via
2944
+ // `addEventListener` (e.g. form `submit` handlers) survive transcript
2945
+ // morphs. `bubble: null` means the fingerprint matched a previous pass and
2946
+ // the live wrapper is reused as-is.
2947
+ type ComponentDirectiveHydrate = {
2948
+ messageId: string;
2949
+ fingerprint: string;
2950
+ bubble: HTMLElement | null;
2951
+ };
2952
+ const componentDirectiveHydrate: ComponentDirectiveHydrate[] = [];
2953
+ const componentStreamingEnabled = config.enableComponentStreaming !== false;
2954
+
2835
2955
  messages.forEach((message) => {
2836
2956
  activeMessageIds.add(message.id);
2837
2957
 
2838
2958
  const askWithPlugin = hasAskPlugin && isAskUserQuestionMessage(message);
2959
+ const hasDirectiveBubble =
2960
+ !askWithPlugin &&
2961
+ message.role === "assistant" &&
2962
+ !message.variant &&
2963
+ componentStreamingEnabled &&
2964
+ hasComponentDirective(message);
2965
+
2966
+ // If a message previously rendered as a directive bubble but no longer
2967
+ // does (e.g. content was rewritten), strip `data-preserve-runtime` from
2968
+ // the live wrapper so the next morph can replace it.
2969
+ if (!hasDirectiveBubble && lastComponentDirectiveFingerprint.has(message.id)) {
2970
+ const existing = container.querySelector<HTMLElement>(`#wrapper-${message.id}`);
2971
+ existing?.removeAttribute("data-preserve-runtime");
2972
+ lastComponentDirectiveFingerprint.delete(message.id);
2973
+ }
2839
2974
 
2840
2975
  // Fingerprint cache: skip re-rendering unchanged messages. Append the
2841
2976
  // ask-user-question answered/answers state so flipping `askUserQuestionAnswered`
@@ -2849,7 +2984,7 @@ export const createAgentExperience = (
2849
2984
  }`
2850
2985
  : "";
2851
2986
  const fingerprint = computeMessageFingerprint(message, configVersion) + askMeta;
2852
- const cachedWrapper = askWithPlugin
2987
+ const cachedWrapper = (askWithPlugin || hasDirectiveBubble)
2853
2988
  ? null
2854
2989
  : getCachedWrapper(messageCache, message.id, fingerprint);
2855
2990
  if (cachedWrapper) {
@@ -3046,19 +3181,26 @@ export const createAgentExperience = (
3046
3181
  }
3047
3182
  }
3048
3183
 
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) {
3184
+ // Check for component directive if no plugin handled it. We use the
3185
+ // same stub-and-hydrate trick as ask_user_question plugins (see comment
3186
+ // above `componentDirectiveHydrate`): build the live element with its
3187
+ // listeners, append a stub for the morph pass, then inject the live
3188
+ // element into the morphed wrapper afterward.
3189
+ if (!bubble && hasDirectiveBubble) {
3190
+ const directive = extractComponentDirectiveFromMessage(message);
3191
+ if (directive) {
3192
+ const lastFp = lastComponentDirectiveFingerprint.get(message.id);
3193
+ const needsRebuild = lastFp !== fingerprint;
3194
+ const wrapChrome = config.wrapComponentDirectiveInBubble !== false;
3195
+ let liveBubble: HTMLElement | null = null;
3196
+
3197
+ if (needsRebuild) {
3055
3198
  const componentBubble = renderComponentDirective(directive, {
3056
3199
  config,
3057
3200
  message,
3058
3201
  transform
3059
3202
  });
3060
3203
  if (componentBubble) {
3061
- const wrapChrome = config.wrapComponentDirectiveInBubble !== false;
3062
3204
  if (wrapChrome) {
3063
3205
  const componentWrapper = document.createElement("div");
3064
3206
  componentWrapper.className = [
@@ -3086,7 +3228,7 @@ export const createAgentExperience = (
3086
3228
  }
3087
3229
 
3088
3230
  componentWrapper.appendChild(componentBubble);
3089
- bubble = componentWrapper;
3231
+ liveBubble = componentWrapper;
3090
3232
  } else {
3091
3233
  const stack = document.createElement("div");
3092
3234
  stack.className =
@@ -3109,10 +3251,33 @@ export const createAgentExperience = (
3109
3251
  }
3110
3252
 
3111
3253
  stack.appendChild(componentBubble);
3112
- bubble = stack;
3254
+ liveBubble = stack;
3113
3255
  }
3114
3256
  }
3115
3257
  }
3258
+
3259
+ // If the directive is registered (live bubble built or already
3260
+ // mounted from a previous pass), use the stub-and-hydrate path.
3261
+ // Otherwise fall through to the standard render path so the message
3262
+ // text is at least visible.
3263
+ if (liveBubble || lastFp != null) {
3264
+ const stub = document.createElement("div");
3265
+ stub.className = "persona-flex";
3266
+ stub.id = `wrapper-${message.id}`;
3267
+ stub.setAttribute("data-wrapper-id", message.id);
3268
+ stub.setAttribute("data-component-directive-stub", "true");
3269
+ stub.setAttribute("data-preserve-runtime", "true");
3270
+ if (!wrapChrome) {
3271
+ stub.classList.add("persona-w-full");
3272
+ }
3273
+ tempContainer.appendChild(stub);
3274
+ componentDirectiveHydrate.push({
3275
+ messageId: message.id,
3276
+ fingerprint,
3277
+ bubble: liveBubble
3278
+ });
3279
+ return;
3280
+ }
3116
3281
  }
3117
3282
  }
3118
3283
 
@@ -3449,13 +3614,515 @@ export const createAgentExperience = (
3449
3614
  if (!activeMessageIds.has(id)) lastAskBubbleFingerprint.delete(id);
3450
3615
  }
3451
3616
  }
3617
+
3618
+ // Hydrate component-directive bubbles into their stub wrappers, mirroring
3619
+ // the ask-question hydration above.
3620
+ if (componentDirectiveHydrate.length > 0) {
3621
+ for (const { messageId, fingerprint, bubble } of componentDirectiveHydrate) {
3622
+ const wrapper = container.querySelector(`#wrapper-${messageId}`);
3623
+ if (!wrapper) continue;
3624
+ if (bubble === null) {
3625
+ // Fingerprint matched the previous pass — the live wrapper (kept
3626
+ // alive by `data-preserve-runtime`) still holds the listener-bearing
3627
+ // bubble from a prior render. Leave it untouched.
3628
+ continue;
3629
+ }
3630
+ wrapper.replaceChildren(bubble);
3631
+ wrapper.setAttribute("data-bubble-fp", fingerprint);
3632
+ lastComponentDirectiveFingerprint.set(messageId, fingerprint);
3633
+ }
3634
+ }
3635
+
3636
+ if (lastComponentDirectiveFingerprint.size > 0) {
3637
+ for (const id of lastComponentDirectiveFingerprint.keys()) {
3638
+ if (!activeMessageIds.has(id)) lastComponentDirectiveFingerprint.delete(id);
3639
+ }
3640
+ }
3452
3641
  };
3453
3642
 
3454
3643
  // Alias for clarity - the implementation handles flicker prevention via typing indicator logic
3455
3644
  const renderMessagesWithPlugins = renderMessagesWithPluginsImpl;
3456
3645
 
3646
+ /**
3647
+ * Composer-bar outside-click dismiss. While the chat is expanded, clicking
3648
+ * anywhere outside the wrapper (i.e. NOT inside the chat panel chrome and
3649
+ * NOT inside the pill) collapses back to just the pill. Uses `pointerdown`
3650
+ * + capture so we run before host-page click handlers (and before any
3651
+ * stop-propagation upstream); composedPath() includes the shadow DOM
3652
+ * subtree, so clicks inside the wrapper (which lives in the shadow root)
3653
+ * are correctly identified as inside.
3654
+ */
3655
+ let composerBarOutsideClickListener: ((e: PointerEvent) => void) | null = null;
3656
+
3657
+ const attachComposerBarOutsideClickDismiss = () => {
3658
+ if (composerBarOutsideClickListener) return;
3659
+ const listener: (e: PointerEvent) => void = (event) => {
3660
+ const path = event.composedPath();
3661
+ // pillRoot is a viewport-fixed sibling of the wrapper, so a click on
3662
+ // the pill or peek wouldn't be in `wrapper`'s composedPath even
3663
+ // though it's logically "inside" the widget.
3664
+ if (path.includes(wrapper)) return;
3665
+ if (pillRoot && path.includes(pillRoot)) return;
3666
+ setOpenState(false, "user");
3667
+ };
3668
+ composerBarOutsideClickListener = listener;
3669
+ const targetDoc = mount.ownerDocument ?? document;
3670
+ targetDoc.addEventListener("pointerdown", listener, true);
3671
+ };
3672
+
3673
+ const detachComposerBarOutsideClickDismiss = () => {
3674
+ if (!composerBarOutsideClickListener) return;
3675
+ const targetDoc = mount.ownerDocument ?? document;
3676
+ targetDoc.removeEventListener(
3677
+ "pointerdown",
3678
+ composerBarOutsideClickListener,
3679
+ true
3680
+ );
3681
+ composerBarOutsideClickListener = null;
3682
+ };
3683
+
3684
+ destroyCallbacks.push(() => detachComposerBarOutsideClickDismiss());
3685
+
3686
+ /**
3687
+ * Composer-bar ESC dismiss. While the chat is expanded, pressing Escape
3688
+ * collapses back to just the pill — same end state as outside-click.
3689
+ * Matches the WAI-ARIA dialog pattern (modal mode is literally a dialog)
3690
+ * and the dominant chat-widget convention (Intercom, Drift, Crisp).
3691
+ * Guards on `event.isComposing` so dismissing an IME suggestion doesn't
3692
+ * also collapse the panel.
3693
+ */
3694
+ let composerBarEscapeListener: ((e: KeyboardEvent) => void) | null = null;
3695
+
3696
+ const attachComposerBarEscapeDismiss = () => {
3697
+ if (composerBarEscapeListener) return;
3698
+ const listener: (e: KeyboardEvent) => void = (event) => {
3699
+ if (event.key !== "Escape") return;
3700
+ if (event.isComposing) return;
3701
+ setOpenState(false, "user");
3702
+ };
3703
+ composerBarEscapeListener = listener;
3704
+ const targetDoc = mount.ownerDocument ?? document;
3705
+ targetDoc.addEventListener("keydown", listener, true);
3706
+ };
3707
+
3708
+ const detachComposerBarEscapeDismiss = () => {
3709
+ if (!composerBarEscapeListener) return;
3710
+ const targetDoc = mount.ownerDocument ?? document;
3711
+ targetDoc.removeEventListener(
3712
+ "keydown",
3713
+ composerBarEscapeListener,
3714
+ true
3715
+ );
3716
+ composerBarEscapeListener = null;
3717
+ };
3718
+
3719
+ destroyCallbacks.push(() => detachComposerBarEscapeDismiss());
3720
+
3721
+ /**
3722
+ * Composer-bar "peek" affordance — a chrome-less row above the pill that
3723
+ * shows a chat-bubble icon, the trailing 100 chars of the most recent
3724
+ * assistant message, and a chevron-up. It is the user's path back into the
3725
+ * expanded chat from the collapsed pill.
3726
+ *
3727
+ * Visible when (collapsed) AND (there is an assistant message with content)
3728
+ * AND (`isStreaming` OR `composerHovered`). Otherwise hidden. The hover
3729
+ * zone is the whole `panel` (not just the pill) so the cursor moving
3730
+ * between the pill and the peek doesn't trigger fade-out.
3731
+ *
3732
+ * Driven from a single `syncComposerBarPeek()` invoked from
3733
+ * `onMessagesChanged`, `onStreamingChanged`, `updateOpenState`, the
3734
+ * pointerenter/pointerleave on `panel`, and once at end-of-init.
3735
+ */
3736
+ let composerHovered = false;
3737
+ // Track which peek-plugins we've already attached for this widget root.
3738
+ // `ensurePluginActive` is idempotent, but the call is guarded behind a flag
3739
+ // so we don't pay the lookup cost on every chunk.
3740
+ const peekActivatedPlugins = new Set<string>();
3741
+
3742
+ /**
3743
+ * Resolve the effective stream animation feature for the peek surface.
3744
+ * `composerBar.peek.streamAnimation` overrides; otherwise the peek inherits
3745
+ * `features.streamAnimation` so the surface for devs is consistent across
3746
+ * the main bubble and the peek banner.
3747
+ */
3748
+ const resolvePeekStreamAnimationFeature = () => {
3749
+ const peekFeature = config.launcher?.composerBar?.peek?.streamAnimation;
3750
+ if (peekFeature) return peekFeature;
3751
+ return config.features?.streamAnimation;
3752
+ };
3753
+
3754
+ const syncComposerBarPeek = () => {
3755
+ if (!isComposerBar()) return;
3756
+ const peekBanner = panelElements.peekBanner;
3757
+ const peekTextNode = panelElements.peekTextNode;
3758
+ if (!peekBanner || !peekTextNode) return;
3759
+
3760
+ if (open) {
3761
+ peekBanner.classList.remove("persona-pill-peek--visible");
3762
+ return;
3763
+ }
3764
+
3765
+ const messages = session?.getMessages() ?? [];
3766
+ let lastAssistant: AgentWidgetMessage | undefined;
3767
+ for (let i = messages.length - 1; i >= 0; i--) {
3768
+ const m = messages[i];
3769
+ if (m.role === "assistant" && m.content) {
3770
+ lastAssistant = m;
3771
+ break;
3772
+ }
3773
+ }
3774
+ if (!lastAssistant) {
3775
+ peekBanner.classList.remove("persona-pill-peek--visible");
3776
+ return;
3777
+ }
3778
+
3779
+ const text = lastAssistant.content;
3780
+ const streaming = Boolean(lastAssistant.streaming);
3781
+
3782
+ // Resolve the same animation surface used by the main bubble. The peek
3783
+ // ignores `bubbleClass` (carve-out: peek has no bubble) but honors
3784
+ // `containerClass`, `wrap`, `useCaret`, `buffer`, `placeholder`,
3785
+ // `speed`/`duration`, and custom plugins.
3786
+ const feature = resolvePeekStreamAnimationFeature();
3787
+ const streamAnimation = resolveStreamAnimation(feature);
3788
+ const plugin =
3789
+ streamAnimation.type !== "none"
3790
+ ? resolveStreamAnimationPlugin(streamAnimation.type, feature?.plugins)
3791
+ : null;
3792
+ const pluginStillAnimating =
3793
+ plugin?.isAnimating?.(lastAssistant) === true;
3794
+ const animationActive =
3795
+ plugin !== null && (streaming || pluginStillAnimating);
3796
+
3797
+ if (animationActive && plugin && !peekActivatedPlugins.has(plugin.name)) {
3798
+ ensurePluginActive(plugin, mount);
3799
+ peekActivatedPlugins.add(plugin.name);
3800
+ }
3801
+
3802
+ // Manage `containerClass` on the peek text node. We track which class is
3803
+ // currently applied so a config swap (or animation deactivating after
3804
+ // stream completion) cleans up the previous class instead of stacking.
3805
+ const desiredContainerClass =
3806
+ animationActive && plugin?.containerClass ? plugin.containerClass : null;
3807
+ const currentContainerClass =
3808
+ peekTextNode.dataset.personaPeekStreamClass ?? null;
3809
+ if (currentContainerClass && currentContainerClass !== desiredContainerClass) {
3810
+ peekTextNode.classList.remove(currentContainerClass);
3811
+ delete peekTextNode.dataset.personaPeekStreamClass;
3812
+ }
3813
+ if (desiredContainerClass && currentContainerClass !== desiredContainerClass) {
3814
+ peekTextNode.classList.add(desiredContainerClass);
3815
+ peekTextNode.dataset.personaPeekStreamClass = desiredContainerClass;
3816
+ }
3817
+
3818
+ if (animationActive) {
3819
+ peekTextNode.style.setProperty(
3820
+ "--persona-stream-step",
3821
+ `${streamAnimation.speed}ms`
3822
+ );
3823
+ peekTextNode.style.setProperty(
3824
+ "--persona-stream-duration",
3825
+ `${streamAnimation.duration}ms`
3826
+ );
3827
+ } else {
3828
+ peekTextNode.style.removeProperty("--persona-stream-step");
3829
+ peekTextNode.style.removeProperty("--persona-stream-duration");
3830
+ }
3831
+
3832
+ // Apply buffering (word/line/plugin custom). If the buffer trims content
3833
+ // to empty AND the placeholder is "skeleton", show the skeleton — that's
3834
+ // the "line buffer between completions" affordance. Otherwise no
3835
+ // pre-content placeholder on the peek (a typing-dots indicator inside a
3836
+ // 1-line ticker would feel cramped).
3837
+ const buffered = animationActive
3838
+ ? applyStreamBuffer(text, streamAnimation.buffer, plugin, lastAssistant, streaming)
3839
+ : text;
3840
+
3841
+ const skeletonEnabled =
3842
+ animationActive && streamAnimation.placeholder === "skeleton";
3843
+ const showSkeletonOnly =
3844
+ skeletonEnabled && streaming && (!buffered || !buffered.trim());
3845
+
3846
+ if (showSkeletonOnly) {
3847
+ // Replace text node contents with just a peek-sized skeleton bar. The
3848
+ // bar carries `data-preserve-animation` so idiomorph keeps its shimmer
3849
+ // running across morph passes.
3850
+ const tempContainer = document.createElement("div");
3851
+ const skeleton = createSkeletonPlaceholder();
3852
+ skeleton.classList.add("persona-pill-peek__skeleton");
3853
+ tempContainer.appendChild(skeleton);
3854
+ morphMessages(peekTextNode, tempContainer);
3855
+ } else {
3856
+ // Trailing 100 chars; for animated modes we keep the slice but use
3857
+ // ABSOLUTE indices so per-char/per-word span IDs stay stable as the
3858
+ // window shifts each chunk — idiomorph then preserves animations on
3859
+ // already-revealed units instead of restarting them. Plain "none" mode
3860
+ // keeps the legacy `…` ellipsis prefix for visual continuity with the
3861
+ // pre-animation behavior.
3862
+ const sliceStart = Math.max(0, buffered.length - 100);
3863
+ const slice = buffered.length > 100 ? buffered.slice(-100) : buffered;
3864
+ const escaped = escapeHtml(slice);
3865
+
3866
+ if (!animationActive || !plugin) {
3867
+ const preview = buffered.length > 100 ? `…${slice}` : slice;
3868
+ if (peekTextNode.textContent !== preview) {
3869
+ peekTextNode.textContent = preview;
3870
+ }
3871
+ } else {
3872
+ let html = escaped;
3873
+ if (plugin.wrap === "char" || plugin.wrap === "word") {
3874
+ html = wrapStreamAnimation(
3875
+ escaped,
3876
+ plugin.wrap,
3877
+ // Namespace span IDs to the peek surface so they don't collide
3878
+ // with the main bubble's spans for the same message id.
3879
+ `peek-${lastAssistant.id}`,
3880
+ { skipTags: plugin.skipTags, startIndex: sliceStart }
3881
+ );
3882
+ }
3883
+
3884
+ const tempContainer = document.createElement("div");
3885
+ tempContainer.innerHTML = html;
3886
+
3887
+ if (plugin.useCaret && slice.length > 0) {
3888
+ const caret = createStreamCaret();
3889
+ const spans = tempContainer.querySelectorAll(
3890
+ ".persona-stream-char, .persona-stream-word"
3891
+ );
3892
+ const lastSpan = spans[spans.length - 1];
3893
+ if (lastSpan?.parentNode) {
3894
+ lastSpan.parentNode.insertBefore(caret, lastSpan.nextSibling);
3895
+ } else {
3896
+ tempContainer.appendChild(caret);
3897
+ }
3898
+ }
3899
+
3900
+ morphMessages(peekTextNode, tempContainer);
3901
+
3902
+ // Fire the plugin's per-render hook so glyph-cycle / wipe / custom
3903
+ // plugins get a chance to mutate the peek's spans the same way they
3904
+ // mutate the main bubble's. The carve-out: `bubble` here is the peek
3905
+ // banner root, not a message bubble — plugins that target
3906
+ // `bubbleClass` should no-op on that surface.
3907
+ plugin.onAfterRender?.({
3908
+ container: peekTextNode,
3909
+ bubble: peekBanner,
3910
+ messageId: lastAssistant.id,
3911
+ message: lastAssistant,
3912
+ speed: streamAnimation.speed,
3913
+ duration: streamAnimation.duration,
3914
+ });
3915
+ }
3916
+ }
3917
+
3918
+ const shouldShow = isStreaming || composerHovered;
3919
+ peekBanner.classList.toggle("persona-pill-peek--visible", shouldShow);
3920
+ };
3921
+
3922
+ if (isComposerBar()) {
3923
+ const peekBanner = panelElements.peekBanner;
3924
+ if (peekBanner) {
3925
+ // pointerdown (not click) so this competes correctly with the
3926
+ // outside-click listener (also pointerdown, capture phase). The
3927
+ // outside-click composedPath check passes for events inside `wrapper`
3928
+ // or `pillRoot` (peek's parent), so the peek can stop propagation
3929
+ // here without breaking dismissal.
3930
+ const onPeekPointerDown = (e: PointerEvent) => {
3931
+ e.preventDefault();
3932
+ e.stopPropagation();
3933
+ setOpenState(true, "user");
3934
+ };
3935
+ peekBanner.addEventListener("pointerdown", onPeekPointerDown);
3936
+ destroyCallbacks.push(() => {
3937
+ peekBanner.removeEventListener("pointerdown", onPeekPointerDown);
3938
+ });
3939
+ }
3940
+
3941
+ const onPanelPointerEnter = () => {
3942
+ if (composerHovered) return;
3943
+ composerHovered = true;
3944
+ syncComposerBarPeek();
3945
+ };
3946
+ const onPanelPointerLeave = () => {
3947
+ if (!composerHovered) return;
3948
+ composerHovered = false;
3949
+ syncComposerBarPeek();
3950
+ };
3951
+ panel.addEventListener("pointerenter", onPanelPointerEnter);
3952
+ panel.addEventListener("pointerleave", onPanelPointerLeave);
3953
+ destroyCallbacks.push(() => {
3954
+ panel.removeEventListener("pointerenter", onPanelPointerEnter);
3955
+ panel.removeEventListener("pointerleave", onPanelPointerLeave);
3956
+ });
3957
+
3958
+ // pillRoot now hosts the pill + peek as viewport-level siblings, so the
3959
+ // panel's pointerenter/leave above no longer fires when the cursor is
3960
+ // over the pill area. Mirror the handlers onto pillRoot so hovering
3961
+ // either surface still drives `composerHovered`. Both handlers are
3962
+ // idempotent against the shared flag, so cross-traffic between panel
3963
+ // and pillRoot doesn't cause spurious flips.
3964
+ if (pillRoot) {
3965
+ pillRoot.addEventListener("pointerenter", onPanelPointerEnter);
3966
+ pillRoot.addEventListener("pointerleave", onPanelPointerLeave);
3967
+ destroyCallbacks.push(() => {
3968
+ pillRoot.removeEventListener("pointerenter", onPanelPointerEnter);
3969
+ pillRoot.removeEventListener("pointerleave", onPanelPointerLeave);
3970
+ });
3971
+ }
3972
+ }
3973
+
3974
+ /**
3975
+ * Composer-bar geometry, owned in one place so collapsed → expanded (and
3976
+ * back) transitions don't leave stale inline styles from a previous state.
3977
+ * `createWrapper` no longer sets any geometry; everything flows through
3978
+ * here.
3979
+ *
3980
+ * Width is expressed as `width: <configured>; max-width: calc(100vw -
3981
+ * 32px)`. The two combine such that `width` wins on wide viewports and
3982
+ * `max-width` clamps on narrow ones — same effect as `min(...)` but
3983
+ * jsdom-compatible. `100vw` is always the viewport, so the containing-
3984
+ * block edge case (host with `transform`/`filter` causing `100%` to
3985
+ * resolve against the host instead of the viewport) is neutralized.
3986
+ */
3987
+ const applyComposerBarGeometry = (isOpen: boolean) => {
3988
+ const cb = config.launcher?.composerBar ?? {};
3989
+ const expandedSize = cb.expandedSize ?? "anchored";
3990
+ const bottomOffset = cb.bottomOffset ?? "16px";
3991
+ // No hardcoded default — when undefined, CSS media queries provide the
3992
+ // responsive width (90vw / 70vw / 50vw at <640 / <1024 / >=1024) on
3993
+ // pillRoot.
3994
+ const collapsedMaxWidth = cb.collapsedMaxWidth;
3995
+ const expandedMaxWidth = cb.expandedMaxWidth ?? "880px";
3996
+ const expandedTopOffset = cb.expandedTopOffset ?? "5vh";
3997
+ const modalMaxWidth = cb.modalMaxWidth ?? "880px";
3998
+ const modalMaxHeight = cb.modalMaxHeight ?? "min(90vh, 800px)";
3999
+ const viewportClamp = "calc(100vw - 32px)";
4000
+ // Static fallback for the pill area's height (pill + 8px gap + peek
4001
+ // slack). Anchored mode uses this to compute the wrapper's bottom edge
4002
+ // so the chat panel chrome doesn't overlap the pill below. Defer
4003
+ // ResizeObserver-based dynamic sizing until we see a real misalignment.
4004
+ const pillAreaClearance = "var(--persona-pill-area-height, 80px)";
4005
+
4006
+ // Reset everything geometry-related so each branch sets exactly what it
4007
+ // needs. Using empty strings drops the inline declaration entirely so
4008
+ // CSS rules can take over (relevant for fullscreen).
4009
+ const s = wrapper.style;
4010
+ s.left = "";
4011
+ s.right = "";
4012
+ s.top = "";
4013
+ s.bottom = "";
4014
+ s.transform = "";
4015
+ s.width = "";
4016
+ s.maxWidth = "";
4017
+ s.height = "";
4018
+ s.maxHeight = "";
4019
+
4020
+ // pillRoot owns its own geometry (bottom offset + collapsed width
4021
+ // override). Reset and re-apply per-config every call so config edits
4022
+ // (e.g. via the demo's mode-switch) propagate cleanly.
4023
+ if (pillRoot) {
4024
+ const ps = pillRoot.style;
4025
+ ps.bottom = bottomOffset;
4026
+ // CSS media queries handle responsive width when no override is set.
4027
+ ps.width = collapsedMaxWidth ?? "";
4028
+ }
4029
+
4030
+ if (!isOpen) {
4031
+ // Collapsed: wrapper has nothing visible to render — the container
4032
+ // inside is `display: none` (via CSS keyed on `[data-state="collapsed"]`)
4033
+ // and the pill lives in pillRoot. Leave wrapper geometry empty so it
4034
+ // collapses to a zero-size positioning frame at the default fixed
4035
+ // origin. The container's fade-in keyframe handles the perceptible
4036
+ // expand animation, so there's no chrome to lose during this state.
4037
+ return;
4038
+ }
4039
+
4040
+ if (expandedSize === "fullscreen") {
4041
+ // Leave inline styles cleared so the CSS rule for fullscreen takes over.
4042
+ return;
4043
+ }
4044
+
4045
+ if (expandedSize === "modal") {
4046
+ s.top = "50%";
4047
+ s.left = "50%";
4048
+ s.transform = "translate(-50%, -50%)";
4049
+ s.bottom = "auto";
4050
+ s.right = "auto";
4051
+ s.width = modalMaxWidth;
4052
+ s.maxWidth = viewportClamp;
4053
+ s.maxHeight = modalMaxHeight;
4054
+ s.height = modalMaxHeight;
4055
+ return;
4056
+ }
4057
+
4058
+ // Default: anchored — pill stays at the viewport bottom (in pillRoot);
4059
+ // wrapper's bottom edge clears the pill area so the chrome doesn't
4060
+ // overlap it.
4061
+ s.left = "50%";
4062
+ s.transform = "translateX(-50%)";
4063
+ s.bottom = `calc(${bottomOffset} + ${pillAreaClearance})`;
4064
+ s.top = expandedTopOffset;
4065
+ s.width = expandedMaxWidth;
4066
+ s.maxWidth = viewportClamp;
4067
+ };
4068
+
3457
4069
  const updateOpenState = () => {
3458
- if (!launcherEnabled) return;
4070
+ if (!isPanelToggleable()) return;
4071
+
4072
+ // Composer-bar mode morphs the wrapper between collapsed pill and
4073
+ // expanded panel via data-attrs + per-state inline geometry. The chat
4074
+ // body and header are hidden in the collapsed state so only the
4075
+ // composer footer remains visible in the pill.
4076
+ if (isComposerBar()) {
4077
+ const cb = config.launcher?.composerBar ?? {};
4078
+ const expandedSize = cb.expandedSize ?? "anchored";
4079
+ const nextState = open ? "expanded" : "collapsed";
4080
+ wrapper.dataset.state = nextState;
4081
+ wrapper.dataset.expandedSize = expandedSize;
4082
+ // pillRoot mirrors wrapper's state attributes so CSS rules keyed off
4083
+ // [data-state] / [data-expanded-size] cascade to pill + peek even
4084
+ // though they live outside the wrapper subtree.
4085
+ if (pillRoot) {
4086
+ pillRoot.dataset.state = nextState;
4087
+ pillRoot.dataset.expandedSize = expandedSize;
4088
+ }
4089
+ wrapper.style.removeProperty("display");
4090
+ wrapper.classList.remove("persona-pointer-events-none", "persona-opacity-0");
4091
+ panel.classList.remove(
4092
+ "persona-scale-95",
4093
+ "persona-opacity-0",
4094
+ "persona-scale-100",
4095
+ "persona-opacity-100"
4096
+ );
4097
+
4098
+ applyComposerBarGeometry(open);
4099
+
4100
+ // Toggle the entire container (chat chrome + body + close button) so
4101
+ // the collapsed pill only shows the footer (which lives as a SIBLING
4102
+ // of the container in the panel — see panel.appendChild(footer) above).
4103
+ // The footer is always visible / interactive.
4104
+ container.style.display = open ? "flex" : "none";
4105
+
4106
+ // Re-run chrome application now that data-state has flipped: collapsed
4107
+ // clears container chrome (pill stands alone), expanded paints it via
4108
+ // the same theme.components.panel.* contract as floating mode.
4109
+ applyFullHeightStyles();
4110
+
4111
+ // Outside-click dismiss: while expanded, clicking anywhere outside the
4112
+ // wrapper (panel chrome + pill) collapses back to just the pill.
4113
+ if (open) {
4114
+ attachComposerBarOutsideClickDismiss();
4115
+ attachComposerBarEscapeDismiss();
4116
+ } else {
4117
+ detachComposerBarOutsideClickDismiss();
4118
+ detachComposerBarEscapeDismiss();
4119
+ }
4120
+ // Peek banner is hidden when expanded (`open === true` short-circuits
4121
+ // visibility); re-sync so collapsing back re-evaluates immediately.
4122
+ syncComposerBarPeek();
4123
+ return;
4124
+ }
4125
+
3459
4126
  const dockedMode = isDockedMountMode(config);
3460
4127
  const ownerWindow = mount.ownerDocument.defaultView ?? window;
3461
4128
  const mobileBreakpoint = config.launcher?.mobileBreakpoint ?? 640;
@@ -3510,7 +4177,7 @@ export const createAgentExperience = (
3510
4177
  };
3511
4178
 
3512
4179
  const setOpenState = (nextOpen: boolean, source: "user" | "auto" | "api" | "system" = "user") => {
3513
- if (!launcherEnabled) return;
4180
+ if (!isPanelToggleable()) return;
3514
4181
  if (open === nextOpen) return;
3515
4182
 
3516
4183
  const prevOpen = open;
@@ -3525,7 +4192,13 @@ export const createAgentExperience = (
3525
4192
  const mb = config.launcher?.mobileBreakpoint ?? 640;
3526
4193
  const isMobile = ow.innerWidth <= mb;
3527
4194
  const dockedMF = isDockedMountMode(config) && mf && isMobile;
3528
- return sm || (mf && isMobile && launcherEnabled) || dockedMF;
4195
+ // Composer-bar in expanded fullscreen mode covers the viewport — lock
4196
+ // background scroll and elevate host stacking to match other
4197
+ // viewport-covering modes (mobile fullscreen, sidebar).
4198
+ const composerBarFS =
4199
+ isComposerBar() &&
4200
+ (config.launcher?.composerBar?.expandedSize ?? "fullscreen") === "fullscreen";
4201
+ return sm || (mf && isMobile && launcherEnabled) || dockedMF || composerBarFS;
3529
4202
  })();
3530
4203
 
3531
4204
  if (open && isViewportCovering) {
@@ -3718,6 +4391,10 @@ export const createAgentExperience = (
3718
4391
 
3719
4392
  voiceState.lastUserMessageWasVoice = Boolean(lastUserMessage?.viaVoice);
3720
4393
  persistState(messages);
4394
+ // Composer-bar peek: re-render the trailing-100-char preview and
4395
+ // re-evaluate visibility (a new message may make it eligible to show
4396
+ // during streaming, or update the preview text on each token).
4397
+ syncComposerBarPeek();
3721
4398
  },
3722
4399
  onStatusChanged(status) {
3723
4400
  const currentStatusConfig = config.statusIndicator ?? {};
@@ -3740,6 +4417,9 @@ export const createAgentExperience = (
3740
4417
  if (!streaming) {
3741
4418
  scheduleAutoScroll(true);
3742
4419
  }
4420
+ // Composer-bar peek: streaming state is one of the two visibility
4421
+ // triggers (the other is composer hover), so re-evaluate now.
4422
+ syncComposerBarPeek();
3743
4423
  },
3744
4424
  onVoiceStatusChanged(status: VoiceStatus) {
3745
4425
  if (config.voiceRecognition?.provider?.type !== 'runtype') return;
@@ -3843,6 +4523,18 @@ export const createAgentExperience = (
3843
4523
  });
3844
4524
  }
3845
4525
 
4526
+ // Centralized so both the default composer (`handleSubmit`) and the plugin
4527
+ // composer (`renderComposer.onSubmit`) auto-expand the composer-bar wrapper
4528
+ // when a message is sent while the panel is collapsed. Without a single
4529
+ // helper the two submit paths drift over time.
4530
+ const maybeExpandComposerBar = () => {
4531
+ if (!isComposerBar()) return;
4532
+ if (open) return;
4533
+ const expandOnSubmit = config.launcher?.composerBar?.expandOnSubmit ?? true;
4534
+ if (!expandOnSubmit) return;
4535
+ setOpenState(true, "auto");
4536
+ };
4537
+
3846
4538
  const handleSubmit = (event: Event) => {
3847
4539
  event.preventDefault();
3848
4540
 
@@ -3860,6 +4552,8 @@ export const createAgentExperience = (
3860
4552
  // Must have text or attachments to send
3861
4553
  if (!value && !hasAttachments) return;
3862
4554
 
4555
+ maybeExpandComposerBar();
4556
+
3863
4557
  // Build content parts if there are attachments
3864
4558
  let contentParts: ContentPart[] | undefined;
3865
4559
  if (hasAttachments) {
@@ -4447,7 +5141,9 @@ export const createAgentExperience = (
4447
5141
  let launcherButtonInstance: ReturnType<typeof createLauncherButton> | null = null;
4448
5142
  let customLauncherElement: HTMLElement | null = null;
4449
5143
 
4450
- if (launcherEnabled) {
5144
+ // Composer-bar mode is launcher-less by design: the persistent pill IS the
5145
+ // entry point, so skip creating any launcher button (default or plugin).
5146
+ if (launcherEnabled && !isComposerBar()) {
4451
5147
  const launcherPlugin = plugins.find(p => p.renderLauncher);
4452
5148
  if (launcherPlugin?.renderLauncher) {
4453
5149
  const customLauncher = launcherPlugin.renderLauncher({
@@ -4462,7 +5158,7 @@ export const createAgentExperience = (
4462
5158
  customLauncherElement = customLauncher;
4463
5159
  }
4464
5160
  }
4465
-
5161
+
4466
5162
  // Use custom launcher if provided, otherwise use default
4467
5163
  if (!customLauncherElement) {
4468
5164
  launcherButtonInstance = createLauncherButton(config, toggleOpen);
@@ -4482,7 +5178,9 @@ export const createAgentExperience = (
4482
5178
  maybeRestoreVoiceFromMetadata();
4483
5179
 
4484
5180
  if (autoFocusInput) {
4485
- if (!launcherEnabled) {
5181
+ // Composer-bar's pill exposes the textarea immediately, so focus it on
5182
+ // init like the inline embed does — even though the panel is collapsed.
5183
+ if (!launcherEnabled || isComposerBar()) {
4486
5184
  setTimeout(() => maybeFocusInput(), 0);
4487
5185
  } else if (open) {
4488
5186
  setTimeout(() => maybeFocusInput(), 200);
@@ -4490,6 +5188,16 @@ export const createAgentExperience = (
4490
5188
  }
4491
5189
 
4492
5190
  const recalcPanelHeight = () => {
5191
+ // Composer-bar mode lets CSS own all sizing — collapsed pill is auto-sized
5192
+ // by the footer; expanded fullscreen/modal are driven by CSS attribute
5193
+ // selectors plus inline maxWidth/maxHeight set in updateOpenState. JS
5194
+ // sizing here would fight the morph transitions.
5195
+ if (isComposerBar()) {
5196
+ updateScrollToBottomButtonOffset();
5197
+ updateOpenState();
5198
+ return;
5199
+ }
5200
+
4493
5201
  const dockedMode = isDockedMountMode(config);
4494
5202
  const sidebarMode = config.launcher?.sidebarMode ?? false;
4495
5203
  const fullHeight = dockedMode || sidebarMode || (config.launcher?.fullHeight ?? false);
@@ -4661,7 +5369,7 @@ export const createAgentExperience = (
4661
5369
  closeButton.removeEventListener("click", closeHandler);
4662
5370
  closeHandler = null;
4663
5371
  }
4664
- if (launcherEnabled) {
5372
+ if (isPanelToggleable()) {
4665
5373
  closeButton.style.display = "";
4666
5374
  closeHandler = () => {
4667
5375
  setOpenState(false, "user");
@@ -5008,12 +5716,12 @@ export const createAgentExperience = (
5008
5716
  // Rebuild header with new layout
5009
5717
  const newHeaderElements = headerLayoutConfig
5010
5718
  ? buildHeaderWithLayout(config, headerLayoutConfig, {
5011
- showClose: launcherEnabled,
5719
+ showClose: isPanelToggleable(),
5012
5720
  onClose: () => setOpenState(false, "user")
5013
5721
  })
5014
5722
  : buildHeader({
5015
5723
  config,
5016
- showClose: launcherEnabled,
5724
+ showClose: isPanelToggleable(),
5017
5725
  onClose: () => setOpenState(false, "user")
5018
5726
  });
5019
5727
 
@@ -5414,9 +6122,15 @@ export const createAgentExperience = (
5414
6122
  if (clearChatButtonWrapper) {
5415
6123
  clearChatButtonWrapper.style.display = shouldShowClearChat ? "" : "none";
5416
6124
 
5417
- // When clear chat is hidden, close button needs ml-auto to stay right-aligned
6125
+ // When clear chat is hidden, close button needs ml-auto to stay right-aligned.
6126
+ // Composer-bar mode positions the close button absolutely, so the
6127
+ // ml-auto layout shim doesn't apply and is skipped below.
5418
6128
  const { closeButtonWrapper } = panelElements;
5419
- if (closeButtonWrapper && !closeButtonWrapper.classList.contains("persona-absolute")) {
6129
+ if (
6130
+ !isComposerBar() &&
6131
+ closeButtonWrapper &&
6132
+ !closeButtonWrapper.classList.contains("persona-absolute")
6133
+ ) {
5420
6134
  if (shouldShowClearChat) {
5421
6135
  closeButtonWrapper.classList.remove("persona-ml-auto");
5422
6136
  } else {
@@ -5424,11 +6138,14 @@ export const createAgentExperience = (
5424
6138
  }
5425
6139
  }
5426
6140
 
5427
- // Update placement if changed
6141
+ // Update placement if changed. Composer-bar mode owns the clear
6142
+ // button's position via panel.ts (absolute, top-right next to ×)
6143
+ // and must not get reshuffled into the floating launcher's
6144
+ // header strip.
5428
6145
  const isTopRight = clearChatPlacement === "top-right";
5429
6146
  const currentlyTopRight = clearChatButtonWrapper.classList.contains("persona-absolute");
5430
6147
 
5431
- if (isTopRight !== currentlyTopRight && shouldShowClearChat) {
6148
+ if (!isComposerBar() && isTopRight !== currentlyTopRight && shouldShowClearChat) {
5432
6149
  clearChatButtonWrapper.remove();
5433
6150
 
5434
6151
  if (isTopRight) {
@@ -5469,10 +6186,14 @@ export const createAgentExperience = (
5469
6186
  }
5470
6187
 
5471
6188
  if (shouldShowClearChat) {
5472
- // Update size
5473
- const clearChatSize = clearChatConfig.size ?? "32px";
5474
- clearChatButton.style.height = clearChatSize;
5475
- clearChatButton.style.width = clearChatSize;
6189
+ // Update size — composer-bar mode owns its sizing (16px to match
6190
+ // the close icon), so leave size alone there. Floating-launcher
6191
+ // and other modes still honor `launcher.clearChat.size`.
6192
+ if (!isComposerBar()) {
6193
+ const clearChatSize = clearChatConfig.size ?? "32px";
6194
+ clearChatButton.style.height = clearChatSize;
6195
+ clearChatButton.style.width = clearChatSize;
6196
+ }
5476
6197
 
5477
6198
  // Update icon
5478
6199
  const clearChatIconName = clearChatConfig.iconName ?? "refresh-cw";
@@ -5481,9 +6202,11 @@ export const createAgentExperience = (
5481
6202
  clearChatButton.style.color =
5482
6203
  clearChatIconColor || HEADER_THEME_CSS.actionIconColor;
5483
6204
 
5484
- // Clear existing icon and render new one
6205
+ // Clear existing icon and render new one. Composer-bar shrinks
6206
+ // the icon to match its 16px button.
5485
6207
  clearChatButton.innerHTML = "";
5486
- const iconSvg = renderLucideIcon(clearChatIconName, "20px", "currentColor", 2);
6208
+ const clearChatIconSize = isComposerBar() ? "14px" : "20px";
6209
+ const iconSvg = renderLucideIcon(clearChatIconName, clearChatIconSize, "currentColor", 2);
5487
6210
  if (iconSvg) {
5488
6211
  clearChatButton.appendChild(iconSvg);
5489
6212
  }
@@ -6046,8 +6769,13 @@ export const createAgentExperience = (
6046
6769
  tooltip.style.display = "none";
6047
6770
  }
6048
6771
 
6049
- // Update contentMaxWidth on messages wrapper and composer
6050
- const updatedContentMaxWidth = config.layout?.contentMaxWidth;
6772
+ // Update contentMaxWidth on messages wrapper and composer. Same
6773
+ // composer-bar fallback as the initial read above.
6774
+ const updatedContentMaxWidth =
6775
+ config.layout?.contentMaxWidth ??
6776
+ (isComposerBar()
6777
+ ? config.launcher?.composerBar?.contentMaxWidth ?? "720px"
6778
+ : undefined);
6051
6779
  if (updatedContentMaxWidth) {
6052
6780
  messagesWrapper.style.maxWidth = updatedContentMaxWidth;
6053
6781
  messagesWrapper.style.marginLeft = "auto";
@@ -6106,15 +6834,15 @@ export const createAgentExperience = (
6106
6834
  statusText.classList.add(alignClass);
6107
6835
  },
6108
6836
  open() {
6109
- if (!launcherEnabled) return;
6837
+ if (!isPanelToggleable()) return;
6110
6838
  setOpenState(true, "api");
6111
6839
  },
6112
6840
  close() {
6113
- if (!launcherEnabled) return;
6841
+ if (!isPanelToggleable()) return;
6114
6842
  setOpenState(false, "api");
6115
6843
  },
6116
6844
  toggle() {
6117
- if (!launcherEnabled) return;
6845
+ if (!isPanelToggleable()) return;
6118
6846
  setOpenState(!open, "api");
6119
6847
  },
6120
6848
  clearChat() {
@@ -6181,8 +6909,8 @@ export const createAgentExperience = (
6181
6909
  if (!textarea) return false;
6182
6910
  if (session.isStreaming()) return false;
6183
6911
 
6184
- // Auto-open widget if closed and launcher is enabled
6185
- if (!open && launcherEnabled) {
6912
+ // Auto-open widget if closed and the panel is toggleable
6913
+ if (!open && isPanelToggleable()) {
6186
6914
  setOpenState(true, "system");
6187
6915
  }
6188
6916
 
@@ -6197,8 +6925,8 @@ export const createAgentExperience = (
6197
6925
  const valueToSubmit = message?.trim() || textarea.value.trim();
6198
6926
  if (!valueToSubmit) return false;
6199
6927
 
6200
- // Auto-open widget if closed and launcher is enabled
6201
- if (!open && launcherEnabled) {
6928
+ // Auto-open widget if closed and the panel is toggleable
6929
+ if (!open && isPanelToggleable()) {
6202
6930
  setOpenState(true, "system");
6203
6931
  }
6204
6932
 
@@ -6211,7 +6939,7 @@ export const createAgentExperience = (
6211
6939
  if (session.isStreaming()) return false;
6212
6940
  if (config.voiceRecognition?.provider?.type === 'runtype') {
6213
6941
  if (session.isVoiceActive()) return true;
6214
- if (!open && launcherEnabled) setOpenState(true, "system");
6942
+ if (!open && isPanelToggleable()) setOpenState(true, "system");
6215
6943
  voiceState.manuallyDeactivated = false;
6216
6944
  persistVoiceMetadata();
6217
6945
  session.toggleVoice().then(() => {
@@ -6224,7 +6952,7 @@ export const createAgentExperience = (
6224
6952
  if (isRecording) return true;
6225
6953
  const SpeechRecognitionClass = getSpeechRecognitionClass();
6226
6954
  if (!SpeechRecognitionClass) return false;
6227
- if (!open && launcherEnabled) setOpenState(true, "system");
6955
+ if (!open && isPanelToggleable()) setOpenState(true, "system");
6228
6956
  voiceState.manuallyDeactivated = false;
6229
6957
  persistVoiceMetadata();
6230
6958
  startVoiceRecognition("user");
@@ -6250,15 +6978,15 @@ export const createAgentExperience = (
6250
6978
  return true;
6251
6979
  },
6252
6980
  injectMessage(options: InjectMessageOptions): AgentWidgetMessage {
6253
- // Auto-open widget if closed and launcher is enabled
6254
- if (!open && launcherEnabled) {
6981
+ // Auto-open widget if closed and the panel is toggleable
6982
+ if (!open && isPanelToggleable()) {
6255
6983
  setOpenState(true, "system");
6256
6984
  }
6257
6985
  return session.injectMessage(options);
6258
6986
  },
6259
6987
  injectAssistantMessage(options: InjectAssistantMessageOptions): AgentWidgetMessage {
6260
- // Auto-open widget if closed and launcher is enabled
6261
- if (!open && launcherEnabled) {
6988
+ // Auto-open widget if closed and the panel is toggleable
6989
+ if (!open && isPanelToggleable()) {
6262
6990
  setOpenState(true, "system");
6263
6991
  }
6264
6992
  const result = session.injectAssistantMessage(options);
@@ -6283,29 +7011,29 @@ export const createAgentExperience = (
6283
7011
  return result;
6284
7012
  },
6285
7013
  injectUserMessage(options: InjectUserMessageOptions): AgentWidgetMessage {
6286
- // Auto-open widget if closed and launcher is enabled
6287
- if (!open && launcherEnabled) {
7014
+ // Auto-open widget if closed and the panel is toggleable
7015
+ if (!open && isPanelToggleable()) {
6288
7016
  setOpenState(true, "system");
6289
7017
  }
6290
7018
  return session.injectUserMessage(options);
6291
7019
  },
6292
7020
  injectSystemMessage(options: InjectSystemMessageOptions): AgentWidgetMessage {
6293
- // Auto-open widget if closed and launcher is enabled
6294
- if (!open && launcherEnabled) {
7021
+ // Auto-open widget if closed and the panel is toggleable
7022
+ if (!open && isPanelToggleable()) {
6295
7023
  setOpenState(true, "system");
6296
7024
  }
6297
7025
  return session.injectSystemMessage(options);
6298
7026
  },
6299
7027
  injectMessageBatch(optionsList: InjectMessageOptions[]): AgentWidgetMessage[] {
6300
- if (!open && launcherEnabled) {
7028
+ if (!open && isPanelToggleable()) {
6301
7029
  setOpenState(true, "system");
6302
7030
  }
6303
7031
  return session.injectMessageBatch(optionsList);
6304
7032
  },
6305
7033
  /** @deprecated Use injectMessage() instead */
6306
7034
  injectTestMessage(event: AgentWidgetEvent) {
6307
- // Auto-open widget if closed and launcher is enabled
6308
- if (!open && launcherEnabled) {
7035
+ // Auto-open widget if closed and the panel is toggleable
7036
+ if (!open && isPanelToggleable()) {
6309
7037
  setOpenState(true, "system");
6310
7038
  }
6311
7039
  session.injectTestEvent(event);
@@ -6370,7 +7098,9 @@ export const createAgentExperience = (
6370
7098
  return session?.getSelectedArtifactId() ?? null;
6371
7099
  },
6372
7100
  focusInput(): boolean {
6373
- if (launcherEnabled && !open) return false;
7101
+ // Composer-bar's textarea is always reachable in the collapsed pill,
7102
+ // so don't gate focus behind `open` for that mode.
7103
+ if (launcherEnabled && !open && !isComposerBar()) return false;
6374
7104
  if (!textarea) return false;
6375
7105
  textarea.focus();
6376
7106
  return true;
@@ -6407,14 +7137,14 @@ export const createAgentExperience = (
6407
7137
  },
6408
7138
  // State query methods
6409
7139
  isOpen(): boolean {
6410
- return launcherEnabled && open;
7140
+ return isPanelToggleable() && open;
6411
7141
  },
6412
7142
  isVoiceActive(): boolean {
6413
7143
  return voiceState.active;
6414
7144
  },
6415
7145
  getState(): AgentWidgetStateSnapshot {
6416
7146
  return {
6417
- open: launcherEnabled && open,
7147
+ open: isPanelToggleable() && open,
6418
7148
  launcherEnabled,
6419
7149
  voiceActive: voiceState.active,
6420
7150
  streaming: session.isStreaming()
@@ -6422,8 +7152,8 @@ export const createAgentExperience = (
6422
7152
  },
6423
7153
  // Feedback methods (CSAT/NPS)
6424
7154
  showCSATFeedback(options?: Partial<CSATFeedbackOptions>) {
6425
- // Auto-open widget if closed and launcher is enabled
6426
- if (!open && launcherEnabled) {
7155
+ // Auto-open widget if closed and the panel is toggleable
7156
+ if (!open && isPanelToggleable()) {
6427
7157
  setOpenState(true, "system");
6428
7158
  }
6429
7159
 
@@ -6449,8 +7179,8 @@ export const createAgentExperience = (
6449
7179
  feedbackEl.scrollIntoView({ behavior: 'smooth', block: 'end' });
6450
7180
  },
6451
7181
  showNPSFeedback(options?: Partial<NPSFeedbackOptions>) {
6452
- // Auto-open widget if closed and launcher is enabled
6453
- if (!open && launcherEnabled) {
7182
+ // Auto-open widget if closed and the panel is toggleable
7183
+ if (!open && isPanelToggleable()) {
6454
7184
  setOpenState(true, "system");
6455
7185
  }
6456
7186
 
@@ -6488,6 +7218,7 @@ export const createAgentExperience = (
6488
7218
  }
6489
7219
  destroyCallbacks.forEach((cb) => cb());
6490
7220
  wrapper.remove();
7221
+ pillRoot?.remove();
6491
7222
  launcherButtonInstance?.destroy();
6492
7223
  customLauncherElement?.remove();
6493
7224
  if (closeHandler) {
@@ -6610,7 +7341,7 @@ export const createAgentExperience = (
6610
7341
  // ============================================================================
6611
7342
  const persistConfig = normalizePersistStateConfig(config.persistState);
6612
7343
 
6613
- if (persistConfig && launcherEnabled) {
7344
+ if (persistConfig && isPanelToggleable()) {
6614
7345
  const storage = getPersistStorage(persistConfig.storage!);
6615
7346
  const openKey = `${persistConfig.keyPrefix}widget-open`;
6616
7347
  const voiceKey = `${persistConfig.keyPrefix}widget-voice`;
@@ -6689,10 +7420,16 @@ export const createAgentExperience = (
6689
7420
  // If onStateLoaded signalled open: true, open the panel after init.
6690
7421
  // Mirrors the same setTimeout(0) pattern used by persistState restore so both
6691
7422
  // can fire independently without interfering with each other.
6692
- if (shouldOpenAfterStateLoaded && launcherEnabled) {
7423
+ if (shouldOpenAfterStateLoaded && isPanelToggleable()) {
6693
7424
  setTimeout(() => { controller.open(); }, 0);
6694
7425
  }
6695
7426
 
7427
+ // Initial sync of the composer-bar peek banner so it reflects any
7428
+ // restored history. Subsequent updates flow through `onMessagesChanged`,
7429
+ // `onStreamingChanged`, `updateOpenState`, and pointerenter/leave on
7430
+ // the panel.
7431
+ syncComposerBarPeek();
7432
+
6696
7433
  return controller;
6697
7434
  };
6698
7435