@runtypelabs/persona 4.14.0 → 4.16.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 +4 -4
- package/dist/animations/glyph-cycle.d.cts +1 -1
- package/dist/animations/glyph-cycle.d.ts +1 -1
- package/dist/animations/{types-DveIaNx6.d.cts → types-BpWGm7_6.d.cts} +17 -0
- package/dist/animations/{types-DveIaNx6.d.ts → types-BpWGm7_6.d.ts} +17 -0
- package/dist/animations/wipe.d.cts +1 -1
- package/dist/animations/wipe.d.ts +1 -1
- package/dist/chunk-QJ4LV2KW.js +1 -0
- package/dist/codegen.cjs +12 -11
- package/dist/codegen.js +14 -13
- package/dist/{context-mentions-ONG7A7M6.js → context-mentions-BTSTIBFC.js} +1 -1
- package/dist/context-mentions.cjs +11 -8
- package/dist/context-mentions.d.cts +520 -21
- package/dist/context-mentions.d.ts +520 -21
- package/dist/context-mentions.js +11 -8
- package/dist/index.cjs +69 -68
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +595 -46
- package/dist/index.d.ts +595 -46
- package/dist/index.global.js +60 -57
- package/dist/index.global.js.map +1 -1
- package/dist/index.js +69 -68
- package/dist/index.js.map +1 -1
- package/dist/launcher.global.js +5 -2
- package/dist/launcher.global.js.map +1 -1
- package/dist/smart-dom-reader.d.cts +520 -21
- package/dist/smart-dom-reader.d.ts +520 -21
- package/dist/testing.cjs +3 -3
- package/dist/testing.d.cts +7 -0
- package/dist/testing.d.ts +7 -0
- package/dist/testing.js +3 -3
- package/dist/theme-editor-preview.cjs +67 -67
- package/dist/theme-editor-preview.d.cts +534 -22
- package/dist/theme-editor-preview.d.ts +534 -22
- package/dist/theme-editor-preview.js +57 -57
- package/dist/theme-editor.cjs +10 -10
- package/dist/theme-editor.d.cts +521 -23
- package/dist/theme-editor.d.ts +521 -23
- package/dist/theme-editor.js +10 -10
- package/dist/theme-reference.cjs +1 -1
- package/dist/theme-reference.d.cts +104 -3
- package/dist/theme-reference.d.ts +104 -3
- package/dist/theme-reference.js +1 -1
- package/dist/widget.css +1 -1
- package/package.json +1 -1
- package/src/ask-user-question-tool.ts +10 -7
- package/src/components/artifact-pane.test.ts +27 -0
- package/src/components/artifact-pane.ts +7 -3
- package/src/components/artifact-preview.ts +1 -1
- package/src/components/composer-parts.test.ts +103 -4
- package/src/components/composer-parts.ts +53 -15
- package/src/components/header-builder.ts +27 -2
- package/src/components/header-layouts.test.ts +56 -0
- package/src/components/header-layouts.ts +29 -9
- package/src/components/launcher-surface.test.ts +403 -0
- package/src/components/launcher.ts +297 -2
- package/src/components/messages.ts +5 -2
- package/src/components/panel.ts +92 -71
- package/src/components/pill-composer-builder.test.ts +18 -0
- package/src/components/suggestions.test.ts +273 -0
- package/src/components/suggestions.ts +528 -86
- package/src/components/welcome.test.ts +121 -0
- package/src/components/welcome.ts +305 -0
- package/src/components/widget-view.test.ts +5 -3
- package/src/components/widget-view.ts +18 -7
- package/src/defaults.ts +7 -5
- package/src/generated/runtype-openapi-contract.ts +17 -0
- package/src/index-core.ts +29 -2
- package/src/launcher-global.ts +11 -8
- package/src/plugins/types.ts +136 -3
- package/src/presets.ts +3 -3
- package/src/session.ts +10 -8
- package/src/styles/widget-styles.test.ts +382 -0
- package/src/styles/widget.css +778 -4
- package/src/suggest-replies-tool.test.ts +233 -2
- package/src/suggest-replies-tool.ts +115 -39
- package/src/testing/mock-stream.test.ts +19 -0
- package/src/testing/mock-stream.ts +20 -0
- package/src/theme-editor/preview-utils.test.ts +34 -0
- package/src/theme-editor/preview-utils.ts +77 -2
- package/src/theme-editor/sections.test.ts +33 -0
- package/src/theme-editor/sections.ts +83 -6
- package/src/theme-reference.ts +34 -5
- package/src/types/theme.ts +72 -3
- package/src/types.ts +312 -18
- package/src/ui.body-top-slot.test.ts +95 -0
- package/src/ui.content-width.test.ts +91 -0
- package/src/ui.detached-panel.test.ts +1 -1
- package/src/ui.docked.test.ts +7 -4
- package/src/ui.launcher-teaser.test.ts +162 -0
- package/src/ui.rebuild-composer.test.ts +491 -0
- package/src/ui.render-welcome.test.ts +505 -0
- package/src/ui.scroll-additive.test.ts +91 -21
- package/src/ui.scroll.test.ts +302 -14
- package/src/ui.suggest-replies.test.ts +1112 -1
- package/src/ui.ts +1008 -149
- package/src/ui.welcome.test.ts +504 -0
- package/src/utils/artifact-file.test.ts +1 -1
- package/src/utils/artifact-gate.test.ts +27 -0
- package/src/utils/artifact-gate.ts +9 -0
- package/src/utils/attachment-manager.test.ts +37 -0
- package/src/utils/attachment-manager.ts +15 -0
- package/src/utils/auto-follow.test.ts +23 -0
- package/src/utils/auto-follow.ts +11 -1
- package/src/utils/code-generators.test.ts +40 -0
- package/src/utils/code-generators.ts +22 -0
- package/src/utils/config-merge.test.ts +24 -0
- package/src/utils/config-merge.ts +2 -0
- package/src/utils/content-width.test.ts +37 -0
- package/src/utils/content-width.ts +17 -0
- package/src/utils/icons.test.ts +26 -0
- package/src/utils/icons.ts +8 -0
- package/src/utils/plugin-storage.test.ts +94 -0
- package/src/utils/plugin-storage.ts +98 -0
- package/src/utils/theme.test.ts +216 -0
- package/src/utils/tokens.ts +113 -3
- package/src/welcome.test.ts +213 -0
- package/src/welcome.ts +130 -0
- package/dist/chunk-MMUPR2JW.js +0 -1
package/src/ui.ts
CHANGED
|
@@ -34,7 +34,10 @@ import {
|
|
|
34
34
|
PersonaArtifactFileMeta,
|
|
35
35
|
PersonaArtifactActionContext,
|
|
36
36
|
AgentWidgetContentSegment,
|
|
37
|
-
AgentWidgetContextMentionRef
|
|
37
|
+
AgentWidgetContextMentionRef,
|
|
38
|
+
AgentWidgetSuggestion,
|
|
39
|
+
AgentWidgetSuggestionSource,
|
|
40
|
+
AgentWidgetWelcomeIcon
|
|
38
41
|
} from "./types";
|
|
39
42
|
import { AttachmentManager } from "./utils/attachment-manager";
|
|
40
43
|
import {
|
|
@@ -47,6 +50,7 @@ import { applyThemeVariables, createThemeObserver, getActiveTheme } from "./util
|
|
|
47
50
|
import { resolveTokenValue } from "./utils/tokens";
|
|
48
51
|
import { renderLucideIcon } from "./utils/icons";
|
|
49
52
|
import { createElement } from "./utils/dom";
|
|
53
|
+
import { resolveContentMaxWidth } from "./utils/content-width";
|
|
50
54
|
import { attachTooltip } from "./utils/tooltip";
|
|
51
55
|
import { downloadInfoFor } from "./utils/artifact-file";
|
|
52
56
|
import { artifactCopyText } from "./components/artifact-preview";
|
|
@@ -83,9 +87,20 @@ import {
|
|
|
83
87
|
import { syncOverlayHostStacking } from "./utils/overlay-host-stacking";
|
|
84
88
|
import { acquireScrollLock } from "./utils/scroll-lock";
|
|
85
89
|
import { isComposerBarMountMode, isDockedMountMode, resolveDockConfig } from "./utils/dock";
|
|
86
|
-
import {
|
|
90
|
+
import { LauncherSurface } from "./components/launcher";
|
|
87
91
|
import { buildHeader, buildComposer, attachHeaderToContainer } from "./components/panel";
|
|
92
|
+
import { buildPillComposer } from "./components/pill-composer-builder";
|
|
88
93
|
import { createWidgetView, resolveLauncher } from "./components/widget-view";
|
|
94
|
+
import {
|
|
95
|
+
animateWelcomeOut,
|
|
96
|
+
applyWelcomeConfig,
|
|
97
|
+
applyWelcomeVisibility,
|
|
98
|
+
clearWelcomePluginContent,
|
|
99
|
+
mountWelcomePluginContent,
|
|
100
|
+
renderWelcomeGreeting,
|
|
101
|
+
} from "./components/welcome";
|
|
102
|
+
import { createPluginStorageFactory } from "./utils/plugin-storage";
|
|
103
|
+
import { isWelcomeVisible, resolveWelcomeConfig } from "./welcome";
|
|
89
104
|
import { HEADER_THEME_CSS } from "./components/header-builder";
|
|
90
105
|
import { buildHeaderWithLayout } from "./components/header-layouts";
|
|
91
106
|
import { positionMap } from "./utils/positioning";
|
|
@@ -111,11 +126,16 @@ import {
|
|
|
111
126
|
import {
|
|
112
127
|
isSuggestRepliesMessage,
|
|
113
128
|
latestAgentSuggestions,
|
|
129
|
+
resolveFollowUpsFeature,
|
|
114
130
|
} from "./suggest-replies-tool";
|
|
115
131
|
import { formatElapsedMs } from "./utils/formatting";
|
|
116
132
|
import { approvalDetailsExpansionState, createApprovalBubble, updateApprovalDetailsUI } from "./components/approval-bubble";
|
|
117
133
|
import { createBuiltInApprovalPlugin } from "./components/approval-actions";
|
|
118
|
-
import {
|
|
134
|
+
import {
|
|
135
|
+
createSuggestionElement,
|
|
136
|
+
createSuggestions,
|
|
137
|
+
normalizeSuggestion,
|
|
138
|
+
} from "./components/suggestions";
|
|
119
139
|
import { EventStreamBuffer } from "./utils/event-stream-buffer";
|
|
120
140
|
import { EventStreamStore } from "./utils/event-stream-store";
|
|
121
141
|
import { ThroughputTracker } from "./utils/throughput-tracker";
|
|
@@ -359,6 +379,19 @@ type Controller = {
|
|
|
359
379
|
* @deprecated Use injectMessage() instead.
|
|
360
380
|
*/
|
|
361
381
|
injectTestMessage: (event: AgentWidgetEvent) => void;
|
|
382
|
+
/**
|
|
383
|
+
* Show follow-up suggestions from host code. An ephemeral UI overlay: the
|
|
384
|
+
* items never enter the transcript, the wire payload, or persisted state, so
|
|
385
|
+
* they do not survive a refresh. Cleared automatically when the next user
|
|
386
|
+
* message appends. Latest writer wins against the agent's `suggest_replies`
|
|
387
|
+
* payloads: this call overrides chips shown now, and a payload arriving
|
|
388
|
+
* afterwards overrides these. Renders through the `suggestions.followUps`
|
|
389
|
+
* presentation config and the full plugin and `persona:suggestReplies:*`
|
|
390
|
+
* event pipeline, with source `"host"`. An empty array clears.
|
|
391
|
+
*/
|
|
392
|
+
setFollowUpSuggestions: (items: AgentWidgetSuggestion[]) => void;
|
|
393
|
+
/** Remove host-set follow-up suggestions. Agent-set chips are untouched. */
|
|
394
|
+
clearFollowUpSuggestions: () => void;
|
|
362
395
|
getMessages: () => AgentWidgetMessage[];
|
|
363
396
|
getStatus: () => AgentWidgetSessionStatus;
|
|
364
397
|
getPersistentMetadata: () => Record<string, unknown>;
|
|
@@ -608,6 +641,14 @@ export const createAgentExperience = (
|
|
|
608
641
|
// Get plugins for this instance
|
|
609
642
|
const plugins = pluginRegistry.getForInstance(config.plugins);
|
|
610
643
|
|
|
644
|
+
// Guards the composer/welcome hooks, which can run before `session` exists
|
|
645
|
+
// (its `let` is declared far below and is in the temporal dead zone here).
|
|
646
|
+
let sessionInitialized = false;
|
|
647
|
+
|
|
648
|
+
// One store per widget instance, namespaced per plugin id. Shared by the
|
|
649
|
+
// `renderWelcome` and `renderComposer` contexts.
|
|
650
|
+
const pluginStorageFor = createPluginStorageFactory(() => config);
|
|
651
|
+
|
|
611
652
|
// The built-in approval renderer, shaped as a plugin. Resolved as a FALLBACK
|
|
612
653
|
// (not pushed into `plugins`) so a user `renderApproval` plugin always wins
|
|
613
654
|
// and a later config-update plugin push can't reorder ahead of it.
|
|
@@ -872,12 +913,17 @@ export const createAgentExperience = (
|
|
|
872
913
|
container,
|
|
873
914
|
body,
|
|
874
915
|
messagesWrapper,
|
|
916
|
+
starterSuggestions,
|
|
917
|
+
transcriptSuggestions,
|
|
875
918
|
suggestions,
|
|
876
919
|
textarea,
|
|
877
920
|
sendButton,
|
|
878
921
|
sendButtonWrapper,
|
|
879
922
|
composerForm,
|
|
880
923
|
statusText,
|
|
924
|
+
welcomeHost,
|
|
925
|
+
welcomeIconHolder,
|
|
926
|
+
greetingHost,
|
|
881
927
|
introTitle,
|
|
882
928
|
introSubtitle,
|
|
883
929
|
closeButton,
|
|
@@ -933,6 +979,7 @@ export const createAgentExperience = (
|
|
|
933
979
|
const isActivityWhilePinnedEnabled = () =>
|
|
934
980
|
scrollBehaviorFeature.showActivityWhilePinned !== false;
|
|
935
981
|
const isAnnounceEnabled = () => scrollBehaviorFeature.announce === true;
|
|
982
|
+
const getScrollbarPolicy = () => scrollBehaviorFeature.scrollbar ?? "on-scroll";
|
|
936
983
|
const scrollToBottomButton = createElement(
|
|
937
984
|
"button",
|
|
938
985
|
"persona-scroll-to-bottom-indicator persona-absolute persona-bottom-3 persona-left-1/2 persona-z-10 persona-flex persona-items-center persona-gap-1 persona-text-xs persona-transform persona--translate-x-1/2 persona-cursor-pointer"
|
|
@@ -962,6 +1009,9 @@ export const createAgentExperience = (
|
|
|
962
1009
|
anchorSpacer.style.flexShrink = "0";
|
|
963
1010
|
anchorSpacer.style.pointerEvents = "none";
|
|
964
1011
|
anchorSpacer.style.height = "0px";
|
|
1012
|
+
// Hidden while unsized: at 0px it still counts as a flex child, so the
|
|
1013
|
+
// body's gap would add phantom scrollable space below the transcript.
|
|
1014
|
+
anchorSpacer.style.display = "none";
|
|
965
1015
|
body.appendChild(anchorSpacer);
|
|
966
1016
|
|
|
967
1017
|
// Visually-hidden polite live region for screen-reader announcements
|
|
@@ -1195,16 +1245,25 @@ export const createAgentExperience = (
|
|
|
1195
1245
|
}
|
|
1196
1246
|
};
|
|
1197
1247
|
|
|
1198
|
-
// Plugin hook: renderComposer - allow plugins to provide custom composer
|
|
1248
|
+
// Plugin hook: renderComposer - allow plugins to provide custom composer.
|
|
1249
|
+
// Arbitration is a function so `rebuildComposer()` can re-run it with a
|
|
1250
|
+
// fresh ctx and swap the footer in place.
|
|
1199
1251
|
const composerPlugin = plugins.find(p => p.renderComposer);
|
|
1200
|
-
|
|
1252
|
+
// True while the footer content came from a plugin (not composition via
|
|
1253
|
+
// defaultRenderer). Plugin composers own their copy: updateCopy must not
|
|
1254
|
+
// stamp inputPlaceholder/sendButtonLabel onto DOM the core did not build.
|
|
1255
|
+
let composerIsPluginOwned = false;
|
|
1256
|
+
const renderComposerFromPlugins = (
|
|
1257
|
+
defaultRenderer: () => HTMLElement
|
|
1258
|
+
): HTMLElement | null => {
|
|
1259
|
+
if (!composerPlugin?.renderComposer) return null;
|
|
1201
1260
|
const composerCfg = config.composer;
|
|
1202
|
-
const
|
|
1261
|
+
const streaming = sessionInitialized ? session.isStreaming() : false;
|
|
1262
|
+
return composerPlugin.renderComposer({
|
|
1203
1263
|
config,
|
|
1204
|
-
defaultRenderer
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
},
|
|
1264
|
+
defaultRenderer,
|
|
1265
|
+
requestRender: () => rebuildComposer(),
|
|
1266
|
+
storage: pluginStorageFor(composerPlugin.id),
|
|
1208
1267
|
onSubmit: (text: string) => {
|
|
1209
1268
|
if (!session || session.isStreaming()) return;
|
|
1210
1269
|
const value = text.trim();
|
|
@@ -1226,8 +1285,8 @@ export const createAgentExperience = (
|
|
|
1226
1285
|
attachmentManager!.clearAttachments();
|
|
1227
1286
|
}
|
|
1228
1287
|
},
|
|
1229
|
-
streaming
|
|
1230
|
-
disabled:
|
|
1288
|
+
streaming,
|
|
1289
|
+
disabled: streaming,
|
|
1231
1290
|
openAttachmentPicker: () => {
|
|
1232
1291
|
attachmentInput?.click();
|
|
1233
1292
|
},
|
|
@@ -1247,11 +1306,16 @@ export const createAgentExperience = (
|
|
|
1247
1306
|
}
|
|
1248
1307
|
: undefined
|
|
1249
1308
|
});
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1309
|
+
};
|
|
1310
|
+
|
|
1311
|
+
const customComposer = renderComposerFromPlugins(
|
|
1312
|
+
() => buildComposer({ config }).footer
|
|
1313
|
+
);
|
|
1314
|
+
if (customComposer) {
|
|
1315
|
+
// Replace the default footer with custom composer (keeps view.composer.footer in sync).
|
|
1316
|
+
view.replaceComposer(customComposer);
|
|
1317
|
+
footer = view.composer.footer;
|
|
1318
|
+
composerIsPluginOwned = true;
|
|
1255
1319
|
}
|
|
1256
1320
|
|
|
1257
1321
|
const bindComposerRefsFromFooter = (rootFooter: HTMLElement) => {
|
|
@@ -1313,59 +1377,80 @@ export const createAgentExperience = (
|
|
|
1313
1377
|
ensureComposerAttachmentSurface(footer);
|
|
1314
1378
|
bindComposerRefsFromFooter(footer);
|
|
1315
1379
|
|
|
1316
|
-
//
|
|
1317
|
-
//
|
|
1318
|
-
//
|
|
1319
|
-
|
|
1320
|
-
// centers horizontally without the host having to wire it up.
|
|
1321
|
-
const contentMaxWidth =
|
|
1322
|
-
config.layout?.contentMaxWidth ??
|
|
1323
|
-
(isComposerBar() ? config.launcher?.composerBar?.contentMaxWidth ?? "720px" : undefined);
|
|
1380
|
+
// Center the content column. Defaults: 768px panels, 720px composer-bar
|
|
1381
|
+
// (resolveContentMaxWidth); "none" opts out. Only engages on panels wider
|
|
1382
|
+
// than the cap, so launcher-width panels are unaffected.
|
|
1383
|
+
const contentMaxWidth = resolveContentMaxWidth(config, isComposerBar());
|
|
1324
1384
|
if (contentMaxWidth) {
|
|
1325
1385
|
messagesWrapper.style.maxWidth = contentMaxWidth;
|
|
1326
1386
|
messagesWrapper.style.marginLeft = "auto";
|
|
1327
1387
|
messagesWrapper.style.marginRight = "auto";
|
|
1328
1388
|
messagesWrapper.style.width = "100%";
|
|
1389
|
+
// The greeting bubble is a transcript sibling: same column geometry.
|
|
1390
|
+
greetingHost.style.maxWidth = contentMaxWidth;
|
|
1391
|
+
greetingHost.style.marginLeft = "auto";
|
|
1392
|
+
greetingHost.style.marginRight = "auto";
|
|
1393
|
+
greetingHost.style.width = "100%";
|
|
1394
|
+
starterSuggestions.style.maxWidth = contentMaxWidth;
|
|
1395
|
+
transcriptSuggestions.style.maxWidth = contentMaxWidth;
|
|
1396
|
+
transcriptSuggestions.style.marginLeft = "auto";
|
|
1397
|
+
transcriptSuggestions.style.marginRight = "auto";
|
|
1398
|
+
transcriptSuggestions.style.width = "100%";
|
|
1329
1399
|
}
|
|
1330
1400
|
// The pill IS the composer in composer-bar mode and should match the
|
|
1331
1401
|
// wrapper's responsive width (50vw / 70vw / 90vw), not be capped by
|
|
1332
1402
|
// contentMaxWidth (which is a centered-column convention for the
|
|
1333
1403
|
// expanded panel's body, not the pill input itself).
|
|
1334
|
-
|
|
1335
|
-
|
|
1336
|
-
|
|
1337
|
-
|
|
1338
|
-
|
|
1339
|
-
|
|
1340
|
-
|
|
1341
|
-
|
|
1342
|
-
|
|
1343
|
-
|
|
1344
|
-
|
|
1345
|
-
|
|
1346
|
-
attachmentPreviewsContainer.style.marginLeft = "auto";
|
|
1347
|
-
attachmentPreviewsContainer.style.marginRight = "auto";
|
|
1348
|
-
}
|
|
1404
|
+
// Re-run after a composer rebuild: the footer's elements are new.
|
|
1405
|
+
const applyComposerContentMaxWidth = () => {
|
|
1406
|
+
const max = resolveContentMaxWidth(config, isComposerBar());
|
|
1407
|
+
if (!max || isComposerBar()) return;
|
|
1408
|
+
for (const element of [composerForm, suggestions, attachmentPreviewsContainer, statusText]) {
|
|
1409
|
+
if (!element) continue;
|
|
1410
|
+
element.style.maxWidth = max;
|
|
1411
|
+
element.style.marginLeft = "auto";
|
|
1412
|
+
element.style.marginRight = "auto";
|
|
1413
|
+
}
|
|
1414
|
+
};
|
|
1415
|
+
applyComposerContentMaxWidth();
|
|
1349
1416
|
|
|
1350
|
-
|
|
1351
|
-
|
|
1352
|
-
|
|
1417
|
+
/**
|
|
1418
|
+
* Bind the attachment surface of the CURRENT footer. On a rebuild the manager
|
|
1419
|
+
* survives and its pending previews are repainted into the new container.
|
|
1420
|
+
*/
|
|
1421
|
+
const setupComposerAttachments = () => {
|
|
1422
|
+
if (!config.attachments?.enabled || !attachmentInput || !attachmentPreviewsContainer) {
|
|
1423
|
+
return;
|
|
1424
|
+
}
|
|
1425
|
+
if (!attachmentManager) {
|
|
1426
|
+
attachmentManager = AttachmentManager.fromConfig(config.attachments);
|
|
1427
|
+
attachmentManager.setPreviewsContainer(attachmentPreviewsContainer);
|
|
1428
|
+
} else {
|
|
1429
|
+
attachmentManager.remountPreviews(attachmentPreviewsContainer);
|
|
1430
|
+
}
|
|
1353
1431
|
attachmentInput.addEventListener("change", (e) => {
|
|
1354
1432
|
const target = e.target as HTMLInputElement;
|
|
1355
1433
|
attachmentManager?.handleFileSelect(target.files);
|
|
1356
1434
|
target.value = "";
|
|
1357
1435
|
});
|
|
1358
1436
|
|
|
1359
|
-
|
|
1360
|
-
|
|
1361
|
-
|
|
1362
|
-
}
|
|
1437
|
+
if (!container.querySelector(".persona-attachment-drop-overlay")) {
|
|
1438
|
+
container.appendChild(buildDropOverlay(config.attachments.dropOverlay));
|
|
1439
|
+
}
|
|
1440
|
+
};
|
|
1441
|
+
setupComposerAttachments();
|
|
1363
1442
|
|
|
1364
1443
|
// Context mentions: render the affordance button + chip row eagerly (so the
|
|
1365
1444
|
// feature is discoverable) and lazy-load the heavy runtime on first use. The
|
|
1366
1445
|
// orchestrator returns null when disabled or sourceless.
|
|
1367
1446
|
const mentionPrefetch = () => mentionOrchestrator?.prefetch();
|
|
1368
|
-
|
|
1447
|
+
/**
|
|
1448
|
+
* Create the orchestrator for the CURRENT composer input. A rebuild destroys
|
|
1449
|
+
* the previous one (its bindings point at the detached textarea), so any
|
|
1450
|
+
* uncommitted mention chips are cleared with it.
|
|
1451
|
+
*/
|
|
1452
|
+
const setupMentionOrchestrator = () => {
|
|
1453
|
+
if (!config.contextMentions?.enabled || !textarea) return;
|
|
1369
1454
|
// Slash-command dispatch (prompt macros write text / submit; client actions
|
|
1370
1455
|
// read/replace the value) and submission are owned by the composer input
|
|
1371
1456
|
// surface itself — the mention runtime builds a textarea (chip) or
|
|
@@ -1402,7 +1487,8 @@ export const createAgentExperience = (
|
|
|
1402
1487
|
// `@` is instant) is registered through the shared `composerListeners`
|
|
1403
1488
|
// registry below, so it survives the inline contenteditable swap too.
|
|
1404
1489
|
}
|
|
1405
|
-
}
|
|
1490
|
+
};
|
|
1491
|
+
setupMentionOrchestrator();
|
|
1406
1492
|
|
|
1407
1493
|
// Slot system: allow custom content injection into specific regions
|
|
1408
1494
|
const renderSlots = () => {
|
|
@@ -1412,8 +1498,9 @@ export const createAgentExperience = (
|
|
|
1412
1498
|
const getDefaultSlotContent = (slot: WidgetLayoutSlot): HTMLElement | null => {
|
|
1413
1499
|
switch (slot) {
|
|
1414
1500
|
case "body-top":
|
|
1415
|
-
//
|
|
1416
|
-
|
|
1501
|
+
// Intro card is identified by its data attribute; its utility classes
|
|
1502
|
+
// are themable and must never be used as a selector.
|
|
1503
|
+
return body.querySelector("[data-persona-intro-card]") as HTMLElement | null;
|
|
1417
1504
|
case "messages":
|
|
1418
1505
|
return messagesWrapper;
|
|
1419
1506
|
case "footer-top":
|
|
@@ -1450,7 +1537,7 @@ export const createAgentExperience = (
|
|
|
1450
1537
|
break;
|
|
1451
1538
|
case "body-top": {
|
|
1452
1539
|
// Replace or prepend to body
|
|
1453
|
-
const introCard = body.querySelector("
|
|
1540
|
+
const introCard = body.querySelector("[data-persona-intro-card]");
|
|
1454
1541
|
if (introCard) {
|
|
1455
1542
|
introCard.replaceWith(element);
|
|
1456
1543
|
} else {
|
|
@@ -3324,19 +3411,52 @@ export const createAgentExperience = (
|
|
|
3324
3411
|
|
|
3325
3412
|
restoreBodyScrollTop();
|
|
3326
3413
|
};
|
|
3414
|
+
// Column var for plugin-rendered content (renderWelcome overlay stacks,
|
|
3415
|
+
// custom composers): resolved config value, matched without config access.
|
|
3416
|
+
// Must land after applyFullHeightStyles, which wipes mount.style.cssText.
|
|
3417
|
+
const applyContentMaxWidthVar = () => {
|
|
3418
|
+
mount.style.setProperty(
|
|
3419
|
+
"--persona-content-max-width",
|
|
3420
|
+
resolveContentMaxWidth(config, isComposerBar())
|
|
3421
|
+
);
|
|
3422
|
+
};
|
|
3423
|
+
// Floating launcher panels get a fixed pixel height (recalcPanelHeight owns
|
|
3424
|
+
// it on resize), but applyFullHeightStyles wipes panel.style.cssText and only
|
|
3425
|
+
// restores width — so every chrome sync must re-stamp the height or the panel
|
|
3426
|
+
// grows to fit its content. Guards mirror recalcPanelHeight's path.
|
|
3427
|
+
const applyFloatingPanelFixedHeight = () => {
|
|
3428
|
+
if (isComposerBar()) return;
|
|
3429
|
+
const dockedMode = isDockedMountMode(config);
|
|
3430
|
+
const sidebarMode = config.launcher?.sidebarMode ?? false;
|
|
3431
|
+
const fullHeight = dockedMode || sidebarMode || (config.launcher?.fullHeight ?? false);
|
|
3432
|
+
if (fullHeight || !launcherEnabled) return;
|
|
3433
|
+
const ownerWindow = mount.ownerDocument.defaultView ?? window;
|
|
3434
|
+
const mobileFullscreen = config.launcher?.mobileFullscreen ?? true;
|
|
3435
|
+
const mobileBreakpoint = config.launcher?.mobileBreakpoint ?? 640;
|
|
3436
|
+
if (mobileFullscreen && ownerWindow.innerWidth <= mobileBreakpoint) return;
|
|
3437
|
+
const viewportHeight = ownerWindow.innerHeight;
|
|
3438
|
+
const verticalMargin = 64; // leave space for launcher's offset
|
|
3439
|
+
const heightOffset = config.launcher?.heightOffset ?? 0;
|
|
3440
|
+
const available = Math.max(200, viewportHeight - verticalMargin);
|
|
3441
|
+
const clamped = Math.min(640, available);
|
|
3442
|
+
panel.style.height = `${Math.max(200, clamped - heightOffset)}px`;
|
|
3443
|
+
};
|
|
3327
3444
|
applyFullHeightStyles();
|
|
3328
3445
|
// Apply theme variables after applyFullHeightStyles since it resets mount.style.cssText
|
|
3329
3446
|
applyThemeVariables(mount, config);
|
|
3330
3447
|
applyArtifactLayoutCssVars(mount, config);
|
|
3331
3448
|
applyArtifactPaneAppearance(mount, config);
|
|
3449
|
+
applyContentMaxWidthVar();
|
|
3332
3450
|
|
|
3333
3451
|
// applyFullHeightStyles wipes mount.style.cssText, so re-apply the theme +
|
|
3334
3452
|
// artifact layout vars after it, mirroring the init sequence above.
|
|
3335
3453
|
syncPanelChrome = () => {
|
|
3336
3454
|
applyFullHeightStyles();
|
|
3455
|
+
applyFloatingPanelFixedHeight();
|
|
3337
3456
|
applyThemeVariables(mount, config);
|
|
3338
3457
|
applyArtifactLayoutCssVars(mount, config);
|
|
3339
3458
|
applyArtifactPaneAppearance(mount, config);
|
|
3459
|
+
applyContentMaxWidthVar();
|
|
3340
3460
|
// Owned here so it lands after applyArtifactPaneAppearance and derives from
|
|
3341
3461
|
// the same resolved panel radius the chat card uses (see applyFullHeightStyles).
|
|
3342
3462
|
if (weldedOuterRadius) {
|
|
@@ -3446,7 +3566,18 @@ export const createAgentExperience = (
|
|
|
3446
3566
|
}
|
|
3447
3567
|
}
|
|
3448
3568
|
|
|
3449
|
-
|
|
3569
|
+
// Reassignable: a composer rebuild swaps the row this manager renders into.
|
|
3570
|
+
let composerSuggestionsManager = createSuggestions(suggestions);
|
|
3571
|
+
const starterSuggestionsManager = createSuggestions(starterSuggestions);
|
|
3572
|
+
const transcriptSuggestionsManager = createSuggestions(transcriptSuggestions);
|
|
3573
|
+
const suggestionManagers = [
|
|
3574
|
+
composerSuggestionsManager,
|
|
3575
|
+
starterSuggestionsManager,
|
|
3576
|
+
transcriptSuggestionsManager,
|
|
3577
|
+
];
|
|
3578
|
+
destroyCallbacks.push(() => {
|
|
3579
|
+
suggestionManagers.forEach((manager) => manager.destroy());
|
|
3580
|
+
});
|
|
3450
3581
|
let closeHandler: (() => void) | null = null;
|
|
3451
3582
|
let session: AgentWidgetSession;
|
|
3452
3583
|
|
|
@@ -3457,35 +3588,223 @@ export const createAgentExperience = (
|
|
|
3457
3588
|
// keep their before-first-user-message behavior. Config updates MUST route
|
|
3458
3589
|
// through here too: re-rendering with only `config.suggestionChips` would
|
|
3459
3590
|
// drop a live agent chip row until the next message change.
|
|
3591
|
+
// Warn once per widget instance: renderSuggestions runs on every change.
|
|
3592
|
+
// Host-set follow-ups (`setFollowUpSuggestions`) are an ephemeral UI overlay:
|
|
3593
|
+
// never appended to the session, never sent on the wire, never persisted.
|
|
3594
|
+
let hostFollowUps: AgentWidgetSuggestion[] | null = null;
|
|
3595
|
+
// Latches: a host that drives the surface itself is a legitimate no-tool
|
|
3596
|
+
// integration, so the never-called hint must stay off even after the overlay
|
|
3597
|
+
// expires.
|
|
3598
|
+
let hostFollowUpsEverSet = false;
|
|
3599
|
+
// Latest-writer-wins bookkeeping captured at set time: the overlay is dropped
|
|
3600
|
+
// once another user message appends or a newer suggest_replies message lands.
|
|
3601
|
+
let hostFollowUpsUserCount = 0;
|
|
3602
|
+
let hostFollowUpsAgentId: string | null = null;
|
|
3603
|
+
|
|
3604
|
+
const countUserMessages = (messages: AgentWidgetMessage[]): number =>
|
|
3605
|
+
messages.reduce(
|
|
3606
|
+
(total, message) => (message.role === "user" ? total + 1 : total),
|
|
3607
|
+
0
|
|
3608
|
+
);
|
|
3609
|
+
|
|
3610
|
+
/** Identity of the newest suggest_replies message, regardless of position. */
|
|
3611
|
+
const latestSuggestRepliesId = (
|
|
3612
|
+
messages: AgentWidgetMessage[]
|
|
3613
|
+
): string | null => {
|
|
3614
|
+
for (let i = messages.length - 1; i >= 0; i--) {
|
|
3615
|
+
if (isSuggestRepliesMessage(messages[i])) return messages[i].id ?? null;
|
|
3616
|
+
}
|
|
3617
|
+
return null;
|
|
3618
|
+
};
|
|
3619
|
+
|
|
3620
|
+
let warnedPinnedStarterPlacement = false;
|
|
3621
|
+
const warnPinnedStarterPlacement = () => {
|
|
3622
|
+
if (warnedPinnedStarterPlacement || config.debug !== true) return;
|
|
3623
|
+
warnedPinnedStarterPlacement = true;
|
|
3624
|
+
console.warn(
|
|
3625
|
+
'[persona] suggestions.starters.placement is pinned to "welcome" but the welcome surface is hidden (welcome.variant: "none" or copy.showWelcomeCard: false), so no starters render. Use "auto" or "composer".',
|
|
3626
|
+
);
|
|
3627
|
+
};
|
|
3628
|
+
// Debug-only setup hint: a stream this session ended without the agent ever
|
|
3629
|
+
// calling suggest_replies, so the model never got the tool. Gated on a
|
|
3630
|
+
// COMPLETED stream, not on transcript shape: a restored transcript
|
|
3631
|
+
// (initialMessages/onStateLoaded) proves nothing about this session's wiring.
|
|
3632
|
+
let hintedFollowUpsNeverCalled = false;
|
|
3633
|
+
let completedStreamThisSession = false;
|
|
3634
|
+
const hintFollowUpsNeverCalled = (
|
|
3635
|
+
messages: AgentWidgetMessage[],
|
|
3636
|
+
feature: ReturnType<typeof resolveFollowUpsFeature>
|
|
3637
|
+
) => {
|
|
3638
|
+
if (hintedFollowUpsNeverCalled || config.debug !== true) return;
|
|
3639
|
+
if (!feature.enabled || feature.expose) return;
|
|
3640
|
+
if (hostFollowUpsEverSet) return;
|
|
3641
|
+
if (!completedStreamThisSession) return;
|
|
3642
|
+
if (session.isStreaming()) return;
|
|
3643
|
+
if (messages.some(isSuggestRepliesMessage)) return;
|
|
3644
|
+
hintedFollowUpsNeverCalled = true;
|
|
3645
|
+
console.info(
|
|
3646
|
+
"[persona] Follow-up suggestions are enabled but the agent has not called suggest_replies. The model needs the tool declared (runtimeTools server-side, suggestions.followUps.expose: true from the widget, or your own backend's tool definition) and benefits from instructions telling it when to offer follow-ups. See the integration paths table in UI-COMPONENTS.md."
|
|
3647
|
+
);
|
|
3648
|
+
};
|
|
3649
|
+
|
|
3460
3650
|
const renderSuggestions = (messages?: AgentWidgetMessage[]) => {
|
|
3461
3651
|
if (!session) return;
|
|
3462
3652
|
const current = messages ?? session.getMessages();
|
|
3463
|
-
const
|
|
3464
|
-
|
|
3465
|
-
|
|
3466
|
-
|
|
3467
|
-
|
|
3468
|
-
|
|
3469
|
-
|
|
3653
|
+
const clearManager = (manager: ReturnType<typeof createSuggestions>) => {
|
|
3654
|
+
manager.render([], session, textarea, current);
|
|
3655
|
+
};
|
|
3656
|
+
const clearOthers = (active: ReturnType<typeof createSuggestions>) => {
|
|
3657
|
+
suggestionManagers.forEach((manager) => {
|
|
3658
|
+
if (manager !== active) clearManager(manager);
|
|
3659
|
+
});
|
|
3660
|
+
};
|
|
3661
|
+
// Same lifecycle as agent follow-ups: the overlay expires on the next user
|
|
3662
|
+
// message, and a suggest_replies message newer than the set call wins.
|
|
3663
|
+
if (
|
|
3664
|
+
hostFollowUps &&
|
|
3665
|
+
(countUserMessages(current) > hostFollowUpsUserCount ||
|
|
3666
|
+
latestSuggestRepliesId(current) !== hostFollowUpsAgentId)
|
|
3667
|
+
) {
|
|
3668
|
+
hostFollowUps = null;
|
|
3669
|
+
}
|
|
3670
|
+
|
|
3671
|
+
// `enabled: false` disables the tool, not the surface: host-set follow-ups
|
|
3672
|
+
// still render.
|
|
3673
|
+
const followUpsFeature = resolveFollowUpsFeature(config);
|
|
3674
|
+
hintFollowUpsNeverCalled(current, followUpsFeature);
|
|
3675
|
+
const agentSuggestions = followUpsFeature.enabled
|
|
3676
|
+
? latestAgentSuggestions(current)
|
|
3677
|
+
: null;
|
|
3678
|
+
const followUpSuggestions = hostFollowUps ?? agentSuggestions;
|
|
3679
|
+
const followUpSource: AgentWidgetSuggestionSource = hostFollowUps
|
|
3680
|
+
? "host"
|
|
3681
|
+
: "agent";
|
|
3682
|
+
|
|
3683
|
+
if (followUpSuggestions) {
|
|
3684
|
+
const followUps = config.suggestions?.followUps;
|
|
3685
|
+
const requestedPlacement =
|
|
3686
|
+
followUps?.placement ?? (followUps ? "auto" : "composer");
|
|
3687
|
+
const placement =
|
|
3688
|
+
requestedPlacement === "auto"
|
|
3689
|
+
? isComposerBar()
|
|
3690
|
+
? "composer"
|
|
3691
|
+
: "after-message"
|
|
3692
|
+
: requestedPlacement;
|
|
3693
|
+
const manager =
|
|
3694
|
+
placement === "after-message"
|
|
3695
|
+
? transcriptSuggestionsManager
|
|
3696
|
+
: composerSuggestionsManager;
|
|
3697
|
+
clearOthers(manager);
|
|
3698
|
+
manager.render(
|
|
3699
|
+
followUpSuggestions,
|
|
3470
3700
|
session,
|
|
3471
3701
|
textarea,
|
|
3472
3702
|
current,
|
|
3473
3703
|
config.suggestionChipsConfig,
|
|
3474
|
-
{
|
|
3704
|
+
{
|
|
3705
|
+
source: followUpSource,
|
|
3706
|
+
surface: "followUp",
|
|
3707
|
+
variant: followUps?.variant ?? "chip",
|
|
3708
|
+
behavior: followUps?.behavior ?? "send",
|
|
3709
|
+
// Wrap by default: 2-4 compact chips always fit at widget width,
|
|
3710
|
+
// and a scroll strip hides most of the set behind a fade. Hosts
|
|
3711
|
+
// with large sets opt back in with overflow: "scroll".
|
|
3712
|
+
overflow: followUps?.overflow ?? "wrap",
|
|
3713
|
+
maxItems: followUps?.maxItems ?? 4,
|
|
3714
|
+
config,
|
|
3715
|
+
plugins,
|
|
3716
|
+
}
|
|
3475
3717
|
);
|
|
3476
|
-
|
|
3477
|
-
|
|
3478
|
-
|
|
3479
|
-
|
|
3480
|
-
|
|
3481
|
-
|
|
3718
|
+
return;
|
|
3719
|
+
}
|
|
3720
|
+
|
|
3721
|
+
if (current.some((message) => message.role === "user")) {
|
|
3722
|
+
suggestionManagers.forEach(clearManager);
|
|
3723
|
+
return;
|
|
3724
|
+
}
|
|
3725
|
+
|
|
3726
|
+
const starters = config.suggestions?.starters;
|
|
3727
|
+
if (starters) {
|
|
3728
|
+
// The starter host lives inside the welcome surface, so a pinned
|
|
3729
|
+
// "welcome" placement has nowhere to render when that surface is hidden.
|
|
3730
|
+
const welcomeCardVisible = isWelcomeVisible(
|
|
3731
|
+
resolveWelcomeConfig(config),
|
|
3732
|
+
current
|
|
3733
|
+
);
|
|
3734
|
+
const requestedPlacement = starters.placement ?? "auto";
|
|
3735
|
+
const placement =
|
|
3736
|
+
requestedPlacement === "auto"
|
|
3737
|
+
? welcomeCardVisible
|
|
3738
|
+
? "welcome"
|
|
3739
|
+
: "composer"
|
|
3740
|
+
: requestedPlacement;
|
|
3741
|
+
if (placement === "welcome" && !welcomeCardVisible) {
|
|
3742
|
+
warnPinnedStarterPlacement();
|
|
3743
|
+
suggestionManagers.forEach(clearManager);
|
|
3744
|
+
return;
|
|
3745
|
+
}
|
|
3746
|
+
const manager =
|
|
3747
|
+
placement === "welcome"
|
|
3748
|
+
? starterSuggestionsManager
|
|
3749
|
+
: composerSuggestionsManager;
|
|
3750
|
+
clearOthers(manager);
|
|
3751
|
+
manager.render(
|
|
3752
|
+
starters.items ?? config.suggestionChips,
|
|
3482
3753
|
session,
|
|
3483
3754
|
textarea,
|
|
3484
3755
|
current,
|
|
3485
|
-
config.suggestionChipsConfig
|
|
3756
|
+
config.suggestionChipsConfig,
|
|
3757
|
+
{
|
|
3758
|
+
surface: "starter",
|
|
3759
|
+
variant: starters.variant ?? "card",
|
|
3760
|
+
behavior: starters.behavior ?? "send",
|
|
3761
|
+
overflow: starters.overflow ?? "wrap",
|
|
3762
|
+
maxItems: starters.maxItems ?? 4,
|
|
3763
|
+
config,
|
|
3764
|
+
plugins,
|
|
3765
|
+
}
|
|
3486
3766
|
);
|
|
3767
|
+
return;
|
|
3487
3768
|
}
|
|
3769
|
+
|
|
3770
|
+
clearOthers(composerSuggestionsManager);
|
|
3771
|
+
composerSuggestionsManager.render(
|
|
3772
|
+
config.suggestionChips,
|
|
3773
|
+
session,
|
|
3774
|
+
textarea,
|
|
3775
|
+
current,
|
|
3776
|
+
config.suggestionChipsConfig,
|
|
3777
|
+
{
|
|
3778
|
+
surface: "starter",
|
|
3779
|
+
variant: "chip",
|
|
3780
|
+
behavior: "send",
|
|
3781
|
+
overflow: "wrap",
|
|
3782
|
+
config,
|
|
3783
|
+
plugins,
|
|
3784
|
+
}
|
|
3785
|
+
);
|
|
3786
|
+
};
|
|
3787
|
+
|
|
3788
|
+
const clearHostFollowUps = () => {
|
|
3789
|
+
if (!hostFollowUps) return;
|
|
3790
|
+
hostFollowUps = null;
|
|
3791
|
+
renderSuggestions();
|
|
3488
3792
|
};
|
|
3793
|
+
|
|
3794
|
+
const setHostFollowUps = (items: AgentWidgetSuggestion[]) => {
|
|
3795
|
+
const list = Array.isArray(items) ? [...items] : [];
|
|
3796
|
+
if (!list.length) {
|
|
3797
|
+
clearHostFollowUps();
|
|
3798
|
+
return;
|
|
3799
|
+
}
|
|
3800
|
+
const current = session ? session.getMessages() : [];
|
|
3801
|
+
hostFollowUps = list;
|
|
3802
|
+
hostFollowUpsEverSet = true;
|
|
3803
|
+
hostFollowUpsUserCount = countUserMessages(current);
|
|
3804
|
+
hostFollowUpsAgentId = latestSuggestRepliesId(current);
|
|
3805
|
+
renderSuggestions(current);
|
|
3806
|
+
};
|
|
3807
|
+
|
|
3489
3808
|
let isStreaming = false;
|
|
3490
3809
|
const messageCache = createMessageCache();
|
|
3491
3810
|
// Tracks the last fingerprint we rendered a plugin-rendered ask_user_question
|
|
@@ -3561,6 +3880,7 @@ export const createAgentExperience = (
|
|
|
3561
3880
|
let readerEngagedThisTurn = false;
|
|
3562
3881
|
const markReaderEngaged = () => {
|
|
3563
3882
|
readerEngagedThisTurn = true;
|
|
3883
|
+
revealTranscriptScrollbar();
|
|
3564
3884
|
};
|
|
3565
3885
|
// Dedupes assistant-turn detection across token-by-token re-renders.
|
|
3566
3886
|
let lastHandledAssistantId: string | null = null;
|
|
@@ -3572,6 +3892,40 @@ export const createAgentExperience = (
|
|
|
3572
3892
|
const USER_SCROLL_THRESHOLD = 4;
|
|
3573
3893
|
const BOTTOM_THRESHOLD = 24;
|
|
3574
3894
|
const AUTO_SCROLL_SNAP_THRESHOLD = 80;
|
|
3895
|
+
|
|
3896
|
+
// Scrollbar visibility policy (features.scrollBehavior.scrollbar). The
|
|
3897
|
+
// resolved policy is stamped on the root once and CSS scopes every
|
|
3898
|
+
// scroller off it. Under "on-scroll" the transcript bar reveals here on
|
|
3899
|
+
// genuine reader input (markReaderEngaged is the funnel for wheel, touch,
|
|
3900
|
+
// pointer, and nav keys, so widget-driven scrolling never reveals it) and
|
|
3901
|
+
// hides again after an idle delay, but only at the resting position: away
|
|
3902
|
+
// from rest the bar is the positional affordance and stays. The scroll
|
|
3903
|
+
// back down re-enters through markReaderEngaged and re-arms the timer.
|
|
3904
|
+
const SCROLLBAR_IDLE_MS = 1000;
|
|
3905
|
+
let scrollbarIdleTimer: ReturnType<typeof setTimeout> | null = null;
|
|
3906
|
+
const applyScrollbarPolicy = () => {
|
|
3907
|
+
mount.setAttribute("data-persona-scrollbar", getScrollbarPolicy());
|
|
3908
|
+
if (getScrollbarPolicy() !== "on-scroll") {
|
|
3909
|
+
if (scrollbarIdleTimer !== null) clearTimeout(scrollbarIdleTimer);
|
|
3910
|
+
scrollbarIdleTimer = null;
|
|
3911
|
+
body.removeAttribute("data-persona-scrollbar-visible");
|
|
3912
|
+
}
|
|
3913
|
+
};
|
|
3914
|
+
const revealTranscriptScrollbar = () => {
|
|
3915
|
+
if (getScrollbarPolicy() !== "on-scroll") return;
|
|
3916
|
+
body.setAttribute("data-persona-scrollbar-visible", "");
|
|
3917
|
+
if (scrollbarIdleTimer !== null) clearTimeout(scrollbarIdleTimer);
|
|
3918
|
+
scrollbarIdleTimer = setTimeout(() => {
|
|
3919
|
+
scrollbarIdleTimer = null;
|
|
3920
|
+
if (isElementNearBottom(body, BOTTOM_THRESHOLD)) {
|
|
3921
|
+
body.removeAttribute("data-persona-scrollbar-visible");
|
|
3922
|
+
}
|
|
3923
|
+
}, SCROLLBAR_IDLE_MS);
|
|
3924
|
+
};
|
|
3925
|
+
applyScrollbarPolicy();
|
|
3926
|
+
destroyCallbacks.push(() => {
|
|
3927
|
+
if (scrollbarIdleTimer !== null) clearTimeout(scrollbarIdleTimer);
|
|
3928
|
+
});
|
|
3575
3929
|
const messageState = new Map<
|
|
3576
3930
|
string,
|
|
3577
3931
|
{ streaming?: boolean; role: AgentWidgetMessage["role"] }
|
|
@@ -3761,14 +4115,33 @@ export const createAgentExperience = (
|
|
|
3761
4115
|
updateScrollToBottomCountBadge();
|
|
3762
4116
|
};
|
|
3763
4117
|
|
|
4118
|
+
// "Near the latest content", ignoring anchor-spacer air: while a turn is
|
|
4119
|
+
// anchored, the spacer keeps scrollable space below the last message, so
|
|
4120
|
+
// container-bottom distance reads "away" even with the whole reply on
|
|
4121
|
+
// screen (and flickers as the spacer drains). Measure against the real
|
|
4122
|
+
// content bottom instead; the button only appears once content actually
|
|
4123
|
+
// extends below the viewport.
|
|
4124
|
+
const isNearLatestContent = () => {
|
|
4125
|
+
const spacerHeight = anchorState?.spacerHeight ?? 0;
|
|
4126
|
+
if (spacerHeight <= 0) return isElementNearBottom(body, BOTTOM_THRESHOLD);
|
|
4127
|
+
const contentHeight = measureContentHeight(spacerHeight);
|
|
4128
|
+
return (
|
|
4129
|
+
contentHeight - body.scrollTop - body.clientHeight <= BOTTOM_THRESHOLD
|
|
4130
|
+
);
|
|
4131
|
+
};
|
|
4132
|
+
|
|
3764
4133
|
// Whether the user is currently away from the latest content: drives both
|
|
3765
4134
|
// the scroll-to-bottom affordance and the new-messages badge. When following
|
|
3766
4135
|
// the bottom (follow mode, or a no-anchor anchor-top fallback turn) that's
|
|
3767
4136
|
// "auto-follow paused"; otherwise it's simply "not near the bottom".
|
|
3768
4137
|
const isAwayFromLatest = () =>
|
|
3769
|
-
isFollowEffective()
|
|
3770
|
-
|
|
3771
|
-
|
|
4138
|
+
isFollowEffective() ? !autoFollow.isFollowing() : !isNearLatestContent();
|
|
4139
|
+
|
|
4140
|
+
// Empty transcript = welcome-only view: there is no "latest message" to
|
|
4141
|
+
// follow, jump to, or badge, and bottom-scrolling clips the greeting when
|
|
4142
|
+
// the welcome content slightly overflows the viewport.
|
|
4143
|
+
const transcriptHasMessages = () =>
|
|
4144
|
+
!!session && session.getMessages().length > 0;
|
|
3772
4145
|
|
|
3773
4146
|
const syncScrollToBottomButton = () => {
|
|
3774
4147
|
if (!isScrollToBottomEnabled() || eventStreamVisible) {
|
|
@@ -3783,7 +4156,7 @@ export const createAgentExperience = (
|
|
|
3783
4156
|
}
|
|
3784
4157
|
updateScrollToBottomButtonOffset();
|
|
3785
4158
|
const hasOverflow = getScrollBottomOffset(body) > 0;
|
|
3786
|
-
const show = hasOverflow && isAwayFromLatest();
|
|
4159
|
+
const show = hasOverflow && isAwayFromLatest() && transcriptHasMessages();
|
|
3787
4160
|
if (!show) {
|
|
3788
4161
|
resetNewMessagesCount();
|
|
3789
4162
|
} else {
|
|
@@ -3810,6 +4183,7 @@ export const createAgentExperience = (
|
|
|
3810
4183
|
// no-anchor fallback turn (see `isFollowEffective`). Anchored anchor-top
|
|
3811
4184
|
// turns and "none" never chase the bottom during streaming.
|
|
3812
4185
|
if (!isFollowEffective()) return;
|
|
4186
|
+
if (!transcriptHasMessages()) return;
|
|
3813
4187
|
|
|
3814
4188
|
if (!autoFollow.isFollowing()) return;
|
|
3815
4189
|
|
|
@@ -3944,6 +4318,10 @@ export const createAgentExperience = (
|
|
|
3944
4318
|
// Instant jump used for initial mount / panel open in non-follow scroll
|
|
3945
4319
|
// modes (where scheduleAutoScroll is inert).
|
|
3946
4320
|
const jumpToBottomInstant = () => {
|
|
4321
|
+
if (!transcriptHasMessages()) {
|
|
4322
|
+
syncScrollToBottomButton();
|
|
4323
|
+
return;
|
|
4324
|
+
}
|
|
3947
4325
|
const element = getScrollableContainer();
|
|
3948
4326
|
isAutoScrolling = true;
|
|
3949
4327
|
element.scrollTop = getScrollBottomOffset(element);
|
|
@@ -4026,12 +4404,55 @@ export const createAgentExperience = (
|
|
|
4026
4404
|
};
|
|
4027
4405
|
|
|
4028
4406
|
const setAnchorSpacerHeight = (height: number) => {
|
|
4029
|
-
|
|
4407
|
+
const rounded = Math.max(0, Math.round(height));
|
|
4408
|
+
anchorSpacer.style.height = `${rounded}px`;
|
|
4409
|
+
anchorSpacer.style.display = rounded > 0 ? "" : "none";
|
|
4030
4410
|
if (anchorState) {
|
|
4031
4411
|
anchorState.spacerHeight = Math.max(0, height);
|
|
4032
4412
|
}
|
|
4033
4413
|
};
|
|
4034
4414
|
|
|
4415
|
+
// True scroll-content height, excluding the anchor spacer. Never derive
|
|
4416
|
+
// this from `scrollHeight - spacer`: scrollHeight floors at clientHeight,
|
|
4417
|
+
// so content shorter than the viewport reads as viewport-sized and the
|
|
4418
|
+
// spacer comes up short by exactly the empty space below the content — the
|
|
4419
|
+
// anchor target then sits past maxScrollTop, the ease clamps early, and
|
|
4420
|
+
// every transient content dip during streaming bounces the viewport.
|
|
4421
|
+
// Layoutless environments (jsdom) measure 0; fall back to the naive value.
|
|
4422
|
+
const measureContentHeight = (previousSpacerHeight: number): number => {
|
|
4423
|
+
const paddingBottom =
|
|
4424
|
+
parseFloat(getComputedStyle(body).paddingBottom) || 0;
|
|
4425
|
+
let bottom = 0;
|
|
4426
|
+
for (const child of Array.from(body.children) as HTMLElement[]) {
|
|
4427
|
+
if (child === anchorSpacer || child.offsetHeight === 0) continue;
|
|
4428
|
+
const childBottom = offsetTopWithinBody(child) + child.offsetHeight;
|
|
4429
|
+
if (childBottom > bottom) bottom = childBottom;
|
|
4430
|
+
}
|
|
4431
|
+
const naive = body.scrollHeight - previousSpacerHeight;
|
|
4432
|
+
return bottom > 0 ? Math.min(bottom + paddingBottom, naive) : naive;
|
|
4433
|
+
};
|
|
4434
|
+
|
|
4435
|
+
// Stream-end spacer trim: the ResizeObserver drain is growth-driven, so it
|
|
4436
|
+
// stops the moment content stops growing and can leave leftover reserve —
|
|
4437
|
+
// dead scrollable air below the last message, plus a scroll-to-bottom
|
|
4438
|
+
// arrow pointing at nothing. Trim to what the reader's current position
|
|
4439
|
+
// actually needs plus the anti-bounce slack (shrink-only, like the drain).
|
|
4440
|
+
const trimAnchorSpacerToRest = () => {
|
|
4441
|
+
if (!anchorState || anchorState.spacerHeight <= 0) return;
|
|
4442
|
+
const contentHeight = measureContentHeight(anchorState.spacerHeight);
|
|
4443
|
+
const required = Math.max(
|
|
4444
|
+
0,
|
|
4445
|
+
Math.round(body.scrollTop) +
|
|
4446
|
+
body.clientHeight -
|
|
4447
|
+
contentHeight +
|
|
4448
|
+
BOTTOM_THRESHOLD
|
|
4449
|
+
);
|
|
4450
|
+
if (required < anchorState.spacerHeight) {
|
|
4451
|
+
setAnchorSpacerHeight(required);
|
|
4452
|
+
syncScrollToBottomButton();
|
|
4453
|
+
}
|
|
4454
|
+
};
|
|
4455
|
+
|
|
4035
4456
|
const resetAnchorState = () => {
|
|
4036
4457
|
if (anchorRAF !== null) {
|
|
4037
4458
|
cancelAnimationFrame(anchorRAF);
|
|
@@ -4043,8 +4464,19 @@ export const createAgentExperience = (
|
|
|
4043
4464
|
cancelSmoothScroll();
|
|
4044
4465
|
anchorState = null;
|
|
4045
4466
|
anchorSpacer.style.height = "0px";
|
|
4467
|
+
anchorSpacer.style.display = "none";
|
|
4046
4468
|
};
|
|
4047
4469
|
|
|
4470
|
+
const escapeMessageId = (messageId: string): string =>
|
|
4471
|
+
typeof CSS !== "undefined" && typeof CSS.escape === "function"
|
|
4472
|
+
? CSS.escape(messageId)
|
|
4473
|
+
: messageId.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
|
|
4474
|
+
|
|
4475
|
+
const queryMessageBubble = (messageId: string): HTMLElement | null =>
|
|
4476
|
+
body.querySelector<HTMLElement>(
|
|
4477
|
+
`[data-message-id="${escapeMessageId(messageId)}"]`
|
|
4478
|
+
);
|
|
4479
|
+
|
|
4048
4480
|
// Anchor-top mode: scroll `messageId` to rest `anchorTopOffset` px below the
|
|
4049
4481
|
// viewport top and hold it there while the response streams in beneath it.
|
|
4050
4482
|
// Deferred one frame so the message bubble has been rendered and laid out.
|
|
@@ -4054,10 +4486,7 @@ export const createAgentExperience = (
|
|
|
4054
4486
|
}
|
|
4055
4487
|
anchorRAF = requestAnimationFrame(() => {
|
|
4056
4488
|
anchorRAF = null;
|
|
4057
|
-
const escapedId =
|
|
4058
|
-
typeof CSS !== "undefined" && typeof CSS.escape === "function"
|
|
4059
|
-
? CSS.escape(messageId)
|
|
4060
|
-
: messageId.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
|
|
4489
|
+
const escapedId = escapeMessageId(messageId);
|
|
4061
4490
|
const bubble = body.querySelector<HTMLElement>(
|
|
4062
4491
|
`[data-message-id="${escapedId}"]`
|
|
4063
4492
|
);
|
|
@@ -4069,12 +4498,17 @@ export const createAgentExperience = (
|
|
|
4069
4498
|
const anchorOffsetTop = offsetTopWithinBody(bubble);
|
|
4070
4499
|
|
|
4071
4500
|
const previousSpacerHeight = anchorState?.spacerHeight ?? 0;
|
|
4072
|
-
const contentHeight =
|
|
4501
|
+
const contentHeight = measureContentHeight(previousSpacerHeight);
|
|
4073
4502
|
const { targetScrollTop, spacerHeight } = computeAnchorScrollState({
|
|
4074
4503
|
anchorOffsetTop,
|
|
4075
4504
|
topOffset: getAnchorTopOffset(),
|
|
4076
4505
|
viewportHeight: body.clientHeight,
|
|
4077
|
-
contentHeight
|
|
4506
|
+
contentHeight,
|
|
4507
|
+
// Slack past the held position so transient content dips during
|
|
4508
|
+
// streaming (indicator swaps) never clamp scrollTop and bounce the
|
|
4509
|
+
// viewport. Capped at BOTTOM_THRESHOLD so the slack region still
|
|
4510
|
+
// counts as "near bottom" and never surfaces the jump affordance.
|
|
4511
|
+
slack: BOTTOM_THRESHOLD
|
|
4078
4512
|
});
|
|
4079
4513
|
|
|
4080
4514
|
anchorState = {
|
|
@@ -4083,10 +4517,56 @@ export const createAgentExperience = (
|
|
|
4083
4517
|
spacerHeight
|
|
4084
4518
|
};
|
|
4085
4519
|
setAnchorSpacerHeight(spacerHeight);
|
|
4086
|
-
|
|
4520
|
+
// Live target: layout above the bubble can shrink mid-ease (the welcome
|
|
4521
|
+
// starter row hides right after this measurement frame), and a frozen
|
|
4522
|
+
// snapshot would carry the bubble past the offset and under the header.
|
|
4523
|
+
// animateScrollTo re-resolves per frame, so re-derive the pin from the
|
|
4524
|
+
// bubble's current position.
|
|
4525
|
+
animateScrollTo(
|
|
4526
|
+
body,
|
|
4527
|
+
() =>
|
|
4528
|
+
bubble.isConnected
|
|
4529
|
+
? Math.max(0, offsetTopWithinBody(bubble) - getAnchorTopOffset())
|
|
4530
|
+
: targetScrollTop,
|
|
4531
|
+
220
|
|
4532
|
+
);
|
|
4087
4533
|
});
|
|
4088
4534
|
};
|
|
4089
4535
|
|
|
4536
|
+
// Welcome dismissal removes the host's layout (and the roomier body gap) in
|
|
4537
|
+
// a single frame after its fade-out — after the anchor ease already scrolled
|
|
4538
|
+
// against the pre-collapse layout, so the browser would clamp scrollTop and
|
|
4539
|
+
// visibly yank the pinned message. Recompute the anchor against the
|
|
4540
|
+
// collapsed layout and jump instead of easing: the bubble is already
|
|
4541
|
+
// resting at the offset, so the re-pin is visually a no-op.
|
|
4542
|
+
const repinAnchoredMessage = () => {
|
|
4543
|
+
if (getScrollMode() !== "anchor-top") return;
|
|
4544
|
+
if (!anchorState || !anchoredMessageId) return;
|
|
4545
|
+
const bubble = queryMessageBubble(anchoredMessageId);
|
|
4546
|
+
if (!bubble) return;
|
|
4547
|
+
cancelSmoothScroll();
|
|
4548
|
+
const previousSpacerHeight = anchorState.spacerHeight;
|
|
4549
|
+
const contentHeight = measureContentHeight(previousSpacerHeight);
|
|
4550
|
+
const { targetScrollTop, spacerHeight } = computeAnchorScrollState({
|
|
4551
|
+
anchorOffsetTop: offsetTopWithinBody(bubble),
|
|
4552
|
+
topOffset: getAnchorTopOffset(),
|
|
4553
|
+
viewportHeight: body.clientHeight,
|
|
4554
|
+
contentHeight,
|
|
4555
|
+
slack: BOTTOM_THRESHOLD
|
|
4556
|
+
});
|
|
4557
|
+
anchorState = {
|
|
4558
|
+
initialSpacerHeight: spacerHeight,
|
|
4559
|
+
contentHeightAtAnchor: contentHeight,
|
|
4560
|
+
spacerHeight
|
|
4561
|
+
};
|
|
4562
|
+
setAnchorSpacerHeight(spacerHeight);
|
|
4563
|
+
isAutoScrolling = true;
|
|
4564
|
+
body.scrollTop = targetScrollTop;
|
|
4565
|
+
lastScrollTop = body.scrollTop;
|
|
4566
|
+
isAutoScrolling = false;
|
|
4567
|
+
syncScrollToBottomButton();
|
|
4568
|
+
};
|
|
4569
|
+
|
|
4090
4570
|
// Content growth handler (ResizeObserver-driven). In follow mode this is
|
|
4091
4571
|
// what keeps the transcript pinned when content grows *without* a render
|
|
4092
4572
|
// event: images/embeds finishing loading mid-stream, fonts swapping,
|
|
@@ -4173,6 +4653,33 @@ export const createAgentExperience = (
|
|
|
4173
4653
|
// Anchoring to the first unread (not the newest) block means this fires at
|
|
4174
4654
|
// most once per turn in practice, and later blocks stream in below the
|
|
4175
4655
|
// anchor exactly as they do today.
|
|
4656
|
+
|
|
4657
|
+
// Minimum readable slice of the first unread block that must fit under the
|
|
4658
|
+
// pinned user message for the anchor to stay put. Below this, the block is
|
|
4659
|
+
// "stranded" and the anchor hands off to it.
|
|
4660
|
+
const ANCHOR_HANDOFF_MIN_VISIBLE = 96;
|
|
4661
|
+
|
|
4662
|
+
// Market pattern (ChatGPT/Claude/Gemini): the sent user message stays
|
|
4663
|
+
// pinned at the top as the visible header of the streaming reply. The
|
|
4664
|
+
// handoff to the first unread block exists for the stranded case — a long
|
|
4665
|
+
// user message or a tall tool-call prelude pushing the reply below the
|
|
4666
|
+
// fold — so it only fires when the block's start is not readably within
|
|
4667
|
+
// the viewport while the user message holds the anchor.
|
|
4668
|
+
const isFirstUnreadStranded = (messageId: string): boolean => {
|
|
4669
|
+
if (lastSentUserMessageId === null) return true;
|
|
4670
|
+
const userBubble = queryMessageBubble(lastSentUserMessageId);
|
|
4671
|
+
const blockBubble = queryMessageBubble(messageId);
|
|
4672
|
+
// Unmeasurable geometry: fall back to handing off (pre-conditional
|
|
4673
|
+
// behavior) rather than risking an answer parked below the fold.
|
|
4674
|
+
if (!userBubble || !blockBubble) return true;
|
|
4675
|
+
const delta =
|
|
4676
|
+
offsetTopWithinBody(blockBubble) - offsetTopWithinBody(userBubble);
|
|
4677
|
+
return (
|
|
4678
|
+
getAnchorTopOffset() + delta >
|
|
4679
|
+
body.clientHeight - ANCHOR_HANDOFF_MIN_VISIBLE
|
|
4680
|
+
);
|
|
4681
|
+
};
|
|
4682
|
+
|
|
4176
4683
|
const maybeAnchorToFirstUnread = (messages: AgentWidgetMessage[]) => {
|
|
4177
4684
|
if (getScrollMode() !== "anchor-top") return;
|
|
4178
4685
|
if (!currentTurnAnchored) return;
|
|
@@ -4188,6 +4695,10 @@ export const createAgentExperience = (
|
|
|
4188
4695
|
if (!isAttentionWorthyMessage(message)) continue;
|
|
4189
4696
|
// Already parked on it: hold still while it streams.
|
|
4190
4697
|
if (message.id === anchoredMessageId) return;
|
|
4698
|
+
// Block starts within view under the pinned user message: keep the
|
|
4699
|
+
// user message anchored. Re-evaluated every render, so a tool prelude
|
|
4700
|
+
// that later pushes the block below the fold still hands off.
|
|
4701
|
+
if (!isFirstUnreadStranded(message.id)) return;
|
|
4191
4702
|
anchoredMessageId = message.id;
|
|
4192
4703
|
scheduleAnchorToMessage(message.id);
|
|
4193
4704
|
return;
|
|
@@ -4511,7 +5022,7 @@ export const createAgentExperience = (
|
|
|
4511
5022
|
// visible instead of silently swallowed.
|
|
4512
5023
|
if (
|
|
4513
5024
|
isSuggestRepliesMessage(message) &&
|
|
4514
|
-
config.
|
|
5025
|
+
resolveFollowUpsFeature(config).enabled
|
|
4515
5026
|
) {
|
|
4516
5027
|
return;
|
|
4517
5028
|
}
|
|
@@ -5720,8 +6231,9 @@ export const createAgentExperience = (
|
|
|
5720
6231
|
|
|
5721
6232
|
// Re-run chrome application now that data-state has flipped: collapsed
|
|
5722
6233
|
// clears container chrome (pill stands alone), expanded paints it via
|
|
5723
|
-
// the same theme.components.panel.* contract as floating mode.
|
|
5724
|
-
applyFullHeightStyles
|
|
6234
|
+
// the same theme.components.panel.* contract as floating mode. Full
|
|
6235
|
+
// sync: applyFullHeightStyles alone wipes the theme vars with cssText.
|
|
6236
|
+
syncPanelChrome();
|
|
5725
6237
|
|
|
5726
6238
|
// Outside-click dismiss: while expanded, clicking anywhere outside the
|
|
5727
6239
|
// wrapper (panel chrome + pill) collapses back to just the pill.
|
|
@@ -5757,8 +6269,9 @@ export const createAgentExperience = (
|
|
|
5757
6269
|
panel.classList.remove("persona-scale-95", "persona-opacity-0");
|
|
5758
6270
|
panel.classList.add("persona-scale-100", "persona-opacity-100");
|
|
5759
6271
|
// Hide launcher button when widget is open
|
|
5760
|
-
if (
|
|
5761
|
-
|
|
6272
|
+
if (launcherSurfaceInstance) {
|
|
6273
|
+
launcherSurfaceInstance.setPanelOpen(true);
|
|
6274
|
+
launcherSurfaceInstance.element.style.display = "none";
|
|
5762
6275
|
} else if (customLauncherElement) {
|
|
5763
6276
|
customLauncherElement.style.display = "none";
|
|
5764
6277
|
}
|
|
@@ -5783,8 +6296,9 @@ export const createAgentExperience = (
|
|
|
5783
6296
|
panel.classList.add("persona-scale-95", "persona-opacity-0");
|
|
5784
6297
|
}
|
|
5785
6298
|
// Show launcher when closed, except docked mode (0px column: use controller.open()).
|
|
5786
|
-
if (
|
|
5787
|
-
|
|
6299
|
+
if (launcherSurfaceInstance) {
|
|
6300
|
+
launcherSurfaceInstance.setPanelOpen(false);
|
|
6301
|
+
launcherSurfaceInstance.element.style.display = dockedMode ? "none" : "";
|
|
5788
6302
|
} else if (customLauncherElement) {
|
|
5789
6303
|
customLauncherElement.style.display = dockedMode ? "none" : "";
|
|
5790
6304
|
}
|
|
@@ -5884,8 +6398,29 @@ export const createAgentExperience = (
|
|
|
5884
6398
|
if (micButton) {
|
|
5885
6399
|
micButton.disabled = disabled;
|
|
5886
6400
|
}
|
|
5887
|
-
|
|
5888
|
-
|
|
6401
|
+
suggestionManagers.forEach((manager) => {
|
|
6402
|
+
manager.elements.forEach((element) => {
|
|
6403
|
+
element.toggleAttribute("data-disabled", disabled);
|
|
6404
|
+
if (!(element instanceof HTMLButtonElement)) {
|
|
6405
|
+
element.setAttribute("aria-disabled", disabled ? "true" : "false");
|
|
6406
|
+
}
|
|
6407
|
+
});
|
|
6408
|
+
manager.buttons.forEach((button) => {
|
|
6409
|
+
button.disabled = disabled;
|
|
6410
|
+
});
|
|
6411
|
+
});
|
|
6412
|
+
// Plugin renderStarter output: created with the streaming flag of its
|
|
6413
|
+
// render moment, so a stream ending must re-enable it here.
|
|
6414
|
+
welcomeStarterElements.forEach((element) => {
|
|
6415
|
+
element.toggleAttribute("data-disabled", disabled);
|
|
6416
|
+
if (element instanceof HTMLButtonElement) {
|
|
6417
|
+
element.disabled = disabled;
|
|
6418
|
+
} else {
|
|
6419
|
+
element.setAttribute("aria-disabled", disabled ? "true" : "false");
|
|
6420
|
+
}
|
|
6421
|
+
element.querySelectorAll<HTMLButtonElement>("button").forEach((button) => {
|
|
6422
|
+
button.disabled = disabled;
|
|
6423
|
+
});
|
|
5889
6424
|
});
|
|
5890
6425
|
footer.dataset.personaComposerStreaming = disabled ? "true" : "false";
|
|
5891
6426
|
footer.querySelectorAll<HTMLElement>("[data-persona-composer-disable-when-streaming]").forEach((el) => {
|
|
@@ -5910,32 +6445,229 @@ export const createAgentExperience = (
|
|
|
5910
6445
|
if (config.autoFocusInput) setTimeout(() => maybeFocusInput(), 200);
|
|
5911
6446
|
});
|
|
5912
6447
|
|
|
5913
|
-
|
|
5914
|
-
|
|
5915
|
-
|
|
5916
|
-
|
|
5917
|
-
|
|
5918
|
-
|
|
5919
|
-
|
|
5920
|
-
|
|
5921
|
-
|
|
5922
|
-
|
|
5923
|
-
|
|
5924
|
-
|
|
5925
|
-
|
|
5926
|
-
|
|
5927
|
-
|
|
5928
|
-
|
|
5929
|
-
|
|
5930
|
-
|
|
6448
|
+
// Welcome visibility is derived, never stored: `resolveWelcomeConfig` owns
|
|
6449
|
+
// the config and the session's user messages own the dismissal.
|
|
6450
|
+
let welcomeShown = !welcomeHost.hidden;
|
|
6451
|
+
// The dismiss runs across many renders (assistant placeholder, every
|
|
6452
|
+
// streaming chunk), so the in-flight animation owns the host until it settles.
|
|
6453
|
+
let welcomeDismissAnimation: Animation | null = null;
|
|
6454
|
+
// Restored history must hide the hero outright, not animate it out.
|
|
6455
|
+
let welcomeHydrated = !pendingStoredState;
|
|
6456
|
+
// Content is re-applied only when the resolved config changes: this runs on
|
|
6457
|
+
// every message change, including each streaming chunk.
|
|
6458
|
+
let lastWelcomeKey: string | null = null;
|
|
6459
|
+
let lastWelcomeIcon: AgentWidgetWelcomeIcon | undefined;
|
|
6460
|
+
let lastGreetingKey: string | null = null;
|
|
6461
|
+
// Element returned by a `renderWelcome` plugin, or null when the default
|
|
6462
|
+
// renderer owns the host. A plugin element ignores derived visibility.
|
|
6463
|
+
let welcomePluginContent: HTMLElement | null = null;
|
|
6464
|
+
const updateWelcome = (messages?: AgentWidgetMessage[]) => {
|
|
6465
|
+
const resolved = resolveWelcomeConfig(config);
|
|
6466
|
+
const welcomeKey = `${resolved.variant}|${resolved.dismiss}|${resolved.title}|${resolved.subtitle}`;
|
|
6467
|
+
if (welcomeKey !== lastWelcomeKey || resolved.icon !== lastWelcomeIcon) {
|
|
6468
|
+
lastWelcomeKey = welcomeKey;
|
|
6469
|
+
lastWelcomeIcon = resolved.icon;
|
|
6470
|
+
applyWelcomeConfig(
|
|
6471
|
+
{
|
|
6472
|
+
host: welcomeHost,
|
|
6473
|
+
iconHolder: welcomeIconHolder,
|
|
6474
|
+
title: introTitle,
|
|
6475
|
+
subtitle: introSubtitle,
|
|
6476
|
+
starterSuggestions,
|
|
6477
|
+
},
|
|
6478
|
+
resolved
|
|
6479
|
+
);
|
|
6480
|
+
}
|
|
6481
|
+
const greetingKey =
|
|
6482
|
+
resolved.message === undefined
|
|
6483
|
+
? null
|
|
6484
|
+
: `${config.layout?.messages?.layout ?? "bubble"}|${resolved.message}`;
|
|
6485
|
+
if (greetingKey !== lastGreetingKey) {
|
|
6486
|
+
lastGreetingKey = greetingKey;
|
|
6487
|
+
renderWelcomeGreeting(greetingHost, resolved.message, config);
|
|
6488
|
+
}
|
|
6489
|
+
|
|
6490
|
+
const current =
|
|
6491
|
+
messages ?? (session ? session.getMessages() : config.initialMessages);
|
|
6492
|
+
const visible = isWelcomeVisible(resolved, current);
|
|
6493
|
+
const flipped = visible !== welcomeShown;
|
|
6494
|
+
welcomeShown = visible;
|
|
6495
|
+
// Plugin content renders regardless of derived visibility and owns the
|
|
6496
|
+
// host until the plugin returns null again.
|
|
6497
|
+
if (welcomePluginContent) {
|
|
6498
|
+
welcomeDismissAnimation?.cancel();
|
|
6499
|
+
welcomeDismissAnimation = null;
|
|
6500
|
+
applyWelcomeVisibility(body, welcomeHost, true);
|
|
6501
|
+
return;
|
|
6502
|
+
}
|
|
6503
|
+
if (
|
|
6504
|
+
flipped &&
|
|
6505
|
+
!visible &&
|
|
6506
|
+
resolved.dismiss === "on-first-message" &&
|
|
6507
|
+
welcomeHydrated
|
|
6508
|
+
) {
|
|
6509
|
+
welcomeDismissAnimation = animateWelcomeOut(welcomeHost, (animation) => {
|
|
6510
|
+
if (welcomeDismissAnimation === animation) {
|
|
6511
|
+
welcomeDismissAnimation = null;
|
|
6512
|
+
}
|
|
6513
|
+
// A re-show (clearChat, or plugin content claiming the host
|
|
6514
|
+
// mid-animation) wins the race.
|
|
6515
|
+
if (!welcomeShown && !welcomePluginContent) {
|
|
6516
|
+
applyWelcomeVisibility(body, welcomeHost, false);
|
|
6517
|
+
repinAnchoredMessage();
|
|
6518
|
+
}
|
|
6519
|
+
// The forwards fill would pin opacity 0 across a later re-show.
|
|
6520
|
+
animation?.cancel();
|
|
6521
|
+
});
|
|
6522
|
+
return;
|
|
6523
|
+
}
|
|
6524
|
+
if (welcomeDismissAnimation) {
|
|
6525
|
+
// Later renders must not hide the host out from under the animation.
|
|
6526
|
+
if (!visible) return;
|
|
6527
|
+
welcomeDismissAnimation.cancel();
|
|
6528
|
+
welcomeDismissAnimation = null;
|
|
6529
|
+
}
|
|
6530
|
+
applyWelcomeVisibility(body, welcomeHost, visible);
|
|
6531
|
+
if (flipped && !visible) repinAnchoredMessage();
|
|
6532
|
+
};
|
|
6533
|
+
|
|
6534
|
+
// `renderWelcome` arbitration. The core owns the host; a re-render runs the
|
|
6535
|
+
// registered cleanups, drops the previous plugin element, and re-arbitrates
|
|
6536
|
+
// with a fresh ctx.
|
|
6537
|
+
const welcomeCleanups: Array<() => void> = [];
|
|
6538
|
+
// renderStarter output is one-shot DOM the suggestion managers never see;
|
|
6539
|
+
// tracked here so setComposerDisabled can sync it on streaming flips.
|
|
6540
|
+
const welcomeStarterElements: HTMLElement[] = [];
|
|
6541
|
+
let welcomeArbitrating = false;
|
|
6542
|
+
let lastWelcomeArbitrationKey: string | null = null;
|
|
6543
|
+
let lastWelcomeArbitrationIcon: AgentWidgetWelcomeIcon | undefined;
|
|
6544
|
+
|
|
6545
|
+
const runWelcomeCleanups = () => {
|
|
6546
|
+
welcomeStarterElements.length = 0;
|
|
6547
|
+
const callbacks = welcomeCleanups.splice(0, welcomeCleanups.length);
|
|
6548
|
+
for (const callback of callbacks) {
|
|
6549
|
+
try {
|
|
6550
|
+
callback();
|
|
6551
|
+
} catch (error) {
|
|
6552
|
+
console.warn("[persona] renderWelcome cleanup threw", error);
|
|
6553
|
+
}
|
|
6554
|
+
}
|
|
6555
|
+
};
|
|
6556
|
+
destroyCallbacks.push(runWelcomeCleanups);
|
|
6557
|
+
|
|
6558
|
+
/** Default starter renderer for plugins: full select pipeline, welcome geometry. */
|
|
6559
|
+
const renderWelcomeStarter = (
|
|
6560
|
+
suggestion: AgentWidgetSuggestion,
|
|
6561
|
+
index: number
|
|
6562
|
+
): HTMLElement => {
|
|
6563
|
+
const normalized = normalizeSuggestion(suggestion, index);
|
|
6564
|
+
// An unlabeled suggestion has nothing to render or send.
|
|
6565
|
+
if (!normalized) {
|
|
6566
|
+
if (config.debug === true) {
|
|
6567
|
+
console.warn("[persona] renderStarter received a suggestion with no label");
|
|
6568
|
+
}
|
|
6569
|
+
return createElement("span");
|
|
6570
|
+
}
|
|
6571
|
+
const starters = config.suggestions?.starters;
|
|
6572
|
+
const element = createSuggestionElement({
|
|
6573
|
+
item: {
|
|
6574
|
+
...normalized,
|
|
6575
|
+
behavior: normalized.behavior ?? starters?.behavior ?? "send",
|
|
6576
|
+
},
|
|
6577
|
+
index,
|
|
6578
|
+
surface: "starter",
|
|
6579
|
+
source: "config",
|
|
6580
|
+
variant: starters?.variant ?? "card",
|
|
6581
|
+
streaming: sessionInitialized ? session.isStreaming() : false,
|
|
6582
|
+
config,
|
|
6583
|
+
plugins,
|
|
6584
|
+
chipsConfig: config.suggestionChipsConfig,
|
|
6585
|
+
session,
|
|
6586
|
+
getTextarea: () => textarea,
|
|
6587
|
+
eventTarget: welcomeHost,
|
|
6588
|
+
});
|
|
6589
|
+
welcomeStarterElements.push(element);
|
|
6590
|
+
return element;
|
|
6591
|
+
};
|
|
6592
|
+
|
|
6593
|
+
const renderWelcomeSurface = () => {
|
|
6594
|
+
// A plugin calling requestRender() from inside its own render would recurse.
|
|
6595
|
+
if (welcomeArbitrating) return;
|
|
6596
|
+
welcomeArbitrating = true;
|
|
6597
|
+
const resolved = resolveWelcomeConfig(config);
|
|
6598
|
+
lastWelcomeArbitrationKey = `${resolved.variant}|${resolved.dismiss}|${resolved.title}|${resolved.subtitle}|${resolved.message ?? ""}`;
|
|
6599
|
+
lastWelcomeArbitrationIcon = resolved.icon;
|
|
6600
|
+
try {
|
|
6601
|
+
runWelcomeCleanups();
|
|
6602
|
+
clearWelcomePluginContent(body, welcomeHost, welcomePluginContent);
|
|
6603
|
+
welcomePluginContent = null;
|
|
6604
|
+
|
|
6605
|
+
const current = session ? session.getMessages() : config.initialMessages;
|
|
6606
|
+
const visible = isWelcomeVisible(resolved, current);
|
|
6607
|
+
let starterIndex = 0;
|
|
6608
|
+
let element: HTMLElement | null = null;
|
|
6609
|
+
for (const plugin of plugins) {
|
|
6610
|
+
if (!plugin.renderWelcome) continue;
|
|
6611
|
+
element = plugin.renderWelcome({
|
|
6612
|
+
config: resolved,
|
|
6613
|
+
variant: resolved.variant,
|
|
6614
|
+
visible,
|
|
6615
|
+
defaultRenderer: () => welcomeHost,
|
|
6616
|
+
sendMessage: (text: string) => {
|
|
6617
|
+
if (!session || session.isStreaming()) return;
|
|
6618
|
+
const value = text.trim();
|
|
6619
|
+
if (!value) return;
|
|
6620
|
+
session.sendMessage(value);
|
|
6621
|
+
},
|
|
6622
|
+
requestRender: () => renderWelcomeSurface(),
|
|
6623
|
+
renderStarter: (suggestion) =>
|
|
6624
|
+
renderWelcomeStarter(suggestion, starterIndex++),
|
|
6625
|
+
storage: pluginStorageFor(plugin.id),
|
|
6626
|
+
onCleanup: (fn) => {
|
|
6627
|
+
welcomeCleanups.push(fn);
|
|
6628
|
+
},
|
|
6629
|
+
});
|
|
6630
|
+
if (element) break;
|
|
6631
|
+
}
|
|
6632
|
+
// Returning `defaultRenderer()` is composition, not a takeover: the
|
|
6633
|
+
// default content stays and derived visibility still governs.
|
|
6634
|
+
if (element && element !== welcomeHost) {
|
|
6635
|
+
welcomePluginContent = element;
|
|
6636
|
+
mountWelcomePluginContent(body, welcomeHost, element);
|
|
5931
6637
|
}
|
|
6638
|
+
} finally {
|
|
6639
|
+
welcomeArbitrating = false;
|
|
5932
6640
|
}
|
|
6641
|
+
updateWelcome();
|
|
6642
|
+
};
|
|
5933
6643
|
|
|
5934
|
-
|
|
5935
|
-
|
|
5936
|
-
const
|
|
5937
|
-
|
|
5938
|
-
|
|
6644
|
+
/** Re-arbitrate only when the resolved welcome config actually changed. */
|
|
6645
|
+
const refreshWelcomePlugins = () => {
|
|
6646
|
+
const resolved = resolveWelcomeConfig(config);
|
|
6647
|
+
const key = `${resolved.variant}|${resolved.dismiss}|${resolved.title}|${resolved.subtitle}|${resolved.message ?? ""}`;
|
|
6648
|
+
if (
|
|
6649
|
+
key === lastWelcomeArbitrationKey &&
|
|
6650
|
+
resolved.icon === lastWelcomeArbitrationIcon
|
|
6651
|
+
) {
|
|
6652
|
+
return;
|
|
6653
|
+
}
|
|
6654
|
+
renderWelcomeSurface();
|
|
6655
|
+
};
|
|
6656
|
+
|
|
6657
|
+
const updateCopy = () => {
|
|
6658
|
+
updateWelcome();
|
|
6659
|
+
// Plugin-owned composers set their own placeholder and send label (the
|
|
6660
|
+
// pre-chat gate keeps its lock reason in the placeholder, for example).
|
|
6661
|
+
if (!composerIsPluginOwned) {
|
|
6662
|
+
textarea.placeholder =
|
|
6663
|
+
config.copy?.inputPlaceholder ?? "How can I help...";
|
|
6664
|
+
|
|
6665
|
+
// Only update send button text if NOT using icon mode. Skip while
|
|
6666
|
+
// streaming so we don't stomp on the "Stop" label.
|
|
6667
|
+
const useIcon = config.sendButton?.useIcon ?? false;
|
|
6668
|
+
if (!useIcon && !session?.isStreaming()) {
|
|
6669
|
+
sendButton.textContent = config.copy?.sendButtonLabel ?? "Send";
|
|
6670
|
+
}
|
|
5939
6671
|
}
|
|
5940
6672
|
|
|
5941
6673
|
textarea.style.fontFamily =
|
|
@@ -6076,6 +6808,7 @@ export const createAgentExperience = (
|
|
|
6076
6808
|
suppressTransition: isStreaming,
|
|
6077
6809
|
});
|
|
6078
6810
|
ensureToolElapsedTimer();
|
|
6811
|
+
updateWelcome(messages);
|
|
6079
6812
|
renderSuggestions(messages);
|
|
6080
6813
|
scheduleAutoScroll(!isStreaming);
|
|
6081
6814
|
trackMessages(messages);
|
|
@@ -6196,6 +6929,17 @@ export const createAgentExperience = (
|
|
|
6196
6929
|
}
|
|
6197
6930
|
if (!streaming) {
|
|
6198
6931
|
scheduleAutoScroll(true);
|
|
6932
|
+
trimAnchorSpacerToRest();
|
|
6933
|
+
// setStreaming only fires on a change, so this is always a real stream
|
|
6934
|
+
// ending. Evaluate the never-called hint here: no later render is
|
|
6935
|
+
// guaranteed on a settled transcript.
|
|
6936
|
+
completedStreamThisSession = true;
|
|
6937
|
+
if (session) {
|
|
6938
|
+
hintFollowUpsNeverCalled(
|
|
6939
|
+
session.getMessages(),
|
|
6940
|
+
resolveFollowUpsFeature(config)
|
|
6941
|
+
);
|
|
6942
|
+
}
|
|
6199
6943
|
}
|
|
6200
6944
|
// Keep the "streaming below" hint and its announcement in sync with the
|
|
6201
6945
|
// streaming lifecycle (Principles 8 + 15).
|
|
@@ -6313,6 +7057,8 @@ export const createAgentExperience = (
|
|
|
6313
7057
|
if (state === "idle") lastReadAloudId = null;
|
|
6314
7058
|
});
|
|
6315
7059
|
|
|
7060
|
+
sessionInitialized = true;
|
|
7061
|
+
|
|
6316
7062
|
// The constructor only emits onMessagesChanged when it has initial
|
|
6317
7063
|
// messages, so seed send-detection explicitly for the empty-session case: // otherwise the user's very first send would be mistaken for the seed.
|
|
6318
7064
|
scrollSendSeeded = true;
|
|
@@ -6396,7 +7142,10 @@ export const createAgentExperience = (
|
|
|
6396
7142
|
console.error("[AgentWidget] Failed to hydrate stored state:", error);
|
|
6397
7143
|
}
|
|
6398
7144
|
})
|
|
6399
|
-
.finally(() =>
|
|
7145
|
+
.finally(() => {
|
|
7146
|
+
welcomeHydrated = true;
|
|
7147
|
+
maybeBootResume();
|
|
7148
|
+
});
|
|
6400
7149
|
} else {
|
|
6401
7150
|
maybeBootResume();
|
|
6402
7151
|
}
|
|
@@ -6416,7 +7165,7 @@ export const createAgentExperience = (
|
|
|
6416
7165
|
// Combine `@`-chip mentions with an inline server-command's context bundle so
|
|
6417
7166
|
// both reach the message in one `mentions` payload. Either side may be null.
|
|
6418
7167
|
type SubmitMentions = NonNullable<
|
|
6419
|
-
ReturnType<
|
|
7168
|
+
ReturnType<ContextMentionOrchestrator["collectForSubmit"]>
|
|
6420
7169
|
>;
|
|
6421
7170
|
const mergeSubmitMentions = (
|
|
6422
7171
|
a: SubmitMentions | null,
|
|
@@ -7256,27 +8005,28 @@ export const createAgentExperience = (
|
|
|
7256
8005
|
};
|
|
7257
8006
|
|
|
7258
8007
|
// Plugin hook: renderLauncher - allow plugins to provide custom launcher
|
|
7259
|
-
let
|
|
8008
|
+
let launcherSurfaceInstance: LauncherSurface | null = null;
|
|
7260
8009
|
let customLauncherElement: HTMLElement | null = null;
|
|
7261
8010
|
|
|
7262
8011
|
// Composer-bar mode is launcher-less by design: the persistent pill IS the
|
|
7263
8012
|
// entry point, so skip creating any launcher button (default or plugin).
|
|
7264
8013
|
if (launcherEnabled && !isComposerBar()) {
|
|
7265
8014
|
const { instance, element } = resolveLauncher({ config, plugins, onToggle: toggleOpen });
|
|
7266
|
-
|
|
8015
|
+
launcherSurfaceInstance = instance;
|
|
7267
8016
|
// A plugin-provided launcher returns no controller instance; track its
|
|
7268
8017
|
// element separately so the update path can manage it.
|
|
7269
8018
|
if (!instance) customLauncherElement = element;
|
|
7270
8019
|
}
|
|
7271
8020
|
|
|
7272
|
-
if (
|
|
7273
|
-
mount.appendChild(
|
|
8021
|
+
if (launcherSurfaceInstance) {
|
|
8022
|
+
mount.appendChild(launcherSurfaceInstance.element);
|
|
7274
8023
|
} else if (customLauncherElement) {
|
|
7275
8024
|
mount.appendChild(customLauncherElement);
|
|
7276
8025
|
}
|
|
7277
8026
|
updateOpenState();
|
|
7278
8027
|
renderSuggestions();
|
|
7279
8028
|
updateCopy();
|
|
8029
|
+
renderWelcomeSurface();
|
|
7280
8030
|
setComposerDisabled(session.isStreaming());
|
|
7281
8031
|
// Reopen-where-left-off takes precedence when opted in (Principle 11);
|
|
7282
8032
|
// otherwise fall back to the historical per-mode positioning.
|
|
@@ -7312,7 +8062,6 @@ export const createAgentExperience = (
|
|
|
7312
8062
|
|
|
7313
8063
|
const dockedMode = isDockedMountMode(config);
|
|
7314
8064
|
const sidebarMode = config.launcher?.sidebarMode ?? false;
|
|
7315
|
-
const fullHeight = dockedMode || sidebarMode || (config.launcher?.fullHeight ?? false);
|
|
7316
8065
|
|
|
7317
8066
|
// Mobile fullscreen: re-apply fullscreen styles on resize (handles orientation changes)
|
|
7318
8067
|
const ownerWindow = mount.ownerDocument.defaultView ?? window;
|
|
@@ -7369,15 +8118,7 @@ export const createAgentExperience = (
|
|
|
7369
8118
|
applyLauncherArtifactPanelWidth();
|
|
7370
8119
|
|
|
7371
8120
|
// In fullHeight mode, don't set a fixed height
|
|
7372
|
-
|
|
7373
|
-
const viewportHeight = ownerWindow.innerHeight;
|
|
7374
|
-
const verticalMargin = 64; // leave space for launcher's offset
|
|
7375
|
-
const heightOffset = config.launcher?.heightOffset ?? 0;
|
|
7376
|
-
const available = Math.max(200, viewportHeight - verticalMargin);
|
|
7377
|
-
const clamped = Math.min(640, available);
|
|
7378
|
-
const finalHeight = Math.max(200, clamped - heightOffset);
|
|
7379
|
-
panel.style.height = `${finalHeight}px`;
|
|
7380
|
-
}
|
|
8121
|
+
applyFloatingPanelFixedHeight();
|
|
7381
8122
|
} finally {
|
|
7382
8123
|
// applyFullHeightStyles() assigns wrapper.style.cssText (e.g. display:flex !important), which
|
|
7383
8124
|
// overwrites updateOpenState()'s display:none when docked+closed. Re-sync after every recalc.
|
|
@@ -7725,11 +8466,91 @@ export const createAgentExperience = (
|
|
|
7725
8466
|
// lazily). When that happens, move the composer listeners onto the new element
|
|
7726
8467
|
// and repoint `textarea` (the swapped element shims the textarea API the rest of
|
|
7727
8468
|
// the composer code relies on). Fires immediately if the swap already occurred.
|
|
7728
|
-
|
|
7729
|
-
|
|
7730
|
-
|
|
7731
|
-
|
|
7732
|
-
|
|
8469
|
+
const registerComposerSwapHandler = () => {
|
|
8470
|
+
mentionOrchestrator?.onComposerSwap((next, prev) => {
|
|
8471
|
+
detachComposerListeners(prev);
|
|
8472
|
+
textarea = next as unknown as HTMLTextAreaElement;
|
|
8473
|
+
attachComposerListeners(next);
|
|
8474
|
+
});
|
|
8475
|
+
};
|
|
8476
|
+
registerComposerSwapHandler();
|
|
8477
|
+
|
|
8478
|
+
/**
|
|
8479
|
+
* Re-run composer plugin arbitration and swap the footer in place. Exposed to
|
|
8480
|
+
* composer plugins as `requestRender()`. Everything bound to the old footer
|
|
8481
|
+
* (listeners, attachment previews, mention affordances, the composer
|
|
8482
|
+
* suggestion row) is torn down and re-bound to the new one.
|
|
8483
|
+
*/
|
|
8484
|
+
let composerRebuilding = false;
|
|
8485
|
+
function rebuildComposer(): void {
|
|
8486
|
+
if (composerRebuilding) return;
|
|
8487
|
+
composerRebuilding = true;
|
|
8488
|
+
try {
|
|
8489
|
+
const previousValue = textarea?.value ?? "";
|
|
8490
|
+
const activeDoc = footer.ownerDocument ?? document;
|
|
8491
|
+
const hadFocus = textarea ? activeDoc.activeElement === textarea : false;
|
|
8492
|
+
|
|
8493
|
+
detachComposerListeners(textarea);
|
|
8494
|
+
composerForm?.removeEventListener("submit", handleSubmit);
|
|
8495
|
+
micButton?.removeEventListener("click", handleMicButtonClick);
|
|
8496
|
+
// Its bindings point at the outgoing textarea; the new footer gets a new one.
|
|
8497
|
+
mentionOrchestrator?.destroy();
|
|
8498
|
+
mentionOrchestrator = null;
|
|
8499
|
+
|
|
8500
|
+
let defaultElements: _ComposerElements | null = null;
|
|
8501
|
+
const defaultRenderer = () => {
|
|
8502
|
+
defaultElements = isComposerBar()
|
|
8503
|
+
? buildPillComposer({ config })
|
|
8504
|
+
: buildComposer({ config });
|
|
8505
|
+
return defaultElements.footer;
|
|
8506
|
+
};
|
|
8507
|
+
const nextFooter =
|
|
8508
|
+
renderComposerFromPlugins(defaultRenderer) ?? defaultRenderer();
|
|
8509
|
+
|
|
8510
|
+
view.replaceComposer(nextFooter);
|
|
8511
|
+
footer = view.composer.footer;
|
|
8512
|
+
// A plugin composer owns its own send affordance, so the built-in
|
|
8513
|
+
// send/stop swap has nothing to drive.
|
|
8514
|
+
const isDefaultFooter =
|
|
8515
|
+
defaultElements !== null &&
|
|
8516
|
+
nextFooter === (defaultElements as _ComposerElements).footer;
|
|
8517
|
+
composerIsPluginOwned = !isDefaultFooter;
|
|
8518
|
+
setSendButtonMode = isDefaultFooter
|
|
8519
|
+
? (defaultElements as unknown as _ComposerElements).setSendButtonMode
|
|
8520
|
+
: () => {};
|
|
8521
|
+
|
|
8522
|
+
ensureComposerAttachmentSurface(footer);
|
|
8523
|
+
bindComposerRefsFromFooter(footer);
|
|
8524
|
+
applyComposerContentMaxWidth();
|
|
8525
|
+
setupComposerAttachments();
|
|
8526
|
+
setupMentionOrchestrator();
|
|
8527
|
+
registerComposerSwapHandler();
|
|
8528
|
+
|
|
8529
|
+
if (composerForm) composerForm.addEventListener("submit", handleSubmit);
|
|
8530
|
+
attachComposerListeners(textarea);
|
|
8531
|
+
micButton?.addEventListener("click", handleMicButtonClick);
|
|
8532
|
+
|
|
8533
|
+
// The composer suggestion manager is bound to its container element, and
|
|
8534
|
+
// the new footer ships a new one.
|
|
8535
|
+
const managerIndex = suggestionManagers.indexOf(composerSuggestionsManager);
|
|
8536
|
+
composerSuggestionsManager.destroy();
|
|
8537
|
+
composerSuggestionsManager = createSuggestions(suggestions);
|
|
8538
|
+
if (managerIndex >= 0) suggestionManagers[managerIndex] = composerSuggestionsManager;
|
|
8539
|
+
|
|
8540
|
+
if (textarea) textarea.value = previousValue;
|
|
8541
|
+
updateCopy();
|
|
8542
|
+
renderSuggestions();
|
|
8543
|
+
setComposerDisabled(session.isStreaming());
|
|
8544
|
+
// The new footer carries none of the per-mode inline styles. Full
|
|
8545
|
+
// chrome sync, not bare applyFullHeightStyles: the latter resets
|
|
8546
|
+
// mount.style.cssText, which silently drops every theme variable.
|
|
8547
|
+
syncPanelChrome();
|
|
8548
|
+
updateScrollToBottomButtonOffset();
|
|
8549
|
+
if (hadFocus) textarea?.focus();
|
|
8550
|
+
} finally {
|
|
8551
|
+
composerRebuilding = false;
|
|
8552
|
+
}
|
|
8553
|
+
}
|
|
7733
8554
|
|
|
7734
8555
|
const escStopDoc = mount.ownerDocument ?? document;
|
|
7735
8556
|
escStopDoc.addEventListener("keydown", handleEscStop, true);
|
|
@@ -7829,9 +8650,9 @@ export const createAgentExperience = (
|
|
|
7829
8650
|
session.cancel();
|
|
7830
8651
|
});
|
|
7831
8652
|
|
|
7832
|
-
if (
|
|
8653
|
+
if (launcherSurfaceInstance) {
|
|
7833
8654
|
destroyCallbacks.push(() => {
|
|
7834
|
-
|
|
8655
|
+
launcherSurfaceInstance?.destroy();
|
|
7835
8656
|
});
|
|
7836
8657
|
} else if (customLauncherElement) {
|
|
7837
8658
|
destroyCallbacks.push(() => {
|
|
@@ -7889,6 +8710,7 @@ export const createAgentExperience = (
|
|
|
7889
8710
|
resetAnchorState();
|
|
7890
8711
|
resumeAutoScroll();
|
|
7891
8712
|
}
|
|
8713
|
+
applyScrollbarPolicy();
|
|
7892
8714
|
renderScrollToBottomButton();
|
|
7893
8715
|
syncScrollToBottomButton();
|
|
7894
8716
|
const prevShowEventStreamToggle = showEventStreamToggle;
|
|
@@ -7958,25 +8780,25 @@ export const createAgentExperience = (
|
|
|
7958
8780
|
throughputTracker = null;
|
|
7959
8781
|
}
|
|
7960
8782
|
|
|
7961
|
-
if (config.launcher?.enabled === false &&
|
|
7962
|
-
|
|
7963
|
-
|
|
8783
|
+
if (config.launcher?.enabled === false && launcherSurfaceInstance) {
|
|
8784
|
+
launcherSurfaceInstance.destroy();
|
|
8785
|
+
launcherSurfaceInstance = null;
|
|
7964
8786
|
}
|
|
7965
8787
|
if (config.launcher?.enabled === false && customLauncherElement) {
|
|
7966
8788
|
customLauncherElement.remove();
|
|
7967
8789
|
customLauncherElement = null;
|
|
7968
8790
|
}
|
|
7969
8791
|
|
|
7970
|
-
if (config.launcher?.enabled !== false && !
|
|
8792
|
+
if (config.launcher?.enabled !== false && !launcherSurfaceInstance && !customLauncherElement) {
|
|
7971
8793
|
// Resolve the launcher again when re-enabling (honors renderLauncher plugin).
|
|
7972
8794
|
const { instance, element } = resolveLauncher({ config, plugins, onToggle: toggleOpen });
|
|
7973
|
-
|
|
8795
|
+
launcherSurfaceInstance = instance;
|
|
7974
8796
|
if (!instance) customLauncherElement = element;
|
|
7975
8797
|
mount.appendChild(element);
|
|
7976
8798
|
}
|
|
7977
8799
|
|
|
7978
|
-
if (
|
|
7979
|
-
|
|
8800
|
+
if (launcherSurfaceInstance) {
|
|
8801
|
+
launcherSurfaceInstance.update(config);
|
|
7980
8802
|
}
|
|
7981
8803
|
// Note: Custom launcher updates are handled by the plugin's own logic
|
|
7982
8804
|
|
|
@@ -8554,8 +9376,11 @@ export const createAgentExperience = (
|
|
|
8554
9376
|
);
|
|
8555
9377
|
renderSuggestions();
|
|
8556
9378
|
updateCopy();
|
|
9379
|
+
// Live `welcome` updates reach `renderWelcome` plugins; unrelated
|
|
9380
|
+
// update() calls leave plugin-owned content (and its state) alone.
|
|
9381
|
+
refreshWelcomePlugins();
|
|
8557
9382
|
setComposerDisabled(session.isStreaming());
|
|
8558
|
-
|
|
9383
|
+
|
|
8559
9384
|
// Update voice recognition mic button visibility
|
|
8560
9385
|
const voiceRecognitionEnabled = config.voiceRecognition?.enabled === true;
|
|
8561
9386
|
const hasSpeechRecognition =
|
|
@@ -8980,16 +9805,20 @@ export const createAgentExperience = (
|
|
|
8980
9805
|
|
|
8981
9806
|
// Update contentMaxWidth on messages wrapper and composer. Same
|
|
8982
9807
|
// composer-bar fallback as the initial read above.
|
|
8983
|
-
const updatedContentMaxWidth =
|
|
8984
|
-
config
|
|
8985
|
-
|
|
8986
|
-
|
|
8987
|
-
|
|
9808
|
+
const updatedContentMaxWidth = resolveContentMaxWidth(
|
|
9809
|
+
config,
|
|
9810
|
+
isComposerBar()
|
|
9811
|
+
);
|
|
9812
|
+
applyContentMaxWidthVar();
|
|
8988
9813
|
if (updatedContentMaxWidth) {
|
|
8989
9814
|
messagesWrapper.style.maxWidth = updatedContentMaxWidth;
|
|
8990
9815
|
messagesWrapper.style.marginLeft = "auto";
|
|
8991
9816
|
messagesWrapper.style.marginRight = "auto";
|
|
8992
9817
|
messagesWrapper.style.width = "100%";
|
|
9818
|
+
greetingHost.style.maxWidth = updatedContentMaxWidth;
|
|
9819
|
+
greetingHost.style.marginLeft = "auto";
|
|
9820
|
+
greetingHost.style.marginRight = "auto";
|
|
9821
|
+
greetingHost.style.width = "100%";
|
|
8993
9822
|
if (composerForm) {
|
|
8994
9823
|
composerForm.style.maxWidth = updatedContentMaxWidth;
|
|
8995
9824
|
composerForm.style.marginLeft = "auto";
|
|
@@ -9000,11 +9829,18 @@ export const createAgentExperience = (
|
|
|
9000
9829
|
suggestions.style.marginLeft = "auto";
|
|
9001
9830
|
suggestions.style.marginRight = "auto";
|
|
9002
9831
|
}
|
|
9832
|
+
statusText.style.maxWidth = updatedContentMaxWidth;
|
|
9833
|
+
statusText.style.marginLeft = "auto";
|
|
9834
|
+
statusText.style.marginRight = "auto";
|
|
9003
9835
|
} else {
|
|
9004
9836
|
messagesWrapper.style.maxWidth = "";
|
|
9005
9837
|
messagesWrapper.style.marginLeft = "";
|
|
9006
9838
|
messagesWrapper.style.marginRight = "";
|
|
9007
9839
|
messagesWrapper.style.width = "";
|
|
9840
|
+
greetingHost.style.maxWidth = "";
|
|
9841
|
+
greetingHost.style.marginLeft = "";
|
|
9842
|
+
greetingHost.style.marginRight = "";
|
|
9843
|
+
greetingHost.style.width = "";
|
|
9008
9844
|
if (composerForm) {
|
|
9009
9845
|
composerForm.style.maxWidth = "";
|
|
9010
9846
|
composerForm.style.marginLeft = "";
|
|
@@ -9015,6 +9851,9 @@ export const createAgentExperience = (
|
|
|
9015
9851
|
suggestions.style.marginLeft = "";
|
|
9016
9852
|
suggestions.style.marginRight = "";
|
|
9017
9853
|
}
|
|
9854
|
+
statusText.style.maxWidth = "";
|
|
9855
|
+
statusText.style.marginLeft = "";
|
|
9856
|
+
statusText.style.marginRight = "";
|
|
9018
9857
|
}
|
|
9019
9858
|
|
|
9020
9859
|
// Update status indicator visibility and text
|
|
@@ -9358,6 +10197,21 @@ export const createAgentExperience = (
|
|
|
9358
10197
|
}
|
|
9359
10198
|
return session.resolveApproval(approvalMessage.approval, decision, options);
|
|
9360
10199
|
},
|
|
10200
|
+
/**
|
|
10201
|
+
* Show follow-up suggestions from host code. Ephemeral UI state only: the
|
|
10202
|
+
* items never enter the transcript, the wire payload, or persistence, so
|
|
10203
|
+
* they do not survive a refresh. Cleared when the next user message
|
|
10204
|
+
* appends. Latest writer wins against agent `suggest_replies` payloads.
|
|
10205
|
+
* Renders through the `suggestions.followUps` presentation config and the
|
|
10206
|
+
* full plugin and DOM-event pipeline with source `"host"`.
|
|
10207
|
+
*/
|
|
10208
|
+
setFollowUpSuggestions(items: AgentWidgetSuggestion[]): void {
|
|
10209
|
+
setHostFollowUps(items);
|
|
10210
|
+
},
|
|
10211
|
+
/** Remove host-set follow-ups. Agent-set chips are untouched. */
|
|
10212
|
+
clearFollowUpSuggestions(): void {
|
|
10213
|
+
clearHostFollowUps();
|
|
10214
|
+
},
|
|
9361
10215
|
getMessages() {
|
|
9362
10216
|
return session.getMessages();
|
|
9363
10217
|
},
|
|
@@ -9488,7 +10342,7 @@ export const createAgentExperience = (
|
|
|
9488
10342
|
destroyCallbacks.forEach((cb) => cb());
|
|
9489
10343
|
wrapper.remove();
|
|
9490
10344
|
pillRoot?.remove();
|
|
9491
|
-
|
|
10345
|
+
launcherSurfaceInstance?.destroy();
|
|
9492
10346
|
customLauncherElement?.remove();
|
|
9493
10347
|
if (closeHandler) {
|
|
9494
10348
|
closeButton.removeEventListener("click", closeHandler);
|
|
@@ -9624,6 +10478,9 @@ export const createAgentExperience = (
|
|
|
9624
10478
|
const wasInVoiceMode = persistConfig.persist?.voiceState && storage.getItem(voiceModeKey) === 'true';
|
|
9625
10479
|
|
|
9626
10480
|
if (wasOpen) {
|
|
10481
|
+
// The teaser's own timer is already queued, so suppress it synchronously
|
|
10482
|
+
// here: the deferred open below would otherwise run one task too late.
|
|
10483
|
+
launcherSurfaceInstance?.setPanelOpen(true, "auto");
|
|
9627
10484
|
// Use setTimeout to ensure DOM is ready
|
|
9628
10485
|
setTimeout(() => {
|
|
9629
10486
|
controller.open();
|
|
@@ -9690,6 +10547,8 @@ export const createAgentExperience = (
|
|
|
9690
10547
|
// Mirrors the same setTimeout(0) pattern used by persistState restore so both
|
|
9691
10548
|
// can fire independently without interfering with each other.
|
|
9692
10549
|
if (shouldOpenAfterStateLoaded && isPanelToggleable()) {
|
|
10550
|
+
// Same one-task-early suppression as the persistState restore above.
|
|
10551
|
+
launcherSurfaceInstance?.setPanelOpen(true, "auto");
|
|
9693
10552
|
setTimeout(() => { controller.open(); }, 0);
|
|
9694
10553
|
}
|
|
9695
10554
|
|