@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.
- package/README.md +45 -2
- package/dist/index.cjs +47 -47
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +383 -6
- package/dist/index.d.ts +383 -6
- package/dist/index.global.js +102 -1636
- package/dist/index.global.js.map +1 -1
- package/dist/index.js +47 -47
- package/dist/index.js.map +1 -1
- package/dist/theme-editor.cjs +1514 -626
- package/dist/theme-editor.d.cts +192 -1
- package/dist/theme-editor.d.ts +192 -1
- package/dist/theme-editor.js +1628 -626
- package/dist/widget.css +348 -0
- package/package.json +1 -1
- package/src/components/composer-builder.test.ts +52 -0
- package/src/components/composer-builder.ts +67 -490
- package/src/components/composer-parts.test.ts +152 -0
- package/src/components/composer-parts.ts +452 -0
- package/src/components/header-builder.ts +22 -299
- package/src/components/header-parts.ts +360 -0
- package/src/components/panel.test.ts +61 -0
- package/src/components/panel.ts +262 -5
- package/src/components/pill-composer-builder.test.ts +85 -0
- package/src/components/pill-composer-builder.ts +183 -0
- package/src/index.ts +5 -0
- package/src/runtime/init.ts +4 -2
- package/src/runtime/persist-state.test.ts +152 -0
- package/src/session.test.ts +123 -0
- package/src/session.ts +58 -4
- package/src/styles/widget.css +348 -0
- package/src/types.ts +196 -1
- package/src/ui.component-directive.test.ts +183 -0
- package/src/ui.composer-bar.test.ts +1009 -0
- package/src/ui.ts +827 -72
- package/src/utils/attachment-manager.ts +1 -1
- package/src/utils/component-middleware.test.ts +134 -0
- package/src/utils/component-middleware.ts +44 -13
- package/src/utils/dock.test.ts +45 -0
- package/src/utils/dock.ts +3 -0
- package/src/utils/icons.ts +314 -58
- 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
|
-
|
|
489
|
-
|
|
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
|
-
|
|
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,
|
|
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:
|
|
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
|
|
1027
|
-
|
|
1028
|
-
|
|
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
|
-
|
|
3051
|
-
|
|
3052
|
-
|
|
3053
|
-
|
|
3054
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 (!
|
|
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 (!
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 (
|
|
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:
|
|
5728
|
+
showClose: isPanelToggleable(),
|
|
5012
5729
|
onClose: () => setOpenState(false, "user")
|
|
5013
5730
|
})
|
|
5014
5731
|
: buildHeader({
|
|
5015
5732
|
config,
|
|
5016
|
-
showClose:
|
|
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 (
|
|
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
|
-
|
|
5474
|
-
|
|
5475
|
-
|
|
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
|
|
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
|
-
|
|
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 (!
|
|
6846
|
+
if (!isPanelToggleable()) return;
|
|
6110
6847
|
setOpenState(true, "api");
|
|
6111
6848
|
},
|
|
6112
6849
|
close() {
|
|
6113
|
-
if (!
|
|
6850
|
+
if (!isPanelToggleable()) return;
|
|
6114
6851
|
setOpenState(false, "api");
|
|
6115
6852
|
},
|
|
6116
6853
|
toggle() {
|
|
6117
|
-
if (!
|
|
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
|
|
6185
|
-
if (!open &&
|
|
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
|
|
6201
|
-
if (!open &&
|
|
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 &&
|
|
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 &&
|
|
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
|
|
6254
|
-
if (!open &&
|
|
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
|
|
6261
|
-
if (!open &&
|
|
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
|
|
6287
|
-
if (!open &&
|
|
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
|
|
6294
|
-
if (!open &&
|
|
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 &&
|
|
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
|
|
6308
|
-
if (!open &&
|
|
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
|
-
|
|
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
|
|
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:
|
|
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
|
|
6426
|
-
if (!open &&
|
|
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
|
|
6453
|
-
if (!open &&
|
|
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 &&
|
|
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 &&
|
|
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
|
|