@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.
- package/README.md +1 -1
- package/dist/index.cjs +47 -47
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +281 -4
- package/dist/index.d.ts +281 -4
- 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 +1438 -619
- package/dist/theme-editor.d.cts +119 -1
- package/dist/theme-editor.d.ts +119 -1
- package/dist/theme-editor.js +1552 -619
- 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 +4 -0
- package/src/runtime/init.ts +4 -2
- package/src/runtime/persist-state.test.ts +152 -0
- package/src/styles/widget.css +348 -0
- package/src/types.ts +121 -1
- package/src/ui.component-directive.test.ts +183 -0
- package/src/ui.composer-bar.test.ts +1009 -0
- package/src/ui.ts +809 -72
- package/src/utils/attachment-manager.ts +1 -1
- 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
|
@@ -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
|
-
|
|
489
|
-
|
|
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
|
-
|
|
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,
|
|
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:
|
|
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
|
|
1027
|
-
|
|
1028
|
-
|
|
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
|
-
|
|
3051
|
-
|
|
3052
|
-
|
|
3053
|
-
|
|
3054
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 (!
|
|
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 (!
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 (
|
|
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:
|
|
5719
|
+
showClose: isPanelToggleable(),
|
|
5012
5720
|
onClose: () => setOpenState(false, "user")
|
|
5013
5721
|
})
|
|
5014
5722
|
: buildHeader({
|
|
5015
5723
|
config,
|
|
5016
|
-
showClose:
|
|
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 (
|
|
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
|
-
|
|
5474
|
-
|
|
5475
|
-
|
|
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
|
|
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
|
-
|
|
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 (!
|
|
6837
|
+
if (!isPanelToggleable()) return;
|
|
6110
6838
|
setOpenState(true, "api");
|
|
6111
6839
|
},
|
|
6112
6840
|
close() {
|
|
6113
|
-
if (!
|
|
6841
|
+
if (!isPanelToggleable()) return;
|
|
6114
6842
|
setOpenState(false, "api");
|
|
6115
6843
|
},
|
|
6116
6844
|
toggle() {
|
|
6117
|
-
if (!
|
|
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
|
|
6185
|
-
if (!open &&
|
|
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
|
|
6201
|
-
if (!open &&
|
|
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 &&
|
|
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 &&
|
|
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
|
|
6254
|
-
if (!open &&
|
|
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
|
|
6261
|
-
if (!open &&
|
|
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
|
|
6287
|
-
if (!open &&
|
|
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
|
|
6294
|
-
if (!open &&
|
|
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 &&
|
|
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
|
|
6308
|
-
if (!open &&
|
|
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
|
-
|
|
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
|
|
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:
|
|
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
|
|
6426
|
-
if (!open &&
|
|
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
|
|
6453
|
-
if (!open &&
|
|
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 &&
|
|
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 &&
|
|
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
|
|