@runtypelabs/persona 4.15.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/dist/codegen.cjs +1 -1
- package/dist/codegen.js +1 -1
- package/dist/context-mentions.d.cts +283 -9
- package/dist/context-mentions.d.ts +283 -9
- package/dist/index.cjs +68 -68
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +299 -10
- package/dist/index.d.ts +299 -10
- package/dist/index.global.js +57 -57
- package/dist/index.global.js.map +1 -1
- package/dist/index.js +69 -69
- package/dist/index.js.map +1 -1
- package/dist/launcher.global.js +2 -2
- package/dist/launcher.global.js.map +1 -1
- package/dist/smart-dom-reader.d.cts +283 -9
- package/dist/smart-dom-reader.d.ts +283 -9
- 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 +64 -64
- package/dist/theme-editor-preview.d.cts +283 -9
- package/dist/theme-editor-preview.d.ts +283 -9
- package/dist/theme-editor-preview.js +56 -56
- package/dist/theme-editor.cjs +10 -10
- package/dist/theme-editor.d.cts +283 -10
- package/dist/theme-editor.d.ts +283 -10
- package/dist/theme-editor.js +10 -10
- package/dist/theme-reference.cjs +1 -1
- package/dist/theme-reference.d.cts +66 -3
- package/dist/theme-reference.d.ts +66 -3
- package/dist/theme-reference.js +1 -1
- package/dist/widget.css +1 -1
- package/package.json +1 -1
- package/src/components/artifact-pane.test.ts +27 -0
- package/src/components/artifact-pane.ts +7 -3
- 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/panel.ts +69 -74
- package/src/components/suggestions.test.ts +40 -0
- package/src/components/suggestions.ts +213 -153
- 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 +14 -7
- package/src/defaults.ts +2 -2
- package/src/index-core.ts +12 -1
- package/src/launcher-global.ts +10 -7
- package/src/plugins/types.ts +66 -1
- package/src/styles/widget-styles.test.ts +220 -0
- package/src/styles/widget.css +353 -9
- package/src/testing/mock-stream.test.ts +19 -0
- package/src/testing/mock-stream.ts +20 -0
- package/src/theme-editor/sections.ts +30 -1
- package/src/theme-reference.ts +26 -4
- package/src/types/theme.ts +40 -3
- package/src/types.ts +164 -6
- package/src/ui.body-top-slot.test.ts +95 -0
- package/src/ui.content-width.test.ts +91 -0
- 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 +46 -0
- package/src/ui.ts +747 -136
- package/src/ui.welcome.test.ts +504 -0
- 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/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/plugin-storage.test.ts +94 -0
- package/src/utils/plugin-storage.ts +98 -0
- package/src/utils/theme.test.ts +137 -0
- package/src/utils/tokens.ts +49 -3
- package/src/welcome.test.ts +213 -0
- package/src/welcome.ts +130 -0
package/src/ui.ts
CHANGED
|
@@ -36,7 +36,8 @@ import {
|
|
|
36
36
|
AgentWidgetContentSegment,
|
|
37
37
|
AgentWidgetContextMentionRef,
|
|
38
38
|
AgentWidgetSuggestion,
|
|
39
|
-
AgentWidgetSuggestionSource
|
|
39
|
+
AgentWidgetSuggestionSource,
|
|
40
|
+
AgentWidgetWelcomeIcon
|
|
40
41
|
} from "./types";
|
|
41
42
|
import { AttachmentManager } from "./utils/attachment-manager";
|
|
42
43
|
import {
|
|
@@ -49,6 +50,7 @@ import { applyThemeVariables, createThemeObserver, getActiveTheme } from "./util
|
|
|
49
50
|
import { resolveTokenValue } from "./utils/tokens";
|
|
50
51
|
import { renderLucideIcon } from "./utils/icons";
|
|
51
52
|
import { createElement } from "./utils/dom";
|
|
53
|
+
import { resolveContentMaxWidth } from "./utils/content-width";
|
|
52
54
|
import { attachTooltip } from "./utils/tooltip";
|
|
53
55
|
import { downloadInfoFor } from "./utils/artifact-file";
|
|
54
56
|
import { artifactCopyText } from "./components/artifact-preview";
|
|
@@ -85,9 +87,20 @@ import {
|
|
|
85
87
|
import { syncOverlayHostStacking } from "./utils/overlay-host-stacking";
|
|
86
88
|
import { acquireScrollLock } from "./utils/scroll-lock";
|
|
87
89
|
import { isComposerBarMountMode, isDockedMountMode, resolveDockConfig } from "./utils/dock";
|
|
88
|
-
import {
|
|
90
|
+
import { LauncherSurface } from "./components/launcher";
|
|
89
91
|
import { buildHeader, buildComposer, attachHeaderToContainer } from "./components/panel";
|
|
92
|
+
import { buildPillComposer } from "./components/pill-composer-builder";
|
|
90
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";
|
|
91
104
|
import { HEADER_THEME_CSS } from "./components/header-builder";
|
|
92
105
|
import { buildHeaderWithLayout } from "./components/header-layouts";
|
|
93
106
|
import { positionMap } from "./utils/positioning";
|
|
@@ -118,7 +131,11 @@ import {
|
|
|
118
131
|
import { formatElapsedMs } from "./utils/formatting";
|
|
119
132
|
import { approvalDetailsExpansionState, createApprovalBubble, updateApprovalDetailsUI } from "./components/approval-bubble";
|
|
120
133
|
import { createBuiltInApprovalPlugin } from "./components/approval-actions";
|
|
121
|
-
import {
|
|
134
|
+
import {
|
|
135
|
+
createSuggestionElement,
|
|
136
|
+
createSuggestions,
|
|
137
|
+
normalizeSuggestion,
|
|
138
|
+
} from "./components/suggestions";
|
|
122
139
|
import { EventStreamBuffer } from "./utils/event-stream-buffer";
|
|
123
140
|
import { EventStreamStore } from "./utils/event-stream-store";
|
|
124
141
|
import { ThroughputTracker } from "./utils/throughput-tracker";
|
|
@@ -624,6 +641,14 @@ export const createAgentExperience = (
|
|
|
624
641
|
// Get plugins for this instance
|
|
625
642
|
const plugins = pluginRegistry.getForInstance(config.plugins);
|
|
626
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
|
+
|
|
627
652
|
// The built-in approval renderer, shaped as a plugin. Resolved as a FALLBACK
|
|
628
653
|
// (not pushed into `plugins`) so a user `renderApproval` plugin always wins
|
|
629
654
|
// and a later config-update plugin push can't reorder ahead of it.
|
|
@@ -896,6 +921,9 @@ export const createAgentExperience = (
|
|
|
896
921
|
sendButtonWrapper,
|
|
897
922
|
composerForm,
|
|
898
923
|
statusText,
|
|
924
|
+
welcomeHost,
|
|
925
|
+
welcomeIconHolder,
|
|
926
|
+
greetingHost,
|
|
899
927
|
introTitle,
|
|
900
928
|
introSubtitle,
|
|
901
929
|
closeButton,
|
|
@@ -951,6 +979,7 @@ export const createAgentExperience = (
|
|
|
951
979
|
const isActivityWhilePinnedEnabled = () =>
|
|
952
980
|
scrollBehaviorFeature.showActivityWhilePinned !== false;
|
|
953
981
|
const isAnnounceEnabled = () => scrollBehaviorFeature.announce === true;
|
|
982
|
+
const getScrollbarPolicy = () => scrollBehaviorFeature.scrollbar ?? "on-scroll";
|
|
954
983
|
const scrollToBottomButton = createElement(
|
|
955
984
|
"button",
|
|
956
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"
|
|
@@ -980,6 +1009,9 @@ export const createAgentExperience = (
|
|
|
980
1009
|
anchorSpacer.style.flexShrink = "0";
|
|
981
1010
|
anchorSpacer.style.pointerEvents = "none";
|
|
982
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";
|
|
983
1015
|
body.appendChild(anchorSpacer);
|
|
984
1016
|
|
|
985
1017
|
// Visually-hidden polite live region for screen-reader announcements
|
|
@@ -1213,16 +1245,25 @@ export const createAgentExperience = (
|
|
|
1213
1245
|
}
|
|
1214
1246
|
};
|
|
1215
1247
|
|
|
1216
|
-
// 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.
|
|
1217
1251
|
const composerPlugin = plugins.find(p => p.renderComposer);
|
|
1218
|
-
|
|
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;
|
|
1219
1260
|
const composerCfg = config.composer;
|
|
1220
|
-
const
|
|
1261
|
+
const streaming = sessionInitialized ? session.isStreaming() : false;
|
|
1262
|
+
return composerPlugin.renderComposer({
|
|
1221
1263
|
config,
|
|
1222
|
-
defaultRenderer
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
},
|
|
1264
|
+
defaultRenderer,
|
|
1265
|
+
requestRender: () => rebuildComposer(),
|
|
1266
|
+
storage: pluginStorageFor(composerPlugin.id),
|
|
1226
1267
|
onSubmit: (text: string) => {
|
|
1227
1268
|
if (!session || session.isStreaming()) return;
|
|
1228
1269
|
const value = text.trim();
|
|
@@ -1244,8 +1285,8 @@ export const createAgentExperience = (
|
|
|
1244
1285
|
attachmentManager!.clearAttachments();
|
|
1245
1286
|
}
|
|
1246
1287
|
},
|
|
1247
|
-
streaming
|
|
1248
|
-
disabled:
|
|
1288
|
+
streaming,
|
|
1289
|
+
disabled: streaming,
|
|
1249
1290
|
openAttachmentPicker: () => {
|
|
1250
1291
|
attachmentInput?.click();
|
|
1251
1292
|
},
|
|
@@ -1265,11 +1306,16 @@ export const createAgentExperience = (
|
|
|
1265
1306
|
}
|
|
1266
1307
|
: undefined
|
|
1267
1308
|
});
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
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;
|
|
1273
1319
|
}
|
|
1274
1320
|
|
|
1275
1321
|
const bindComposerRefsFromFooter = (rootFooter: HTMLElement) => {
|
|
@@ -1331,19 +1377,20 @@ export const createAgentExperience = (
|
|
|
1331
1377
|
ensureComposerAttachmentSurface(footer);
|
|
1332
1378
|
bindComposerRefsFromFooter(footer);
|
|
1333
1379
|
|
|
1334
|
-
//
|
|
1335
|
-
//
|
|
1336
|
-
//
|
|
1337
|
-
|
|
1338
|
-
// centers horizontally without the host having to wire it up.
|
|
1339
|
-
const contentMaxWidth =
|
|
1340
|
-
config.layout?.contentMaxWidth ??
|
|
1341
|
-
(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());
|
|
1342
1384
|
if (contentMaxWidth) {
|
|
1343
1385
|
messagesWrapper.style.maxWidth = contentMaxWidth;
|
|
1344
1386
|
messagesWrapper.style.marginLeft = "auto";
|
|
1345
1387
|
messagesWrapper.style.marginRight = "auto";
|
|
1346
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%";
|
|
1347
1394
|
starterSuggestions.style.maxWidth = contentMaxWidth;
|
|
1348
1395
|
transcriptSuggestions.style.maxWidth = contentMaxWidth;
|
|
1349
1396
|
transcriptSuggestions.style.marginLeft = "auto";
|
|
@@ -1354,41 +1401,56 @@ export const createAgentExperience = (
|
|
|
1354
1401
|
// wrapper's responsive width (50vw / 70vw / 90vw), not be capped by
|
|
1355
1402
|
// contentMaxWidth (which is a centered-column convention for the
|
|
1356
1403
|
// expanded panel's body, not the pill input itself).
|
|
1357
|
-
|
|
1358
|
-
|
|
1359
|
-
|
|
1360
|
-
|
|
1361
|
-
|
|
1362
|
-
|
|
1363
|
-
|
|
1364
|
-
|
|
1365
|
-
|
|
1366
|
-
|
|
1367
|
-
|
|
1368
|
-
|
|
1369
|
-
attachmentPreviewsContainer.style.marginLeft = "auto";
|
|
1370
|
-
attachmentPreviewsContainer.style.marginRight = "auto";
|
|
1371
|
-
}
|
|
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();
|
|
1372
1416
|
|
|
1373
|
-
|
|
1374
|
-
|
|
1375
|
-
|
|
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
|
+
}
|
|
1376
1431
|
attachmentInput.addEventListener("change", (e) => {
|
|
1377
1432
|
const target = e.target as HTMLInputElement;
|
|
1378
1433
|
attachmentManager?.handleFileSelect(target.files);
|
|
1379
1434
|
target.value = "";
|
|
1380
1435
|
});
|
|
1381
1436
|
|
|
1382
|
-
|
|
1383
|
-
|
|
1384
|
-
|
|
1385
|
-
}
|
|
1437
|
+
if (!container.querySelector(".persona-attachment-drop-overlay")) {
|
|
1438
|
+
container.appendChild(buildDropOverlay(config.attachments.dropOverlay));
|
|
1439
|
+
}
|
|
1440
|
+
};
|
|
1441
|
+
setupComposerAttachments();
|
|
1386
1442
|
|
|
1387
1443
|
// Context mentions: render the affordance button + chip row eagerly (so the
|
|
1388
1444
|
// feature is discoverable) and lazy-load the heavy runtime on first use. The
|
|
1389
1445
|
// orchestrator returns null when disabled or sourceless.
|
|
1390
1446
|
const mentionPrefetch = () => mentionOrchestrator?.prefetch();
|
|
1391
|
-
|
|
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;
|
|
1392
1454
|
// Slash-command dispatch (prompt macros write text / submit; client actions
|
|
1393
1455
|
// read/replace the value) and submission are owned by the composer input
|
|
1394
1456
|
// surface itself — the mention runtime builds a textarea (chip) or
|
|
@@ -1425,7 +1487,8 @@ export const createAgentExperience = (
|
|
|
1425
1487
|
// `@` is instant) is registered through the shared `composerListeners`
|
|
1426
1488
|
// registry below, so it survives the inline contenteditable swap too.
|
|
1427
1489
|
}
|
|
1428
|
-
}
|
|
1490
|
+
};
|
|
1491
|
+
setupMentionOrchestrator();
|
|
1429
1492
|
|
|
1430
1493
|
// Slot system: allow custom content injection into specific regions
|
|
1431
1494
|
const renderSlots = () => {
|
|
@@ -1435,8 +1498,9 @@ export const createAgentExperience = (
|
|
|
1435
1498
|
const getDefaultSlotContent = (slot: WidgetLayoutSlot): HTMLElement | null => {
|
|
1436
1499
|
switch (slot) {
|
|
1437
1500
|
case "body-top":
|
|
1438
|
-
//
|
|
1439
|
-
|
|
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;
|
|
1440
1504
|
case "messages":
|
|
1441
1505
|
return messagesWrapper;
|
|
1442
1506
|
case "footer-top":
|
|
@@ -1473,7 +1537,7 @@ export const createAgentExperience = (
|
|
|
1473
1537
|
break;
|
|
1474
1538
|
case "body-top": {
|
|
1475
1539
|
// Replace or prepend to body
|
|
1476
|
-
const introCard = body.querySelector("
|
|
1540
|
+
const introCard = body.querySelector("[data-persona-intro-card]");
|
|
1477
1541
|
if (introCard) {
|
|
1478
1542
|
introCard.replaceWith(element);
|
|
1479
1543
|
} else {
|
|
@@ -3347,19 +3411,52 @@ export const createAgentExperience = (
|
|
|
3347
3411
|
|
|
3348
3412
|
restoreBodyScrollTop();
|
|
3349
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
|
+
};
|
|
3350
3444
|
applyFullHeightStyles();
|
|
3351
3445
|
// Apply theme variables after applyFullHeightStyles since it resets mount.style.cssText
|
|
3352
3446
|
applyThemeVariables(mount, config);
|
|
3353
3447
|
applyArtifactLayoutCssVars(mount, config);
|
|
3354
3448
|
applyArtifactPaneAppearance(mount, config);
|
|
3449
|
+
applyContentMaxWidthVar();
|
|
3355
3450
|
|
|
3356
3451
|
// applyFullHeightStyles wipes mount.style.cssText, so re-apply the theme +
|
|
3357
3452
|
// artifact layout vars after it, mirroring the init sequence above.
|
|
3358
3453
|
syncPanelChrome = () => {
|
|
3359
3454
|
applyFullHeightStyles();
|
|
3455
|
+
applyFloatingPanelFixedHeight();
|
|
3360
3456
|
applyThemeVariables(mount, config);
|
|
3361
3457
|
applyArtifactLayoutCssVars(mount, config);
|
|
3362
3458
|
applyArtifactPaneAppearance(mount, config);
|
|
3459
|
+
applyContentMaxWidthVar();
|
|
3363
3460
|
// Owned here so it lands after applyArtifactPaneAppearance and derives from
|
|
3364
3461
|
// the same resolved panel radius the chat card uses (see applyFullHeightStyles).
|
|
3365
3462
|
if (weldedOuterRadius) {
|
|
@@ -3469,7 +3566,8 @@ export const createAgentExperience = (
|
|
|
3469
3566
|
}
|
|
3470
3567
|
}
|
|
3471
3568
|
|
|
3472
|
-
|
|
3569
|
+
// Reassignable: a composer rebuild swaps the row this manager renders into.
|
|
3570
|
+
let composerSuggestionsManager = createSuggestions(suggestions);
|
|
3473
3571
|
const starterSuggestionsManager = createSuggestions(starterSuggestions);
|
|
3474
3572
|
const transcriptSuggestionsManager = createSuggestions(transcriptSuggestions);
|
|
3475
3573
|
const suggestionManagers = [
|
|
@@ -3524,7 +3622,7 @@ export const createAgentExperience = (
|
|
|
3524
3622
|
if (warnedPinnedStarterPlacement || config.debug !== true) return;
|
|
3525
3623
|
warnedPinnedStarterPlacement = true;
|
|
3526
3624
|
console.warn(
|
|
3527
|
-
'[persona] suggestions.starters.placement is pinned to "welcome" but copy.showWelcomeCard
|
|
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".',
|
|
3528
3626
|
);
|
|
3529
3627
|
};
|
|
3530
3628
|
// Debug-only setup hint: a stream this session ended without the agent ever
|
|
@@ -3608,7 +3706,10 @@ export const createAgentExperience = (
|
|
|
3608
3706
|
surface: "followUp",
|
|
3609
3707
|
variant: followUps?.variant ?? "chip",
|
|
3610
3708
|
behavior: followUps?.behavior ?? "send",
|
|
3611
|
-
|
|
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",
|
|
3612
3713
|
maxItems: followUps?.maxItems ?? 4,
|
|
3613
3714
|
config,
|
|
3614
3715
|
plugins,
|
|
@@ -3624,9 +3725,12 @@ export const createAgentExperience = (
|
|
|
3624
3725
|
|
|
3625
3726
|
const starters = config.suggestions?.starters;
|
|
3626
3727
|
if (starters) {
|
|
3627
|
-
// The starter host lives inside the welcome
|
|
3628
|
-
// placement has nowhere to render when that
|
|
3629
|
-
const welcomeCardVisible =
|
|
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
|
+
);
|
|
3630
3734
|
const requestedPlacement = starters.placement ?? "auto";
|
|
3631
3735
|
const placement =
|
|
3632
3736
|
requestedPlacement === "auto"
|
|
@@ -3776,6 +3880,7 @@ export const createAgentExperience = (
|
|
|
3776
3880
|
let readerEngagedThisTurn = false;
|
|
3777
3881
|
const markReaderEngaged = () => {
|
|
3778
3882
|
readerEngagedThisTurn = true;
|
|
3883
|
+
revealTranscriptScrollbar();
|
|
3779
3884
|
};
|
|
3780
3885
|
// Dedupes assistant-turn detection across token-by-token re-renders.
|
|
3781
3886
|
let lastHandledAssistantId: string | null = null;
|
|
@@ -3787,6 +3892,40 @@ export const createAgentExperience = (
|
|
|
3787
3892
|
const USER_SCROLL_THRESHOLD = 4;
|
|
3788
3893
|
const BOTTOM_THRESHOLD = 24;
|
|
3789
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
|
+
});
|
|
3790
3929
|
const messageState = new Map<
|
|
3791
3930
|
string,
|
|
3792
3931
|
{ streaming?: boolean; role: AgentWidgetMessage["role"] }
|
|
@@ -3976,14 +4115,33 @@ export const createAgentExperience = (
|
|
|
3976
4115
|
updateScrollToBottomCountBadge();
|
|
3977
4116
|
};
|
|
3978
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
|
+
|
|
3979
4133
|
// Whether the user is currently away from the latest content: drives both
|
|
3980
4134
|
// the scroll-to-bottom affordance and the new-messages badge. When following
|
|
3981
4135
|
// the bottom (follow mode, or a no-anchor anchor-top fallback turn) that's
|
|
3982
4136
|
// "auto-follow paused"; otherwise it's simply "not near the bottom".
|
|
3983
4137
|
const isAwayFromLatest = () =>
|
|
3984
|
-
isFollowEffective()
|
|
3985
|
-
|
|
3986
|
-
|
|
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;
|
|
3987
4145
|
|
|
3988
4146
|
const syncScrollToBottomButton = () => {
|
|
3989
4147
|
if (!isScrollToBottomEnabled() || eventStreamVisible) {
|
|
@@ -3998,7 +4156,7 @@ export const createAgentExperience = (
|
|
|
3998
4156
|
}
|
|
3999
4157
|
updateScrollToBottomButtonOffset();
|
|
4000
4158
|
const hasOverflow = getScrollBottomOffset(body) > 0;
|
|
4001
|
-
const show = hasOverflow && isAwayFromLatest();
|
|
4159
|
+
const show = hasOverflow && isAwayFromLatest() && transcriptHasMessages();
|
|
4002
4160
|
if (!show) {
|
|
4003
4161
|
resetNewMessagesCount();
|
|
4004
4162
|
} else {
|
|
@@ -4025,6 +4183,7 @@ export const createAgentExperience = (
|
|
|
4025
4183
|
// no-anchor fallback turn (see `isFollowEffective`). Anchored anchor-top
|
|
4026
4184
|
// turns and "none" never chase the bottom during streaming.
|
|
4027
4185
|
if (!isFollowEffective()) return;
|
|
4186
|
+
if (!transcriptHasMessages()) return;
|
|
4028
4187
|
|
|
4029
4188
|
if (!autoFollow.isFollowing()) return;
|
|
4030
4189
|
|
|
@@ -4159,6 +4318,10 @@ export const createAgentExperience = (
|
|
|
4159
4318
|
// Instant jump used for initial mount / panel open in non-follow scroll
|
|
4160
4319
|
// modes (where scheduleAutoScroll is inert).
|
|
4161
4320
|
const jumpToBottomInstant = () => {
|
|
4321
|
+
if (!transcriptHasMessages()) {
|
|
4322
|
+
syncScrollToBottomButton();
|
|
4323
|
+
return;
|
|
4324
|
+
}
|
|
4162
4325
|
const element = getScrollableContainer();
|
|
4163
4326
|
isAutoScrolling = true;
|
|
4164
4327
|
element.scrollTop = getScrollBottomOffset(element);
|
|
@@ -4241,12 +4404,55 @@ export const createAgentExperience = (
|
|
|
4241
4404
|
};
|
|
4242
4405
|
|
|
4243
4406
|
const setAnchorSpacerHeight = (height: number) => {
|
|
4244
|
-
|
|
4407
|
+
const rounded = Math.max(0, Math.round(height));
|
|
4408
|
+
anchorSpacer.style.height = `${rounded}px`;
|
|
4409
|
+
anchorSpacer.style.display = rounded > 0 ? "" : "none";
|
|
4245
4410
|
if (anchorState) {
|
|
4246
4411
|
anchorState.spacerHeight = Math.max(0, height);
|
|
4247
4412
|
}
|
|
4248
4413
|
};
|
|
4249
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
|
+
|
|
4250
4456
|
const resetAnchorState = () => {
|
|
4251
4457
|
if (anchorRAF !== null) {
|
|
4252
4458
|
cancelAnimationFrame(anchorRAF);
|
|
@@ -4258,8 +4464,19 @@ export const createAgentExperience = (
|
|
|
4258
4464
|
cancelSmoothScroll();
|
|
4259
4465
|
anchorState = null;
|
|
4260
4466
|
anchorSpacer.style.height = "0px";
|
|
4467
|
+
anchorSpacer.style.display = "none";
|
|
4261
4468
|
};
|
|
4262
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
|
+
|
|
4263
4480
|
// Anchor-top mode: scroll `messageId` to rest `anchorTopOffset` px below the
|
|
4264
4481
|
// viewport top and hold it there while the response streams in beneath it.
|
|
4265
4482
|
// Deferred one frame so the message bubble has been rendered and laid out.
|
|
@@ -4269,10 +4486,7 @@ export const createAgentExperience = (
|
|
|
4269
4486
|
}
|
|
4270
4487
|
anchorRAF = requestAnimationFrame(() => {
|
|
4271
4488
|
anchorRAF = null;
|
|
4272
|
-
const escapedId =
|
|
4273
|
-
typeof CSS !== "undefined" && typeof CSS.escape === "function"
|
|
4274
|
-
? CSS.escape(messageId)
|
|
4275
|
-
: messageId.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
|
|
4489
|
+
const escapedId = escapeMessageId(messageId);
|
|
4276
4490
|
const bubble = body.querySelector<HTMLElement>(
|
|
4277
4491
|
`[data-message-id="${escapedId}"]`
|
|
4278
4492
|
);
|
|
@@ -4284,12 +4498,17 @@ export const createAgentExperience = (
|
|
|
4284
4498
|
const anchorOffsetTop = offsetTopWithinBody(bubble);
|
|
4285
4499
|
|
|
4286
4500
|
const previousSpacerHeight = anchorState?.spacerHeight ?? 0;
|
|
4287
|
-
const contentHeight =
|
|
4501
|
+
const contentHeight = measureContentHeight(previousSpacerHeight);
|
|
4288
4502
|
const { targetScrollTop, spacerHeight } = computeAnchorScrollState({
|
|
4289
4503
|
anchorOffsetTop,
|
|
4290
4504
|
topOffset: getAnchorTopOffset(),
|
|
4291
4505
|
viewportHeight: body.clientHeight,
|
|
4292
|
-
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
|
|
4293
4512
|
});
|
|
4294
4513
|
|
|
4295
4514
|
anchorState = {
|
|
@@ -4298,10 +4517,56 @@ export const createAgentExperience = (
|
|
|
4298
4517
|
spacerHeight
|
|
4299
4518
|
};
|
|
4300
4519
|
setAnchorSpacerHeight(spacerHeight);
|
|
4301
|
-
|
|
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
|
+
);
|
|
4302
4533
|
});
|
|
4303
4534
|
};
|
|
4304
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
|
+
|
|
4305
4570
|
// Content growth handler (ResizeObserver-driven). In follow mode this is
|
|
4306
4571
|
// what keeps the transcript pinned when content grows *without* a render
|
|
4307
4572
|
// event: images/embeds finishing loading mid-stream, fonts swapping,
|
|
@@ -4388,6 +4653,33 @@ export const createAgentExperience = (
|
|
|
4388
4653
|
// Anchoring to the first unread (not the newest) block means this fires at
|
|
4389
4654
|
// most once per turn in practice, and later blocks stream in below the
|
|
4390
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
|
+
|
|
4391
4683
|
const maybeAnchorToFirstUnread = (messages: AgentWidgetMessage[]) => {
|
|
4392
4684
|
if (getScrollMode() !== "anchor-top") return;
|
|
4393
4685
|
if (!currentTurnAnchored) return;
|
|
@@ -4403,6 +4695,10 @@ export const createAgentExperience = (
|
|
|
4403
4695
|
if (!isAttentionWorthyMessage(message)) continue;
|
|
4404
4696
|
// Already parked on it: hold still while it streams.
|
|
4405
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;
|
|
4406
4702
|
anchoredMessageId = message.id;
|
|
4407
4703
|
scheduleAnchorToMessage(message.id);
|
|
4408
4704
|
return;
|
|
@@ -5935,8 +6231,9 @@ export const createAgentExperience = (
|
|
|
5935
6231
|
|
|
5936
6232
|
// Re-run chrome application now that data-state has flipped: collapsed
|
|
5937
6233
|
// clears container chrome (pill stands alone), expanded paints it via
|
|
5938
|
-
// the same theme.components.panel.* contract as floating mode.
|
|
5939
|
-
applyFullHeightStyles
|
|
6234
|
+
// the same theme.components.panel.* contract as floating mode. Full
|
|
6235
|
+
// sync: applyFullHeightStyles alone wipes the theme vars with cssText.
|
|
6236
|
+
syncPanelChrome();
|
|
5940
6237
|
|
|
5941
6238
|
// Outside-click dismiss: while expanded, clicking anywhere outside the
|
|
5942
6239
|
// wrapper (panel chrome + pill) collapses back to just the pill.
|
|
@@ -5972,8 +6269,9 @@ export const createAgentExperience = (
|
|
|
5972
6269
|
panel.classList.remove("persona-scale-95", "persona-opacity-0");
|
|
5973
6270
|
panel.classList.add("persona-scale-100", "persona-opacity-100");
|
|
5974
6271
|
// Hide launcher button when widget is open
|
|
5975
|
-
if (
|
|
5976
|
-
|
|
6272
|
+
if (launcherSurfaceInstance) {
|
|
6273
|
+
launcherSurfaceInstance.setPanelOpen(true);
|
|
6274
|
+
launcherSurfaceInstance.element.style.display = "none";
|
|
5977
6275
|
} else if (customLauncherElement) {
|
|
5978
6276
|
customLauncherElement.style.display = "none";
|
|
5979
6277
|
}
|
|
@@ -5998,8 +6296,9 @@ export const createAgentExperience = (
|
|
|
5998
6296
|
panel.classList.add("persona-scale-95", "persona-opacity-0");
|
|
5999
6297
|
}
|
|
6000
6298
|
// Show launcher when closed, except docked mode (0px column: use controller.open()).
|
|
6001
|
-
if (
|
|
6002
|
-
|
|
6299
|
+
if (launcherSurfaceInstance) {
|
|
6300
|
+
launcherSurfaceInstance.setPanelOpen(false);
|
|
6301
|
+
launcherSurfaceInstance.element.style.display = dockedMode ? "none" : "";
|
|
6003
6302
|
} else if (customLauncherElement) {
|
|
6004
6303
|
customLauncherElement.style.display = dockedMode ? "none" : "";
|
|
6005
6304
|
}
|
|
@@ -6110,6 +6409,19 @@ export const createAgentExperience = (
|
|
|
6110
6409
|
button.disabled = disabled;
|
|
6111
6410
|
});
|
|
6112
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
|
+
});
|
|
6424
|
+
});
|
|
6113
6425
|
footer.dataset.personaComposerStreaming = disabled ? "true" : "false";
|
|
6114
6426
|
footer.querySelectorAll<HTMLElement>("[data-persona-composer-disable-when-streaming]").forEach((el) => {
|
|
6115
6427
|
if (
|
|
@@ -6133,32 +6445,229 @@ export const createAgentExperience = (
|
|
|
6133
6445
|
if (config.autoFocusInput) setTimeout(() => maybeFocusInput(), 200);
|
|
6134
6446
|
});
|
|
6135
6447
|
|
|
6136
|
-
|
|
6137
|
-
|
|
6138
|
-
|
|
6139
|
-
|
|
6140
|
-
|
|
6141
|
-
|
|
6142
|
-
|
|
6143
|
-
|
|
6144
|
-
|
|
6145
|
-
|
|
6146
|
-
|
|
6147
|
-
|
|
6148
|
-
|
|
6149
|
-
|
|
6150
|
-
|
|
6151
|
-
|
|
6152
|
-
|
|
6153
|
-
|
|
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);
|
|
6154
6553
|
}
|
|
6155
6554
|
}
|
|
6555
|
+
};
|
|
6556
|
+
destroyCallbacks.push(runWelcomeCleanups);
|
|
6156
6557
|
|
|
6157
|
-
|
|
6158
|
-
|
|
6159
|
-
|
|
6160
|
-
|
|
6161
|
-
|
|
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);
|
|
6637
|
+
}
|
|
6638
|
+
} finally {
|
|
6639
|
+
welcomeArbitrating = false;
|
|
6640
|
+
}
|
|
6641
|
+
updateWelcome();
|
|
6642
|
+
};
|
|
6643
|
+
|
|
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
|
+
}
|
|
6162
6671
|
}
|
|
6163
6672
|
|
|
6164
6673
|
textarea.style.fontFamily =
|
|
@@ -6299,6 +6808,7 @@ export const createAgentExperience = (
|
|
|
6299
6808
|
suppressTransition: isStreaming,
|
|
6300
6809
|
});
|
|
6301
6810
|
ensureToolElapsedTimer();
|
|
6811
|
+
updateWelcome(messages);
|
|
6302
6812
|
renderSuggestions(messages);
|
|
6303
6813
|
scheduleAutoScroll(!isStreaming);
|
|
6304
6814
|
trackMessages(messages);
|
|
@@ -6419,6 +6929,7 @@ export const createAgentExperience = (
|
|
|
6419
6929
|
}
|
|
6420
6930
|
if (!streaming) {
|
|
6421
6931
|
scheduleAutoScroll(true);
|
|
6932
|
+
trimAnchorSpacerToRest();
|
|
6422
6933
|
// setStreaming only fires on a change, so this is always a real stream
|
|
6423
6934
|
// ending. Evaluate the never-called hint here: no later render is
|
|
6424
6935
|
// guaranteed on a settled transcript.
|
|
@@ -6546,6 +7057,8 @@ export const createAgentExperience = (
|
|
|
6546
7057
|
if (state === "idle") lastReadAloudId = null;
|
|
6547
7058
|
});
|
|
6548
7059
|
|
|
7060
|
+
sessionInitialized = true;
|
|
7061
|
+
|
|
6549
7062
|
// The constructor only emits onMessagesChanged when it has initial
|
|
6550
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.
|
|
6551
7064
|
scrollSendSeeded = true;
|
|
@@ -6629,7 +7142,10 @@ export const createAgentExperience = (
|
|
|
6629
7142
|
console.error("[AgentWidget] Failed to hydrate stored state:", error);
|
|
6630
7143
|
}
|
|
6631
7144
|
})
|
|
6632
|
-
.finally(() =>
|
|
7145
|
+
.finally(() => {
|
|
7146
|
+
welcomeHydrated = true;
|
|
7147
|
+
maybeBootResume();
|
|
7148
|
+
});
|
|
6633
7149
|
} else {
|
|
6634
7150
|
maybeBootResume();
|
|
6635
7151
|
}
|
|
@@ -6649,7 +7165,7 @@ export const createAgentExperience = (
|
|
|
6649
7165
|
// Combine `@`-chip mentions with an inline server-command's context bundle so
|
|
6650
7166
|
// both reach the message in one `mentions` payload. Either side may be null.
|
|
6651
7167
|
type SubmitMentions = NonNullable<
|
|
6652
|
-
ReturnType<
|
|
7168
|
+
ReturnType<ContextMentionOrchestrator["collectForSubmit"]>
|
|
6653
7169
|
>;
|
|
6654
7170
|
const mergeSubmitMentions = (
|
|
6655
7171
|
a: SubmitMentions | null,
|
|
@@ -7489,27 +8005,28 @@ export const createAgentExperience = (
|
|
|
7489
8005
|
};
|
|
7490
8006
|
|
|
7491
8007
|
// Plugin hook: renderLauncher - allow plugins to provide custom launcher
|
|
7492
|
-
let
|
|
8008
|
+
let launcherSurfaceInstance: LauncherSurface | null = null;
|
|
7493
8009
|
let customLauncherElement: HTMLElement | null = null;
|
|
7494
8010
|
|
|
7495
8011
|
// Composer-bar mode is launcher-less by design: the persistent pill IS the
|
|
7496
8012
|
// entry point, so skip creating any launcher button (default or plugin).
|
|
7497
8013
|
if (launcherEnabled && !isComposerBar()) {
|
|
7498
8014
|
const { instance, element } = resolveLauncher({ config, plugins, onToggle: toggleOpen });
|
|
7499
|
-
|
|
8015
|
+
launcherSurfaceInstance = instance;
|
|
7500
8016
|
// A plugin-provided launcher returns no controller instance; track its
|
|
7501
8017
|
// element separately so the update path can manage it.
|
|
7502
8018
|
if (!instance) customLauncherElement = element;
|
|
7503
8019
|
}
|
|
7504
8020
|
|
|
7505
|
-
if (
|
|
7506
|
-
mount.appendChild(
|
|
8021
|
+
if (launcherSurfaceInstance) {
|
|
8022
|
+
mount.appendChild(launcherSurfaceInstance.element);
|
|
7507
8023
|
} else if (customLauncherElement) {
|
|
7508
8024
|
mount.appendChild(customLauncherElement);
|
|
7509
8025
|
}
|
|
7510
8026
|
updateOpenState();
|
|
7511
8027
|
renderSuggestions();
|
|
7512
8028
|
updateCopy();
|
|
8029
|
+
renderWelcomeSurface();
|
|
7513
8030
|
setComposerDisabled(session.isStreaming());
|
|
7514
8031
|
// Reopen-where-left-off takes precedence when opted in (Principle 11);
|
|
7515
8032
|
// otherwise fall back to the historical per-mode positioning.
|
|
@@ -7545,7 +8062,6 @@ export const createAgentExperience = (
|
|
|
7545
8062
|
|
|
7546
8063
|
const dockedMode = isDockedMountMode(config);
|
|
7547
8064
|
const sidebarMode = config.launcher?.sidebarMode ?? false;
|
|
7548
|
-
const fullHeight = dockedMode || sidebarMode || (config.launcher?.fullHeight ?? false);
|
|
7549
8065
|
|
|
7550
8066
|
// Mobile fullscreen: re-apply fullscreen styles on resize (handles orientation changes)
|
|
7551
8067
|
const ownerWindow = mount.ownerDocument.defaultView ?? window;
|
|
@@ -7602,15 +8118,7 @@ export const createAgentExperience = (
|
|
|
7602
8118
|
applyLauncherArtifactPanelWidth();
|
|
7603
8119
|
|
|
7604
8120
|
// In fullHeight mode, don't set a fixed height
|
|
7605
|
-
|
|
7606
|
-
const viewportHeight = ownerWindow.innerHeight;
|
|
7607
|
-
const verticalMargin = 64; // leave space for launcher's offset
|
|
7608
|
-
const heightOffset = config.launcher?.heightOffset ?? 0;
|
|
7609
|
-
const available = Math.max(200, viewportHeight - verticalMargin);
|
|
7610
|
-
const clamped = Math.min(640, available);
|
|
7611
|
-
const finalHeight = Math.max(200, clamped - heightOffset);
|
|
7612
|
-
panel.style.height = `${finalHeight}px`;
|
|
7613
|
-
}
|
|
8121
|
+
applyFloatingPanelFixedHeight();
|
|
7614
8122
|
} finally {
|
|
7615
8123
|
// applyFullHeightStyles() assigns wrapper.style.cssText (e.g. display:flex !important), which
|
|
7616
8124
|
// overwrites updateOpenState()'s display:none when docked+closed. Re-sync after every recalc.
|
|
@@ -7958,11 +8466,91 @@ export const createAgentExperience = (
|
|
|
7958
8466
|
// lazily). When that happens, move the composer listeners onto the new element
|
|
7959
8467
|
// and repoint `textarea` (the swapped element shims the textarea API the rest of
|
|
7960
8468
|
// the composer code relies on). Fires immediately if the swap already occurred.
|
|
7961
|
-
|
|
7962
|
-
|
|
7963
|
-
|
|
7964
|
-
|
|
7965
|
-
|
|
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
|
+
}
|
|
7966
8554
|
|
|
7967
8555
|
const escStopDoc = mount.ownerDocument ?? document;
|
|
7968
8556
|
escStopDoc.addEventListener("keydown", handleEscStop, true);
|
|
@@ -8062,9 +8650,9 @@ export const createAgentExperience = (
|
|
|
8062
8650
|
session.cancel();
|
|
8063
8651
|
});
|
|
8064
8652
|
|
|
8065
|
-
if (
|
|
8653
|
+
if (launcherSurfaceInstance) {
|
|
8066
8654
|
destroyCallbacks.push(() => {
|
|
8067
|
-
|
|
8655
|
+
launcherSurfaceInstance?.destroy();
|
|
8068
8656
|
});
|
|
8069
8657
|
} else if (customLauncherElement) {
|
|
8070
8658
|
destroyCallbacks.push(() => {
|
|
@@ -8122,6 +8710,7 @@ export const createAgentExperience = (
|
|
|
8122
8710
|
resetAnchorState();
|
|
8123
8711
|
resumeAutoScroll();
|
|
8124
8712
|
}
|
|
8713
|
+
applyScrollbarPolicy();
|
|
8125
8714
|
renderScrollToBottomButton();
|
|
8126
8715
|
syncScrollToBottomButton();
|
|
8127
8716
|
const prevShowEventStreamToggle = showEventStreamToggle;
|
|
@@ -8191,25 +8780,25 @@ export const createAgentExperience = (
|
|
|
8191
8780
|
throughputTracker = null;
|
|
8192
8781
|
}
|
|
8193
8782
|
|
|
8194
|
-
if (config.launcher?.enabled === false &&
|
|
8195
|
-
|
|
8196
|
-
|
|
8783
|
+
if (config.launcher?.enabled === false && launcherSurfaceInstance) {
|
|
8784
|
+
launcherSurfaceInstance.destroy();
|
|
8785
|
+
launcherSurfaceInstance = null;
|
|
8197
8786
|
}
|
|
8198
8787
|
if (config.launcher?.enabled === false && customLauncherElement) {
|
|
8199
8788
|
customLauncherElement.remove();
|
|
8200
8789
|
customLauncherElement = null;
|
|
8201
8790
|
}
|
|
8202
8791
|
|
|
8203
|
-
if (config.launcher?.enabled !== false && !
|
|
8792
|
+
if (config.launcher?.enabled !== false && !launcherSurfaceInstance && !customLauncherElement) {
|
|
8204
8793
|
// Resolve the launcher again when re-enabling (honors renderLauncher plugin).
|
|
8205
8794
|
const { instance, element } = resolveLauncher({ config, plugins, onToggle: toggleOpen });
|
|
8206
|
-
|
|
8795
|
+
launcherSurfaceInstance = instance;
|
|
8207
8796
|
if (!instance) customLauncherElement = element;
|
|
8208
8797
|
mount.appendChild(element);
|
|
8209
8798
|
}
|
|
8210
8799
|
|
|
8211
|
-
if (
|
|
8212
|
-
|
|
8800
|
+
if (launcherSurfaceInstance) {
|
|
8801
|
+
launcherSurfaceInstance.update(config);
|
|
8213
8802
|
}
|
|
8214
8803
|
// Note: Custom launcher updates are handled by the plugin's own logic
|
|
8215
8804
|
|
|
@@ -8787,8 +9376,11 @@ export const createAgentExperience = (
|
|
|
8787
9376
|
);
|
|
8788
9377
|
renderSuggestions();
|
|
8789
9378
|
updateCopy();
|
|
9379
|
+
// Live `welcome` updates reach `renderWelcome` plugins; unrelated
|
|
9380
|
+
// update() calls leave plugin-owned content (and its state) alone.
|
|
9381
|
+
refreshWelcomePlugins();
|
|
8790
9382
|
setComposerDisabled(session.isStreaming());
|
|
8791
|
-
|
|
9383
|
+
|
|
8792
9384
|
// Update voice recognition mic button visibility
|
|
8793
9385
|
const voiceRecognitionEnabled = config.voiceRecognition?.enabled === true;
|
|
8794
9386
|
const hasSpeechRecognition =
|
|
@@ -9213,16 +9805,20 @@ export const createAgentExperience = (
|
|
|
9213
9805
|
|
|
9214
9806
|
// Update contentMaxWidth on messages wrapper and composer. Same
|
|
9215
9807
|
// composer-bar fallback as the initial read above.
|
|
9216
|
-
const updatedContentMaxWidth =
|
|
9217
|
-
config
|
|
9218
|
-
|
|
9219
|
-
|
|
9220
|
-
|
|
9808
|
+
const updatedContentMaxWidth = resolveContentMaxWidth(
|
|
9809
|
+
config,
|
|
9810
|
+
isComposerBar()
|
|
9811
|
+
);
|
|
9812
|
+
applyContentMaxWidthVar();
|
|
9221
9813
|
if (updatedContentMaxWidth) {
|
|
9222
9814
|
messagesWrapper.style.maxWidth = updatedContentMaxWidth;
|
|
9223
9815
|
messagesWrapper.style.marginLeft = "auto";
|
|
9224
9816
|
messagesWrapper.style.marginRight = "auto";
|
|
9225
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%";
|
|
9226
9822
|
if (composerForm) {
|
|
9227
9823
|
composerForm.style.maxWidth = updatedContentMaxWidth;
|
|
9228
9824
|
composerForm.style.marginLeft = "auto";
|
|
@@ -9233,11 +9829,18 @@ export const createAgentExperience = (
|
|
|
9233
9829
|
suggestions.style.marginLeft = "auto";
|
|
9234
9830
|
suggestions.style.marginRight = "auto";
|
|
9235
9831
|
}
|
|
9832
|
+
statusText.style.maxWidth = updatedContentMaxWidth;
|
|
9833
|
+
statusText.style.marginLeft = "auto";
|
|
9834
|
+
statusText.style.marginRight = "auto";
|
|
9236
9835
|
} else {
|
|
9237
9836
|
messagesWrapper.style.maxWidth = "";
|
|
9238
9837
|
messagesWrapper.style.marginLeft = "";
|
|
9239
9838
|
messagesWrapper.style.marginRight = "";
|
|
9240
9839
|
messagesWrapper.style.width = "";
|
|
9840
|
+
greetingHost.style.maxWidth = "";
|
|
9841
|
+
greetingHost.style.marginLeft = "";
|
|
9842
|
+
greetingHost.style.marginRight = "";
|
|
9843
|
+
greetingHost.style.width = "";
|
|
9241
9844
|
if (composerForm) {
|
|
9242
9845
|
composerForm.style.maxWidth = "";
|
|
9243
9846
|
composerForm.style.marginLeft = "";
|
|
@@ -9248,6 +9851,9 @@ export const createAgentExperience = (
|
|
|
9248
9851
|
suggestions.style.marginLeft = "";
|
|
9249
9852
|
suggestions.style.marginRight = "";
|
|
9250
9853
|
}
|
|
9854
|
+
statusText.style.maxWidth = "";
|
|
9855
|
+
statusText.style.marginLeft = "";
|
|
9856
|
+
statusText.style.marginRight = "";
|
|
9251
9857
|
}
|
|
9252
9858
|
|
|
9253
9859
|
// Update status indicator visibility and text
|
|
@@ -9736,7 +10342,7 @@ export const createAgentExperience = (
|
|
|
9736
10342
|
destroyCallbacks.forEach((cb) => cb());
|
|
9737
10343
|
wrapper.remove();
|
|
9738
10344
|
pillRoot?.remove();
|
|
9739
|
-
|
|
10345
|
+
launcherSurfaceInstance?.destroy();
|
|
9740
10346
|
customLauncherElement?.remove();
|
|
9741
10347
|
if (closeHandler) {
|
|
9742
10348
|
closeButton.removeEventListener("click", closeHandler);
|
|
@@ -9872,6 +10478,9 @@ export const createAgentExperience = (
|
|
|
9872
10478
|
const wasInVoiceMode = persistConfig.persist?.voiceState && storage.getItem(voiceModeKey) === 'true';
|
|
9873
10479
|
|
|
9874
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");
|
|
9875
10484
|
// Use setTimeout to ensure DOM is ready
|
|
9876
10485
|
setTimeout(() => {
|
|
9877
10486
|
controller.open();
|
|
@@ -9938,6 +10547,8 @@ export const createAgentExperience = (
|
|
|
9938
10547
|
// Mirrors the same setTimeout(0) pattern used by persistState restore so both
|
|
9939
10548
|
// can fire independently without interfering with each other.
|
|
9940
10549
|
if (shouldOpenAfterStateLoaded && isPanelToggleable()) {
|
|
10550
|
+
// Same one-task-early suppression as the persistState restore above.
|
|
10551
|
+
launcherSurfaceInstance?.setPanelOpen(true, "auto");
|
|
9941
10552
|
setTimeout(() => { controller.open(); }, 0);
|
|
9942
10553
|
}
|
|
9943
10554
|
|