@runtypelabs/persona 3.15.1 → 3.17.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/animations/glyph-cycle.cjs +279 -0
- package/dist/animations/glyph-cycle.d.cts +5 -0
- package/dist/animations/glyph-cycle.d.ts +5 -0
- package/dist/animations/glyph-cycle.js +252 -0
- package/dist/animations/types-HPZY7oAI.d.cts +282 -0
- package/dist/animations/types-HPZY7oAI.d.ts +282 -0
- package/dist/animations/wipe.cjs +107 -0
- package/dist/animations/wipe.d.cts +5 -0
- package/dist/animations/wipe.d.ts +5 -0
- package/dist/animations/wipe.js +80 -0
- package/dist/index.cjs +49 -48
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +216 -1
- package/dist/index.d.ts +216 -1
- package/dist/index.global.js +137 -82
- package/dist/index.global.js.map +1 -1
- package/dist/index.js +49 -48
- package/dist/index.js.map +1 -1
- package/dist/testing.cjs +85 -0
- package/dist/testing.d.cts +39 -0
- package/dist/testing.d.ts +39 -0
- package/dist/testing.js +56 -0
- package/dist/theme-editor.cjs +847 -127
- package/dist/theme-editor.d.cts +225 -2
- package/dist/theme-editor.d.ts +225 -2
- package/dist/theme-editor.js +845 -127
- package/dist/widget.css +133 -0
- package/package.json +20 -3
- package/src/animations/glyph-cycle.ts +332 -0
- package/src/animations/wipe.ts +66 -0
- package/src/client.test.ts +141 -0
- package/src/client.ts +197 -2
- package/src/components/composer-builder.ts +61 -10
- package/src/components/header-builder.ts +18 -7
- package/src/components/header-layouts.ts +3 -1
- package/src/components/message-bubble.test.ts +181 -2
- package/src/components/message-bubble.ts +209 -14
- package/src/components/panel.ts +4 -1
- package/src/defaults.ts +22 -0
- package/src/index-global.ts +31 -0
- package/src/index.ts +18 -0
- package/src/session.test.ts +93 -1
- package/src/session.ts +5 -0
- package/src/styles/widget.css +133 -0
- package/src/testing/index.ts +11 -0
- package/src/testing/mock-stream.test.ts +80 -0
- package/src/testing/mock-stream.ts +94 -0
- package/src/testing.ts +2 -0
- package/src/theme-editor/index.ts +4 -0
- package/src/theme-editor/preview-utils.test.ts +60 -0
- package/src/theme-editor/preview-utils.ts +129 -0
- package/src/theme-editor/sections.test.ts +19 -0
- package/src/theme-editor/sections.ts +84 -1
- package/src/types.ts +221 -0
- package/src/ui.stop-button.test.ts +165 -0
- package/src/ui.ts +79 -8
- package/src/utils/message-fingerprint.ts +2 -0
- package/src/utils/morph.ts +7 -0
- package/src/utils/stream-animation.test.ts +417 -0
- package/src/utils/stream-animation.ts +449 -0
package/src/ui.ts
CHANGED
|
@@ -41,6 +41,11 @@ import {
|
|
|
41
41
|
resolveFollowStateFromWheel
|
|
42
42
|
} from "./utils/auto-follow";
|
|
43
43
|
import { statusCopy, DEFAULT_OVERLAY_Z_INDEX, PORTALED_OVERLAY_Z_INDEX } from "./utils/constants";
|
|
44
|
+
import {
|
|
45
|
+
detachAllPlugins,
|
|
46
|
+
ensurePluginActive,
|
|
47
|
+
resolveStreamAnimationPlugin,
|
|
48
|
+
} from "./utils/stream-animation";
|
|
44
49
|
import { syncOverlayHostStacking } from "./utils/overlay-host-stacking";
|
|
45
50
|
import { acquireScrollLock } from "./utils/scroll-lock";
|
|
46
51
|
import { isDockedMountMode, resolveDockConfig } from "./utils/dock";
|
|
@@ -701,6 +706,7 @@ export const createAgentExperience = (
|
|
|
701
706
|
leftActions,
|
|
702
707
|
rightActions
|
|
703
708
|
} = panelElements;
|
|
709
|
+
let setSendButtonMode = panelElements.setSendButtonMode;
|
|
704
710
|
|
|
705
711
|
// Use mutable references for mic button so we can update them dynamically
|
|
706
712
|
let micButton: HTMLButtonElement | null = panelElements.micButton;
|
|
@@ -1671,6 +1677,16 @@ export const createAgentExperience = (
|
|
|
1671
1677
|
const panelShadow = resolvePanelChrome(panelPartial?.shadow, defaultPanelShadow);
|
|
1672
1678
|
const panelBorderRadius = resolvePanelChrome(panelPartial?.borderRadius, defaultPanelBorderRadius);
|
|
1673
1679
|
|
|
1680
|
+
// Clearing body.style.cssText below wipes the inline `flex: 1 1 0%` /
|
|
1681
|
+
// `min-height: 0` / `overflow-y: auto` that make the messages area a
|
|
1682
|
+
// scroll container. Between the reset and the mode-specific reapply,
|
|
1683
|
+
// the body's clientHeight == scrollHeight momentarily, so the browser
|
|
1684
|
+
// clamps scrollTop to 0 — and a synchronous restore at the end of this
|
|
1685
|
+
// function runs before layout has reflowed, so the write is also
|
|
1686
|
+
// clamped. Defer the restore to the next frame, once the reapplied
|
|
1687
|
+
// styles have produced a scrollable container again.
|
|
1688
|
+
const prevBodyScrollTop = body.scrollTop;
|
|
1689
|
+
|
|
1674
1690
|
// Reset all inline styles first to handle mode toggling
|
|
1675
1691
|
// This ensures styles don't persist when switching between modes
|
|
1676
1692
|
mount.style.cssText = '';
|
|
@@ -1679,6 +1695,18 @@ export const createAgentExperience = (
|
|
|
1679
1695
|
container.style.cssText = '';
|
|
1680
1696
|
body.style.cssText = '';
|
|
1681
1697
|
footer.style.cssText = '';
|
|
1698
|
+
|
|
1699
|
+
const restoreBodyScrollTop = (): void => {
|
|
1700
|
+
if (prevBodyScrollTop <= 0) return;
|
|
1701
|
+
const ownerWindow = body.ownerDocument.defaultView ?? window;
|
|
1702
|
+
ownerWindow.requestAnimationFrame(() => {
|
|
1703
|
+
if (body.scrollTop === prevBodyScrollTop) return;
|
|
1704
|
+
// If scrollHeight collapsed (content actually shrank), don't fight it
|
|
1705
|
+
const maxScrollTop = body.scrollHeight - body.clientHeight;
|
|
1706
|
+
if (maxScrollTop <= 0) return;
|
|
1707
|
+
body.scrollTop = Math.min(prevBodyScrollTop, maxScrollTop);
|
|
1708
|
+
});
|
|
1709
|
+
};
|
|
1682
1710
|
|
|
1683
1711
|
// Mobile fullscreen: fill entire viewport with no radius/shadow/margins
|
|
1684
1712
|
if (shouldGoFullscreen) {
|
|
@@ -1742,6 +1770,7 @@ export const createAgentExperience = (
|
|
|
1742
1770
|
footer.style.flexShrink = '0';
|
|
1743
1771
|
|
|
1744
1772
|
wasMobileFullscreen = true;
|
|
1773
|
+
restoreBodyScrollTop();
|
|
1745
1774
|
return; // Skip remaining mode logic
|
|
1746
1775
|
}
|
|
1747
1776
|
|
|
@@ -1926,6 +1955,8 @@ export const createAgentExperience = (
|
|
|
1926
1955
|
: '';
|
|
1927
1956
|
wrapper.style.cssText += maxHeightStyles + paddingStyles + zIndexStyles;
|
|
1928
1957
|
}
|
|
1958
|
+
|
|
1959
|
+
restoreBodyScrollTop();
|
|
1929
1960
|
};
|
|
1930
1961
|
applyFullHeightStyles();
|
|
1931
1962
|
// Apply theme variables after applyFullHeightStyles since it resets mount.style.cssText
|
|
@@ -2003,6 +2034,23 @@ export const createAgentExperience = (
|
|
|
2003
2034
|
}
|
|
2004
2035
|
});
|
|
2005
2036
|
|
|
2037
|
+
// Activate the stream-animation plugin for this widget instance. Plugins
|
|
2038
|
+
// with `styles` inject their CSS into the widget root once; plugins with
|
|
2039
|
+
// `onAttach` (e.g., glyph-cycle's MutationObserver for real glyph tick
|
|
2040
|
+
// loops) can register long-lived DOM listeners here. Detach callbacks are
|
|
2041
|
+
// deferred to widget destroy.
|
|
2042
|
+
const streamAnimationConfig = config.features?.streamAnimation;
|
|
2043
|
+
if (streamAnimationConfig?.type && streamAnimationConfig.type !== "none") {
|
|
2044
|
+
const plugin = resolveStreamAnimationPlugin(
|
|
2045
|
+
streamAnimationConfig.type,
|
|
2046
|
+
streamAnimationConfig.plugins
|
|
2047
|
+
);
|
|
2048
|
+
if (plugin) {
|
|
2049
|
+
ensurePluginActive(plugin, mount);
|
|
2050
|
+
destroyCallbacks.push(() => detachAllPlugins(mount));
|
|
2051
|
+
}
|
|
2052
|
+
}
|
|
2053
|
+
|
|
2006
2054
|
const suggestionsManager = createSuggestions(suggestions);
|
|
2007
2055
|
let closeHandler: (() => void) | null = null;
|
|
2008
2056
|
let session: AgentWidgetSession;
|
|
@@ -2921,9 +2969,10 @@ export const createAgentExperience = (
|
|
|
2921
2969
|
};
|
|
2922
2970
|
|
|
2923
2971
|
const setComposerDisabled = (disabled: boolean) => {
|
|
2924
|
-
//
|
|
2925
|
-
//
|
|
2926
|
-
|
|
2972
|
+
// The send button stays enabled while streaming — it doubles as a stop
|
|
2973
|
+
// button. Ancillary controls (mic, suggestions, opt-in targets) still
|
|
2974
|
+
// disable so the user can't race a send against an in-flight stream.
|
|
2975
|
+
setSendButtonMode(disabled ? "stop" : "send");
|
|
2927
2976
|
if (micButton) {
|
|
2928
2977
|
micButton.disabled = disabled;
|
|
2929
2978
|
}
|
|
@@ -2974,9 +3023,10 @@ export const createAgentExperience = (
|
|
|
2974
3023
|
}
|
|
2975
3024
|
}
|
|
2976
3025
|
|
|
2977
|
-
// Only update send button text if NOT using icon mode
|
|
3026
|
+
// Only update send button text if NOT using icon mode. Skip while
|
|
3027
|
+
// streaming so we don't stomp on the "Stop" label.
|
|
2978
3028
|
const useIcon = config.sendButton?.useIcon ?? false;
|
|
2979
|
-
if (!useIcon) {
|
|
3029
|
+
if (!useIcon && !session?.isStreaming()) {
|
|
2980
3030
|
sendButton.textContent = config.copy?.sendButtonLabel ?? "Send";
|
|
2981
3031
|
}
|
|
2982
3032
|
|
|
@@ -3178,6 +3228,15 @@ export const createAgentExperience = (
|
|
|
3178
3228
|
|
|
3179
3229
|
const handleSubmit = (event: Event) => {
|
|
3180
3230
|
event.preventDefault();
|
|
3231
|
+
|
|
3232
|
+
// While a response is streaming, the submit button acts as a stop button.
|
|
3233
|
+
// Abort the in-flight stream and leave textarea contents / attachments
|
|
3234
|
+
// intact so the user can edit and resend without retyping.
|
|
3235
|
+
if (session.isStreaming()) {
|
|
3236
|
+
session.cancel();
|
|
3237
|
+
return;
|
|
3238
|
+
}
|
|
3239
|
+
|
|
3181
3240
|
const value = textarea.value.trim();
|
|
3182
3241
|
const hasAttachments = attachmentManager?.hasAttachments() ?? false;
|
|
3183
3242
|
|
|
@@ -3914,16 +3973,26 @@ export const createAgentExperience = (
|
|
|
3914
3973
|
}
|
|
3915
3974
|
|
|
3916
3975
|
lastScrollTop = body.scrollTop;
|
|
3976
|
+
let lastScrollHeight = body.scrollHeight;
|
|
3917
3977
|
|
|
3918
3978
|
const handleScroll = () => {
|
|
3919
3979
|
const scrollTop = body.scrollTop;
|
|
3980
|
+
// When content mutates (e.g. stream-animation plugins re-rendering text),
|
|
3981
|
+
// scrollHeight can shrink and force the browser to clamp scrollTop downward.
|
|
3982
|
+
// That emits a scroll event with a negative delta that would otherwise be
|
|
3983
|
+
// misread as the user scrolling up, pausing auto-follow and flashing the
|
|
3984
|
+
// scroll-to-bottom button. Treat those as non-user events.
|
|
3985
|
+
const currentScrollHeight = body.scrollHeight;
|
|
3986
|
+
const scrollHeightShrank = currentScrollHeight < lastScrollHeight;
|
|
3987
|
+
lastScrollHeight = currentScrollHeight;
|
|
3988
|
+
|
|
3920
3989
|
const { action, nextLastScrollTop } = resolveFollowStateFromScroll({
|
|
3921
3990
|
following: autoFollow.isFollowing(),
|
|
3922
3991
|
currentScrollTop: scrollTop,
|
|
3923
3992
|
lastScrollTop,
|
|
3924
3993
|
nearBottom: isElementNearBottom(body, BOTTOM_THRESHOLD),
|
|
3925
3994
|
userScrollThreshold: USER_SCROLL_THRESHOLD,
|
|
3926
|
-
isAutoScrolling: isAutoScrolling || hasPendingAutoScroll,
|
|
3995
|
+
isAutoScrolling: isAutoScrolling || hasPendingAutoScroll || scrollHeightShrank,
|
|
3927
3996
|
pauseOnUpwardScroll: true,
|
|
3928
3997
|
pauseWhenAwayFromBottom: false,
|
|
3929
3998
|
resumeRequiresDownwardScroll: true
|
|
@@ -4617,9 +4686,11 @@ export const createAgentExperience = (
|
|
|
4617
4686
|
const closeButtonIconName = launcher.closeButtonIconName ?? "x";
|
|
4618
4687
|
const closeButtonIconText = launcher.closeButtonIconText ?? "×";
|
|
4619
4688
|
|
|
4620
|
-
// Clear existing content and render new icon
|
|
4689
|
+
// Clear existing content and render new icon.
|
|
4690
|
+
// Larger intrinsic size compensates for the X glyph's sparse
|
|
4691
|
+
// viewBox so the close button visually matches sibling icons.
|
|
4621
4692
|
closeButton.innerHTML = "";
|
|
4622
|
-
const iconSvg = renderLucideIcon(closeButtonIconName, "
|
|
4693
|
+
const iconSvg = renderLucideIcon(closeButtonIconName, "28px", "currentColor", 1);
|
|
4623
4694
|
if (iconSvg) {
|
|
4624
4695
|
closeButton.appendChild(iconSvg);
|
|
4625
4696
|
} else {
|
|
@@ -24,6 +24,7 @@ export type FingerprintableMessage = {
|
|
|
24
24
|
};
|
|
25
25
|
reasoning?: { chunks?: string[]; status?: string; [key: string]: unknown };
|
|
26
26
|
contentParts?: unknown[];
|
|
27
|
+
stopReason?: string;
|
|
27
28
|
};
|
|
28
29
|
|
|
29
30
|
export type MessageCacheEntry = {
|
|
@@ -63,6 +64,7 @@ export function computeMessageFingerprint(
|
|
|
63
64
|
: 0,
|
|
64
65
|
message.reasoning?.chunks?.length ?? 0,
|
|
65
66
|
message.contentParts?.length ?? 0,
|
|
67
|
+
message.stopReason ?? "",
|
|
66
68
|
configVersion,
|
|
67
69
|
].join("\x00");
|
|
68
70
|
}
|
package/src/utils/morph.ts
CHANGED
|
@@ -30,6 +30,13 @@ export const morphMessages = (
|
|
|
30
30
|
if (oldNode.classList.contains("persona-animate-typing")) {
|
|
31
31
|
return false;
|
|
32
32
|
}
|
|
33
|
+
// Plugins actively mutating a node (e.g. glyph-cycle's tick loop)
|
|
34
|
+
// opt out of morph entirely via this attribute. Unlike
|
|
35
|
+
// `data-preserve-animation`, this is honored regardless of whether
|
|
36
|
+
// the new DOM carries the attribute — it's a runtime-only marker.
|
|
37
|
+
if (oldNode.hasAttribute("data-preserve-runtime")) {
|
|
38
|
+
return false;
|
|
39
|
+
}
|
|
33
40
|
if (oldNode.hasAttribute("data-preserve-animation")) {
|
|
34
41
|
// Allow morph when the new node drops the attribute (e.g. tool completed)
|
|
35
42
|
if (newNode instanceof HTMLElement && !newNode.hasAttribute("data-preserve-animation")) {
|
|
@@ -0,0 +1,417 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import { describe, it, expect } from "vitest";
|
|
3
|
+
import {
|
|
4
|
+
wrapStreamAnimation,
|
|
5
|
+
createSkeletonPlaceholder,
|
|
6
|
+
createStreamCaret,
|
|
7
|
+
resolveStreamAnimation,
|
|
8
|
+
streamAnimationContainerClass,
|
|
9
|
+
streamAnimationBubbleClass,
|
|
10
|
+
isWrappingAnimation,
|
|
11
|
+
resolveStreamAnimationPlugin,
|
|
12
|
+
registerStreamAnimationPlugin,
|
|
13
|
+
unregisterStreamAnimationPlugin,
|
|
14
|
+
listRegisteredStreamAnimations,
|
|
15
|
+
applyStreamBuffer,
|
|
16
|
+
ensurePluginActive,
|
|
17
|
+
detachAllPlugins,
|
|
18
|
+
} from "./stream-animation";
|
|
19
|
+
import type { AgentWidgetMessage, StreamAnimationPlugin } from "../types";
|
|
20
|
+
// Side-import the subpath plugin modules so tests can resolve their types.
|
|
21
|
+
// `letter-rise` and `word-fade` are core built-ins and need no import.
|
|
22
|
+
import "../animations/wipe";
|
|
23
|
+
import "../animations/glyph-cycle";
|
|
24
|
+
|
|
25
|
+
describe("wrapStreamAnimation — char mode", () => {
|
|
26
|
+
it("wraps every character in a plain paragraph into a stream-char span", () => {
|
|
27
|
+
const out = wrapStreamAnimation("<p>Hi!</p>", "char", "m1");
|
|
28
|
+
const parser = document.createElement("div");
|
|
29
|
+
parser.innerHTML = out;
|
|
30
|
+
const spans = parser.querySelectorAll(".persona-stream-char");
|
|
31
|
+
expect(spans.length).toBe(3);
|
|
32
|
+
expect(spans[0].textContent).toBe("H");
|
|
33
|
+
expect(spans[1].textContent).toBe("i");
|
|
34
|
+
expect(spans[2].textContent).toBe("!");
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it("assigns monotonic --char-index starting at 0", () => {
|
|
38
|
+
const out = wrapStreamAnimation("<p>abc</p>", "char", "m1");
|
|
39
|
+
const parser = document.createElement("div");
|
|
40
|
+
parser.innerHTML = out;
|
|
41
|
+
const spans = parser.querySelectorAll(".persona-stream-char");
|
|
42
|
+
expect(spans[0].getAttribute("style")).toContain("--char-index: 0");
|
|
43
|
+
expect(spans[1].getAttribute("style")).toContain("--char-index: 1");
|
|
44
|
+
expect(spans[2].getAttribute("style")).toContain("--char-index: 2");
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it("emits stable ids scoped by messageId", () => {
|
|
48
|
+
const out = wrapStreamAnimation("<p>ab</p>", "char", "msg-42");
|
|
49
|
+
const parser = document.createElement("div");
|
|
50
|
+
parser.innerHTML = out;
|
|
51
|
+
expect(parser.querySelector("#stream-c-msg-42-0")?.textContent).toBe("a");
|
|
52
|
+
expect(parser.querySelector("#stream-c-msg-42-1")?.textContent).toBe("b");
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it("preserves formatting tags and wraps text inside them", () => {
|
|
56
|
+
const out = wrapStreamAnimation("<p>Hi <strong>bold</strong></p>", "char", "m1");
|
|
57
|
+
const parser = document.createElement("div");
|
|
58
|
+
parser.innerHTML = out;
|
|
59
|
+
expect(parser.querySelector("strong")).toBeTruthy();
|
|
60
|
+
const spans = parser.querySelectorAll(".persona-stream-char");
|
|
61
|
+
// "Hi" (2) + "bold" (4) = 6 wrapped chars; the space between stays as a
|
|
62
|
+
// plain text node so natural line-wrap works.
|
|
63
|
+
expect(spans.length).toBe(6);
|
|
64
|
+
expect(parser.querySelector("strong")?.querySelectorAll(".persona-stream-char").length).toBe(4);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it("skips descendants of <code> so code spans render as plain text", () => {
|
|
68
|
+
const out = wrapStreamAnimation("<p>see <code>x.y</code></p>", "char", "m1");
|
|
69
|
+
const parser = document.createElement("div");
|
|
70
|
+
parser.innerHTML = out;
|
|
71
|
+
expect(parser.querySelector("code")?.textContent).toBe("x.y");
|
|
72
|
+
expect(parser.querySelector("code")?.querySelectorAll(".persona-stream-char").length).toBe(0);
|
|
73
|
+
// "see" is 3 wrapped chars; the trailing space stays as a plain text node.
|
|
74
|
+
expect(parser.querySelectorAll(".persona-stream-char").length).toBe(3);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it("skips descendants of <pre>", () => {
|
|
78
|
+
const out = wrapStreamAnimation("<pre>code\nblock</pre>", "char", "m1");
|
|
79
|
+
const parser = document.createElement("div");
|
|
80
|
+
parser.innerHTML = out;
|
|
81
|
+
expect(parser.querySelector("pre")?.textContent).toBe("code\nblock");
|
|
82
|
+
expect(parser.querySelectorAll(".persona-stream-char").length).toBe(0);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it("skips descendants of <a>", () => {
|
|
86
|
+
const out = wrapStreamAnimation('<p>go <a href="/x">home</a></p>', "char", "m1");
|
|
87
|
+
const parser = document.createElement("div");
|
|
88
|
+
parser.innerHTML = out;
|
|
89
|
+
expect(parser.querySelector("a")?.textContent).toBe("home");
|
|
90
|
+
expect(parser.querySelector("a")?.querySelectorAll(".persona-stream-char").length).toBe(0);
|
|
91
|
+
// Only "go" is wrapped (2 chars); the trailing space stays plain.
|
|
92
|
+
expect(parser.querySelectorAll(".persona-stream-char").length).toBe(2);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it("leaves whitespace as a plain text node so word breaks survive", () => {
|
|
96
|
+
const out = wrapStreamAnimation("<p>a b</p>", "char", "m1");
|
|
97
|
+
const parser = document.createElement("div");
|
|
98
|
+
parser.innerHTML = out;
|
|
99
|
+
const spans = parser.querySelectorAll(".persona-stream-char");
|
|
100
|
+
expect(spans.length).toBe(2);
|
|
101
|
+
expect(spans[0].textContent).toBe("a");
|
|
102
|
+
expect(spans[1].textContent).toBe("b");
|
|
103
|
+
const p = parser.querySelector("p")!;
|
|
104
|
+
expect(p.childNodes.length).toBe(3);
|
|
105
|
+
expect(p.childNodes[1].nodeType).toBe(Node.TEXT_NODE);
|
|
106
|
+
expect(p.childNodes[1].textContent).toBe(" ");
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it("wraps each word run in a word-group so chars can't break mid-word", () => {
|
|
110
|
+
const out = wrapStreamAnimation("<p>Hi there</p>", "char", "m1");
|
|
111
|
+
const parser = document.createElement("div");
|
|
112
|
+
parser.innerHTML = out;
|
|
113
|
+
const groups = parser.querySelectorAll(".persona-stream-word-group");
|
|
114
|
+
expect(groups.length).toBe(2);
|
|
115
|
+
expect(groups[0].textContent).toBe("Hi");
|
|
116
|
+
expect(groups[1].textContent).toBe("there");
|
|
117
|
+
expect(groups[0].querySelectorAll(".persona-stream-char").length).toBe(2);
|
|
118
|
+
expect(groups[1].querySelectorAll(".persona-stream-char").length).toBe(5);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it("keeps newlines and multi-space runs intact as text nodes", () => {
|
|
122
|
+
const out = wrapStreamAnimation("<p>a\n b</p>", "char", "m1");
|
|
123
|
+
const parser = document.createElement("div");
|
|
124
|
+
parser.innerHTML = out;
|
|
125
|
+
const p = parser.querySelector("p")!;
|
|
126
|
+
expect(p.childNodes.length).toBe(3);
|
|
127
|
+
expect(p.childNodes[1].nodeType).toBe(Node.TEXT_NODE);
|
|
128
|
+
expect(p.childNodes[1].textContent).toBe("\n ");
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it("is idempotent on re-wrap for streaming: same input yields identical ids/indices", () => {
|
|
132
|
+
const input = "<p>Hello</p>";
|
|
133
|
+
const first = wrapStreamAnimation(input, "char", "m1");
|
|
134
|
+
const second = wrapStreamAnimation(input, "char", "m1");
|
|
135
|
+
expect(first).toBe(second);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it("extends indices for appended text across calls with growing content", () => {
|
|
139
|
+
const first = wrapStreamAnimation("<p>Hi</p>", "char", "m1");
|
|
140
|
+
const second = wrapStreamAnimation("<p>Hi there</p>", "char", "m1");
|
|
141
|
+
const parse = (html: string) => {
|
|
142
|
+
const div = document.createElement("div");
|
|
143
|
+
div.innerHTML = html;
|
|
144
|
+
return div.querySelectorAll(".persona-stream-char");
|
|
145
|
+
};
|
|
146
|
+
const firstSpans = parse(first);
|
|
147
|
+
const secondSpans = parse(second);
|
|
148
|
+
// First two ids are stable — idiomorph match contract. The space between
|
|
149
|
+
// "Hi" and "there" is a plain text node, not a span, so the next wrapped
|
|
150
|
+
// char after "Hi" jumps to index 2 for "t" in "there".
|
|
151
|
+
expect(firstSpans[0].id).toBe(secondSpans[0].id);
|
|
152
|
+
expect(firstSpans[1].id).toBe(secondSpans[1].id);
|
|
153
|
+
expect(secondSpans.length).toBeGreaterThan(firstSpans.length);
|
|
154
|
+
// "there" is 5 wrapped chars, starting at index 2.
|
|
155
|
+
expect(secondSpans[2].id).toBe("stream-c-m1-2");
|
|
156
|
+
expect(secondSpans[2].textContent).toBe("t");
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it("returns input unchanged on empty string", () => {
|
|
160
|
+
expect(wrapStreamAnimation("", "char", "m1")).toBe("");
|
|
161
|
+
});
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
describe("wrapStreamAnimation — word mode", () => {
|
|
165
|
+
it("splits on whitespace and wraps each non-whitespace token", () => {
|
|
166
|
+
const out = wrapStreamAnimation("<p>Hello brave world</p>", "word", "m1");
|
|
167
|
+
const parser = document.createElement("div");
|
|
168
|
+
parser.innerHTML = out;
|
|
169
|
+
const words = parser.querySelectorAll(".persona-stream-word");
|
|
170
|
+
expect(words.length).toBe(3);
|
|
171
|
+
expect(words[0].textContent).toBe("Hello");
|
|
172
|
+
expect(words[1].textContent).toBe("brave");
|
|
173
|
+
expect(words[2].textContent).toBe("world");
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
it("preserves whitespace between word spans as plain text", () => {
|
|
177
|
+
const out = wrapStreamAnimation("<p>a b</p>", "word", "m1");
|
|
178
|
+
const parser = document.createElement("div");
|
|
179
|
+
parser.innerHTML = out;
|
|
180
|
+
const p = parser.querySelector("p")!;
|
|
181
|
+
// Expected DOM: <span>a</span>" "<span>b</span>
|
|
182
|
+
expect(p.childNodes.length).toBe(3);
|
|
183
|
+
expect(p.childNodes[1].nodeType).toBe(Node.TEXT_NODE);
|
|
184
|
+
expect(p.childNodes[1].textContent).toBe(" ");
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
it("assigns monotonic --word-index", () => {
|
|
188
|
+
const out = wrapStreamAnimation("<p>one two three</p>", "word", "m1");
|
|
189
|
+
const parser = document.createElement("div");
|
|
190
|
+
parser.innerHTML = out;
|
|
191
|
+
const words = parser.querySelectorAll(".persona-stream-word");
|
|
192
|
+
expect(words[0].getAttribute("style")).toContain("--word-index: 0");
|
|
193
|
+
expect(words[2].getAttribute("style")).toContain("--word-index: 2");
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
it("emits stable word ids scoped by messageId", () => {
|
|
197
|
+
const out = wrapStreamAnimation("<p>foo bar</p>", "word", "abc");
|
|
198
|
+
const parser = document.createElement("div");
|
|
199
|
+
parser.innerHTML = out;
|
|
200
|
+
expect(parser.querySelector("#stream-w-abc-0")?.textContent).toBe("foo");
|
|
201
|
+
expect(parser.querySelector("#stream-w-abc-1")?.textContent).toBe("bar");
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
it("skips words inside <pre>, <code>, <a>", () => {
|
|
205
|
+
const out = wrapStreamAnimation(
|
|
206
|
+
'<p>see <code>foo</code> and <a href="/x">link</a></p>',
|
|
207
|
+
"word",
|
|
208
|
+
"m1"
|
|
209
|
+
);
|
|
210
|
+
const parser = document.createElement("div");
|
|
211
|
+
parser.innerHTML = out;
|
|
212
|
+
expect(parser.querySelector("code")?.querySelectorAll(".persona-stream-word").length).toBe(0);
|
|
213
|
+
expect(parser.querySelector("a")?.querySelectorAll(".persona-stream-word").length).toBe(0);
|
|
214
|
+
// "see", "and" are the wrapped words
|
|
215
|
+
const wrapped = Array.from(parser.querySelectorAll(".persona-stream-word")).map(
|
|
216
|
+
(el) => el.textContent
|
|
217
|
+
);
|
|
218
|
+
expect(wrapped).toEqual(["see", "and"]);
|
|
219
|
+
});
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
describe("resolveStreamAnimation", () => {
|
|
223
|
+
it("returns all defaults when feature is undefined", () => {
|
|
224
|
+
const resolved = resolveStreamAnimation(undefined);
|
|
225
|
+
expect(resolved.type).toBe("none");
|
|
226
|
+
expect(resolved.placeholder).toBe("none");
|
|
227
|
+
expect(resolved.speed).toBe(120);
|
|
228
|
+
expect(resolved.duration).toBe(1800);
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
it("applies partial overrides", () => {
|
|
232
|
+
const resolved = resolveStreamAnimation({ type: "typewriter", speed: 50 });
|
|
233
|
+
expect(resolved.type).toBe("typewriter");
|
|
234
|
+
expect(resolved.speed).toBe(50);
|
|
235
|
+
expect(resolved.duration).toBe(1800);
|
|
236
|
+
expect(resolved.placeholder).toBe("none");
|
|
237
|
+
});
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
describe("streamAnimationContainerClass / streamAnimationBubbleClass", () => {
|
|
241
|
+
it("returns null for 'none'", () => {
|
|
242
|
+
expect(streamAnimationContainerClass("none")).toBeNull();
|
|
243
|
+
expect(streamAnimationBubbleClass("none")).toBeNull();
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
it("maps per-unit types to container classes", () => {
|
|
247
|
+
expect(streamAnimationContainerClass("typewriter")).toBe("persona-stream-typewriter");
|
|
248
|
+
expect(streamAnimationContainerClass("letter-rise")).toBe("persona-stream-letter-rise");
|
|
249
|
+
expect(streamAnimationContainerClass("word-fade")).toBe("persona-stream-word-fade");
|
|
250
|
+
expect(streamAnimationContainerClass("glyph-cycle")).toBe("persona-stream-glyph-cycle");
|
|
251
|
+
expect(streamAnimationContainerClass("wipe")).toBe("persona-stream-wipe");
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
it("puts pop-bubble on the bubble, not the content container", () => {
|
|
255
|
+
expect(streamAnimationContainerClass("pop-bubble")).toBeNull();
|
|
256
|
+
expect(streamAnimationBubbleClass("pop-bubble")).toBe("persona-stream-pop");
|
|
257
|
+
});
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
describe("isWrappingAnimation", () => {
|
|
261
|
+
it("is true for char and word modes", () => {
|
|
262
|
+
expect(isWrappingAnimation("typewriter")).toBe(true);
|
|
263
|
+
expect(isWrappingAnimation("letter-rise")).toBe(true);
|
|
264
|
+
expect(isWrappingAnimation("glyph-cycle")).toBe(true);
|
|
265
|
+
expect(isWrappingAnimation("word-fade")).toBe(true);
|
|
266
|
+
expect(isWrappingAnimation("wipe")).toBe(true);
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
it("is false for container-only modes and none", () => {
|
|
270
|
+
expect(isWrappingAnimation("none")).toBe(false);
|
|
271
|
+
expect(isWrappingAnimation("pop-bubble")).toBe(false);
|
|
272
|
+
});
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
describe("createSkeletonPlaceholder", () => {
|
|
276
|
+
it("renders a single full-width shimmer line", () => {
|
|
277
|
+
const el = createSkeletonPlaceholder();
|
|
278
|
+
expect(el.classList.contains("persona-stream-skeleton")).toBe(true);
|
|
279
|
+
expect(el.querySelectorAll(".persona-stream-skeleton-line").length).toBe(1);
|
|
280
|
+
expect(el.getAttribute("data-preserve-animation")).toBe("stream-skeleton");
|
|
281
|
+
});
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
describe("createStreamCaret", () => {
|
|
285
|
+
it("creates a span with data-preserve-animation so idiomorph keeps blink going", () => {
|
|
286
|
+
const caret = createStreamCaret();
|
|
287
|
+
expect(caret.tagName).toBe("SPAN");
|
|
288
|
+
expect(caret.classList.contains("persona-stream-caret")).toBe(true);
|
|
289
|
+
expect(caret.getAttribute("data-preserve-animation")).toBe("stream-caret");
|
|
290
|
+
expect(caret.getAttribute("aria-hidden")).toBe("true");
|
|
291
|
+
});
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
describe("plugin registry", () => {
|
|
295
|
+
it("resolves built-in types without requiring registration", () => {
|
|
296
|
+
expect(resolveStreamAnimationPlugin("typewriter")?.name).toBe("typewriter");
|
|
297
|
+
expect(resolveStreamAnimationPlugin("pop-bubble")?.name).toBe("pop-bubble");
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
it("returns null for 'none' and unknown types", () => {
|
|
301
|
+
expect(resolveStreamAnimationPlugin("none")).toBeNull();
|
|
302
|
+
expect(resolveStreamAnimationPlugin("totally-made-up")).toBeNull();
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
it("prefers per-instance overrides over the global registry", () => {
|
|
306
|
+
const custom: StreamAnimationPlugin = {
|
|
307
|
+
name: "typewriter",
|
|
308
|
+
containerClass: "custom-typewriter",
|
|
309
|
+
wrap: "char",
|
|
310
|
+
};
|
|
311
|
+
const plugin = resolveStreamAnimationPlugin("typewriter", { typewriter: custom });
|
|
312
|
+
expect(plugin?.containerClass).toBe("custom-typewriter");
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
it("registerStreamAnimationPlugin makes the plugin globally resolvable", () => {
|
|
316
|
+
const sparkle: StreamAnimationPlugin = {
|
|
317
|
+
name: "sparkle",
|
|
318
|
+
containerClass: "sparkle-fx",
|
|
319
|
+
wrap: "char",
|
|
320
|
+
};
|
|
321
|
+
registerStreamAnimationPlugin(sparkle);
|
|
322
|
+
expect(resolveStreamAnimationPlugin("sparkle")?.containerClass).toBe("sparkle-fx");
|
|
323
|
+
expect(listRegisteredStreamAnimations()).toContain("sparkle");
|
|
324
|
+
unregisterStreamAnimationPlugin("sparkle");
|
|
325
|
+
expect(resolveStreamAnimationPlugin("sparkle")).toBeNull();
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
it("unregisterStreamAnimationPlugin refuses to remove built-ins", () => {
|
|
329
|
+
unregisterStreamAnimationPlugin("typewriter");
|
|
330
|
+
expect(resolveStreamAnimationPlugin("typewriter")?.name).toBe("typewriter");
|
|
331
|
+
});
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
describe("applyStreamBuffer", () => {
|
|
335
|
+
const message = { id: "m1", role: "assistant", content: "" } as AgentWidgetMessage;
|
|
336
|
+
|
|
337
|
+
it("passes through when streaming is false", () => {
|
|
338
|
+
expect(applyStreamBuffer("abc", "word", null, message, false)).toBe("abc");
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
it("passes through when buffer is 'none'", () => {
|
|
342
|
+
expect(applyStreamBuffer("abc def", "none", null, message, true)).toBe("abc def");
|
|
343
|
+
});
|
|
344
|
+
|
|
345
|
+
it("word mode trims to the last whitespace boundary", () => {
|
|
346
|
+
expect(applyStreamBuffer("hello wor", "word", null, message, true)).toBe("hello");
|
|
347
|
+
expect(applyStreamBuffer("hello world ", "word", null, message, true)).toBe(
|
|
348
|
+
"hello world"
|
|
349
|
+
);
|
|
350
|
+
});
|
|
351
|
+
|
|
352
|
+
it("word mode hides all content until the first word boundary", () => {
|
|
353
|
+
expect(applyStreamBuffer("partial", "word", null, message, true)).toBe("");
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
it("line mode trims to the last newline", () => {
|
|
357
|
+
expect(applyStreamBuffer("line1\nmid", "line", null, message, true)).toBe("line1");
|
|
358
|
+
});
|
|
359
|
+
|
|
360
|
+
it("plugin.bufferContent takes precedence over the built-in strategy", () => {
|
|
361
|
+
const plugin: StreamAnimationPlugin = {
|
|
362
|
+
name: "capper",
|
|
363
|
+
bufferContent: (content) => content.slice(0, 3),
|
|
364
|
+
};
|
|
365
|
+
expect(applyStreamBuffer("hello world", "word", plugin, message, true)).toBe("hel");
|
|
366
|
+
});
|
|
367
|
+
});
|
|
368
|
+
|
|
369
|
+
describe("ensurePluginActive / detachAllPlugins", () => {
|
|
370
|
+
it("injects plugin styles once and runs onAttach cleanup on detach", () => {
|
|
371
|
+
const root = document.createElement("div");
|
|
372
|
+
document.body.appendChild(root);
|
|
373
|
+
let attached = 0;
|
|
374
|
+
let detached = 0;
|
|
375
|
+
const plugin: StreamAnimationPlugin = {
|
|
376
|
+
name: "test-attach",
|
|
377
|
+
styles: ".test-attach { color: red; }",
|
|
378
|
+
onAttach() {
|
|
379
|
+
attached += 1;
|
|
380
|
+
return () => {
|
|
381
|
+
detached += 1;
|
|
382
|
+
};
|
|
383
|
+
},
|
|
384
|
+
};
|
|
385
|
+
ensurePluginActive(plugin, root);
|
|
386
|
+
ensurePluginActive(plugin, root); // second call is a no-op
|
|
387
|
+
|
|
388
|
+
expect(attached).toBe(1);
|
|
389
|
+
expect(root.querySelectorAll("style[data-persona-animation='test-attach']").length).toBe(1);
|
|
390
|
+
|
|
391
|
+
detachAllPlugins(root);
|
|
392
|
+
expect(detached).toBe(1);
|
|
393
|
+
|
|
394
|
+
document.body.removeChild(root);
|
|
395
|
+
});
|
|
396
|
+
|
|
397
|
+
it("re-injects plugin styles after the root's children are cleared", () => {
|
|
398
|
+
const root = document.createElement("div");
|
|
399
|
+
document.body.appendChild(root);
|
|
400
|
+
const plugin: StreamAnimationPlugin = {
|
|
401
|
+
name: "test-reinject",
|
|
402
|
+
styles: ".test-reinject { color: red; }",
|
|
403
|
+
};
|
|
404
|
+
|
|
405
|
+
ensurePluginActive(plugin, root);
|
|
406
|
+
expect(root.querySelectorAll("style[data-persona-animation='test-reinject']").length).toBe(1);
|
|
407
|
+
|
|
408
|
+
// Simulate widget re-init: destroy callbacks run, then host is wiped.
|
|
409
|
+
detachAllPlugins(root);
|
|
410
|
+
root.innerHTML = "";
|
|
411
|
+
|
|
412
|
+
ensurePluginActive(plugin, root);
|
|
413
|
+
expect(root.querySelectorAll("style[data-persona-animation='test-reinject']").length).toBe(1);
|
|
414
|
+
|
|
415
|
+
document.body.removeChild(root);
|
|
416
|
+
});
|
|
417
|
+
});
|