@runtypelabs/persona 4.12.0 → 4.14.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 +98 -6
- package/dist/context-mentions.d.ts +98 -6
- package/dist/index.cjs +64 -64
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +183 -8
- package/dist/index.d.ts +183 -8
- package/dist/index.global.js +54 -54
- package/dist/index.global.js.map +1 -1
- package/dist/index.js +67 -67
- package/dist/index.js.map +1 -1
- package/dist/launcher.global.js +1 -1
- package/dist/launcher.global.js.map +1 -1
- package/dist/smart-dom-reader.d.cts +98 -6
- package/dist/smart-dom-reader.d.ts +98 -6
- package/dist/theme-editor-preview.cjs +66 -66
- package/dist/theme-editor-preview.d.cts +100 -6
- package/dist/theme-editor-preview.d.ts +100 -6
- package/dist/theme-editor-preview.js +75 -75
- package/dist/theme-editor.cjs +10 -10
- package/dist/theme-editor.d.cts +134 -7
- package/dist/theme-editor.d.ts +134 -7
- package/dist/theme-editor.js +10 -10
- package/dist/theme-reference.cjs +1 -1
- package/dist/widget.css +1 -1
- package/package.json +1 -1
- package/src/artifacts-session.test.ts +106 -0
- package/src/client.test.ts +2 -2
- package/src/client.ts +4 -1
- package/src/components/artifact-card.ts +13 -2
- package/src/components/artifact-inline.ts +11 -2
- package/src/components/composer-builder.test.ts +2 -0
- package/src/components/composer-builder.ts +1 -1
- package/src/components/composer-parts.test.ts +2 -0
- package/src/components/composer-parts.ts +1 -1
- package/src/components/message-bubble.test.ts +7 -1
- package/src/components/message-bubble.ts +11 -8
- package/src/index-core.ts +34 -0
- package/src/session.ts +45 -4
- package/src/styles/widget.css +10 -1
- package/src/theme-editor/index.ts +5 -0
- package/src/theme-editor/presets.test.ts +43 -0
- package/src/theme-editor/presets.ts +101 -87
- package/src/theme-editor/sections.test.ts +39 -7
- package/src/theme-editor/sections.ts +60 -8
- package/src/theme-editor/state-preferences.test.ts +15 -0
- package/src/theme-editor/state.ts +46 -0
- package/src/theme-editor/types.ts +15 -1
- package/src/theme-editor/webmcp/tools.test.ts +13 -0
- package/src/theme-editor/webmcp/tools.ts +5 -0
- package/src/types.ts +139 -9
- package/src/ui.artifact-pane-gating.test.ts +5 -5
- package/src/ui.config-preferences.test.ts +166 -0
- package/src/ui.scroll-additive.test.ts +365 -0
- package/src/ui.scroll.test.ts +19 -6
- package/src/ui.ts +158 -53
- package/src/ui.typing-indicator.test.ts +135 -0
- package/src/utils/artifact-display.test.ts +180 -16
- package/src/utils/artifact-display.ts +155 -14
- package/src/utils/auto-follow.test.ts +38 -0
- package/src/utils/auto-follow.ts +35 -0
- package/src/utils/config-merge.test.ts +60 -0
- package/src/utils/config-merge.ts +6 -1
- package/src/utils/feature-preferences.test.ts +326 -0
- package/src/utils/feature-preferences.ts +498 -0
- package/src/utils/theme.test.ts +92 -0
- package/src/utils/tokens.ts +7 -7
package/src/ui.ts
CHANGED
|
@@ -64,6 +64,7 @@ import {
|
|
|
64
64
|
createFollowStateController,
|
|
65
65
|
getScrollBottomOffset,
|
|
66
66
|
hasSelectionWithin,
|
|
67
|
+
isAttentionWorthyMessage,
|
|
67
68
|
isElementNearBottom,
|
|
68
69
|
resolveFollowStateFromScroll,
|
|
69
70
|
resolveFollowStateFromWheel
|
|
@@ -90,7 +91,7 @@ import { buildHeaderWithLayout } from "./components/header-layouts";
|
|
|
90
91
|
import { positionMap } from "./utils/positioning";
|
|
91
92
|
import type { HeaderElements as _HeaderElements, ComposerElements as _ComposerElements } from "./components/panel";
|
|
92
93
|
import { MessageTransform, MessageActionCallbacks, LoadingIndicatorRenderer } from "./components/message-bubble";
|
|
93
|
-
import { createStandardBubble, createTypingIndicator } from "./components/message-bubble";
|
|
94
|
+
import { createStandardBubble, createTypingIndicator, getBubbleClasses } from "./components/message-bubble";
|
|
94
95
|
import { createReasoningBubble, reasoningExpansionState, updateReasoningBubbleUI } from "./components/reasoning-bubble";
|
|
95
96
|
import { createToolBubble, toolExpansionState, updateToolBubbleUI } from "./components/tool-bubble";
|
|
96
97
|
import {
|
|
@@ -132,6 +133,7 @@ import {
|
|
|
132
133
|
shouldExpandLauncherForArtifacts
|
|
133
134
|
} from "./utils/artifact-gate";
|
|
134
135
|
import { resolveArtifactDisplayMode } from "./utils/artifact-display";
|
|
136
|
+
import { resolveConfigPreferences } from "./utils/feature-preferences";
|
|
135
137
|
import { readFlexGapPx, resolveArtifactPaneWidthPx } from "./utils/artifact-resize";
|
|
136
138
|
import { enhanceWithForms } from "./components/forms";
|
|
137
139
|
import { pluginRegistry } from "./plugins/registry";
|
|
@@ -595,6 +597,11 @@ export const createAgentExperience = (
|
|
|
595
597
|
}
|
|
596
598
|
|
|
597
599
|
let config = mergeWithDefaults(initialConfig) as AgentWidgetConfig;
|
|
600
|
+
// `config.features` holds the preference-resolved view every read site uses;
|
|
601
|
+
// `baseFeatures` keeps the pre-preference base so update() re-resolves from
|
|
602
|
+
// it instead of stacking new preferences onto already-overlaid values.
|
|
603
|
+
let baseFeatures = config.features;
|
|
604
|
+
config = resolveConfigPreferences(config);
|
|
598
605
|
// Note: applyThemeVariables is called after applyFullHeightStyles() below
|
|
599
606
|
// because applyFullHeightStyles resets mount.style.cssText
|
|
600
607
|
|
|
@@ -1799,7 +1806,7 @@ export const createAgentExperience = (
|
|
|
1799
1806
|
let artifactPaneExpandedPinned = false;
|
|
1800
1807
|
// Whether the user explicitly opened the pane (card click, inline Expand,
|
|
1801
1808
|
// showArtifacts(), programmatic upsert). Auto-open is otherwise reserved for
|
|
1802
|
-
// artifacts whose resolved display mode is "panel": "
|
|
1809
|
+
// artifacts whose resolved display mode is "panel": "collapsed" keeps the card as
|
|
1803
1810
|
// the only affordance and "inline" renders in the transcript. "inline" no
|
|
1804
1811
|
// longer *auto*-opens the pane, but its Expand control is a deliberate,
|
|
1805
1812
|
// user-driven open (setting artifactsPaneUserOpened, same as a card click);
|
|
@@ -1810,7 +1817,7 @@ export const createAgentExperience = (
|
|
|
1810
1817
|
artifactsPaneUserOpened ||
|
|
1811
1818
|
lastArtifactsState.artifacts.some(
|
|
1812
1819
|
(a) =>
|
|
1813
|
-
resolveArtifactDisplayMode(config.features?.artifacts, a
|
|
1820
|
+
resolveArtifactDisplayMode(config.features?.artifacts, a) === "panel"
|
|
1814
1821
|
);
|
|
1815
1822
|
const artifactPaneVisible = () =>
|
|
1816
1823
|
lastArtifactsState.artifacts.length > 0 &&
|
|
@@ -2009,7 +2016,7 @@ export const createAgentExperience = (
|
|
|
2009
2016
|
event.preventDefault();
|
|
2010
2017
|
event.stopPropagation();
|
|
2011
2018
|
artifactsPaneUserHidden = false;
|
|
2012
|
-
// Card click is an explicit open: it overrides the "
|
|
2019
|
+
// Card click is an explicit open: it overrides the "collapsed"/"inline"
|
|
2013
2020
|
// auto-open suppression for as long as artifacts exist.
|
|
2014
2021
|
artifactsPaneUserOpened = true;
|
|
2015
2022
|
session.selectArtifact(artifactId);
|
|
@@ -2517,7 +2524,7 @@ export const createAgentExperience = (
|
|
|
2517
2524
|
} else if (lastArtifactsState.artifacts.length > 0 && artifactPaneCanShow()) {
|
|
2518
2525
|
// User chose “show” again (e.g. programmatic showArtifacts): clear dismiss chrome
|
|
2519
2526
|
// and force drawer open so narrow-host / mobile slide-out is not stuck off-screen.
|
|
2520
|
-
// Artifacts whose display mode is "
|
|
2527
|
+
// Artifacts whose display mode is "collapsed" or "inline" don't auto-open the
|
|
2521
2528
|
// pane (artifactPaneCanShow); it stays hidden until an explicit open or
|
|
2522
2529
|
// until a "panel"-mode artifact arrives.
|
|
2523
2530
|
artifactPaneApi.element.classList.remove("persona-hidden");
|
|
@@ -3537,6 +3544,24 @@ export const createAgentExperience = (
|
|
|
3537
3544
|
// pinned and never re-arms the fallback, so a late-loading embed can't yank
|
|
3538
3545
|
// the viewport down to the bottom.
|
|
3539
3546
|
let currentTurnAnchored = false;
|
|
3547
|
+
// Which message the anchor is currently parked on. Starts as the sent user
|
|
3548
|
+
// message and moves once to the turn's first attention-worthy block (see
|
|
3549
|
+
// `maybeAnchorToFirstUnread`).
|
|
3550
|
+
let anchoredMessageId: string | null = null;
|
|
3551
|
+
// Has the reader taken over positioning this turn? First-unread anchoring is
|
|
3552
|
+
// a guess about where someone who ISN'T watching wants to land; the moment
|
|
3553
|
+
// they demonstrably are watching, the guess stands down and never moves the
|
|
3554
|
+
// viewport under them. Reset on each user send (an explicit "take me along").
|
|
3555
|
+
//
|
|
3556
|
+
// Driven by real INPUT (wheel, touch drag, pointer press, transcript
|
|
3557
|
+
// keyboard nav, focus, selection) rather than by scroll deltas: streaming
|
|
3558
|
+
// content under a pinned anchor makes the browser clamp and then restore
|
|
3559
|
+
// scrollTop, which is indistinguishable from a deliberate scroll if you only
|
|
3560
|
+
// look at the scroll event.
|
|
3561
|
+
let readerEngagedThisTurn = false;
|
|
3562
|
+
const markReaderEngaged = () => {
|
|
3563
|
+
readerEngagedThisTurn = true;
|
|
3564
|
+
};
|
|
3540
3565
|
// Dedupes assistant-turn detection across token-by-token re-renders.
|
|
3541
3566
|
let lastHandledAssistantId: string | null = null;
|
|
3542
3567
|
|
|
@@ -3940,6 +3965,24 @@ export const createAgentExperience = (
|
|
|
3940
3965
|
return top;
|
|
3941
3966
|
};
|
|
3942
3967
|
|
|
3968
|
+
// Defined here (rather than beside the scroll listeners) because
|
|
3969
|
+
// `maybeAnchorToFirstUnread` runs on every render and would otherwise hit
|
|
3970
|
+
// these consts in their temporal dead zone on a synchronous first paint.
|
|
3971
|
+
const getTranscriptSelection = (): Selection | null => {
|
|
3972
|
+
// Selections inside a shadow root are not always reflected by
|
|
3973
|
+
// document.getSelection(); prefer the shadow root's view when available
|
|
3974
|
+
// (non-standard but supported where it matters).
|
|
3975
|
+
const root = body.getRootNode();
|
|
3976
|
+
const shadowSelection =
|
|
3977
|
+
typeof (root as ShadowRoot & { getSelection?: () => Selection | null })
|
|
3978
|
+
.getSelection === "function"
|
|
3979
|
+
? (root as ShadowRoot & { getSelection: () => Selection | null }).getSelection()
|
|
3980
|
+
: null;
|
|
3981
|
+
return shadowSelection ?? body.ownerDocument.getSelection();
|
|
3982
|
+
};
|
|
3983
|
+
const hasActiveTranscriptSelection = () =>
|
|
3984
|
+
hasSelectionWithin(getTranscriptSelection(), body);
|
|
3985
|
+
|
|
3943
3986
|
// Principle 11: reopen where the reader left off. When `restorePosition` is
|
|
3944
3987
|
// "last-user-turn" and there is pre-existing history, land with the last user
|
|
3945
3988
|
// message pinned near the top of the viewport instead of jumping to the
|
|
@@ -4002,11 +4045,10 @@ export const createAgentExperience = (
|
|
|
4002
4045
|
anchorSpacer.style.height = "0px";
|
|
4003
4046
|
};
|
|
4004
4047
|
|
|
4005
|
-
// Anchor-top mode: scroll
|
|
4006
|
-
//
|
|
4007
|
-
//
|
|
4008
|
-
|
|
4009
|
-
const scheduleAnchorToUserMessage = (messageId: string) => {
|
|
4048
|
+
// Anchor-top mode: scroll `messageId` to rest `anchorTopOffset` px below the
|
|
4049
|
+
// viewport top and hold it there while the response streams in beneath it.
|
|
4050
|
+
// Deferred one frame so the message bubble has been rendered and laid out.
|
|
4051
|
+
const scheduleAnchorToMessage = (messageId: string) => {
|
|
4010
4052
|
if (anchorRAF !== null) {
|
|
4011
4053
|
cancelAnimationFrame(anchorRAF);
|
|
4012
4054
|
}
|
|
@@ -4087,7 +4129,11 @@ export const createAgentExperience = (
|
|
|
4087
4129
|
// next user send.
|
|
4088
4130
|
followFallbackActive = false;
|
|
4089
4131
|
currentTurnAnchored = true;
|
|
4090
|
-
|
|
4132
|
+
// A send is an explicit "take me along": it re-arms first-unread
|
|
4133
|
+
// positioning even if the reader had scrolled away during the last turn.
|
|
4134
|
+
readerEngagedThisTurn = false;
|
|
4135
|
+
anchoredMessageId = messageId;
|
|
4136
|
+
scheduleAnchorToMessage(messageId);
|
|
4091
4137
|
}
|
|
4092
4138
|
};
|
|
4093
4139
|
|
|
@@ -4110,6 +4156,44 @@ export const createAgentExperience = (
|
|
|
4110
4156
|
scheduleAutoScroll(true);
|
|
4111
4157
|
};
|
|
4112
4158
|
|
|
4159
|
+
// Anchor-top positioning: park on the turn's FIRST UNREAD attention-worthy
|
|
4160
|
+
// block.
|
|
4161
|
+
//
|
|
4162
|
+
// The read boundary is the last user send — the reader wrote it, so it (and
|
|
4163
|
+
// everything above) is read; every attention-worthy block after it is not.
|
|
4164
|
+
// Tool calls and reasoning are chrome and never targeted, so they scroll
|
|
4165
|
+
// past underneath rather than parking the reader on progress output.
|
|
4166
|
+
//
|
|
4167
|
+
// The policy assumes the reader is NOT watching the stream, and picks the
|
|
4168
|
+
// best place for them to land when they look back: the start of what they
|
|
4169
|
+
// haven't seen. `readerEngagedThisTurn` is the escape hatch — once someone
|
|
4170
|
+
// is demonstrably driving, their position wins and this stands down until
|
|
4171
|
+
// the next send.
|
|
4172
|
+
//
|
|
4173
|
+
// Anchoring to the first unread (not the newest) block means this fires at
|
|
4174
|
+
// most once per turn in practice, and later blocks stream in below the
|
|
4175
|
+
// anchor exactly as they do today.
|
|
4176
|
+
const maybeAnchorToFirstUnread = (messages: AgentWidgetMessage[]) => {
|
|
4177
|
+
if (getScrollMode() !== "anchor-top") return;
|
|
4178
|
+
if (!currentTurnAnchored) return;
|
|
4179
|
+
if (readerEngagedThisTurn) return;
|
|
4180
|
+
if (hasActiveTranscriptSelection()) return;
|
|
4181
|
+
|
|
4182
|
+
let pastReadBoundary = lastSentUserMessageId === null;
|
|
4183
|
+
for (const message of messages) {
|
|
4184
|
+
if (!pastReadBoundary) {
|
|
4185
|
+
if (message.id === lastSentUserMessageId) pastReadBoundary = true;
|
|
4186
|
+
continue;
|
|
4187
|
+
}
|
|
4188
|
+
if (!isAttentionWorthyMessage(message)) continue;
|
|
4189
|
+
// Already parked on it: hold still while it streams.
|
|
4190
|
+
if (message.id === anchoredMessageId) return;
|
|
4191
|
+
anchoredMessageId = message.id;
|
|
4192
|
+
scheduleAnchorToMessage(message.id);
|
|
4193
|
+
return;
|
|
4194
|
+
}
|
|
4195
|
+
};
|
|
4196
|
+
|
|
4113
4197
|
const trackMessages = (messages: AgentWidgetMessage[]) => {
|
|
4114
4198
|
const nextState = new Map<
|
|
4115
4199
|
string,
|
|
@@ -4967,28 +5051,30 @@ export const createAgentExperience = (
|
|
|
4967
5051
|
|
|
4968
5052
|
// Only render if we have an indicator (allows hiding via returning null)
|
|
4969
5053
|
if (typingIndicator) {
|
|
4970
|
-
//
|
|
5054
|
+
// Bubble wrapper for the typing indicator. It borrows the assistant
|
|
5055
|
+
// bubble's classes and background so the "thinking" bubble matches the
|
|
5056
|
+
// message bubbles that replace it under every layout preset.
|
|
4971
5057
|
const typingBubble = document.createElement("div");
|
|
4972
5058
|
const showBubble = config.loadingIndicator?.showBubble !== false; // default true
|
|
5059
|
+
const messageLayout = config.layout?.messages?.layout ?? "bubble";
|
|
4973
5060
|
typingBubble.className = showBubble
|
|
4974
|
-
?
|
|
4975
|
-
"persona-rounded-2xl",
|
|
4976
|
-
"persona-text-sm",
|
|
4977
|
-
"persona-leading-relaxed",
|
|
4978
|
-
"persona-shadow-sm",
|
|
4979
|
-
"persona-bg-persona-surface",
|
|
4980
|
-
"persona-border",
|
|
4981
|
-
"persona-text-persona-primary",
|
|
4982
|
-
"persona-px-5",
|
|
4983
|
-
"persona-py-3"
|
|
4984
|
-
].join(" ")
|
|
5061
|
+
? getBubbleClasses("assistant", messageLayout).join(" ")
|
|
4985
5062
|
: [
|
|
4986
5063
|
"persona-text-sm",
|
|
4987
5064
|
"persona-leading-relaxed",
|
|
4988
|
-
"persona-text-persona-
|
|
5065
|
+
"persona-text-persona-text"
|
|
4989
5066
|
].join(" ");
|
|
4990
5067
|
typingBubble.setAttribute("data-typing-indicator", "true");
|
|
4991
|
-
|
|
5068
|
+
if (showBubble) {
|
|
5069
|
+
typingBubble.style.color =
|
|
5070
|
+
"var(--persona-message-assistant-text, var(--persona-text))";
|
|
5071
|
+
if (messageLayout !== "flat") {
|
|
5072
|
+
typingBubble.style.backgroundColor =
|
|
5073
|
+
"var(--persona-message-assistant-bg, var(--persona-container))";
|
|
5074
|
+
typingBubble.style.borderColor =
|
|
5075
|
+
"var(--persona-message-assistant-border, var(--persona-border, #e5e7eb))";
|
|
5076
|
+
}
|
|
5077
|
+
}
|
|
4992
5078
|
|
|
4993
5079
|
typingBubble.appendChild(typingIndicator);
|
|
4994
5080
|
|
|
@@ -5998,6 +6084,8 @@ export const createAgentExperience = (
|
|
|
5998
6084
|
resetAnchorState();
|
|
5999
6085
|
followFallbackActive = true;
|
|
6000
6086
|
currentTurnAnchored = false;
|
|
6087
|
+
anchoredMessageId = null;
|
|
6088
|
+
readerEngagedThisTurn = false;
|
|
6001
6089
|
}
|
|
6002
6090
|
if (!scrollSendSeeded || suppressScrollSend) {
|
|
6003
6091
|
scrollSendSeeded = true;
|
|
@@ -6013,6 +6101,7 @@ export const createAgentExperience = (
|
|
|
6013
6101
|
handleAssistantTurnStarted();
|
|
6014
6102
|
}
|
|
6015
6103
|
if (lastAssistantMessage) lastHandledAssistantId = lastAssistantMessage.id;
|
|
6104
|
+
maybeAnchorToFirstUnread(messages);
|
|
6016
6105
|
|
|
6017
6106
|
const prevLastUserMessageId = voiceState.lastUserMessageId;
|
|
6018
6107
|
if (lastUserMessage && lastUserMessage.id !== prevLastUserMessageId) {
|
|
@@ -7341,21 +7430,6 @@ export const createAgentExperience = (
|
|
|
7341
7430
|
lastScrollTop = body.scrollTop;
|
|
7342
7431
|
let lastBottomOffset = getScrollBottomOffset(body);
|
|
7343
7432
|
|
|
7344
|
-
const getTranscriptSelection = (): Selection | null => {
|
|
7345
|
-
// Selections inside a shadow root are not always reflected by
|
|
7346
|
-
// document.getSelection(); prefer the shadow root's view when available
|
|
7347
|
-
// (non-standard but supported where it matters).
|
|
7348
|
-
const root = body.getRootNode();
|
|
7349
|
-
const shadowSelection =
|
|
7350
|
-
typeof (root as ShadowRoot & { getSelection?: () => Selection | null })
|
|
7351
|
-
.getSelection === "function"
|
|
7352
|
-
? (root as ShadowRoot & { getSelection: () => Selection | null }).getSelection()
|
|
7353
|
-
: null;
|
|
7354
|
-
return shadowSelection ?? body.ownerDocument.getSelection();
|
|
7355
|
-
};
|
|
7356
|
-
const hasActiveTranscriptSelection = () =>
|
|
7357
|
-
hasSelectionWithin(getTranscriptSelection(), body);
|
|
7358
|
-
|
|
7359
7433
|
const handleScroll = () => {
|
|
7360
7434
|
const scrollTop = body.scrollTop;
|
|
7361
7435
|
// When content mutates (e.g. stream-animation plugins re-rendering text)
|
|
@@ -7373,6 +7447,14 @@ export const createAgentExperience = (
|
|
|
7373
7447
|
if (!isFollowEffective()) {
|
|
7374
7448
|
// No follow state to manage (anchored anchor-top / none): just keep the
|
|
7375
7449
|
// scroll-to-bottom affordance in sync with the user's position.
|
|
7450
|
+
//
|
|
7451
|
+
// Reader intent is deliberately NOT inferred from scroll deltas here.
|
|
7452
|
+
// Growing the transcript under a pinned anchor makes the browser clamp
|
|
7453
|
+
// scrollTop and then restore it, which lands as a matched pair of real
|
|
7454
|
+
// scroll events (e.g. -12 then +12) with no user involved. The restore
|
|
7455
|
+
// arrives while the bottom offset is GROWING, so the shrink mask above
|
|
7456
|
+
// doesn't cover it, and it reads as a deliberate scroll. Engagement is
|
|
7457
|
+
// tracked from actual input instead (see `markReaderEngaged` callers).
|
|
7376
7458
|
lastScrollTop = scrollTop;
|
|
7377
7459
|
syncScrollToBottomButton();
|
|
7378
7460
|
return;
|
|
@@ -7459,21 +7541,23 @@ export const createAgentExperience = (
|
|
|
7459
7541
|
"ArrowDown",
|
|
7460
7542
|
]);
|
|
7461
7543
|
const handleTranscriptKeydown = (event: KeyboardEvent) => {
|
|
7544
|
+
if (!NAV_KEYS.has(event.key)) return;
|
|
7545
|
+
// Reader intent for first-unread anchoring is tracked unconditionally;
|
|
7546
|
+
// only the follow-mode *pause* below stays behind the opt-in flag.
|
|
7547
|
+
markReaderEngaged();
|
|
7462
7548
|
if (!isPauseOnInteractionEnabled()) return;
|
|
7463
7549
|
if (!isFollowEffective()) return;
|
|
7464
7550
|
if (!autoFollow.isFollowing()) return;
|
|
7465
|
-
|
|
7466
|
-
pauseAutoScroll();
|
|
7467
|
-
}
|
|
7551
|
+
pauseAutoScroll();
|
|
7468
7552
|
};
|
|
7469
7553
|
const handleTranscriptFocusIn = (event: FocusEvent) => {
|
|
7554
|
+
const target = event.target as Element | null;
|
|
7555
|
+
if (!target?.closest("a, button, [tabindex], input, textarea, select")) return;
|
|
7556
|
+
markReaderEngaged();
|
|
7470
7557
|
if (!isPauseOnInteractionEnabled()) return;
|
|
7471
7558
|
if (!isFollowEffective()) return;
|
|
7472
7559
|
if (!autoFollow.isFollowing()) return;
|
|
7473
|
-
|
|
7474
|
-
if (target && target.closest("a, button, [tabindex], input, textarea, select")) {
|
|
7475
|
-
pauseAutoScroll();
|
|
7476
|
-
}
|
|
7560
|
+
pauseAutoScroll();
|
|
7477
7561
|
};
|
|
7478
7562
|
body.addEventListener("keydown", handleTranscriptKeydown);
|
|
7479
7563
|
body.addEventListener("focusin", handleTranscriptFocusIn);
|
|
@@ -7483,6 +7567,9 @@ export const createAgentExperience = (
|
|
|
7483
7567
|
});
|
|
7484
7568
|
|
|
7485
7569
|
const handleWheel = (event: WheelEvent) => {
|
|
7570
|
+
// A wheel tick is unambiguous reader intent in every mode — including
|
|
7571
|
+
// anchored anchor-top, where there is no follow state to pause.
|
|
7572
|
+
if (event.deltaY !== 0) markReaderEngaged();
|
|
7486
7573
|
if (!isFollowEffective()) return;
|
|
7487
7574
|
const action = resolveFollowStateFromWheel({
|
|
7488
7575
|
following: autoFollow.isFollowing(),
|
|
@@ -7498,6 +7585,14 @@ export const createAgentExperience = (
|
|
|
7498
7585
|
}
|
|
7499
7586
|
};
|
|
7500
7587
|
body.addEventListener("wheel", handleWheel, { passive: true });
|
|
7588
|
+
// Touch drag and pointer press (scrollbar drags, tapping a tool bubble open)
|
|
7589
|
+
// are the remaining ways a reader takes over without a wheel or key event.
|
|
7590
|
+
body.addEventListener("touchmove", markReaderEngaged, { passive: true });
|
|
7591
|
+
body.addEventListener("pointerdown", markReaderEngaged, { passive: true });
|
|
7592
|
+
destroyCallbacks.push(() => {
|
|
7593
|
+
body.removeEventListener("touchmove", markReaderEngaged);
|
|
7594
|
+
body.removeEventListener("pointerdown", markReaderEngaged);
|
|
7595
|
+
});
|
|
7501
7596
|
destroyCallbacks.push(() => body.removeEventListener("wheel", handleWheel));
|
|
7502
7597
|
scrollToBottomButton.addEventListener("click", () => {
|
|
7503
7598
|
// Jumping to the latest abandons the current anchor: drop the spacer
|
|
@@ -7759,8 +7854,11 @@ export const createAgentExperience = (
|
|
|
7759
7854
|
const previousStreamAnimationType = config.features?.streamAnimation?.type;
|
|
7760
7855
|
// One consistent recursive patch policy across the live controller and the
|
|
7761
7856
|
// init handle. See utils/config-merge.ts for the replace-leaf list and
|
|
7762
|
-
// explicit-undefined reset semantics.
|
|
7763
|
-
|
|
7857
|
+
// explicit-undefined reset semantics. The patch merges over the
|
|
7858
|
+
// pre-preference base features, then preferences re-resolve on top.
|
|
7859
|
+
config = mergeConfigUpdate({ ...config, features: baseFeatures }, nextConfig);
|
|
7860
|
+
baseFeatures = config.features;
|
|
7861
|
+
config = resolveConfigPreferences(config);
|
|
7764
7862
|
// applyFullHeightStyles resets mount.style.cssText, so call it before applyThemeVariables
|
|
7765
7863
|
applyFullHeightStyles();
|
|
7766
7864
|
applyThemeVariables(mount, config);
|
|
@@ -9195,13 +9293,20 @@ export const createAgentExperience = (
|
|
|
9195
9293
|
if (!artifactsSidebarEnabled(config)) return null;
|
|
9196
9294
|
// Programmatic upserts match the streamed UX: only "panel"-mode
|
|
9197
9295
|
// artifacts auto-open the pane (overriding a previous Close), while
|
|
9198
|
-
// "
|
|
9296
|
+
// "collapsed"/"inline" stay calm — the injected transcript block is the
|
|
9199
9297
|
// affordance. Independent of `transcript: false`: pane-only callers
|
|
9200
|
-
// (e.g. the theme editor preview) rely on the
|
|
9201
|
-
// callers that want the pane in
|
|
9298
|
+
// (e.g. the theme editor preview) rely on the auto-open default
|
|
9299
|
+
// surfacing; callers that want the pane in another mode call
|
|
9300
|
+
// showArtifacts().
|
|
9202
9301
|
const mode = resolveArtifactDisplayMode(
|
|
9203
9302
|
config.features?.artifacts,
|
|
9204
|
-
|
|
9303
|
+
{
|
|
9304
|
+
artifactType: manual.artifactType,
|
|
9305
|
+
...(manual.artifactType === "markdown" && manual.file
|
|
9306
|
+
? { file: manual.file }
|
|
9307
|
+
: {}),
|
|
9308
|
+
...(manual.presentation ? { presentation: manual.presentation } : {}),
|
|
9309
|
+
}
|
|
9205
9310
|
);
|
|
9206
9311
|
if (mode === "panel") {
|
|
9207
9312
|
artifactsPaneUserHidden = false;
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
|
|
3
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
4
|
+
|
|
5
|
+
import { createAgentExperience } from "./ui";
|
|
6
|
+
|
|
7
|
+
const createMount = () => {
|
|
8
|
+
const mount = document.createElement("div");
|
|
9
|
+
document.body.appendChild(mount);
|
|
10
|
+
return mount;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Mount a widget with a never-resolving fetch, send a message, and return the
|
|
15
|
+
* standalone typing indicator bubble rendered during the silent gap.
|
|
16
|
+
*/
|
|
17
|
+
const renderTypingBubble = async (
|
|
18
|
+
config: Record<string, unknown>
|
|
19
|
+
): Promise<{ bubble: HTMLElement; destroy: () => void }> => {
|
|
20
|
+
global.fetch = vi.fn().mockImplementation(
|
|
21
|
+
() => new Promise(() => {})
|
|
22
|
+
) as unknown as typeof fetch;
|
|
23
|
+
|
|
24
|
+
const mount = createMount();
|
|
25
|
+
const controller = createAgentExperience(mount, {
|
|
26
|
+
apiUrl: "https://api.example.com/chat",
|
|
27
|
+
launcher: { enabled: false },
|
|
28
|
+
...config,
|
|
29
|
+
} as unknown as Parameters<typeof createAgentExperience>[1]);
|
|
30
|
+
|
|
31
|
+
controller.submitMessage("hello");
|
|
32
|
+
await Promise.resolve();
|
|
33
|
+
await Promise.resolve();
|
|
34
|
+
|
|
35
|
+
const bubble = mount.querySelector<HTMLElement>('[data-typing-indicator="true"]')!;
|
|
36
|
+
return { bubble, destroy: () => controller.destroy() };
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
describe("standalone typing indicator bubble", () => {
|
|
40
|
+
afterEach(() => {
|
|
41
|
+
document.body.innerHTML = "";
|
|
42
|
+
if (typeof localStorage !== "undefined") localStorage.clear();
|
|
43
|
+
vi.restoreAllMocks();
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it("uses the assistant bubble classes under the default bubble layout", async () => {
|
|
47
|
+
const { bubble, destroy } = await renderTypingBubble({});
|
|
48
|
+
|
|
49
|
+
expect(bubble).not.toBeNull();
|
|
50
|
+
expect(bubble.classList.contains("persona-message-assistant-bubble")).toBe(true);
|
|
51
|
+
expect(bubble.classList.contains("persona-shadow-sm")).toBe(true);
|
|
52
|
+
expect(bubble.classList.contains("persona-border")).toBe(true);
|
|
53
|
+
expect(bubble.classList.contains("persona-px-5")).toBe(true);
|
|
54
|
+
|
|
55
|
+
destroy();
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it("drops the shadow and border under the minimal layout", async () => {
|
|
59
|
+
const { bubble, destroy } = await renderTypingBubble({
|
|
60
|
+
layout: { messages: { layout: "minimal" } },
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
expect(bubble.classList.contains("persona-message-assistant-bubble")).toBe(true);
|
|
64
|
+
expect(bubble.classList.contains("persona-shadow-sm")).toBe(false);
|
|
65
|
+
expect(bubble.classList.contains("persona-border")).toBe(false);
|
|
66
|
+
expect(bubble.classList.contains("persona-px-3")).toBe(true);
|
|
67
|
+
// Minimal keeps the surface background, matching its message bubbles.
|
|
68
|
+
expect(bubble.style.backgroundColor).toContain("--persona-message-assistant-bg");
|
|
69
|
+
|
|
70
|
+
destroy();
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it("drops the background entirely under the flat layout", async () => {
|
|
74
|
+
const { bubble, destroy } = await renderTypingBubble({
|
|
75
|
+
layout: { messages: { layout: "flat" } },
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
expect(bubble.classList.contains("persona-shadow-sm")).toBe(false);
|
|
79
|
+
expect(bubble.classList.contains("persona-border")).toBe(false);
|
|
80
|
+
expect(bubble.style.backgroundColor).toBe("");
|
|
81
|
+
expect(bubble.style.color).toBe(
|
|
82
|
+
"var(--persona-message-assistant-text, var(--persona-text))"
|
|
83
|
+
);
|
|
84
|
+
|
|
85
|
+
destroy();
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it("renders bare text classes when loadingIndicator.showBubble is false", async () => {
|
|
89
|
+
const { bubble, destroy } = await renderTypingBubble({
|
|
90
|
+
loadingIndicator: { showBubble: false },
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
expect(bubble.classList.contains("persona-message-assistant-bubble")).toBe(false);
|
|
94
|
+
expect(bubble.classList.contains("persona-shadow-sm")).toBe(false);
|
|
95
|
+
expect(bubble.classList.contains("persona-border")).toBe(false);
|
|
96
|
+
expect(bubble.classList.contains("persona-text-persona-text")).toBe(true);
|
|
97
|
+
expect(bubble.classList.contains("persona-text-persona-primary")).toBe(false);
|
|
98
|
+
expect(bubble.style.backgroundColor).toBe("");
|
|
99
|
+
|
|
100
|
+
destroy();
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it("honors the assistant bubble theme background token", async () => {
|
|
104
|
+
const { bubble, destroy } = await renderTypingBubble({
|
|
105
|
+
layout: { messages: { layout: "minimal" } },
|
|
106
|
+
theme: {
|
|
107
|
+
semantic: {
|
|
108
|
+
colors: {
|
|
109
|
+
container: "#112233",
|
|
110
|
+
text: "#ddeeff",
|
|
111
|
+
border: "#445566",
|
|
112
|
+
},
|
|
113
|
+
},
|
|
114
|
+
},
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
// The bubble reads the same variable the assistant message bubbles read,
|
|
118
|
+
// so `theme.components.message.assistant.background` covers both.
|
|
119
|
+
expect(bubble.style.backgroundColor).toBe(
|
|
120
|
+
"var(--persona-message-assistant-bg, var(--persona-container))"
|
|
121
|
+
);
|
|
122
|
+
const root = bubble.closest<HTMLElement>("[data-persona-root]");
|
|
123
|
+
expect(root?.style.getPropertyValue("--persona-message-assistant-bg")).toBe(
|
|
124
|
+
"#112233"
|
|
125
|
+
);
|
|
126
|
+
expect(root?.style.getPropertyValue("--persona-message-assistant-text")).toBe(
|
|
127
|
+
"#ddeeff"
|
|
128
|
+
);
|
|
129
|
+
expect(root?.style.getPropertyValue("--persona-message-assistant-border")).toBe(
|
|
130
|
+
"#445566"
|
|
131
|
+
);
|
|
132
|
+
|
|
133
|
+
destroy();
|
|
134
|
+
});
|
|
135
|
+
});
|