@runtypelabs/persona 3.34.1 → 3.36.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 +11 -11
- package/dist/animations/glyph-cycle.d.cts +1 -1
- package/dist/animations/glyph-cycle.d.ts +1 -1
- package/dist/animations/{types-CthJFfNx.d.cts → types-DgYsuwXL.d.cts} +15 -14
- package/dist/animations/{types-CthJFfNx.d.ts → types-DgYsuwXL.d.ts} +15 -14
- package/dist/animations/wipe.d.cts +1 -1
- package/dist/animations/wipe.d.ts +1 -1
- package/dist/codegen.cjs +11 -11
- package/dist/codegen.js +10 -10
- package/dist/index.cjs +46 -44
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +514 -158
- package/dist/index.d.ts +514 -158
- package/dist/index.global.js +41 -89
- package/dist/index.global.js.map +1 -1
- package/dist/index.js +46 -44
- package/dist/index.js.map +1 -1
- package/dist/install.global.js +1 -1
- package/dist/install.global.js.map +1 -1
- package/dist/launcher.global.js +2 -2
- package/dist/launcher.global.js.map +1 -1
- package/dist/markdown-parsers.js +53 -0
- package/dist/plugin-kit.d.cts +4 -4
- package/dist/plugin-kit.d.ts +4 -4
- package/dist/runtype-tts.js +1 -0
- package/dist/smart-dom-reader.cjs +1 -1
- package/dist/smart-dom-reader.d.cts +352 -103
- package/dist/smart-dom-reader.d.ts +352 -103
- package/dist/smart-dom-reader.js +1 -1
- package/dist/theme-editor-preview.cjs +203 -0
- package/dist/theme-editor-preview.d.cts +5748 -0
- package/dist/theme-editor-preview.d.ts +5748 -0
- package/dist/theme-editor-preview.js +203 -0
- package/dist/theme-editor.cjs +14 -131
- package/dist/theme-editor.d.cts +362 -574
- package/dist/theme-editor.d.ts +362 -574
- package/dist/theme-editor.js +14 -131
- package/dist/theme-reference.cjs +1 -1
- package/dist/theme-reference.d.cts +1 -1
- package/dist/theme-reference.d.ts +1 -1
- package/dist/theme-reference.js +1 -1
- package/dist/voice-worklet-player.cjs +80 -0
- package/dist/voice-worklet-player.d.cts +215 -0
- package/dist/voice-worklet-player.d.ts +215 -0
- package/dist/voice-worklet-player.js +80 -0
- package/dist/widget.css +54 -36
- package/package.json +16 -2
- package/src/animations/glyph-cycle.ts +11 -14
- package/src/animations/wipe.ts +1 -1
- package/src/ask-user-question-tool.test.ts +1 -1
- package/src/ask-user-question-tool.ts +6 -8
- package/src/client.test.ts +56 -12
- package/src/client.ts +35 -31
- package/src/codegen.test.ts +1 -1
- package/src/codegen.ts +1 -1
- package/src/components/approval-bubble.test.ts +1 -1
- package/src/components/approval-bubble.ts +3 -3
- package/src/components/artifact-pane.ts +3 -3
- package/src/components/ask-user-question-bubble.test.ts +14 -14
- package/src/components/ask-user-question-bubble.ts +10 -10
- package/src/components/composer-builder.ts +17 -16
- package/src/components/composer-parts.test.ts +54 -0
- package/src/components/composer-parts.ts +95 -126
- package/src/components/demo-carousel.ts +1 -1
- package/src/components/event-stream-view.test.ts +7 -7
- package/src/components/event-stream-view.ts +5 -5
- package/src/components/header-builder.ts +33 -27
- package/src/components/header-layouts.ts +1 -1
- package/src/components/header-parts.test.ts +90 -0
- package/src/components/header-parts.ts +62 -93
- package/src/components/launcher.test.ts +11 -0
- package/src/components/launcher.ts +1 -1
- package/src/components/message-bubble.test.ts +74 -2
- package/src/components/message-bubble.ts +25 -11
- package/src/components/messages.ts +2 -2
- package/src/components/panel.test.ts +1 -1
- package/src/components/panel.ts +92 -97
- package/src/components/pill-composer-builder.ts +28 -24
- package/src/components/reasoning-bubble.ts +2 -2
- package/src/components/suggestions.ts +1 -1
- package/src/components/tool-bubble.ts +1 -1
- package/src/components/widget-view.test.ts +181 -0
- package/src/components/widget-view.ts +234 -0
- package/src/defaults.ts +54 -51
- package/src/generated/runtype-openapi-contract.ts +5 -0
- package/src/index-core.ts +30 -4
- package/src/index-global.ts +73 -6
- package/src/index.ts +7 -1
- package/src/install.test.ts +11 -11
- package/src/install.ts +28 -18
- package/src/launcher-global.ts +25 -14
- package/src/markdown-parsers-eager.ts +20 -0
- package/src/markdown-parsers-entry.ts +4 -0
- package/src/markdown-parsers-loader.ts +49 -0
- package/src/plugin-kit.test.ts +1 -1
- package/src/plugin-kit.ts +7 -7
- package/src/plugins/types.ts +5 -5
- package/src/postprocessors.ts +27 -14
- package/src/runtime/host-layout.test.ts +1 -1
- package/src/runtime/host-layout.ts +4 -5
- package/src/runtime/init.ts +2 -2
- package/src/session.test.ts +5 -6
- package/src/session.ts +333 -227
- package/src/session.voice.test.ts +146 -0
- package/src/session.webmcp.test.ts +24 -25
- package/src/smart-dom-reader.ts +3 -4
- package/src/styles/widget.css +54 -36
- package/src/suggest-replies-tool.test.ts +3 -3
- package/src/suggest-replies-tool.ts +5 -5
- package/src/testing/index.ts +1 -1
- package/src/theme-editor/color-utils.ts +2 -2
- package/src/theme-editor/index.ts +4 -13
- package/src/theme-editor/presets.ts +1 -1
- package/src/theme-editor/preview-utils.ts +22 -20
- package/src/theme-editor/preview.ts +4 -1
- package/src/theme-editor/role-mappings.ts +3 -3
- package/src/theme-editor/sections.ts +21 -20
- package/src/theme-editor/types.ts +1 -1
- package/src/theme-editor/webmcp/index.ts +1 -1
- package/src/theme-editor/webmcp/summary.ts +1 -2
- package/src/theme-editor/webmcp/tools.ts +30 -30
- package/src/theme-editor/webmcp/types.ts +2 -2
- package/src/theme-editor-preview.ts +10 -0
- package/src/theme-reference.ts +26 -26
- package/src/types.ts +368 -99
- package/src/ui.approval-plugin.test.ts +3 -3
- package/src/ui.ask-user-question-plugin.test.ts +9 -9
- package/src/ui.component-directive.test.ts +4 -4
- package/src/ui.composer-bar.test.ts +10 -11
- package/src/ui.composer-keyboard.test.ts +5 -5
- package/src/ui.header-icon-color.test.ts +59 -0
- package/src/ui.scroll.test.ts +5 -6
- package/src/ui.stop-button.test.ts +3 -4
- package/src/ui.suggest-replies.test.ts +2 -2
- package/src/ui.ts +292 -131
- package/src/utils/buttons.ts +1 -1
- package/src/utils/code-generators.ts +1 -1
- package/src/utils/composer-history.ts +3 -3
- package/src/utils/dom-context.test.ts +2 -2
- package/src/utils/dom-context.ts +7 -7
- package/src/utils/dom.test.ts +67 -0
- package/src/utils/dom.ts +75 -0
- package/src/utils/dropdown.ts +2 -2
- package/src/utils/event-stream-buffer.test.ts +2 -2
- package/src/utils/message-fingerprint.test.ts +9 -0
- package/src/utils/message-fingerprint.ts +5 -0
- package/src/utils/morph.ts +1 -1
- package/src/utils/sanitize.ts +32 -16
- package/src/utils/sequence-buffer.test.ts +8 -8
- package/src/utils/sequence-buffer.ts +4 -4
- package/src/utils/smart-dom-adapter.test.ts +1 -1
- package/src/utils/smart-dom-adapter.ts +4 -5
- package/src/utils/speech-text.test.ts +122 -0
- package/src/utils/speech-text.ts +101 -0
- package/src/utils/stream-animation.test.ts +3 -3
- package/src/utils/stream-animation.ts +6 -6
- package/src/utils/theme.test.ts +1 -1
- package/src/utils/throughput-tracker.test.ts +11 -11
- package/src/utils/throughput-tracker.ts +9 -9
- package/src/utils/tokens.ts +13 -13
- package/src/utils/virtual-scroller.ts +1 -1
- package/src/vendor/smart-dom-reader/README.md +4 -4
- package/src/vendor/smart-dom-reader/index.d.ts +1 -1
- package/src/vendor/smart-dom-reader/index.js +2 -2
- package/src/version.ts +1 -1
- package/src/voice/audio-playback-manager.test.ts +159 -0
- package/src/voice/audio-playback-manager.ts +145 -30
- package/src/voice/browser-speech-engine.ts +130 -0
- package/src/voice/fallback-speech-engine.ts +89 -0
- package/src/voice/index.ts +21 -0
- package/src/voice/read-aloud-controller.test.ts +143 -0
- package/src/voice/read-aloud-controller.ts +136 -0
- package/src/voice/runtype-speech-engine.test.ts +280 -0
- package/src/voice/runtype-speech-engine.ts +211 -0
- package/src/voice/runtype-tts-entry.ts +10 -0
- package/src/voice/runtype-tts-loader.test.ts +47 -0
- package/src/voice/runtype-tts-loader.ts +42 -0
- package/src/voice/runtype-voice-provider.ts +332 -537
- package/src/voice/voice-factory.ts +26 -4
- package/src/voice/voice.test.ts +321 -7
- package/src/voice/worklet-playback-engine.ts +272 -0
- package/src/voice-worklet-player.ts +39 -0
- package/src/webmcp-bridge.test.ts +6 -6
- package/src/webmcp-bridge.ts +24 -25
- package/src/webmcp-polyfill.ts +1 -1
- package/src/voice/voice-activity-detector.ts +0 -90
package/src/ui.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { escapeHtml, createMarkdownProcessorFromConfig } from "./postprocessors";
|
|
2
2
|
import { resolveSanitizer } from "./utils/sanitize";
|
|
3
|
+
import { loadMarkdownParsers, getMarkdownParsersSync } from "./markdown-parsers-loader";
|
|
3
4
|
import { AgentWidgetSession, AgentWidgetSessionStatus } from "./session";
|
|
4
5
|
import {
|
|
5
6
|
AgentWidgetConfig,
|
|
@@ -24,6 +25,7 @@ import {
|
|
|
24
25
|
LoadingIndicatorRenderContext,
|
|
25
26
|
IdleIndicatorRenderContext,
|
|
26
27
|
VoiceStatus,
|
|
28
|
+
ReadAloudState,
|
|
27
29
|
PersonaArtifactRecord,
|
|
28
30
|
PersonaArtifactManualUpsert
|
|
29
31
|
} from "./types";
|
|
@@ -64,8 +66,9 @@ import {
|
|
|
64
66
|
import { syncOverlayHostStacking } from "./utils/overlay-host-stacking";
|
|
65
67
|
import { acquireScrollLock } from "./utils/scroll-lock";
|
|
66
68
|
import { isComposerBarMountMode, isDockedMountMode, resolveDockConfig } from "./utils/dock";
|
|
67
|
-
import {
|
|
68
|
-
import {
|
|
69
|
+
import { LauncherButton } from "./components/launcher";
|
|
70
|
+
import { buildHeader, buildComposer, attachHeaderToContainer } from "./components/panel";
|
|
71
|
+
import { createWidgetView, resolveLauncher } from "./components/widget-view";
|
|
69
72
|
import { HEADER_THEME_CSS } from "./components/header-builder";
|
|
70
73
|
import { buildHeaderWithLayout } from "./components/header-layouts";
|
|
71
74
|
import { positionMap } from "./utils/positioning";
|
|
@@ -313,7 +316,7 @@ type Controller = {
|
|
|
313
316
|
injectMessageBatch: (optionsList: InjectMessageOptions[]) => AgentWidgetMessage[];
|
|
314
317
|
/**
|
|
315
318
|
* Convenience method for injecting an assistant message that renders as a
|
|
316
|
-
* registered component
|
|
319
|
+
* registered component: same shape Persona produces from a streamed
|
|
317
320
|
* `{ "text": "...", "component": "...", "props": {...} }` payload.
|
|
318
321
|
*/
|
|
319
322
|
injectComponentDirective: (
|
|
@@ -340,6 +343,13 @@ type Controller = {
|
|
|
340
343
|
// State query methods
|
|
341
344
|
isOpen: () => boolean;
|
|
342
345
|
isVoiceActive: () => boolean;
|
|
346
|
+
// Read-aloud (text-to-speech) methods
|
|
347
|
+
toggleReadAloud: (messageId: string) => void;
|
|
348
|
+
stopReadAloud: () => void;
|
|
349
|
+
getReadAloudState: (messageId: string) => ReadAloudState;
|
|
350
|
+
onReadAloudChange: (
|
|
351
|
+
listener: (activeId: string | null, state: ReadAloudState) => void
|
|
352
|
+
) => () => void;
|
|
343
353
|
getState: () => AgentWidgetStateSnapshot;
|
|
344
354
|
// Feedback methods (CSAT/NPS)
|
|
345
355
|
showCSATFeedback: (options?: Partial<CSATFeedbackOptions>) => void;
|
|
@@ -520,7 +530,7 @@ export const createAgentExperience = (
|
|
|
520
530
|
const eventBus = createEventBus<AgentWidgetControllerEventMap>();
|
|
521
531
|
|
|
522
532
|
// When persistState is explicitly false, message-history persistence is
|
|
523
|
-
// disabled
|
|
533
|
+
// disabled: including any user-supplied storageAdapter. This is the strict
|
|
524
534
|
// kill-switch semantic; pass `persistState: true` (or omit it) to opt in.
|
|
525
535
|
const messagePersistenceDisabled = config.persistState === false;
|
|
526
536
|
const storageAdapter: AgentWidgetStorageAdapter | null =
|
|
@@ -758,8 +768,13 @@ export const createAgentExperience = (
|
|
|
758
768
|
}
|
|
759
769
|
}
|
|
760
770
|
|
|
761
|
-
|
|
762
|
-
|
|
771
|
+
// The view layer (`components/widget-view.ts`) owns the one-time structural
|
|
772
|
+
// assembly (shell + panel) and groups the resulting refs into named regions.
|
|
773
|
+
// Behavior stays here in ui.ts; the locals below mirror the grouped refs so
|
|
774
|
+
// the surrounding orchestration code keeps its existing variable names.
|
|
775
|
+
const view = createWidgetView({ config, showClose: isPanelToggleable() });
|
|
776
|
+
const { wrapper, panel, pillRoot } = view.shell;
|
|
777
|
+
const panelElements = view.panelElements;
|
|
763
778
|
let {
|
|
764
779
|
container,
|
|
765
780
|
body,
|
|
@@ -882,6 +897,9 @@ export const createAgentExperience = (
|
|
|
882
897
|
if (existingHeader) {
|
|
883
898
|
existingHeader.replaceWith(customHeader);
|
|
884
899
|
header = customHeader;
|
|
900
|
+
// Keep the view's tracked header element in sync so a later
|
|
901
|
+
// header-layout rebuild (view.replaceHeader) targets the mounted node.
|
|
902
|
+
view.header.element = customHeader;
|
|
885
903
|
}
|
|
886
904
|
}
|
|
887
905
|
}
|
|
@@ -985,12 +1003,15 @@ export const createAgentExperience = (
|
|
|
985
1003
|
const ensureComposerAttachmentSurface = (rootFooter: HTMLElement) => {
|
|
986
1004
|
const att = config.attachments;
|
|
987
1005
|
if (!att?.enabled) return;
|
|
988
|
-
let previews =
|
|
1006
|
+
let previews =
|
|
1007
|
+
rootFooter.querySelector<HTMLElement>("[data-persona-composer-attachment-previews]") ??
|
|
1008
|
+
rootFooter.querySelector<HTMLElement>(".persona-attachment-previews");
|
|
989
1009
|
if (!previews) {
|
|
990
1010
|
previews = createElement(
|
|
991
1011
|
"div",
|
|
992
1012
|
"persona-attachment-previews persona-flex persona-flex-wrap persona-gap-2 persona-mb-2"
|
|
993
1013
|
);
|
|
1014
|
+
previews.setAttribute("data-persona-composer-attachment-previews", "");
|
|
994
1015
|
previews.style.display = "none";
|
|
995
1016
|
const form = rootFooter.querySelector("[data-persona-composer-form]");
|
|
996
1017
|
if (form?.parentNode) {
|
|
@@ -999,9 +1020,13 @@ export const createAgentExperience = (
|
|
|
999
1020
|
rootFooter.insertBefore(previews, rootFooter.firstChild);
|
|
1000
1021
|
}
|
|
1001
1022
|
}
|
|
1002
|
-
|
|
1023
|
+
const hasFileInput =
|
|
1024
|
+
rootFooter.querySelector<HTMLInputElement>("[data-persona-composer-attachment-input]") ??
|
|
1025
|
+
rootFooter.querySelector<HTMLInputElement>('input[type="file"]');
|
|
1026
|
+
if (!hasFileInput) {
|
|
1003
1027
|
const fileIn = createElement("input") as HTMLInputElement;
|
|
1004
1028
|
fileIn.type = "file";
|
|
1029
|
+
fileIn.setAttribute("data-persona-composer-attachment-input", "");
|
|
1005
1030
|
fileIn.accept = (att.allowedTypes ?? ALL_SUPPORTED_MIME_TYPES).join(",");
|
|
1006
1031
|
fileIn.multiple = (att.maxFiles ?? 4) > 1;
|
|
1007
1032
|
fileIn.style.display = "none";
|
|
@@ -1063,13 +1088,24 @@ export const createAgentExperience = (
|
|
|
1063
1088
|
: undefined
|
|
1064
1089
|
});
|
|
1065
1090
|
if (customComposer) {
|
|
1066
|
-
// Replace the default footer with custom composer
|
|
1067
|
-
|
|
1068
|
-
footer =
|
|
1091
|
+
// Replace the default footer with custom composer (keeps view.composer.footer in sync).
|
|
1092
|
+
view.replaceComposer(customComposer);
|
|
1093
|
+
footer = view.composer.footer;
|
|
1069
1094
|
}
|
|
1070
1095
|
}
|
|
1071
1096
|
|
|
1072
1097
|
const bindComposerRefsFromFooter = (rootFooter: HTMLElement) => {
|
|
1098
|
+
// Prefer stable `data-persona-composer-*` refs (set by the default and
|
|
1099
|
+
// pill builders); fall back to the legacy class selectors so custom
|
|
1100
|
+
// plugin composers built before these refs existed still bind.
|
|
1101
|
+
const pick = <T extends HTMLElement>(...selectors: string[]): T | null => {
|
|
1102
|
+
for (const selector of selectors) {
|
|
1103
|
+
const found = rootFooter.querySelector<T>(selector);
|
|
1104
|
+
if (found) return found;
|
|
1105
|
+
}
|
|
1106
|
+
return null;
|
|
1107
|
+
};
|
|
1108
|
+
|
|
1073
1109
|
const form = rootFooter.querySelector<HTMLFormElement>("[data-persona-composer-form]");
|
|
1074
1110
|
const ta = rootFooter.querySelector<HTMLTextAreaElement>("[data-persona-composer-input]");
|
|
1075
1111
|
const sb = rootFooter.querySelector<HTMLButtonElement>("[data-persona-composer-submit]");
|
|
@@ -1083,18 +1119,31 @@ export const createAgentExperience = (
|
|
|
1083
1119
|
micButtonWrapper = mic.parentElement as HTMLElement | null;
|
|
1084
1120
|
}
|
|
1085
1121
|
if (st) statusText = st;
|
|
1086
|
-
const sug =
|
|
1122
|
+
const sug = pick<HTMLElement>(
|
|
1123
|
+
"[data-persona-composer-suggestions]",
|
|
1087
1124
|
".persona-mb-3.persona-flex.persona-flex-wrap.persona-gap-2"
|
|
1088
1125
|
);
|
|
1089
1126
|
if (sug) suggestions = sug;
|
|
1090
|
-
const attBtn =
|
|
1127
|
+
const attBtn = pick<HTMLButtonElement>(
|
|
1128
|
+
"[data-persona-composer-attachment-button]",
|
|
1129
|
+
".persona-attachment-button"
|
|
1130
|
+
);
|
|
1091
1131
|
if (attBtn) {
|
|
1092
1132
|
attachmentButton = attBtn;
|
|
1093
1133
|
attachmentButtonWrapper = attBtn.parentElement as HTMLElement | null;
|
|
1094
1134
|
}
|
|
1095
|
-
attachmentInput =
|
|
1096
|
-
|
|
1097
|
-
|
|
1135
|
+
attachmentInput = pick<HTMLInputElement>(
|
|
1136
|
+
"[data-persona-composer-attachment-input]",
|
|
1137
|
+
'input[type="file"]'
|
|
1138
|
+
);
|
|
1139
|
+
attachmentPreviewsContainer = pick<HTMLElement>(
|
|
1140
|
+
"[data-persona-composer-attachment-previews]",
|
|
1141
|
+
".persona-attachment-previews"
|
|
1142
|
+
);
|
|
1143
|
+
const ar = pick<HTMLElement>(
|
|
1144
|
+
"[data-persona-composer-actions]",
|
|
1145
|
+
".persona-widget-composer .persona-flex.persona-items-center.persona-justify-between"
|
|
1146
|
+
);
|
|
1098
1147
|
if (ar) _actionsRow = ar;
|
|
1099
1148
|
};
|
|
1100
1149
|
ensureComposerAttachmentSurface(footer);
|
|
@@ -1310,6 +1359,47 @@ export const createAgentExperience = (
|
|
|
1310
1359
|
// This handles clicks even after idiomorph morphs the DOM and strips inline listeners
|
|
1311
1360
|
const messageVoteState = new Map<string, "upvote" | "downvote">();
|
|
1312
1361
|
|
|
1362
|
+
// Read-aloud (text-to-speech) button state. The ReadAloudController in the
|
|
1363
|
+
// session is the source of truth; these mirror its last-known state so the
|
|
1364
|
+
// button visuals can be re-applied after every render/morph (which would
|
|
1365
|
+
// otherwise revert the swapped icon to the default "volume-2").
|
|
1366
|
+
let readAloudActiveId: string | null = null;
|
|
1367
|
+
let readAloudActiveState: ReadAloudState = "idle";
|
|
1368
|
+
|
|
1369
|
+
const READ_ALOUD_ICONS: Record<ReadAloudState, { icon: string; label: string }> = {
|
|
1370
|
+
idle: { icon: "volume-2", label: "Read aloud" },
|
|
1371
|
+
loading: { icon: "loader-circle", label: "Loading…" },
|
|
1372
|
+
playing: { icon: "pause", label: "Pause" },
|
|
1373
|
+
paused: { icon: "play", label: "Resume" },
|
|
1374
|
+
};
|
|
1375
|
+
|
|
1376
|
+
const applyReadAloudButton = (btn: HTMLElement, state: ReadAloudState) => {
|
|
1377
|
+
const { icon, label } = READ_ALOUD_ICONS[state];
|
|
1378
|
+
btn.setAttribute("aria-label", label);
|
|
1379
|
+
btn.title = label;
|
|
1380
|
+
btn.setAttribute("aria-pressed", state === "idle" ? "false" : "true");
|
|
1381
|
+
btn.classList.toggle("persona-message-action-active", state !== "idle");
|
|
1382
|
+
btn.classList.toggle("persona-message-action-loading", state === "loading");
|
|
1383
|
+
const svg = renderLucideIcon(icon, 14, "currentColor", 2);
|
|
1384
|
+
if (svg) {
|
|
1385
|
+
btn.innerHTML = "";
|
|
1386
|
+
btn.appendChild(svg);
|
|
1387
|
+
}
|
|
1388
|
+
};
|
|
1389
|
+
|
|
1390
|
+
// Re-apply the current read-aloud state to every read-aloud button in the
|
|
1391
|
+
// thread. Called on state change and after each render so a button that is
|
|
1392
|
+
// playing/paused keeps its icon across DOM morphs.
|
|
1393
|
+
const refreshReadAloudButtons = () => {
|
|
1394
|
+
const buttons = messagesWrapper.querySelectorAll<HTMLElement>('[data-action="read-aloud"]');
|
|
1395
|
+
buttons.forEach((btn) => {
|
|
1396
|
+
const container = btn.closest("[data-actions-for]");
|
|
1397
|
+
const id = container?.getAttribute("data-actions-for") ?? null;
|
|
1398
|
+
const state: ReadAloudState = id && id === readAloudActiveId ? readAloudActiveState : "idle";
|
|
1399
|
+
applyReadAloudButton(btn, state);
|
|
1400
|
+
});
|
|
1401
|
+
};
|
|
1402
|
+
|
|
1313
1403
|
messagesWrapper.addEventListener('click', (event) => {
|
|
1314
1404
|
const target = event.target as HTMLElement;
|
|
1315
1405
|
const actionBtn = target.closest('.persona-message-action-btn[data-action]') as HTMLElement;
|
|
@@ -1356,13 +1446,17 @@ export const createAgentExperience = (
|
|
|
1356
1446
|
});
|
|
1357
1447
|
messageActionCallbacks.onCopy(message);
|
|
1358
1448
|
}
|
|
1449
|
+
} else if (action === 'read-aloud') {
|
|
1450
|
+
// Toggle play/pause/resume; ReadAloudController drives the engine and
|
|
1451
|
+
// notifies onReadAloudChange, which refreshes the button icon.
|
|
1452
|
+
session.toggleReadAloud(messageId);
|
|
1359
1453
|
} else if (action === 'upvote' || action === 'downvote') {
|
|
1360
1454
|
const currentVote = messageVoteState.get(messageId) ?? null;
|
|
1361
1455
|
const wasActive = currentVote === action;
|
|
1362
1456
|
const iconName = action === 'upvote' ? 'thumbs-up' : 'thumbs-down';
|
|
1363
1457
|
|
|
1364
1458
|
if (wasActive) {
|
|
1365
|
-
// Toggle off
|
|
1459
|
+
// Toggle off: revert to outline icon
|
|
1366
1460
|
messageVoteState.delete(messageId);
|
|
1367
1461
|
actionBtn.classList.remove("persona-message-action-active");
|
|
1368
1462
|
const outlineIcon = renderLucideIcon(iconName, 14, "currentColor", 2);
|
|
@@ -1486,7 +1580,7 @@ export const createAgentExperience = (
|
|
|
1486
1580
|
let markdown = artifact?.markdown;
|
|
1487
1581
|
let title = artifact?.title || 'artifact';
|
|
1488
1582
|
if (!markdown) {
|
|
1489
|
-
// After page refresh, session state is gone
|
|
1583
|
+
// After page refresh, session state is gone: read from the persisted card message
|
|
1490
1584
|
const cardEl = dlBtn.closest('[data-open-artifact]');
|
|
1491
1585
|
const msgEl = cardEl?.closest('[data-message-id]');
|
|
1492
1586
|
const msgId = msgEl?.getAttribute('data-message-id');
|
|
@@ -1617,7 +1711,7 @@ export const createAgentExperience = (
|
|
|
1617
1711
|
|
|
1618
1712
|
/**
|
|
1619
1713
|
* If `groupedAutoAdvance` is enabled (default) and we're not on the final
|
|
1620
|
-
* page, advance one step. The final page never auto-submits
|
|
1714
|
+
* page, advance one step. The final page never auto-submits: users always
|
|
1621
1715
|
* confirm with an explicit Submit-all click so they can review.
|
|
1622
1716
|
*/
|
|
1623
1717
|
const maybeAutoAdvance = (sheet: HTMLElement): void => {
|
|
@@ -1657,7 +1751,7 @@ export const createAgentExperience = (
|
|
|
1657
1751
|
|
|
1658
1752
|
// Best-effort: if this sheet corresponds to a LOCAL-awaiting tool,
|
|
1659
1753
|
// unblock the paused execution with a sentinel answer so the server
|
|
1660
|
-
// doesn't sit in waiting_for_local forever. Fire-and-forget
|
|
1754
|
+
// doesn't sit in waiting_for_local forever. Fire-and-forget: errors
|
|
1661
1755
|
// are surfaced to the onError callback. Flip the answered flag first
|
|
1662
1756
|
// so a racing render pass doesn't re-mount the sheet mid-dismissal.
|
|
1663
1757
|
const sourceMessage = sessionRef.current
|
|
@@ -1693,7 +1787,7 @@ export const createAgentExperience = (
|
|
|
1693
1787
|
return;
|
|
1694
1788
|
}
|
|
1695
1789
|
|
|
1696
|
-
// 1-question modes
|
|
1790
|
+
// 1-question modes: preserve original UX.
|
|
1697
1791
|
if (multiSelect) {
|
|
1698
1792
|
const pressed = trigger.getAttribute("aria-pressed") === "true";
|
|
1699
1793
|
trigger.setAttribute("aria-pressed", pressed ? "false" : "true");
|
|
@@ -2137,7 +2231,7 @@ export const createAgentExperience = (
|
|
|
2137
2231
|
// Composer-bar mode: the pill (footer) and peek banner live in a
|
|
2138
2232
|
// viewport-fixed sibling of the wrapper (`pillRoot`) so they're
|
|
2139
2233
|
// independent of the wrapper's geometry transitions. Critical for
|
|
2140
|
-
// modal mode
|
|
2234
|
+
// modal mode: the wrapper there has `transform: translate(-50%, -50%)`
|
|
2141
2235
|
// which would establish a containing block trapping any `position: fixed`
|
|
2142
2236
|
// descendant. Order inside pillRoot: peekBanner (slim row above pill)
|
|
2143
2237
|
// → footer (pill). pillRoot's `gap` spaces them; the peek is hidden by
|
|
@@ -2165,7 +2259,7 @@ export const createAgentExperience = (
|
|
|
2165
2259
|
// `applyComposerBarGeometry()` (per-state inline on the wrapper), the
|
|
2166
2260
|
// pill carries its own chrome via `.persona-pill-composer`, and the
|
|
2167
2261
|
// expanded chat panel chrome (border + radius + shadow + bg) is painted
|
|
2168
|
-
// inline on the `container` (NOT the panel
|
|
2262
|
+
// inline on the `container` (NOT the panel: the panel is a transparent
|
|
2169
2263
|
// flex column with a gap so the pill renders as a sibling below the
|
|
2170
2264
|
// chrome). Same theme contract as floating mode
|
|
2171
2265
|
// (`theme.components.panel.{shadow,border,borderRadius}`); collapsed
|
|
@@ -2252,7 +2346,7 @@ export const createAgentExperience = (
|
|
|
2252
2346
|
// `min-height: 0` / `overflow-y: auto` that make the messages area a
|
|
2253
2347
|
// scroll container. Between the reset and the mode-specific reapply,
|
|
2254
2348
|
// the body's clientHeight == scrollHeight momentarily, so the browser
|
|
2255
|
-
// clamps scrollTop to 0
|
|
2349
|
+
// clamps scrollTop to 0, and a synchronous restore at the end of this
|
|
2256
2350
|
// function runs before layout has reflowed, so the write is also
|
|
2257
2351
|
// clamped. Defer the restore to the next frame, once the reapplied
|
|
2258
2352
|
// styles have produced a scrollable container again.
|
|
@@ -2287,7 +2381,7 @@ export const createAgentExperience = (
|
|
|
2287
2381
|
'persona-bottom-4', 'persona-right-4', 'persona-left-4', 'persona-top-4'
|
|
2288
2382
|
);
|
|
2289
2383
|
|
|
2290
|
-
// Wrapper
|
|
2384
|
+
// Wrapper: fill entire viewport
|
|
2291
2385
|
wrapper.style.cssText = `
|
|
2292
2386
|
position: fixed !important;
|
|
2293
2387
|
inset: 0 !important;
|
|
@@ -2302,7 +2396,7 @@ export const createAgentExperience = (
|
|
|
2302
2396
|
background-color: var(--persona-surface, #ffffff) !important;
|
|
2303
2397
|
`;
|
|
2304
2398
|
|
|
2305
|
-
// Panel
|
|
2399
|
+
// Panel: fill wrapper, no radius/shadow
|
|
2306
2400
|
panel.style.cssText = `
|
|
2307
2401
|
position: relative !important;
|
|
2308
2402
|
display: flex !important;
|
|
@@ -2318,7 +2412,7 @@ export const createAgentExperience = (
|
|
|
2318
2412
|
border-radius: 0 !important;
|
|
2319
2413
|
`;
|
|
2320
2414
|
|
|
2321
|
-
// Container
|
|
2415
|
+
// Container: fill panel, no radius/border
|
|
2322
2416
|
container.style.cssText = `
|
|
2323
2417
|
display: flex !important;
|
|
2324
2418
|
flex-direction: column !important;
|
|
@@ -2332,12 +2426,12 @@ export const createAgentExperience = (
|
|
|
2332
2426
|
border: none !important;
|
|
2333
2427
|
`;
|
|
2334
2428
|
|
|
2335
|
-
// Body
|
|
2429
|
+
// Body: scrollable messages
|
|
2336
2430
|
body.style.flex = '1 1 0%';
|
|
2337
2431
|
body.style.minHeight = '0';
|
|
2338
2432
|
body.style.overflowY = 'auto';
|
|
2339
2433
|
|
|
2340
|
-
// Footer
|
|
2434
|
+
// Footer: pinned at bottom
|
|
2341
2435
|
footer.style.flexShrink = '0';
|
|
2342
2436
|
|
|
2343
2437
|
wasMobileFullscreen = true;
|
|
@@ -2635,7 +2729,7 @@ export const createAgentExperience = (
|
|
|
2635
2729
|
// chips win when the latest-turn rule yields any (last suggest_replies tool
|
|
2636
2730
|
// message with no user message after it); otherwise static config chips
|
|
2637
2731
|
// keep their before-first-user-message behavior. Config updates MUST route
|
|
2638
|
-
// through here too
|
|
2732
|
+
// through here too: re-rendering with only `config.suggestionChips` would
|
|
2639
2733
|
// drop a live agent chip row until the next message change.
|
|
2640
2734
|
const renderSuggestions = (messages?: AgentWidgetMessage[]) => {
|
|
2641
2735
|
if (!session) return;
|
|
@@ -2686,6 +2780,11 @@ export const createAgentExperience = (
|
|
|
2686
2780
|
// pending.
|
|
2687
2781
|
const lastApprovalBubbleFingerprint = new Map<string, string>();
|
|
2688
2782
|
let configVersion = 0;
|
|
2783
|
+
// Whether the markdown parsers (marked + dompurify) were already loaded when
|
|
2784
|
+
// this widget mounted. False only on the IIFE/CDN lazy path before the
|
|
2785
|
+
// `markdown-parsers.js` chunk resolves; in that window messages render as
|
|
2786
|
+
// escaped plain text and are re-rendered once the chunk lands (see below).
|
|
2787
|
+
const markdownReadyAtInit = getMarkdownParsersSync() !== null;
|
|
2689
2788
|
const autoFollow = createFollowStateController();
|
|
2690
2789
|
let lastScrollTop = 0;
|
|
2691
2790
|
let scrollRAF: number | null = null;
|
|
@@ -2856,7 +2955,7 @@ export const createAgentExperience = (
|
|
|
2856
2955
|
updateScrollToBottomCountBadge();
|
|
2857
2956
|
};
|
|
2858
2957
|
|
|
2859
|
-
// Whether the user is currently away from the latest content
|
|
2958
|
+
// Whether the user is currently away from the latest content: drives both
|
|
2860
2959
|
// the scroll-to-bottom affordance and the new-messages badge. In follow
|
|
2861
2960
|
// mode that's "auto-follow paused"; in anchor-top/none modes (where there
|
|
2862
2961
|
// is no follow state) it's simply "not near the bottom".
|
|
@@ -2906,7 +3005,7 @@ export const createAgentExperience = (
|
|
|
2906
3005
|
|
|
2907
3006
|
if (!force && !isStreaming) return;
|
|
2908
3007
|
|
|
2909
|
-
// Only cancel the pending schedule rAF
|
|
3008
|
+
// Only cancel the pending schedule rAF: keep the ongoing smooth scroll
|
|
2910
3009
|
// animation alive so isAutoScrolling stays true. This prevents scroll
|
|
2911
3010
|
// events fired by DOM morphing (between cancel and the next rAF) from
|
|
2912
3011
|
// being misinterpreted as user-initiated upward scrolls that would
|
|
@@ -3044,7 +3143,7 @@ export const createAgentExperience = (
|
|
|
3044
3143
|
cancelAnimationFrame(anchorRAF);
|
|
3045
3144
|
anchorRAF = null;
|
|
3046
3145
|
}
|
|
3047
|
-
// Also stop an in-flight anchor scroll animation
|
|
3146
|
+
// Also stop an in-flight anchor scroll animation: otherwise its
|
|
3048
3147
|
// remaining frames keep easing scrollTop toward the stale anchor target
|
|
3049
3148
|
// after a jump-to-latest, chat clear, or scroll-mode change.
|
|
3050
3149
|
cancelSmoothScroll();
|
|
@@ -3103,7 +3202,7 @@ export const createAgentExperience = (
|
|
|
3103
3202
|
|
|
3104
3203
|
// Content growth handler (ResizeObserver-driven). In follow mode this is
|
|
3105
3204
|
// what keeps the transcript pinned when content grows *without* a render
|
|
3106
|
-
// event
|
|
3205
|
+
// event: images/embeds finishing loading mid-stream, fonts swapping,
|
|
3107
3206
|
// the panel or composer resizing. In anchor-top mode it gives spacer room
|
|
3108
3207
|
// back as the streamed response grows (shrink-only, so total scroll height
|
|
3109
3208
|
// stays constant and nothing jumps).
|
|
@@ -3130,7 +3229,7 @@ export const createAgentExperience = (
|
|
|
3130
3229
|
|
|
3131
3230
|
// Reacts to a user message the user just sent (seeded so restored history
|
|
3132
3231
|
// never triggers it). Follow mode re-sticks to the bottom even if the user
|
|
3133
|
-
// had scrolled up
|
|
3232
|
+
// had scrolled up: sending is an unambiguous "take me to the latest"
|
|
3134
3233
|
// signal. Anchor-top mode pins the sent message near the viewport top.
|
|
3135
3234
|
const handleUserMessageSent = (messageId: string) => {
|
|
3136
3235
|
const mode = getScrollMode();
|
|
@@ -3240,12 +3339,12 @@ export const createAgentExperience = (
|
|
|
3240
3339
|
// Track active message IDs for cache pruning
|
|
3241
3340
|
const activeMessageIds = new Set<string>();
|
|
3242
3341
|
// Track ask_user_question tool-call ids whose bubbles were rendered this
|
|
3243
|
-
// pass
|
|
3342
|
+
// pass: used to prune stale sheets from the composer overlay afterward.
|
|
3244
3343
|
const liveAskToolIds = new Set<string>();
|
|
3245
3344
|
|
|
3246
3345
|
// Plugins that render `ask_user_question` typically attach DOM listeners
|
|
3247
3346
|
// directly to their buttons. The wrapper cache uses `cloneNode(true)` and
|
|
3248
|
-
// idiomorph inserts new nodes via `document.importNode
|
|
3347
|
+
// idiomorph inserts new nodes via `document.importNode`: both strip
|
|
3249
3348
|
// listeners. For plugin-handled ask messages we therefore append an empty
|
|
3250
3349
|
// stub during the morph pass and hydrate the live plugin bubble into the
|
|
3251
3350
|
// morphed wrapper afterward (see post-morph loop below). The stub carries
|
|
@@ -3365,7 +3464,7 @@ export const createAgentExperience = (
|
|
|
3365
3464
|
}
|
|
3366
3465
|
// Approval plugins are handled via the stub-and-hydrate path below
|
|
3367
3466
|
// (see `approvalWithPlugin`), not this inline morph path, so their
|
|
3368
|
-
// listeners survive
|
|
3467
|
+
// listeners survive, so they are intentionally excluded here.
|
|
3369
3468
|
if (!message.variant && p.renderMessage) {
|
|
3370
3469
|
return true;
|
|
3371
3470
|
}
|
|
@@ -3376,9 +3475,9 @@ export const createAgentExperience = (
|
|
|
3376
3475
|
const messageLayoutConfig = config.layout?.messages;
|
|
3377
3476
|
|
|
3378
3477
|
// ask_user_question has two rendering modes while waiting for an answer:
|
|
3379
|
-
// 1. Plugin `renderAskUserQuestion
|
|
3478
|
+
// 1. Plugin `renderAskUserQuestion`: returns an inline transcript
|
|
3380
3479
|
// element with its own UI; the composer-overlay sheet is suppressed.
|
|
3381
|
-
// 2. Built-in composer-overlay answer-pill sheet
|
|
3480
|
+
// 2. Built-in composer-overlay answer-pill sheet: no transcript stub.
|
|
3382
3481
|
// Plugins win when they return a non-null element; otherwise fall
|
|
3383
3482
|
// through to the built-in overlay.
|
|
3384
3483
|
//
|
|
@@ -3400,7 +3499,7 @@ export const createAgentExperience = (
|
|
|
3400
3499
|
return;
|
|
3401
3500
|
}
|
|
3402
3501
|
|
|
3403
|
-
// suggest_replies renders no transcript bubble
|
|
3502
|
+
// suggest_replies renders no transcript bubble: the chips above the
|
|
3404
3503
|
// composer are the only UI, and the session auto-resumes the call.
|
|
3405
3504
|
// When the feature is disabled the message falls through to the generic
|
|
3406
3505
|
// tool bubble (and is never auto-resumed), keeping the parked execution
|
|
@@ -3507,7 +3606,7 @@ export const createAgentExperience = (
|
|
|
3507
3606
|
if (needsRebuild && approvalPlugin?.renderApproval) {
|
|
3508
3607
|
// Re-find the live message at decision time so we resolve against
|
|
3509
3608
|
// current state, and route WebMCP gate approvals to the local
|
|
3510
|
-
// resolver
|
|
3609
|
+
// resolver: mirroring the built-in delegated handler.
|
|
3511
3610
|
const approvalMessageId = message.id;
|
|
3512
3611
|
const resolveDecision = (
|
|
3513
3612
|
decision: "approved" | "denied",
|
|
@@ -3535,8 +3634,8 @@ export const createAgentExperience = (
|
|
|
3535
3634
|
if (needsRebuild && liveBubble === null) {
|
|
3536
3635
|
// Plugin opted out for this state (e.g. a resolved approval, where the
|
|
3537
3636
|
// demo plugin defers to the built-in approved/denied bubble). Render
|
|
3538
|
-
// the built-in bubble
|
|
3539
|
-
// handler and morphs normally
|
|
3637
|
+
// the built-in bubble: it resolves via the delegated `messagesWrapper`
|
|
3638
|
+
// handler and morphs normally, and drop any preserved live wrapper so
|
|
3540
3639
|
// morph can replace the now-stale pending bubble.
|
|
3541
3640
|
const existing = container.querySelector<HTMLElement>(`#wrapper-${message.id}`);
|
|
3542
3641
|
existing?.removeAttribute("data-preserve-runtime");
|
|
@@ -3866,7 +3965,7 @@ export const createAgentExperience = (
|
|
|
3866
3965
|
|
|
3867
3966
|
// Also check if there's a recently completed assistant message (streaming just ended)
|
|
3868
3967
|
// This prevents flicker when the message completes but isStreaming hasn't updated yet
|
|
3869
|
-
// Approval-variant messages are UI controls, not content
|
|
3968
|
+
// Approval-variant messages are UI controls, not content: exclude them so the typing
|
|
3870
3969
|
// indicator still shows while the agent resumes after approval
|
|
3871
3970
|
const lastMessage = messages[messages.length - 1];
|
|
3872
3971
|
const hasRecentAssistantResponse = lastMessage?.role === "assistant" && !lastMessage.streaming && lastMessage.variant !== "approval";
|
|
@@ -4006,14 +4105,14 @@ export const createAgentExperience = (
|
|
|
4006
4105
|
|
|
4007
4106
|
// Hydrate plugin-rendered ask-question bubbles into their stub wrappers.
|
|
4008
4107
|
// Idiomorph imports new nodes via `document.importNode`, which strips
|
|
4009
|
-
// listeners
|
|
4108
|
+
// listeners, so we built only an empty stub during morph and now inject
|
|
4010
4109
|
// the real, listener-bearing bubble directly into the live DOM.
|
|
4011
4110
|
if (askPluginHydrate.length > 0) {
|
|
4012
4111
|
for (const { messageId, fingerprint, bubble } of askPluginHydrate) {
|
|
4013
4112
|
const wrapper = container.querySelector(`#wrapper-${messageId}`);
|
|
4014
4113
|
if (!wrapper) continue;
|
|
4015
4114
|
if (bubble === null) {
|
|
4016
|
-
// No fresh bubble built this pass
|
|
4115
|
+
// No fresh bubble built this pass: either the plugin opted out
|
|
4017
4116
|
// and a previously-mounted bubble already lives here (preserved by
|
|
4018
4117
|
// `data-preserve-runtime`), or we skipped the rebuild because the
|
|
4019
4118
|
// fingerprint matched. Either way, leave the live wrapper alone.
|
|
@@ -4040,7 +4139,7 @@ export const createAgentExperience = (
|
|
|
4040
4139
|
const wrapper = container.querySelector(`#wrapper-${messageId}`);
|
|
4041
4140
|
if (!wrapper) continue;
|
|
4042
4141
|
if (bubble === null) {
|
|
4043
|
-
// Fingerprint matched the previous pass
|
|
4142
|
+
// Fingerprint matched the previous pass: the live wrapper (kept
|
|
4044
4143
|
// alive by `data-preserve-runtime`) still holds the listener-bearing
|
|
4045
4144
|
// bubble from a prior render. Leave it untouched.
|
|
4046
4145
|
continue;
|
|
@@ -4065,7 +4164,7 @@ export const createAgentExperience = (
|
|
|
4065
4164
|
if (!wrapper) continue;
|
|
4066
4165
|
if (bubble === null) {
|
|
4067
4166
|
// Fingerprint matched the previous pass (or the plugin opted out
|
|
4068
|
-
// after a prior render)
|
|
4167
|
+
// after a prior render): the live wrapper, kept alive by
|
|
4069
4168
|
// `data-preserve-runtime`, still holds the listener-bearing bubble.
|
|
4070
4169
|
continue;
|
|
4071
4170
|
}
|
|
@@ -4082,8 +4181,17 @@ export const createAgentExperience = (
|
|
|
4082
4181
|
}
|
|
4083
4182
|
};
|
|
4084
4183
|
|
|
4085
|
-
// Alias for clarity - the implementation handles flicker prevention via typing indicator logic
|
|
4086
|
-
|
|
4184
|
+
// Alias for clarity - the implementation handles flicker prevention via typing indicator logic.
|
|
4185
|
+
// Re-apply read-aloud button state after each render so a playing/paused
|
|
4186
|
+
// message keeps its icon across idiomorph DOM morphs.
|
|
4187
|
+
const renderMessagesWithPlugins = (
|
|
4188
|
+
container: HTMLElement,
|
|
4189
|
+
messages: AgentWidgetMessage[],
|
|
4190
|
+
transform: MessageTransform
|
|
4191
|
+
) => {
|
|
4192
|
+
renderMessagesWithPluginsImpl(container, messages, transform);
|
|
4193
|
+
refreshReadAloudButtons();
|
|
4194
|
+
};
|
|
4087
4195
|
|
|
4088
4196
|
/**
|
|
4089
4197
|
* Composer-bar outside-click dismiss. While the chat is expanded, clicking
|
|
@@ -4127,7 +4235,7 @@ export const createAgentExperience = (
|
|
|
4127
4235
|
|
|
4128
4236
|
/**
|
|
4129
4237
|
* Composer-bar ESC dismiss. While the chat is expanded, pressing Escape
|
|
4130
|
-
* collapses back to just the pill
|
|
4238
|
+
* collapses back to just the pill: same end state as outside-click.
|
|
4131
4239
|
* Matches the WAI-ARIA dialog pattern (modal mode is literally a dialog)
|
|
4132
4240
|
* and the dominant chat-widget convention (Intercom, Drift, Crisp).
|
|
4133
4241
|
* Guards on `event.isComposing` so dismissing an IME suggestion doesn't
|
|
@@ -4161,7 +4269,7 @@ export const createAgentExperience = (
|
|
|
4161
4269
|
destroyCallbacks.push(() => detachComposerBarEscapeDismiss());
|
|
4162
4270
|
|
|
4163
4271
|
/**
|
|
4164
|
-
* Composer-bar "peek" affordance
|
|
4272
|
+
* Composer-bar "peek" affordance: a chrome-less row above the pill that
|
|
4165
4273
|
* shows a chat-bubble icon, the trailing 100 chars of the most recent
|
|
4166
4274
|
* assistant message, and a chevron-up. It is the user's path back into the
|
|
4167
4275
|
* expanded chat from the collapsed pill.
|
|
@@ -4272,7 +4380,7 @@ export const createAgentExperience = (
|
|
|
4272
4380
|
}
|
|
4273
4381
|
|
|
4274
4382
|
// Apply buffering (word/line/plugin custom). If the buffer trims content
|
|
4275
|
-
// to empty AND the placeholder is "skeleton", show the skeleton
|
|
4383
|
+
// to empty AND the placeholder is "skeleton", show the skeleton: that's
|
|
4276
4384
|
// the "line buffer between completions" affordance. Otherwise no
|
|
4277
4385
|
// pre-content placeholder on the peek (a typing-dots indicator inside a
|
|
4278
4386
|
// 1-line ticker would feel cramped).
|
|
@@ -4297,7 +4405,7 @@ export const createAgentExperience = (
|
|
|
4297
4405
|
} else {
|
|
4298
4406
|
// Trailing 100 chars; for animated modes we keep the slice but use
|
|
4299
4407
|
// ABSOLUTE indices so per-char/per-word span IDs stay stable as the
|
|
4300
|
-
// window shifts each chunk
|
|
4408
|
+
// window shifts each chunk: idiomorph then preserves animations on
|
|
4301
4409
|
// already-revealed units instead of restarting them. Plain "none" mode
|
|
4302
4410
|
// keeps the legacy `…` ellipsis prefix for visual continuity with the
|
|
4303
4411
|
// pre-animation behavior.
|
|
@@ -4344,7 +4452,7 @@ export const createAgentExperience = (
|
|
|
4344
4452
|
// Fire the plugin's per-render hook so glyph-cycle / wipe / custom
|
|
4345
4453
|
// plugins get a chance to mutate the peek's spans the same way they
|
|
4346
4454
|
// mutate the main bubble's. The carve-out: `bubble` here is the peek
|
|
4347
|
-
// banner root, not a message bubble
|
|
4455
|
+
// banner root, not a message bubble: plugins that target
|
|
4348
4456
|
// `bubbleClass` should no-op on that surface.
|
|
4349
4457
|
plugin.onAfterRender?.({
|
|
4350
4458
|
container: peekTextNode,
|
|
@@ -4421,7 +4529,7 @@ export const createAgentExperience = (
|
|
|
4421
4529
|
*
|
|
4422
4530
|
* Width is expressed as `width: <configured>; max-width: calc(100vw -
|
|
4423
4531
|
* 32px)`. The two combine such that `width` wins on wide viewports and
|
|
4424
|
-
* `max-width` clamps on narrow ones
|
|
4532
|
+
* `max-width` clamps on narrow ones: same effect as `min(...)` but
|
|
4425
4533
|
* jsdom-compatible. `100vw` is always the viewport, so the containing-
|
|
4426
4534
|
* block edge case (host with `transform`/`filter` causing `100%` to
|
|
4427
4535
|
* resolve against the host instead of the viewport) is neutralized.
|
|
@@ -4430,7 +4538,7 @@ export const createAgentExperience = (
|
|
|
4430
4538
|
const cb = config.launcher?.composerBar ?? {};
|
|
4431
4539
|
const expandedSize = cb.expandedSize ?? "anchored";
|
|
4432
4540
|
const bottomOffset = cb.bottomOffset ?? "16px";
|
|
4433
|
-
// No hardcoded default
|
|
4541
|
+
// No hardcoded default: when undefined, CSS media queries provide the
|
|
4434
4542
|
// responsive width (90vw / 70vw / 50vw at <640 / <1024 / >=1024) on
|
|
4435
4543
|
// pillRoot.
|
|
4436
4544
|
const collapsedMaxWidth = cb.collapsedMaxWidth;
|
|
@@ -4470,7 +4578,7 @@ export const createAgentExperience = (
|
|
|
4470
4578
|
}
|
|
4471
4579
|
|
|
4472
4580
|
if (!isOpen) {
|
|
4473
|
-
// Collapsed: wrapper has nothing visible to render
|
|
4581
|
+
// Collapsed: wrapper has nothing visible to render: the container
|
|
4474
4582
|
// inside is `display: none` (via CSS keyed on `[data-state="collapsed"]`)
|
|
4475
4583
|
// and the pill lives in pillRoot. Leave wrapper geometry empty so it
|
|
4476
4584
|
// collapses to a zero-size positioning frame at the default fixed
|
|
@@ -4497,7 +4605,7 @@ export const createAgentExperience = (
|
|
|
4497
4605
|
return;
|
|
4498
4606
|
}
|
|
4499
4607
|
|
|
4500
|
-
// Default: anchored
|
|
4608
|
+
// Default: anchored: pill stays at the viewport bottom (in pillRoot);
|
|
4501
4609
|
// wrapper's bottom edge clears the pill area so the chrome doesn't
|
|
4502
4610
|
// overlap it.
|
|
4503
4611
|
s.left = "50%";
|
|
@@ -4541,7 +4649,7 @@ export const createAgentExperience = (
|
|
|
4541
4649
|
|
|
4542
4650
|
// Toggle the entire container (chat chrome + body + close button) so
|
|
4543
4651
|
// the collapsed pill only shows the footer (which lives as a SIBLING
|
|
4544
|
-
// of the container in the panel
|
|
4652
|
+
// of the container in the panel: see panel.appendChild(footer) above).
|
|
4545
4653
|
// The footer is always visible / interactive.
|
|
4546
4654
|
container.style.display = open ? "flex" : "none";
|
|
4547
4655
|
|
|
@@ -4609,7 +4717,7 @@ export const createAgentExperience = (
|
|
|
4609
4717
|
panel.classList.remove("persona-scale-100", "persona-opacity-100");
|
|
4610
4718
|
panel.classList.add("persona-scale-95", "persona-opacity-0");
|
|
4611
4719
|
}
|
|
4612
|
-
// Show launcher when closed, except docked mode (0px column
|
|
4720
|
+
// Show launcher when closed, except docked mode (0px column: use controller.open()).
|
|
4613
4721
|
if (launcherButtonInstance) {
|
|
4614
4722
|
launcherButtonInstance.element.style.display = dockedMode ? "none" : "";
|
|
4615
4723
|
} else if (customLauncherElement) {
|
|
@@ -4634,7 +4742,7 @@ export const createAgentExperience = (
|
|
|
4634
4742
|
const mb = config.launcher?.mobileBreakpoint ?? 640;
|
|
4635
4743
|
const isMobile = ow.innerWidth <= mb;
|
|
4636
4744
|
const dockedMF = isDockedMountMode(config) && mf && isMobile;
|
|
4637
|
-
// Composer-bar in expanded fullscreen mode covers the viewport
|
|
4745
|
+
// Composer-bar in expanded fullscreen mode covers the viewport: lock
|
|
4638
4746
|
// background scroll and elevate host stacking to match other
|
|
4639
4747
|
// viewport-covering modes (mobile fullscreen, sidebar).
|
|
4640
4748
|
const composerBarFS =
|
|
@@ -4700,7 +4808,7 @@ export const createAgentExperience = (
|
|
|
4700
4808
|
};
|
|
4701
4809
|
|
|
4702
4810
|
const setComposerDisabled = (disabled: boolean) => {
|
|
4703
|
-
// The send button stays enabled while streaming
|
|
4811
|
+
// The send button stays enabled while streaming: it doubles as a stop
|
|
4704
4812
|
// button. Ancillary controls (mic, suggestions, opt-in targets) still
|
|
4705
4813
|
// disable so the user can't race a send against an in-flight stream.
|
|
4706
4814
|
setSendButtonMode(disabled ? "stop" : "send");
|
|
@@ -4811,7 +4919,7 @@ export const createAgentExperience = (
|
|
|
4811
4919
|
renderMessagesWithPlugins(messagesWrapper, messages, postprocess);
|
|
4812
4920
|
// Start elapsed timer if any active tool has a live duration span
|
|
4813
4921
|
ensureToolElapsedTimer();
|
|
4814
|
-
// Re-render suggestions
|
|
4922
|
+
// Re-render suggestions: agent chips vs config chips, one shared rule.
|
|
4815
4923
|
// Pass messages directly to avoid calling session.getMessages() during construction
|
|
4816
4924
|
renderSuggestions(messages);
|
|
4817
4925
|
scheduleAutoScroll(!isStreaming);
|
|
@@ -4875,11 +4983,21 @@ export const createAgentExperience = (
|
|
|
4875
4983
|
syncComposerBarPeek();
|
|
4876
4984
|
},
|
|
4877
4985
|
onVoiceStatusChanged(status: VoiceStatus) {
|
|
4986
|
+
// Surface the granular status publicly so consumers can render their own
|
|
4987
|
+
// per-state UI (e.g. a listening/speaking status dock). Fires for every
|
|
4988
|
+
// provider; the mic-button styling below is runtype-specific.
|
|
4989
|
+
eventBus.emit("voice:status", { status, timestamp: Date.now() });
|
|
4878
4990
|
if (config.voiceRecognition?.provider?.type !== 'runtype') return;
|
|
4879
4991
|
|
|
4880
4992
|
switch (status) {
|
|
4881
4993
|
case 'listening':
|
|
4882
|
-
//
|
|
4994
|
+
// A continuous realtime call re-enters `listening` after every spoken
|
|
4995
|
+
// reply, so reassert the recording styles here (they were replaced by
|
|
4996
|
+
// the `processing`/`speaking` states during the turn). The initial
|
|
4997
|
+
// listen is also styled by the toggleVoice()/startVoiceRecognition()
|
|
4998
|
+
// flows; reapplying is idempotent.
|
|
4999
|
+
removeRuntypeMicStateStyles();
|
|
5000
|
+
applyRuntypeMicRecordingStyles();
|
|
4883
5001
|
break;
|
|
4884
5002
|
case 'processing':
|
|
4885
5003
|
removeRuntypeMicStateStyles();
|
|
@@ -4892,7 +5010,7 @@ export const createAgentExperience = (
|
|
|
4892
5010
|
default:
|
|
4893
5011
|
// idle, connected, disconnected, error
|
|
4894
5012
|
if (status === 'idle' && session.isBargeInActive()) {
|
|
4895
|
-
// Barge-in mic is still hot between turns
|
|
5013
|
+
// Barge-in mic is still hot between turns: show it as active
|
|
4896
5014
|
removeRuntypeMicStateStyles();
|
|
4897
5015
|
applyRuntypeMicRecordingStyles();
|
|
4898
5016
|
micButton?.setAttribute("aria-label", "End voice session");
|
|
@@ -4914,9 +5032,32 @@ export const createAgentExperience = (
|
|
|
4914
5032
|
|
|
4915
5033
|
sessionRef.current = session;
|
|
4916
5034
|
|
|
5035
|
+
// Mirror read-aloud playback state into the action buttons, and surface it as
|
|
5036
|
+
// a controller event (parallel to message:copy / message:feedback).
|
|
5037
|
+
let lastReadAloudId: string | null = null;
|
|
5038
|
+
session.onReadAloudChange((activeId, state) => {
|
|
5039
|
+
readAloudActiveId = activeId;
|
|
5040
|
+
readAloudActiveState = state;
|
|
5041
|
+
refreshReadAloudButtons();
|
|
5042
|
+
|
|
5043
|
+
// On the terminal `idle` transition activeId is null, so fall back to the
|
|
5044
|
+
// last active id to identify the message that just finished/stopped.
|
|
5045
|
+
const messageId = activeId ?? lastReadAloudId;
|
|
5046
|
+
if (activeId) lastReadAloudId = activeId;
|
|
5047
|
+
const message = messageId
|
|
5048
|
+
? session.getMessages().find((m) => m.id === messageId) ?? null
|
|
5049
|
+
: null;
|
|
5050
|
+
eventBus.emit("message:read-aloud", {
|
|
5051
|
+
messageId,
|
|
5052
|
+
message,
|
|
5053
|
+
state,
|
|
5054
|
+
timestamp: Date.now(),
|
|
5055
|
+
});
|
|
5056
|
+
if (state === "idle") lastReadAloudId = null;
|
|
5057
|
+
});
|
|
5058
|
+
|
|
4917
5059
|
// The constructor only emits onMessagesChanged when it has initial
|
|
4918
|
-
// messages, so seed send-detection explicitly for the empty-session case
|
|
4919
|
-
// otherwise the user's very first send would be mistaken for the seed.
|
|
5060
|
+
// messages, so seed send-detection explicitly for the empty-session case: // otherwise the user's very first send would be mistaken for the seed.
|
|
4920
5061
|
scrollSendSeeded = true;
|
|
4921
5062
|
|
|
4922
5063
|
// Setup Runtype voice provider when configured (connects WebSocket for server-side STT)
|
|
@@ -5052,7 +5193,7 @@ export const createAgentExperience = (
|
|
|
5052
5193
|
// --- Composer message-history navigation (Up/Down arrows) ---
|
|
5053
5194
|
// Lets users recall and edit previously sent messages, shell/Slack style.
|
|
5054
5195
|
// The pure state machine lives in utils/composer-history.ts; here we feed it
|
|
5055
|
-
// caret info and apply the value it returns. Text-only recall
|
|
5196
|
+
// caret info and apply the value it returns. Text-only recall: attachments
|
|
5056
5197
|
// on past messages are not restored.
|
|
5057
5198
|
const historyNavigationEnabled = () =>
|
|
5058
5199
|
config.features?.composerHistory !== false;
|
|
@@ -5121,11 +5262,11 @@ export const createAgentExperience = (
|
|
|
5121
5262
|
}
|
|
5122
5263
|
return;
|
|
5123
5264
|
}
|
|
5124
|
-
// Not handled
|
|
5265
|
+
// Not handled: fall through to default cursor movement.
|
|
5125
5266
|
}
|
|
5126
5267
|
|
|
5127
5268
|
// Enter: send, unless a response is streaming. While streaming, Enter is
|
|
5128
|
-
// inert (never a stop trigger)
|
|
5269
|
+
// inert (never a stop trigger): the visible Stop button / Esc stop it.
|
|
5129
5270
|
if (event.key === "Enter" && !event.shiftKey) {
|
|
5130
5271
|
if (session.isStreaming()) {
|
|
5131
5272
|
event.preventDefault();
|
|
@@ -5147,7 +5288,7 @@ export const createAgentExperience = (
|
|
|
5147
5288
|
if (!session.isStreaming()) return;
|
|
5148
5289
|
if (!event.composedPath().includes(container)) return;
|
|
5149
5290
|
session.cancel();
|
|
5150
|
-
// Cancelling emits no terminal/error SSE frame
|
|
5291
|
+
// Cancelling emits no terminal/error SSE frame: reset throughput so the
|
|
5151
5292
|
// Events row doesn't keep showing a live rate from the stopped stream.
|
|
5152
5293
|
throughputTracker?.reset();
|
|
5153
5294
|
eventStreamView?.update();
|
|
@@ -5364,7 +5505,10 @@ export const createAgentExperience = (
|
|
|
5364
5505
|
(typeof (window as any).webkitSpeechRecognition !== 'undefined' ||
|
|
5365
5506
|
typeof (window as any).SpeechRecognition !== 'undefined');
|
|
5366
5507
|
const hasRuntypeProvider = voiceConfig?.provider?.type === 'runtype';
|
|
5367
|
-
|
|
5508
|
+
// Bring-your-own (`custom`) providers own their own input pipeline (cloud
|
|
5509
|
+
// STT, etc.), so the mic should render regardless of Web Speech support.
|
|
5510
|
+
const hasCustomProvider = voiceConfig?.provider?.type === 'custom';
|
|
5511
|
+
const hasVoiceInput = hasSpeechRecognition || hasRuntypeProvider || hasCustomProvider;
|
|
5368
5512
|
|
|
5369
5513
|
if (!hasVoiceInput) return null;
|
|
5370
5514
|
|
|
@@ -5709,31 +5853,17 @@ export const createAgentExperience = (
|
|
|
5709
5853
|
};
|
|
5710
5854
|
|
|
5711
5855
|
// Plugin hook: renderLauncher - allow plugins to provide custom launcher
|
|
5712
|
-
let launcherButtonInstance:
|
|
5856
|
+
let launcherButtonInstance: LauncherButton | null = null;
|
|
5713
5857
|
let customLauncherElement: HTMLElement | null = null;
|
|
5714
5858
|
|
|
5715
5859
|
// Composer-bar mode is launcher-less by design: the persistent pill IS the
|
|
5716
5860
|
// entry point, so skip creating any launcher button (default or plugin).
|
|
5717
5861
|
if (launcherEnabled && !isComposerBar()) {
|
|
5718
|
-
const
|
|
5719
|
-
|
|
5720
|
-
|
|
5721
|
-
|
|
5722
|
-
|
|
5723
|
-
const btn = createLauncherButton(config, toggleOpen);
|
|
5724
|
-
return btn.element;
|
|
5725
|
-
},
|
|
5726
|
-
onToggle: toggleOpen
|
|
5727
|
-
});
|
|
5728
|
-
if (customLauncher) {
|
|
5729
|
-
customLauncherElement = customLauncher;
|
|
5730
|
-
}
|
|
5731
|
-
}
|
|
5732
|
-
|
|
5733
|
-
// Use custom launcher if provided, otherwise use default
|
|
5734
|
-
if (!customLauncherElement) {
|
|
5735
|
-
launcherButtonInstance = createLauncherButton(config, toggleOpen);
|
|
5736
|
-
}
|
|
5862
|
+
const { instance, element } = resolveLauncher({ config, plugins, onToggle: toggleOpen });
|
|
5863
|
+
launcherButtonInstance = instance;
|
|
5864
|
+
// A plugin-provided launcher returns no controller instance; track its
|
|
5865
|
+
// element separately so the update path can manage it.
|
|
5866
|
+
if (!instance) customLauncherElement = element;
|
|
5737
5867
|
}
|
|
5738
5868
|
|
|
5739
5869
|
if (launcherButtonInstance) {
|
|
@@ -5754,7 +5884,7 @@ export const createAgentExperience = (
|
|
|
5754
5884
|
|
|
5755
5885
|
if (autoFocusInput) {
|
|
5756
5886
|
// Composer-bar's pill exposes the textarea immediately, so focus it on
|
|
5757
|
-
// init like the inline embed does
|
|
5887
|
+
// init like the inline embed does: even though the panel is collapsed.
|
|
5758
5888
|
if (!launcherEnabled || isComposerBar()) {
|
|
5759
5889
|
setTimeout(() => maybeFocusInput(), 0);
|
|
5760
5890
|
} else if (open) {
|
|
@@ -5763,7 +5893,7 @@ export const createAgentExperience = (
|
|
|
5763
5893
|
}
|
|
5764
5894
|
|
|
5765
5895
|
const recalcPanelHeight = () => {
|
|
5766
|
-
// Composer-bar mode lets CSS own all sizing
|
|
5896
|
+
// Composer-bar mode lets CSS own all sizing: collapsed pill is auto-sized
|
|
5767
5897
|
// by the footer; expanded fullscreen/modal are driven by CSS attribute
|
|
5768
5898
|
// selectors plus inline maxWidth/maxHeight set in updateOpenState. JS
|
|
5769
5899
|
// sizing here would fight the morph transitions.
|
|
@@ -5791,7 +5921,7 @@ export const createAgentExperience = (
|
|
|
5791
5921
|
return;
|
|
5792
5922
|
}
|
|
5793
5923
|
|
|
5794
|
-
// Exiting mobile fullscreen (e.g., orientation change to landscape)
|
|
5924
|
+
// Exiting mobile fullscreen (e.g., orientation change to landscape): reset all styles
|
|
5795
5925
|
if (wasMobileFullscreen) {
|
|
5796
5926
|
wasMobileFullscreen = false;
|
|
5797
5927
|
applyFullHeightStyles();
|
|
@@ -5905,7 +6035,7 @@ export const createAgentExperience = (
|
|
|
5905
6035
|
lastBottomOffset = currentBottomOffset;
|
|
5906
6036
|
|
|
5907
6037
|
if (getScrollMode() !== "follow") {
|
|
5908
|
-
// No follow state to manage
|
|
6038
|
+
// No follow state to manage: just keep the scroll-to-bottom
|
|
5909
6039
|
// affordance in sync with the user's position.
|
|
5910
6040
|
lastScrollTop = scrollTop;
|
|
5911
6041
|
syncScrollToBottomButton();
|
|
@@ -6039,7 +6169,7 @@ export const createAgentExperience = (
|
|
|
6039
6169
|
messageCache.clear();
|
|
6040
6170
|
resumeAutoScroll();
|
|
6041
6171
|
|
|
6042
|
-
// Drop any open ask_user_question sheets
|
|
6172
|
+
// Drop any open ask_user_question sheets: their source messages are gone.
|
|
6043
6173
|
removeAskUserQuestionSheet(panelElements.composerOverlay);
|
|
6044
6174
|
|
|
6045
6175
|
// Always clear the default localStorage key
|
|
@@ -6168,7 +6298,7 @@ export const createAgentExperience = (
|
|
|
6168
6298
|
// may expose `dataTransfer.types` as a DOMStringList or restrict access
|
|
6169
6299
|
// during certain drag phases. The cost is minimal: we suppress the native
|
|
6170
6300
|
// "open file" default for ALL drag-overs while the widget is alive and
|
|
6171
|
-
// attachments are on
|
|
6301
|
+
// attachments are on: text drags into the textarea still work because
|
|
6172
6302
|
// element-level handlers are unaffected (we don't stopPropagation here).
|
|
6173
6303
|
const ownerDoc = mount.ownerDocument;
|
|
6174
6304
|
const handleDocDragOver = (e: DragEvent) => {
|
|
@@ -6338,26 +6468,11 @@ export const createAgentExperience = (
|
|
|
6338
6468
|
}
|
|
6339
6469
|
|
|
6340
6470
|
if (config.launcher?.enabled !== false && !launcherButtonInstance && !customLauncherElement) {
|
|
6341
|
-
//
|
|
6342
|
-
const
|
|
6343
|
-
|
|
6344
|
-
|
|
6345
|
-
|
|
6346
|
-
defaultRenderer: () => {
|
|
6347
|
-
const btn = createLauncherButton(config, toggleOpen);
|
|
6348
|
-
return btn.element;
|
|
6349
|
-
},
|
|
6350
|
-
onToggle: toggleOpen
|
|
6351
|
-
});
|
|
6352
|
-
if (customLauncher) {
|
|
6353
|
-
customLauncherElement = customLauncher;
|
|
6354
|
-
mount.appendChild(customLauncherElement);
|
|
6355
|
-
}
|
|
6356
|
-
}
|
|
6357
|
-
if (!customLauncherElement) {
|
|
6358
|
-
launcherButtonInstance = createLauncherButton(config, toggleOpen);
|
|
6359
|
-
mount.appendChild(launcherButtonInstance.element);
|
|
6360
|
-
}
|
|
6471
|
+
// Resolve the launcher again when re-enabling (honors renderLauncher plugin).
|
|
6472
|
+
const { instance, element } = resolveLauncher({ config, plugins, onToggle: toggleOpen });
|
|
6473
|
+
launcherButtonInstance = instance;
|
|
6474
|
+
if (!instance) customLauncherElement = element;
|
|
6475
|
+
mount.appendChild(element);
|
|
6361
6476
|
}
|
|
6362
6477
|
|
|
6363
6478
|
if (launcherButtonInstance) {
|
|
@@ -6390,15 +6505,15 @@ export const createAgentExperience = (
|
|
|
6390
6505
|
onClose: () => setOpenState(false, "user")
|
|
6391
6506
|
});
|
|
6392
6507
|
|
|
6393
|
-
// Replace the old header with the new one
|
|
6394
|
-
|
|
6508
|
+
// Replace the old header with the new one (keeps view.header in sync).
|
|
6509
|
+
view.replaceHeader(newHeaderElements);
|
|
6395
6510
|
|
|
6396
|
-
//
|
|
6397
|
-
header =
|
|
6398
|
-
iconHolder =
|
|
6399
|
-
headerTitle =
|
|
6400
|
-
headerSubtitle =
|
|
6401
|
-
closeButton =
|
|
6511
|
+
// Mirror the view's refreshed header refs into the local bindings.
|
|
6512
|
+
header = view.header.element;
|
|
6513
|
+
iconHolder = view.header.iconHolder;
|
|
6514
|
+
headerTitle = view.header.headerTitle;
|
|
6515
|
+
headerSubtitle = view.header.headerSubtitle;
|
|
6516
|
+
closeButton = view.header.closeButton;
|
|
6402
6517
|
|
|
6403
6518
|
prevHeaderLayout = headerLayoutConfig?.layout;
|
|
6404
6519
|
} else if (headerLayoutConfig) {
|
|
@@ -6532,9 +6647,13 @@ export const createAgentExperience = (
|
|
|
6532
6647
|
|
|
6533
6648
|
// Update icon content based on priority: Lucide icon > iconUrl > agentIconText
|
|
6534
6649
|
if (headerIconName) {
|
|
6535
|
-
// Use Lucide icon
|
|
6650
|
+
// Use Lucide icon. Stroke `currentColor` (not a hardcoded white) so the
|
|
6651
|
+
// glyph inherits iconHolder's `color: var(--persona-header-icon-fg, …)`,
|
|
6652
|
+
// matching the initial render in header-builder.ts. Without this, any
|
|
6653
|
+
// controller.update() (e.g. a theme-editor change) re-rendered the icon
|
|
6654
|
+
// as white and the configured header icon color "wouldn't stick".
|
|
6536
6655
|
const iconSize = parseFloat(headerIconSize) || 24;
|
|
6537
|
-
const iconSvg = renderLucideIcon(headerIconName, iconSize * 0.6, "
|
|
6656
|
+
const iconSvg = renderLucideIcon(headerIconName, iconSize * 0.6, "currentColor", 1);
|
|
6538
6657
|
if (iconSvg) {
|
|
6539
6658
|
iconHolder.replaceChildren(iconSvg);
|
|
6540
6659
|
} else {
|
|
@@ -6851,7 +6970,7 @@ export const createAgentExperience = (
|
|
|
6851
6970
|
}
|
|
6852
6971
|
|
|
6853
6972
|
if (shouldShowClearChat) {
|
|
6854
|
-
// Update size
|
|
6973
|
+
// Update size: composer-bar mode owns its sizing (16px to match
|
|
6855
6974
|
// the close icon), so leave size alone there. Floating-launcher
|
|
6856
6975
|
// and other modes still honor `launcher.clearChat.size`.
|
|
6857
6976
|
if (!isComposerBar()) {
|
|
@@ -7830,6 +7949,28 @@ export const createAgentExperience = (
|
|
|
7830
7949
|
isVoiceActive(): boolean {
|
|
7831
7950
|
return voiceState.active;
|
|
7832
7951
|
},
|
|
7952
|
+
/**
|
|
7953
|
+
* Toggle "Read aloud" for an assistant message: play → pause → resume (or
|
|
7954
|
+
* play → stop when the engine can't pause). Speaks via the configured
|
|
7955
|
+
* speech engine (browser Web Speech API by default).
|
|
7956
|
+
*/
|
|
7957
|
+
toggleReadAloud(messageId: string): void {
|
|
7958
|
+
session.toggleReadAloud(messageId);
|
|
7959
|
+
},
|
|
7960
|
+
/** Stop any in-progress read-aloud / text-to-speech playback. */
|
|
7961
|
+
stopReadAloud(): void {
|
|
7962
|
+
session.stopSpeaking();
|
|
7963
|
+
},
|
|
7964
|
+
/** Current read-aloud playback state for a message (`idle` unless active). */
|
|
7965
|
+
getReadAloudState(messageId: string): ReadAloudState {
|
|
7966
|
+
return session.getReadAloudState(messageId);
|
|
7967
|
+
},
|
|
7968
|
+
/** Subscribe to read-aloud state changes. Returns an unsubscribe function. */
|
|
7969
|
+
onReadAloudChange(
|
|
7970
|
+
listener: (activeId: string | null, state: ReadAloudState) => void
|
|
7971
|
+
): () => void {
|
|
7972
|
+
return session.onReadAloudChange(listener);
|
|
7973
|
+
},
|
|
7833
7974
|
getState(): AgentWidgetStateSnapshot {
|
|
7834
7975
|
return {
|
|
7835
7976
|
open: isPanelToggleable() && open,
|
|
@@ -8118,6 +8259,26 @@ export const createAgentExperience = (
|
|
|
8118
8259
|
// the panel.
|
|
8119
8260
|
syncComposerBarPeek();
|
|
8120
8261
|
|
|
8262
|
+
// IIFE/CDN lazy path only: the parsers were not ready at mount, so any
|
|
8263
|
+
// messages rendered so far (restored history, eager intro/injected messages)
|
|
8264
|
+
// were escaped to plain text. Once the `markdown-parsers.js` chunk resolves,
|
|
8265
|
+
// bust the message cache and re-render so they pick up real markdown. Bumping
|
|
8266
|
+
// `configVersion` + clearing the cache is required because the message
|
|
8267
|
+
// content is unchanged, so the fingerprint cache would otherwise reuse the
|
|
8268
|
+
// stale escaped wrappers. No-op for the ESM build (parsers ready at init).
|
|
8269
|
+
if (!markdownReadyAtInit) {
|
|
8270
|
+
loadMarkdownParsers()
|
|
8271
|
+
.then(() => {
|
|
8272
|
+
if (!session) return;
|
|
8273
|
+
configVersion++;
|
|
8274
|
+
messageCache.clear();
|
|
8275
|
+
renderMessagesWithPlugins(messagesWrapper, session.getMessages(), postprocess);
|
|
8276
|
+
})
|
|
8277
|
+
.catch(() => {
|
|
8278
|
+
/* chunk failed to load (e.g. ad blocker): keep the escaped fallback */
|
|
8279
|
+
});
|
|
8280
|
+
}
|
|
8281
|
+
|
|
8121
8282
|
return controller;
|
|
8122
8283
|
};
|
|
8123
8284
|
|