@runtypelabs/persona 3.16.0 → 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 +48 -47
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +205 -1
- package/dist/index.d.ts +205 -1
- package/dist/index.global.js +136 -81
- package/dist/index.global.js.map +1 -1
- package/dist/index.js +48 -47
- 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 +714 -99
- package/dist/theme-editor.d.cts +214 -2
- package/dist/theme-editor.d.ts +214 -2
- package/dist/theme-editor.js +712 -99
- 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 +28 -0
- package/src/components/composer-builder.ts +61 -10
- 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 +16 -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 +210 -0
- package/src/ui.stop-button.test.ts +165 -0
- package/src/ui.ts +75 -6
- 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/types.ts
CHANGED
|
@@ -680,6 +680,167 @@ export type AgentWidgetReasoningDisplayFeature = {
|
|
|
680
680
|
loadingAnimation?: AgentWidgetToolCallLoadingAnimation;
|
|
681
681
|
};
|
|
682
682
|
|
|
683
|
+
/**
|
|
684
|
+
* Reveal animation applied to assistant message text while it is streaming.
|
|
685
|
+
*
|
|
686
|
+
* Built-in types always available:
|
|
687
|
+
* - `none` — text appears as tokens arrive (default).
|
|
688
|
+
* - `typewriter` — characters fade in with a blinking caret.
|
|
689
|
+
* - `pop-bubble` — the bubble scales in; text streams normally afterward.
|
|
690
|
+
* - `letter-rise` — per-char translateY + fade reveal.
|
|
691
|
+
* - `word-fade` — per-word blur + translateY fade-in.
|
|
692
|
+
*
|
|
693
|
+
* Subpath plugins (import from `@runtypelabs/persona/animations/*` to register):
|
|
694
|
+
* - `wipe`, `glyph-cycle`.
|
|
695
|
+
*
|
|
696
|
+
* Custom types are allowed — register a plugin with any string name and
|
|
697
|
+
* reference it by that name in `type`.
|
|
698
|
+
*/
|
|
699
|
+
export type AgentWidgetStreamAnimationBuiltinType =
|
|
700
|
+
| "none"
|
|
701
|
+
| "typewriter"
|
|
702
|
+
| "word-fade"
|
|
703
|
+
| "letter-rise"
|
|
704
|
+
| "glyph-cycle"
|
|
705
|
+
| "wipe"
|
|
706
|
+
| "pop-bubble";
|
|
707
|
+
|
|
708
|
+
export type AgentWidgetStreamAnimationType =
|
|
709
|
+
| AgentWidgetStreamAnimationBuiltinType
|
|
710
|
+
| (string & {});
|
|
711
|
+
|
|
712
|
+
/**
|
|
713
|
+
* Placeholder shown inside a streaming assistant bubble before the first token arrives.
|
|
714
|
+
* - `none` — use the default typing-dots indicator (existing behavior).
|
|
715
|
+
* - `skeleton` — shimmer bars, replaced by streaming content once it starts.
|
|
716
|
+
*/
|
|
717
|
+
export type AgentWidgetStreamAnimationPlaceholder = "none" | "skeleton";
|
|
718
|
+
|
|
719
|
+
/**
|
|
720
|
+
* How much of the accumulated streaming content to display while tokens are
|
|
721
|
+
* still arriving. Trimming to a boundary means in-progress words or lines
|
|
722
|
+
* stay hidden until they complete — useful for animations that benefit from
|
|
723
|
+
* unit-complete reveals (e.g. wipe, glyph-cycle).
|
|
724
|
+
* - `none` — show every character as it arrives (default).
|
|
725
|
+
* - `word` — trim to the last whitespace boundary.
|
|
726
|
+
* - `line` — trim to the last newline boundary.
|
|
727
|
+
*/
|
|
728
|
+
export type AgentWidgetStreamAnimationBuffer = "none" | "word" | "line";
|
|
729
|
+
|
|
730
|
+
/**
|
|
731
|
+
* Context passed to plugin lifecycle hooks. Carries the live DOM references
|
|
732
|
+
* and resolved animation settings for the currently-streaming message.
|
|
733
|
+
*/
|
|
734
|
+
export type StreamAnimationContext = {
|
|
735
|
+
/** The `.persona-message-content` element owning the streamed text. */
|
|
736
|
+
container: HTMLElement;
|
|
737
|
+
/** The outer message bubble element. */
|
|
738
|
+
bubble: HTMLElement;
|
|
739
|
+
/** ID of the streaming message. */
|
|
740
|
+
messageId: string;
|
|
741
|
+
/** Read-only reference to the message being streamed. */
|
|
742
|
+
message: AgentWidgetMessage;
|
|
743
|
+
/** Effective `speed` from `streamAnimation.speed`. */
|
|
744
|
+
speed: number;
|
|
745
|
+
/** Effective `duration` from `streamAnimation.duration`. */
|
|
746
|
+
duration: number;
|
|
747
|
+
};
|
|
748
|
+
|
|
749
|
+
/**
|
|
750
|
+
* Pluggable stream animation. Third-party packages and inline registrations
|
|
751
|
+
* implement this interface to add custom reveal effects.
|
|
752
|
+
*
|
|
753
|
+
* Lifecycle:
|
|
754
|
+
* - When the widget mounts and detects a plugin (either passed via config or
|
|
755
|
+
* auto-registered in the IIFE bundle), it injects `styles` once into the
|
|
756
|
+
* widget's style host.
|
|
757
|
+
* - For each streaming assistant message whose `type` matches `name`, the
|
|
758
|
+
* widget applies `containerClass` / `bubbleClass`, wraps text per `wrap`,
|
|
759
|
+
* and — if `useCaret` is true — appends a blinking caret.
|
|
760
|
+
* - Hooks fire after the live DOM is morphed; plugins use stable element IDs
|
|
761
|
+
* and `data-preserve-animation` to safely mutate per-char or per-word spans
|
|
762
|
+
* without idiomorph clobbering in-flight work.
|
|
763
|
+
*/
|
|
764
|
+
export type StreamAnimationPlugin = {
|
|
765
|
+
/** Plugin identifier. Matches the `type` field in `streamAnimation`. */
|
|
766
|
+
name: string;
|
|
767
|
+
/** Class added to `.persona-message-content` while streaming. */
|
|
768
|
+
containerClass?: string;
|
|
769
|
+
/** Class added to the bubble element (e.g. a one-shot scale animation). */
|
|
770
|
+
bubbleClass?: string;
|
|
771
|
+
/** Wrap mode applied to text nodes during streaming. @default "none" */
|
|
772
|
+
wrap?: "none" | "char" | "word";
|
|
773
|
+
/**
|
|
774
|
+
* HTML tags whose descendant text is skipped during wrapping. Defaults to
|
|
775
|
+
* `["pre", "code", "a", "script", "style"]` — useful for keeping code
|
|
776
|
+
* blocks legible and link click-targets intact. Plugins that want to
|
|
777
|
+
* animate characters inside inline code (e.g. `glyph-cycle`) can narrow
|
|
778
|
+
* the list.
|
|
779
|
+
*/
|
|
780
|
+
skipTags?: string[];
|
|
781
|
+
/** Append a blinking caret after the last rendered char/word. */
|
|
782
|
+
useCaret?: boolean;
|
|
783
|
+
/** CSS string injected into the widget style host on first activation. */
|
|
784
|
+
styles?: string;
|
|
785
|
+
/**
|
|
786
|
+
* Optional custom buffering strategy. Returns the portion of `content`
|
|
787
|
+
* that should be rendered during streaming. Use this for buffering
|
|
788
|
+
* schemes beyond the built-in `word` / `line` strategies.
|
|
789
|
+
*/
|
|
790
|
+
bufferContent?: (content: string, message: AgentWidgetMessage) => string;
|
|
791
|
+
/**
|
|
792
|
+
* Fires once when the plugin is first activated inside a widget instance.
|
|
793
|
+
* Use this to set up MutationObservers or other long-lived listeners.
|
|
794
|
+
* Return an optional cleanup function that runs on widget destroy.
|
|
795
|
+
*/
|
|
796
|
+
onAttach?: (root: HTMLElement | ShadowRoot) => (() => void) | void;
|
|
797
|
+
/** Fires after each render that reaches the live DOM. */
|
|
798
|
+
onAfterRender?: (ctx: StreamAnimationContext) => void;
|
|
799
|
+
/** Fires when a streamed message's `streaming` flag flips to false. */
|
|
800
|
+
onStreamComplete?: (ctx: StreamAnimationContext) => void;
|
|
801
|
+
/**
|
|
802
|
+
* Report whether the plugin still has in-flight animation work for a
|
|
803
|
+
* message. When `true`, the widget keeps rendering the message in its
|
|
804
|
+
* "streaming-animated" mode even after `message.streaming` flips false —
|
|
805
|
+
* preventing the final non-animated render from yanking the rug out from
|
|
806
|
+
* under unfinished per-char cycles or reveals.
|
|
807
|
+
*/
|
|
808
|
+
isAnimating?: (message: AgentWidgetMessage) => boolean;
|
|
809
|
+
};
|
|
810
|
+
|
|
811
|
+
export type AgentWidgetStreamAnimationFeature = {
|
|
812
|
+
/** Reveal animation to apply while streaming. @default "none" */
|
|
813
|
+
type?: AgentWidgetStreamAnimationType;
|
|
814
|
+
/** Pre-first-token placeholder. @default "none" */
|
|
815
|
+
placeholder?: AgentWidgetStreamAnimationPlaceholder;
|
|
816
|
+
/**
|
|
817
|
+
* Per-unit animation duration (ms) for `typewriter`, `letter-rise`, `word-fade`,
|
|
818
|
+
* and per-unit plugin animations. Each arriving character/word animates from
|
|
819
|
+
* invisible to visible over this duration, independent of its position — the
|
|
820
|
+
* streaming cadence itself provides the visible stagger.
|
|
821
|
+
* @default 120
|
|
822
|
+
*/
|
|
823
|
+
speed?: number;
|
|
824
|
+
/**
|
|
825
|
+
* Total duration of container-level animations (`pop-bubble` and custom
|
|
826
|
+
* plugin animations), in milliseconds.
|
|
827
|
+
* @default 1800
|
|
828
|
+
*/
|
|
829
|
+
duration?: number;
|
|
830
|
+
/**
|
|
831
|
+
* Trim the accumulated streaming content to a word or line boundary before
|
|
832
|
+
* rendering. Hides in-progress units until they complete.
|
|
833
|
+
* @default "none"
|
|
834
|
+
*/
|
|
835
|
+
buffer?: AgentWidgetStreamAnimationBuffer;
|
|
836
|
+
/**
|
|
837
|
+
* Extra animation plugins available to this widget instance. Keys are
|
|
838
|
+
* plugin names; the matching plugin activates when `type` is set to that
|
|
839
|
+
* name. Built-in types (`typewriter`, `pop-bubble`) are always registered.
|
|
840
|
+
*/
|
|
841
|
+
plugins?: Record<string, StreamAnimationPlugin>;
|
|
842
|
+
};
|
|
843
|
+
|
|
683
844
|
export type AgentWidgetFeatureFlags = {
|
|
684
845
|
showReasoning?: boolean;
|
|
685
846
|
showToolCalls?: boolean;
|
|
@@ -694,6 +855,8 @@ export type AgentWidgetFeatureFlags = {
|
|
|
694
855
|
eventStream?: EventStreamConfig;
|
|
695
856
|
/** Optional artifact sidebar (split pane / mobile drawer) */
|
|
696
857
|
artifacts?: AgentWidgetArtifactsFeature;
|
|
858
|
+
/** Reveal animation for streaming assistant text. */
|
|
859
|
+
streamAnimation?: AgentWidgetStreamAnimationFeature;
|
|
697
860
|
};
|
|
698
861
|
|
|
699
862
|
export type SSEEventRecord = {
|
|
@@ -996,6 +1159,10 @@ export type AgentWidgetSendButtonConfig = {
|
|
|
996
1159
|
backgroundColor?: string;
|
|
997
1160
|
textColor?: string;
|
|
998
1161
|
size?: string;
|
|
1162
|
+
/** Lucide icon name shown while a response is streaming. Clicking the button in this state aborts the stream. Default: "square". */
|
|
1163
|
+
stopIconName?: string;
|
|
1164
|
+
/** Tooltip text shown while streaming. Default: "Stop generating". */
|
|
1165
|
+
stopTooltipText?: string;
|
|
999
1166
|
};
|
|
1000
1167
|
|
|
1001
1168
|
/** Optional composer UI state for custom `renderComposer` implementations. */
|
|
@@ -2608,11 +2775,21 @@ export type AgentWidgetConfig = {
|
|
|
2608
2775
|
welcomeSubtitle?: string;
|
|
2609
2776
|
inputPlaceholder?: string;
|
|
2610
2777
|
sendButtonLabel?: string;
|
|
2778
|
+
/** Button label shown in text mode while a response is streaming. Default: "Stop". */
|
|
2779
|
+
stopButtonLabel?: string;
|
|
2611
2780
|
/**
|
|
2612
2781
|
* When false, the welcome / intro card is not shown above the message list.
|
|
2613
2782
|
* @default true
|
|
2614
2783
|
*/
|
|
2615
2784
|
showWelcomeCard?: boolean;
|
|
2785
|
+
/**
|
|
2786
|
+
* Per-stop-reason copy for the inline notice rendered on assistant
|
|
2787
|
+
* bubbles when the runtime reports a non-natural stop (e.g. the agent
|
|
2788
|
+
* loop hit `max_tool_calls` and was cut off mid-loop). Each key is
|
|
2789
|
+
* optional — keys you omit fall back to the built-in defaults. Set a
|
|
2790
|
+
* key to an empty string to suppress the notice for that reason.
|
|
2791
|
+
*/
|
|
2792
|
+
stopReasonNotice?: Partial<Record<StopReasonKind, string>>;
|
|
2616
2793
|
};
|
|
2617
2794
|
/**
|
|
2618
2795
|
* Semantic design tokens (`palette`, `semantic`, `components`).
|
|
@@ -3121,6 +3298,28 @@ export type AgentWidgetApproval = {
|
|
|
3121
3298
|
|
|
3122
3299
|
export type AgentWidgetMessageVariant = "assistant" | "reasoning" | "tool" | "approval";
|
|
3123
3300
|
|
|
3301
|
+
/**
|
|
3302
|
+
* Per-turn / per-step stop reason emitted by the runtime on
|
|
3303
|
+
* `agent_turn_complete` and `step_complete` SSE events. The vocabulary is
|
|
3304
|
+
* owned by the upstream Runtype API — do not extend without coordination.
|
|
3305
|
+
*
|
|
3306
|
+
* - `end_turn` — natural completion (no affordance needed)
|
|
3307
|
+
* - `max_tool_calls` — agent loop tripped the configured tool-call ceiling
|
|
3308
|
+
* - `length` — provider hit max output tokens
|
|
3309
|
+
* - `content_filter` — provider content filter intervened
|
|
3310
|
+
* - `error` — provider/runtime error (prefer existing error rendering)
|
|
3311
|
+
* - `unknown` — explicitly reported but uninformative
|
|
3312
|
+
*
|
|
3313
|
+
* Absent (`undefined`) means "not reported" — distinct from `'unknown'`.
|
|
3314
|
+
*/
|
|
3315
|
+
export type StopReasonKind =
|
|
3316
|
+
| 'end_turn'
|
|
3317
|
+
| 'max_tool_calls'
|
|
3318
|
+
| 'length'
|
|
3319
|
+
| 'content_filter'
|
|
3320
|
+
| 'error'
|
|
3321
|
+
| 'unknown';
|
|
3322
|
+
|
|
3124
3323
|
/**
|
|
3125
3324
|
* Represents a message in the chat conversation.
|
|
3126
3325
|
*
|
|
@@ -3207,6 +3406,17 @@ export type AgentWidgetMessage = {
|
|
|
3207
3406
|
* Contains execution context like iteration number and turn ID.
|
|
3208
3407
|
*/
|
|
3209
3408
|
agentMetadata?: AgentMessageMetadata;
|
|
3409
|
+
/**
|
|
3410
|
+
* Per-turn stop reason reported by the runtime on `agent_turn_complete`
|
|
3411
|
+
* (agent-loop path) or the last `step_complete` for a prompt step
|
|
3412
|
+
* (dispatch / flow path). Absent when the API did not report a value.
|
|
3413
|
+
*
|
|
3414
|
+
* When set to a non-natural value (`max_tool_calls`, `length`,
|
|
3415
|
+
* `content_filter`, `error`), the widget renders an inline notice on
|
|
3416
|
+
* the assistant bubble. See `config.copy.stopReasonNotice` to override
|
|
3417
|
+
* the default copy.
|
|
3418
|
+
*/
|
|
3419
|
+
stopReason?: StopReasonKind;
|
|
3210
3420
|
};
|
|
3211
3421
|
|
|
3212
3422
|
// ============================================================================
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
|
|
3
|
+
import { afterEach, beforeEach, 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
|
+
const flush = async (times = 4) => {
|
|
14
|
+
for (let i = 0; i < times; i += 1) {
|
|
15
|
+
// eslint-disable-next-line no-await-in-loop
|
|
16
|
+
await Promise.resolve();
|
|
17
|
+
}
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
describe("createAgentExperience stop-streaming submit button", () => {
|
|
21
|
+
const originalFetch = global.fetch;
|
|
22
|
+
let capturedSignals: AbortSignal[] = [];
|
|
23
|
+
|
|
24
|
+
beforeEach(() => {
|
|
25
|
+
capturedSignals = [];
|
|
26
|
+
vi.stubGlobal("requestAnimationFrame", (cb: (time: number) => void) => {
|
|
27
|
+
cb(0);
|
|
28
|
+
return 1;
|
|
29
|
+
});
|
|
30
|
+
vi.stubGlobal("cancelAnimationFrame", () => {});
|
|
31
|
+
window.scrollTo = vi.fn();
|
|
32
|
+
|
|
33
|
+
// Fetch hangs until the caller aborts the signal — models an in-flight
|
|
34
|
+
// SSE stream so the widget stays in the "streaming" state.
|
|
35
|
+
global.fetch = vi.fn().mockImplementation((_url: string, options: any) => {
|
|
36
|
+
const signal = options.signal as AbortSignal;
|
|
37
|
+
capturedSignals.push(signal);
|
|
38
|
+
return new Promise((_resolve, reject) => {
|
|
39
|
+
signal.addEventListener("abort", () => {
|
|
40
|
+
const err = new Error("aborted");
|
|
41
|
+
err.name = "AbortError";
|
|
42
|
+
reject(err);
|
|
43
|
+
});
|
|
44
|
+
});
|
|
45
|
+
}) as any;
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
afterEach(() => {
|
|
49
|
+
document.body.innerHTML = "";
|
|
50
|
+
global.fetch = originalFetch;
|
|
51
|
+
vi.restoreAllMocks();
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it("keeps the submit button enabled while streaming and clicking it cancels the stream", async () => {
|
|
55
|
+
const mount = createMount();
|
|
56
|
+
const controller = createAgentExperience(mount, {
|
|
57
|
+
apiUrl: "https://api.example.com/chat",
|
|
58
|
+
launcher: { enabled: false },
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
const submit = mount.querySelector<HTMLButtonElement>(
|
|
62
|
+
"[data-persona-composer-submit]"
|
|
63
|
+
);
|
|
64
|
+
expect(submit).not.toBeNull();
|
|
65
|
+
|
|
66
|
+
const textarea = mount.querySelector<HTMLTextAreaElement>(
|
|
67
|
+
"[data-persona-composer-input]"
|
|
68
|
+
)!;
|
|
69
|
+
textarea.value = "Hello";
|
|
70
|
+
submit!.click();
|
|
71
|
+
|
|
72
|
+
await flush();
|
|
73
|
+
|
|
74
|
+
// Streaming is active: the button must stay enabled so it can be clicked
|
|
75
|
+
// again to stop the response.
|
|
76
|
+
expect(controller.getState().streaming).toBe(true);
|
|
77
|
+
expect(submit!.disabled).toBe(false);
|
|
78
|
+
expect(capturedSignals).toHaveLength(1);
|
|
79
|
+
expect(capturedSignals[0].aborted).toBe(false);
|
|
80
|
+
|
|
81
|
+
// Second click — acts as "stop generating".
|
|
82
|
+
submit!.click();
|
|
83
|
+
|
|
84
|
+
await flush();
|
|
85
|
+
|
|
86
|
+
expect(controller.getState().streaming).toBe(false);
|
|
87
|
+
expect(capturedSignals[0].aborted).toBe(true);
|
|
88
|
+
// No new request should have been fired by the stop click.
|
|
89
|
+
expect(capturedSignals).toHaveLength(1);
|
|
90
|
+
// Typed text is preserved so the user can resend after stopping.
|
|
91
|
+
expect(textarea.value).toBe("");
|
|
92
|
+
// (The textarea was cleared on the *first* submit, not by the stop click —
|
|
93
|
+
// that's fine because after cancel the user can keep typing.)
|
|
94
|
+
|
|
95
|
+
controller.destroy();
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it("swaps to the stop icon while streaming and back to the send icon after cancel (icon mode)", async () => {
|
|
99
|
+
const mount = createMount();
|
|
100
|
+
const controller = createAgentExperience(mount, {
|
|
101
|
+
apiUrl: "https://api.example.com/chat",
|
|
102
|
+
launcher: { enabled: false },
|
|
103
|
+
sendButton: { useIcon: true, iconName: "arrow-up" },
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
const submit = mount.querySelector<HTMLButtonElement>(
|
|
107
|
+
"[data-persona-composer-submit]"
|
|
108
|
+
)!;
|
|
109
|
+
const textarea = mount.querySelector<HTMLTextAreaElement>(
|
|
110
|
+
"[data-persona-composer-input]"
|
|
111
|
+
)!;
|
|
112
|
+
|
|
113
|
+
// Initial state: send icon (aria-label tracks tooltip default).
|
|
114
|
+
expect(submit.getAttribute("aria-label")).toBe("Send message");
|
|
115
|
+
|
|
116
|
+
textarea.value = "Hi";
|
|
117
|
+
submit.click();
|
|
118
|
+
await flush();
|
|
119
|
+
|
|
120
|
+
expect(controller.getState().streaming).toBe(true);
|
|
121
|
+
expect(submit.getAttribute("aria-label")).toBe("Stop generating");
|
|
122
|
+
|
|
123
|
+
submit.click();
|
|
124
|
+
await flush();
|
|
125
|
+
|
|
126
|
+
expect(controller.getState().streaming).toBe(false);
|
|
127
|
+
expect(submit.getAttribute("aria-label")).toBe("Send message");
|
|
128
|
+
|
|
129
|
+
controller.destroy();
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it("swaps the text label in text mode", async () => {
|
|
133
|
+
const mount = createMount();
|
|
134
|
+
const controller = createAgentExperience(mount, {
|
|
135
|
+
apiUrl: "https://api.example.com/chat",
|
|
136
|
+
launcher: { enabled: false },
|
|
137
|
+
sendButton: { useIcon: false },
|
|
138
|
+
copy: { sendButtonLabel: "Send", stopButtonLabel: "Stop" },
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
const submit = mount.querySelector<HTMLButtonElement>(
|
|
142
|
+
"[data-persona-composer-submit]"
|
|
143
|
+
)!;
|
|
144
|
+
const textarea = mount.querySelector<HTMLTextAreaElement>(
|
|
145
|
+
"[data-persona-composer-input]"
|
|
146
|
+
)!;
|
|
147
|
+
|
|
148
|
+
expect(submit.textContent).toBe("Send");
|
|
149
|
+
|
|
150
|
+
textarea.value = "Hi";
|
|
151
|
+
submit.click();
|
|
152
|
+
await flush();
|
|
153
|
+
|
|
154
|
+
expect(controller.getState().streaming).toBe(true);
|
|
155
|
+
expect(submit.textContent).toBe("Stop");
|
|
156
|
+
|
|
157
|
+
submit.click();
|
|
158
|
+
await flush();
|
|
159
|
+
|
|
160
|
+
expect(controller.getState().streaming).toBe(false);
|
|
161
|
+
expect(submit.textContent).toBe("Send");
|
|
162
|
+
|
|
163
|
+
controller.destroy();
|
|
164
|
+
});
|
|
165
|
+
});
|
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
|
|
@@ -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")) {
|