@runtypelabs/persona 4.4.2 → 4.6.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.d.cts +1 -1
- package/dist/animations/glyph-cycle.d.ts +1 -1
- package/dist/animations/{types-C6tFDxKy.d.cts → types-8RICZWQe.d.cts} +6 -0
- package/dist/animations/{types-C6tFDxKy.d.ts → types-8RICZWQe.d.ts} +6 -0
- package/dist/animations/wipe.d.cts +1 -1
- package/dist/animations/wipe.d.ts +1 -1
- package/dist/chunk-DFBSCFYN.js +1 -0
- package/dist/codegen.cjs +1 -1
- package/dist/codegen.js +1 -1
- package/dist/index.cjs +50 -50
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +286 -11
- package/dist/index.d.ts +286 -11
- package/dist/index.global.js +39 -39
- package/dist/index.global.js.map +1 -1
- package/dist/index.js +51 -51
- package/dist/index.js.map +1 -1
- package/dist/launcher.global.js.map +1 -1
- package/dist/markdown-parsers-entry-NVFT3TE6.js +1 -0
- package/dist/runtype-tts-entry-HFUV2UF7.js +1 -0
- package/dist/session-reconnect-U77QFUR7.js +1 -0
- package/dist/smart-dom-reader.d.cts +147 -8
- package/dist/smart-dom-reader.d.ts +147 -8
- package/dist/theme-editor-preview.cjs +48 -48
- package/dist/theme-editor-preview.d.cts +187 -9
- package/dist/theme-editor-preview.d.ts +187 -9
- package/dist/theme-editor-preview.js +48 -48
- package/dist/theme-editor.cjs +1 -1
- package/dist/theme-editor.d.cts +147 -8
- package/dist/theme-editor.d.ts +147 -8
- package/dist/theme-editor.js +1 -1
- package/package.json +2 -2
- package/src/client.ts +48 -7
- package/src/defaults.ts +9 -1
- package/src/generated/runtype-openapi-contract.ts +6 -0
- package/src/index-core.ts +5 -0
- package/src/reconnect-wake.test.ts +162 -0
- package/src/reconnect.test.ts +430 -0
- package/src/session-reconnect.ts +282 -0
- package/src/session.ts +408 -5
- package/src/types.ts +165 -9
- package/src/ui.scroll-additive.test.ts +451 -0
- package/src/ui.scroll.test.ts +8 -2
- package/src/ui.stream-animation-update.test.ts +99 -0
- package/src/ui.ts +371 -41
- package/src/utils/constants.ts +3 -1
package/src/ui.ts
CHANGED
|
@@ -294,6 +294,11 @@ type Controller = {
|
|
|
294
294
|
clearChat: () => void;
|
|
295
295
|
setMessage: (message: string) => boolean;
|
|
296
296
|
submitMessage: (message?: string) => boolean;
|
|
297
|
+
/**
|
|
298
|
+
* Manually retry a dropped durable stream (e.g. from a "Reconnect" button).
|
|
299
|
+
* No-op unless a resumable durable turn dropped and `reconnectStream` is set.
|
|
300
|
+
*/
|
|
301
|
+
reconnect: () => void;
|
|
297
302
|
startVoiceRecognition: () => boolean;
|
|
298
303
|
stopVoiceRecognition: () => boolean;
|
|
299
304
|
/**
|
|
@@ -775,6 +780,8 @@ export const createAgentExperience = (
|
|
|
775
780
|
if (status === "connecting") return statusConfig.connectingText ?? statusCopy.connecting;
|
|
776
781
|
if (status === "connected") return statusConfig.connectedText ?? statusCopy.connected;
|
|
777
782
|
if (status === "error") return statusConfig.errorText ?? statusCopy.error;
|
|
783
|
+
if (status === "paused") return statusConfig.pausedText ?? statusCopy.paused;
|
|
784
|
+
if (status === "resuming") return statusConfig.resumingText ?? statusCopy.resuming;
|
|
778
785
|
return statusCopy[status];
|
|
779
786
|
};
|
|
780
787
|
|
|
@@ -842,8 +849,27 @@ export const createAgentExperience = (
|
|
|
842
849
|
const getScrollToBottomLabel = () => scrollToBottomFeature.label ?? "";
|
|
843
850
|
const getScrollToBottomIconName = () => scrollToBottomFeature.iconName ?? "arrow-down";
|
|
844
851
|
const isScrollToBottomEnabled = () => scrollToBottomFeature.enabled !== false;
|
|
845
|
-
|
|
852
|
+
// Default is "anchor-top" (see DEFAULT_WIDGET_CONFIG). This `??` only applies
|
|
853
|
+
// when a partial config sets `scrollBehavior.mode` to undefined explicitly;
|
|
854
|
+
// it must agree with the declared default.
|
|
855
|
+
const getScrollMode = () => scrollBehaviorFeature.mode ?? "anchor-top";
|
|
856
|
+
// "Effectively following the bottom" for streaming auto-scroll: true in
|
|
857
|
+
// follow mode, and in anchor-top when the current turn has no anchor (the
|
|
858
|
+
// no-anchor fallback). Drives `scheduleAutoScroll`, `handleContentResize`,
|
|
859
|
+
// and `isAwayFromLatest` so a no-anchor anchor-top turn behaves like follow.
|
|
860
|
+
const isFollowEffective = () =>
|
|
861
|
+
getScrollMode() === "follow" ||
|
|
862
|
+
(getScrollMode() === "anchor-top" && followFallbackActive);
|
|
846
863
|
const getAnchorTopOffset = () => scrollBehaviorFeature.anchorTopOffset ?? 16;
|
|
864
|
+
const getScrollRestorePosition = () =>
|
|
865
|
+
scrollBehaviorFeature.restorePosition ?? "bottom";
|
|
866
|
+
const isPauseOnInteractionEnabled = () =>
|
|
867
|
+
scrollBehaviorFeature.pauseOnInteraction === true;
|
|
868
|
+
// Defaults on alongside the anchor-top default so the pinned-turn UX keeps the
|
|
869
|
+
// unread count + "streaming below" hint; opt out with `false`.
|
|
870
|
+
const isActivityWhilePinnedEnabled = () =>
|
|
871
|
+
scrollBehaviorFeature.showActivityWhilePinned !== false;
|
|
872
|
+
const isAnnounceEnabled = () => scrollBehaviorFeature.announce === true;
|
|
847
873
|
const scrollToBottomButton = createElement(
|
|
848
874
|
"button",
|
|
849
875
|
"persona-scroll-to-bottom-indicator persona-absolute persona-bottom-3 persona-left-1/2 persona-z-10 persona-flex persona-items-center persona-gap-1 persona-text-xs persona-transform persona--translate-x-1/2 persona-cursor-pointer"
|
|
@@ -875,6 +901,47 @@ export const createAgentExperience = (
|
|
|
875
901
|
anchorSpacer.style.height = "0px";
|
|
876
902
|
body.appendChild(anchorSpacer);
|
|
877
903
|
|
|
904
|
+
// Visually-hidden polite live region for screen-reader announcements
|
|
905
|
+
// (Principle 15: announce important events at a comfortable pace, never
|
|
906
|
+
// token-by-token). Created unconditionally but only written to when
|
|
907
|
+
// `features.scrollBehavior.announce` is opted in.
|
|
908
|
+
const liveRegion = createElement("div", "persona-sr-only");
|
|
909
|
+
liveRegion.setAttribute("aria-live", "polite");
|
|
910
|
+
liveRegion.setAttribute("aria-atomic", "true");
|
|
911
|
+
liveRegion.setAttribute("role", "status");
|
|
912
|
+
liveRegion.setAttribute("data-persona-live-region", "");
|
|
913
|
+
Object.assign(liveRegion.style, {
|
|
914
|
+
position: "absolute",
|
|
915
|
+
width: "1px",
|
|
916
|
+
height: "1px",
|
|
917
|
+
margin: "-1px",
|
|
918
|
+
padding: "0",
|
|
919
|
+
overflow: "hidden",
|
|
920
|
+
clip: "rect(0 0 0 0)",
|
|
921
|
+
clipPath: "inset(50%)",
|
|
922
|
+
whiteSpace: "nowrap",
|
|
923
|
+
border: "0",
|
|
924
|
+
} satisfies Partial<CSSStyleDeclaration>);
|
|
925
|
+
container.appendChild(liveRegion);
|
|
926
|
+
// Debounce announcements so a fast event sequence (e.g. several messages
|
|
927
|
+
// landing while away) collapses into one calm spoken update.
|
|
928
|
+
let announceTimer: ReturnType<typeof setTimeout> | null = null;
|
|
929
|
+
let pendingAnnouncement: string | null = null;
|
|
930
|
+
const announce = (message: string) => {
|
|
931
|
+
if (!isAnnounceEnabled() || !message) return;
|
|
932
|
+
pendingAnnouncement = message;
|
|
933
|
+
if (announceTimer !== null) return;
|
|
934
|
+
announceTimer = setTimeout(() => {
|
|
935
|
+
announceTimer = null;
|
|
936
|
+
// Re-check: `update()` may have disabled `announce` within the debounce
|
|
937
|
+
// window, and a stale message must not slip through to the live region.
|
|
938
|
+
if (pendingAnnouncement && isAnnounceEnabled()) {
|
|
939
|
+
liveRegion.textContent = pendingAnnouncement;
|
|
940
|
+
}
|
|
941
|
+
pendingAnnouncement = null;
|
|
942
|
+
}, 400);
|
|
943
|
+
};
|
|
944
|
+
|
|
878
945
|
const updateScrollToBottomButtonOffset = () => {
|
|
879
946
|
const footerHidden = footer.style.display === "none";
|
|
880
947
|
const footerHeight = footerHidden ? 0 : footer.offsetHeight;
|
|
@@ -2690,6 +2757,10 @@ export const createAgentExperience = (
|
|
|
2690
2757
|
destroyCallbacks.push(() => {
|
|
2691
2758
|
document.removeEventListener("keydown", handleAskUserDigitKey);
|
|
2692
2759
|
});
|
|
2760
|
+
// Clear any pending live-region announcement timer on teardown.
|
|
2761
|
+
destroyCallbacks.push(() => {
|
|
2762
|
+
if (announceTimer !== null) clearTimeout(announceTimer);
|
|
2763
|
+
});
|
|
2693
2764
|
|
|
2694
2765
|
let teardownHostStacking: (() => void) | null = null;
|
|
2695
2766
|
let releaseScrollLock: (() => void) | null = null;
|
|
@@ -2865,6 +2936,22 @@ export const createAgentExperience = (
|
|
|
2865
2936
|
let scrollSendSeeded = false;
|
|
2866
2937
|
let suppressScrollSend = false;
|
|
2867
2938
|
let lastSentUserMessageId: string | null = null;
|
|
2939
|
+
// anchor-top no-anchor fallback: anchor-top pins on a USER send. An assistant
|
|
2940
|
+
// message that streams when NO user send has anchored the conversation yet
|
|
2941
|
+
// (first-load / proactive-first streaming) has nothing to anchor to, so it
|
|
2942
|
+
// falls back to follow-to-bottom — otherwise its content streams in
|
|
2943
|
+
// off-screen. `true` by default (nothing anchored yet); a user send clears it
|
|
2944
|
+
// and the anchor takes over. Inert in follow/none mode (see
|
|
2945
|
+
// `isFollowEffective`).
|
|
2946
|
+
let followFallbackActive = true;
|
|
2947
|
+
// True once a user send has anchored the current conversation (until the chat
|
|
2948
|
+
// is cleared). While anchored, follow-on assistant content — the response, a
|
|
2949
|
+
// multi-part reply, an injected embed (tweet/image), a tool result — stays
|
|
2950
|
+
// pinned and never re-arms the fallback, so a late-loading embed can't yank
|
|
2951
|
+
// the viewport down to the bottom.
|
|
2952
|
+
let currentTurnAnchored = false;
|
|
2953
|
+
// Dedupes assistant-turn detection across token-by-token re-renders.
|
|
2954
|
+
let lastHandledAssistantId: string | null = null;
|
|
2868
2955
|
|
|
2869
2956
|
// Scroll events caused by layout, scroll anchoring, and smooth-scroll
|
|
2870
2957
|
// easing can easily move by a couple pixels. Keep manual wheel intent
|
|
@@ -2992,20 +3079,36 @@ export const createAgentExperience = (
|
|
|
2992
3079
|
cancelSmoothScroll();
|
|
2993
3080
|
};
|
|
2994
3081
|
|
|
3082
|
+
// True when a response is streaming in below the reader's current position,
|
|
3083
|
+
// i.e. content is arriving out of view. Drives the "still streaming" hint on
|
|
3084
|
+
// the scroll-to-bottom affordance (Principle 8: show what's happening out of
|
|
3085
|
+
// view). In anchor-top mode this is gated behind `showActivityWhilePinned`
|
|
3086
|
+
// so the historical "silent while pinned" behavior is preserved by default.
|
|
3087
|
+
const isStreamingOutOfView = () =>
|
|
3088
|
+
isStreaming &&
|
|
3089
|
+
isAwayFromLatest() &&
|
|
3090
|
+
(getScrollMode() !== "anchor-top" || isActivityWhilePinnedEnabled());
|
|
3091
|
+
|
|
2995
3092
|
const updateScrollToBottomCountBadge = () => {
|
|
3093
|
+
const base = getScrollToBottomLabel() || "Jump to latest";
|
|
3094
|
+
const streamingBelow = isStreamingOutOfView();
|
|
3095
|
+
scrollToBottomButton.toggleAttribute(
|
|
3096
|
+
"data-persona-scroll-to-bottom-streaming",
|
|
3097
|
+
streamingBelow
|
|
3098
|
+
);
|
|
2996
3099
|
if (newMessagesSincePause > 0) {
|
|
2997
3100
|
scrollToBottomCount.textContent = String(newMessagesSincePause);
|
|
2998
3101
|
scrollToBottomCount.style.display = "";
|
|
2999
3102
|
scrollToBottomButton.setAttribute(
|
|
3000
3103
|
"aria-label",
|
|
3001
|
-
`${
|
|
3104
|
+
`${base} (${newMessagesSincePause} new)`
|
|
3002
3105
|
);
|
|
3003
3106
|
} else {
|
|
3004
3107
|
scrollToBottomCount.textContent = "";
|
|
3005
3108
|
scrollToBottomCount.style.display = "none";
|
|
3006
3109
|
scrollToBottomButton.setAttribute(
|
|
3007
3110
|
"aria-label",
|
|
3008
|
-
|
|
3111
|
+
streamingBelow ? `${base} (response streaming below)` : base
|
|
3009
3112
|
);
|
|
3010
3113
|
}
|
|
3011
3114
|
};
|
|
@@ -3017,11 +3120,11 @@ export const createAgentExperience = (
|
|
|
3017
3120
|
};
|
|
3018
3121
|
|
|
3019
3122
|
// Whether the user is currently away from the latest content: drives both
|
|
3020
|
-
// the scroll-to-bottom affordance and the new-messages badge.
|
|
3021
|
-
//
|
|
3022
|
-
//
|
|
3123
|
+
// the scroll-to-bottom affordance and the new-messages badge. When following
|
|
3124
|
+
// the bottom (follow mode, or a no-anchor anchor-top fallback turn) that's
|
|
3125
|
+
// "auto-follow paused"; otherwise it's simply "not near the bottom".
|
|
3023
3126
|
const isAwayFromLatest = () =>
|
|
3024
|
-
|
|
3127
|
+
isFollowEffective()
|
|
3025
3128
|
? !autoFollow.isFollowing()
|
|
3026
3129
|
: !isElementNearBottom(body, BOTTOM_THRESHOLD);
|
|
3027
3130
|
|
|
@@ -3041,6 +3144,9 @@ export const createAgentExperience = (
|
|
|
3041
3144
|
const show = hasOverflow && isAwayFromLatest();
|
|
3042
3145
|
if (!show) {
|
|
3043
3146
|
resetNewMessagesCount();
|
|
3147
|
+
} else {
|
|
3148
|
+
// Refresh the streaming-below hint while the affordance is visible.
|
|
3149
|
+
updateScrollToBottomCountBadge();
|
|
3044
3150
|
}
|
|
3045
3151
|
scrollToBottomButton.style.display = show ? "" : "none";
|
|
3046
3152
|
};
|
|
@@ -3058,9 +3164,10 @@ export const createAgentExperience = (
|
|
|
3058
3164
|
};
|
|
3059
3165
|
|
|
3060
3166
|
const scheduleAutoScroll = (force = false) => {
|
|
3061
|
-
// Auto-follow
|
|
3062
|
-
//
|
|
3063
|
-
|
|
3167
|
+
// Auto-follow applies in "follow" mode, and in anchor-top only for a
|
|
3168
|
+
// no-anchor fallback turn (see `isFollowEffective`). Anchored anchor-top
|
|
3169
|
+
// turns and "none" never chase the bottom during streaming.
|
|
3170
|
+
if (!isFollowEffective()) return;
|
|
3064
3171
|
|
|
3065
3172
|
if (!autoFollow.isFollowing()) return;
|
|
3066
3173
|
|
|
@@ -3108,6 +3215,17 @@ export const createAgentExperience = (
|
|
|
3108
3215
|
// Cancel any ongoing smooth scroll animation
|
|
3109
3216
|
cancelSmoothScroll();
|
|
3110
3217
|
|
|
3218
|
+
// Nothing to scroll: land exactly on target and skip the rAF loop. Avoids a
|
|
3219
|
+
// no-op animation when already in place (e.g. anchoring with zero overflow),
|
|
3220
|
+
// which also keeps environments with a synchronous rAF from spinning.
|
|
3221
|
+
if (Math.abs(distance) < 1) {
|
|
3222
|
+
isAutoScrolling = true;
|
|
3223
|
+
element.scrollTop = target;
|
|
3224
|
+
lastScrollTop = element.scrollTop;
|
|
3225
|
+
isAutoScrolling = false;
|
|
3226
|
+
return;
|
|
3227
|
+
}
|
|
3228
|
+
|
|
3111
3229
|
const startTime = performance.now();
|
|
3112
3230
|
isAutoScrolling = true;
|
|
3113
3231
|
|
|
@@ -3192,6 +3310,61 @@ export const createAgentExperience = (
|
|
|
3192
3310
|
syncScrollToBottomButton();
|
|
3193
3311
|
};
|
|
3194
3312
|
|
|
3313
|
+
// Walk offsetParents up to `body` (the positioned scroll ancestor) to get a
|
|
3314
|
+
// node's top relative to the scroll content. offsetTop avoids skew from any
|
|
3315
|
+
// in-flight entrance transforms. Mirrors the anchor-top geometry.
|
|
3316
|
+
const offsetTopWithinBody = (el: HTMLElement): number => {
|
|
3317
|
+
let top = 0;
|
|
3318
|
+
let node: HTMLElement | null = el;
|
|
3319
|
+
while (node && node !== body) {
|
|
3320
|
+
top += node.offsetTop;
|
|
3321
|
+
node = node.offsetParent as HTMLElement | null;
|
|
3322
|
+
}
|
|
3323
|
+
return top;
|
|
3324
|
+
};
|
|
3325
|
+
|
|
3326
|
+
// Principle 11: reopen where the reader left off. When `restorePosition` is
|
|
3327
|
+
// "last-user-turn" and there is pre-existing history, land with the last user
|
|
3328
|
+
// message pinned near the top of the viewport instead of jumping to the
|
|
3329
|
+
// absolute bottom. Returns true when it handled positioning. Opt-in; the
|
|
3330
|
+
// default ("bottom") returns false so callers fall back to jump-to-bottom.
|
|
3331
|
+
const restoreScrollPosition = (): boolean => {
|
|
3332
|
+
if (getScrollRestorePosition() !== "last-user-turn") return false;
|
|
3333
|
+
const messages = session?.getMessages() ?? [];
|
|
3334
|
+
// A *restore* only makes sense when reopening existing history; a fresh
|
|
3335
|
+
// (empty or single-turn) conversation should still start at the latest.
|
|
3336
|
+
if (messages.length < 2) return false;
|
|
3337
|
+
const lastUser = [...messages].reverse().find((m) => m.role === "user");
|
|
3338
|
+
if (!lastUser) return false;
|
|
3339
|
+
const escapedId =
|
|
3340
|
+
typeof CSS !== "undefined" && typeof CSS.escape === "function"
|
|
3341
|
+
? CSS.escape(lastUser.id)
|
|
3342
|
+
: lastUser.id.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
|
|
3343
|
+
const bubble = body.querySelector<HTMLElement>(
|
|
3344
|
+
`[data-message-id="${escapedId}"]`
|
|
3345
|
+
);
|
|
3346
|
+
if (!bubble) return false;
|
|
3347
|
+
const target = Math.min(
|
|
3348
|
+
Math.max(0, offsetTopWithinBody(bubble) - getAnchorTopOffset()),
|
|
3349
|
+
getScrollBottomOffset(body)
|
|
3350
|
+
);
|
|
3351
|
+
isAutoScrolling = true;
|
|
3352
|
+
body.scrollTop = target;
|
|
3353
|
+
lastScrollTop = body.scrollTop;
|
|
3354
|
+
isAutoScrolling = false;
|
|
3355
|
+
// In follow mode, deliberately landing above the bottom means we are not
|
|
3356
|
+
// following; pause so the first streamed token doesn't yank the reader
|
|
3357
|
+
// down. (In anchor-top/none there is no follow state to manage.)
|
|
3358
|
+
if (
|
|
3359
|
+
getScrollMode() === "follow" &&
|
|
3360
|
+
!isElementNearBottom(body, BOTTOM_THRESHOLD)
|
|
3361
|
+
) {
|
|
3362
|
+
autoFollow.pause();
|
|
3363
|
+
}
|
|
3364
|
+
syncScrollToBottomButton();
|
|
3365
|
+
return true;
|
|
3366
|
+
};
|
|
3367
|
+
|
|
3195
3368
|
const setAnchorSpacerHeight = (height: number) => {
|
|
3196
3369
|
anchorSpacer.style.height = `${Math.max(0, Math.round(height))}px`;
|
|
3197
3370
|
if (anchorState) {
|
|
@@ -3225,22 +3398,16 @@ export const createAgentExperience = (
|
|
|
3225
3398
|
const escapedId =
|
|
3226
3399
|
typeof CSS !== "undefined" && typeof CSS.escape === "function"
|
|
3227
3400
|
? CSS.escape(messageId)
|
|
3228
|
-
: messageId.replace(/"/g, '\\"');
|
|
3401
|
+
: messageId.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
|
|
3229
3402
|
const bubble = body.querySelector<HTMLElement>(
|
|
3230
3403
|
`[data-message-id="${escapedId}"]`
|
|
3231
3404
|
);
|
|
3232
3405
|
if (!bubble) return;
|
|
3233
3406
|
|
|
3234
|
-
// Bubble top relative to the scroll content.
|
|
3235
|
-
//
|
|
3236
|
-
//
|
|
3237
|
-
|
|
3238
|
-
let anchorOffsetTop = 0;
|
|
3239
|
-
let node: HTMLElement | null = bubble;
|
|
3240
|
-
while (node && node !== body) {
|
|
3241
|
-
anchorOffsetTop += node.offsetTop;
|
|
3242
|
-
node = node.offsetParent as HTMLElement | null;
|
|
3243
|
-
}
|
|
3407
|
+
// Bubble top relative to the scroll content. offsetTop is used instead
|
|
3408
|
+
// of getBoundingClientRect so in-flight entrance animations (transforms)
|
|
3409
|
+
// can't skew the target.
|
|
3410
|
+
const anchorOffsetTop = offsetTopWithinBody(bubble);
|
|
3244
3411
|
|
|
3245
3412
|
const previousSpacerHeight = anchorState?.spacerHeight ?? 0;
|
|
3246
3413
|
const contentHeight = body.scrollHeight - previousSpacerHeight;
|
|
@@ -3268,7 +3435,7 @@ export const createAgentExperience = (
|
|
|
3268
3435
|
// back as the streamed response grows (shrink-only, so total scroll height
|
|
3269
3436
|
// stays constant and nothing jumps).
|
|
3270
3437
|
const handleContentResize = () => {
|
|
3271
|
-
if (
|
|
3438
|
+
if (isFollowEffective()) {
|
|
3272
3439
|
if (!autoFollow.isFollowing()) return;
|
|
3273
3440
|
if (isElementNearBottom(body, 1)) return;
|
|
3274
3441
|
scheduleAutoScroll(!isStreaming);
|
|
@@ -3298,10 +3465,34 @@ export const createAgentExperience = (
|
|
|
3298
3465
|
resumeAutoScroll();
|
|
3299
3466
|
scheduleAutoScroll(true);
|
|
3300
3467
|
} else if (mode === "anchor-top") {
|
|
3468
|
+
// A real anchor now drives the conversation: disarm the no-anchor
|
|
3469
|
+
// fallback. Every follow-on assistant message stays anchored until the
|
|
3470
|
+
// next user send.
|
|
3471
|
+
followFallbackActive = false;
|
|
3472
|
+
currentTurnAnchored = true;
|
|
3301
3473
|
scheduleAnchorToUserMessage(messageId);
|
|
3302
3474
|
}
|
|
3303
3475
|
};
|
|
3304
3476
|
|
|
3477
|
+
// Reacts to a new assistant message that arrived without a fresh user send.
|
|
3478
|
+
// Only meaningful in anchor-top. While the conversation is anchored (a user
|
|
3479
|
+
// has sent at least once), follow-on assistant content — the response, a
|
|
3480
|
+
// multi-part reply, an injected embed, a tool result — keeps the anchor so a
|
|
3481
|
+
// late-loading embed never yanks the viewport. Only when nothing has anchored
|
|
3482
|
+
// yet (first-load / proactive-first streaming) does it fall back to
|
|
3483
|
+
// follow-to-bottom so the content isn't stranded off-screen.
|
|
3484
|
+
const handleAssistantTurnStarted = () => {
|
|
3485
|
+
if (getScrollMode() !== "anchor-top") return;
|
|
3486
|
+
if (currentTurnAnchored) {
|
|
3487
|
+
followFallbackActive = false;
|
|
3488
|
+
return;
|
|
3489
|
+
}
|
|
3490
|
+
followFallbackActive = true;
|
|
3491
|
+
resetAnchorState();
|
|
3492
|
+
resumeAutoScroll();
|
|
3493
|
+
scheduleAutoScroll(true);
|
|
3494
|
+
};
|
|
3495
|
+
|
|
3305
3496
|
const trackMessages = (messages: AgentWidgetMessage[]) => {
|
|
3306
3497
|
const nextState = new Map<
|
|
3307
3498
|
string,
|
|
@@ -3319,16 +3510,23 @@ export const createAgentExperience = (
|
|
|
3319
3510
|
eventBus.emit("assistant:message", message);
|
|
3320
3511
|
// Count messages the user hasn't seen for the scroll-to-bottom badge.
|
|
3321
3512
|
// Skipped in anchor-top (the user is already reading the latest turn
|
|
3322
|
-
// from its top, so a "new" count would mislead) and during
|
|
3323
|
-
// hydration (restored messages aren't "missed").
|
|
3513
|
+
// from its top, so a "new" count would normally mislead) and during
|
|
3514
|
+
// history hydration (restored messages aren't "missed"). When
|
|
3515
|
+
// `showActivityWhilePinned` is opted in, anchor-top *does* count so the
|
|
3516
|
+
// reader is told content is arriving offscreen below (Principle 8).
|
|
3324
3517
|
if (
|
|
3325
3518
|
!suppressScrollSend &&
|
|
3326
|
-
getScrollMode() !== "anchor-top" &&
|
|
3519
|
+
(getScrollMode() !== "anchor-top" || isActivityWhilePinnedEnabled()) &&
|
|
3327
3520
|
isAwayFromLatest()
|
|
3328
3521
|
) {
|
|
3329
3522
|
newMessagesSincePause += 1;
|
|
3330
3523
|
updateScrollToBottomCountBadge();
|
|
3331
3524
|
syncScrollToBottomButton();
|
|
3525
|
+
announce(
|
|
3526
|
+
newMessagesSincePause === 1
|
|
3527
|
+
? "1 new message below."
|
|
3528
|
+
: `${newMessagesSincePause} new messages below.`
|
|
3529
|
+
);
|
|
3332
3530
|
}
|
|
3333
3531
|
}
|
|
3334
3532
|
|
|
@@ -4841,12 +5039,16 @@ export const createAgentExperience = (
|
|
|
4841
5039
|
|
|
4842
5040
|
if (open) {
|
|
4843
5041
|
recalcPanelHeight();
|
|
4844
|
-
|
|
4845
|
-
|
|
4846
|
-
|
|
4847
|
-
|
|
4848
|
-
|
|
4849
|
-
|
|
5042
|
+
// Reopen-where-left-off takes precedence when opted in (Principle 11);
|
|
5043
|
+
// otherwise fall back to the historical per-mode positioning.
|
|
5044
|
+
if (!restoreScrollPosition()) {
|
|
5045
|
+
if (getScrollMode() === "follow") {
|
|
5046
|
+
scheduleAutoScroll(true);
|
|
5047
|
+
} else {
|
|
5048
|
+
// Non-follow modes still start at the latest content when the panel
|
|
5049
|
+
// opens; they just never chase it during streaming.
|
|
5050
|
+
jumpToBottomInstant();
|
|
5051
|
+
}
|
|
4850
5052
|
}
|
|
4851
5053
|
}
|
|
4852
5054
|
|
|
@@ -4993,19 +5195,38 @@ export const createAgentExperience = (
|
|
|
4993
5195
|
const lastUserMessage = [...messages]
|
|
4994
5196
|
.reverse()
|
|
4995
5197
|
.find((msg) => msg.role === "user");
|
|
5198
|
+
const lastAssistantMessage = [...messages]
|
|
5199
|
+
.reverse()
|
|
5200
|
+
.find((msg) => msg.role === "assistant");
|
|
4996
5201
|
|
|
4997
5202
|
// Scroll-on-send / anchor-top. Seeded so restored history (constructor
|
|
4998
5203
|
// initialMessages and async storage hydration) never reads as a fresh
|
|
4999
5204
|
// send; clearing the chat resets any anchor spacer.
|
|
5000
5205
|
if (messages.length === 0) {
|
|
5001
5206
|
resetAnchorState();
|
|
5207
|
+
// Cleared: nothing anchored, so re-arm the no-anchor follow fallback.
|
|
5208
|
+
followFallbackActive = true;
|
|
5209
|
+
currentTurnAnchored = false;
|
|
5002
5210
|
}
|
|
5003
5211
|
if (!scrollSendSeeded || suppressScrollSend) {
|
|
5004
5212
|
scrollSendSeeded = true;
|
|
5005
5213
|
lastSentUserMessageId = lastUserMessage?.id ?? null;
|
|
5214
|
+
// Seed assistant-turn tracking too, so restored history doesn't read
|
|
5215
|
+
// as a fresh assistant turn and trigger the no-anchor fallback.
|
|
5216
|
+
lastHandledAssistantId = lastAssistantMessage?.id ?? null;
|
|
5006
5217
|
} else if (lastUserMessage && lastUserMessage.id !== lastSentUserMessageId) {
|
|
5007
5218
|
lastSentUserMessageId = lastUserMessage.id;
|
|
5008
5219
|
handleUserMessageSent(lastUserMessage.id);
|
|
5220
|
+
} else if (
|
|
5221
|
+
lastAssistantMessage &&
|
|
5222
|
+
lastAssistantMessage.id !== lastHandledAssistantId
|
|
5223
|
+
) {
|
|
5224
|
+
// A new assistant turn with no fresh user send: the anchor-top
|
|
5225
|
+
// no-anchor fallback (proactive/injected/resubmit/first-load streaming).
|
|
5226
|
+
handleAssistantTurnStarted();
|
|
5227
|
+
}
|
|
5228
|
+
if (lastAssistantMessage) {
|
|
5229
|
+
lastHandledAssistantId = lastAssistantMessage.id;
|
|
5009
5230
|
}
|
|
5010
5231
|
|
|
5011
5232
|
// Emit user:message event when a new user message is detected
|
|
@@ -5029,6 +5250,8 @@ export const createAgentExperience = (
|
|
|
5029
5250
|
if (s === "connecting") return currentStatusConfig.connectingText ?? statusCopy.connecting;
|
|
5030
5251
|
if (s === "connected") return currentStatusConfig.connectedText ?? statusCopy.connected;
|
|
5031
5252
|
if (s === "error") return currentStatusConfig.errorText ?? statusCopy.error;
|
|
5253
|
+
if (s === "paused") return currentStatusConfig.pausedText ?? statusCopy.paused;
|
|
5254
|
+
if (s === "resuming") return currentStatusConfig.resumingText ?? statusCopy.resuming;
|
|
5032
5255
|
return statusCopy[s];
|
|
5033
5256
|
};
|
|
5034
5257
|
applyStatusToElement(statusText, getCurrentStatusText(status), currentStatusConfig, status);
|
|
@@ -5043,6 +5266,10 @@ export const createAgentExperience = (
|
|
|
5043
5266
|
if (!streaming) {
|
|
5044
5267
|
scheduleAutoScroll(true);
|
|
5045
5268
|
}
|
|
5269
|
+
// Keep the "streaming below" hint and its announcement in sync with the
|
|
5270
|
+
// streaming lifecycle (Principles 8 + 15).
|
|
5271
|
+
syncScrollToBottomButton();
|
|
5272
|
+
announce(streaming ? "Responding…" : "Response complete.");
|
|
5046
5273
|
// Composer-bar peek: streaming state is one of the two visibility
|
|
5047
5274
|
// triggers (the other is composer hover), so re-evaluate now.
|
|
5048
5275
|
syncComposerBarPeek();
|
|
@@ -5092,11 +5319,31 @@ export const createAgentExperience = (
|
|
|
5092
5319
|
lastArtifactsState = state;
|
|
5093
5320
|
syncArtifactPane();
|
|
5094
5321
|
persistState();
|
|
5322
|
+
},
|
|
5323
|
+
onReconnect(event) {
|
|
5324
|
+
// Map the durable-reconnect lifecycle to public controller events.
|
|
5325
|
+
const { executionId, lastEventId } = event.handle;
|
|
5326
|
+
if (event.phase === "paused") {
|
|
5327
|
+
eventBus.emit("stream:paused", { executionId, after: lastEventId });
|
|
5328
|
+
} else if (event.phase === "resuming") {
|
|
5329
|
+
eventBus.emit("stream:resuming", {
|
|
5330
|
+
executionId,
|
|
5331
|
+
after: lastEventId,
|
|
5332
|
+
attempt: event.attempt ?? 1,
|
|
5333
|
+
});
|
|
5334
|
+
} else {
|
|
5335
|
+
eventBus.emit("stream:resumed", { executionId, after: lastEventId });
|
|
5336
|
+
}
|
|
5095
5337
|
}
|
|
5096
5338
|
});
|
|
5097
5339
|
|
|
5098
5340
|
sessionRef.current = session;
|
|
5099
5341
|
|
|
5342
|
+
// On teardown, cancel any in-flight turn/reconnect so a pending durable
|
|
5343
|
+
// reconnect's backoff timer and focus/online listeners don't outlive the
|
|
5344
|
+
// widget (cancel() → teardownReconnect()).
|
|
5345
|
+
destroyCallbacks.push(() => session.cancel());
|
|
5346
|
+
|
|
5100
5347
|
// Mirror read-aloud playback state into the action buttons, and surface it as
|
|
5101
5348
|
// a controller event (parallel to message:copy / message:feedback).
|
|
5102
5349
|
let lastReadAloudId: string | null = null;
|
|
@@ -5162,6 +5409,17 @@ export const createAgentExperience = (
|
|
|
5162
5409
|
});
|
|
5163
5410
|
}
|
|
5164
5411
|
|
|
5412
|
+
// Durable-session reconnect boot path: once history is in place,
|
|
5413
|
+
// if the host passed a non-terminal resume handle (+ a reconnect transport),
|
|
5414
|
+
// immediately re-enter `resuming` and replay everything past the cursor into
|
|
5415
|
+
// the restored conversation. Fires AFTER hydration so the trailing partial
|
|
5416
|
+
// assistant bubble exists for the replay to append to.
|
|
5417
|
+
const maybeBootResume = () => {
|
|
5418
|
+
if (config.resume && typeof config.reconnectStream === "function") {
|
|
5419
|
+
session.resumeFromHandle(config.resume);
|
|
5420
|
+
}
|
|
5421
|
+
};
|
|
5422
|
+
|
|
5165
5423
|
if (pendingStoredState) {
|
|
5166
5424
|
pendingStoredState
|
|
5167
5425
|
.then((state) => {
|
|
@@ -5192,7 +5450,10 @@ export const createAgentExperience = (
|
|
|
5192
5450
|
// eslint-disable-next-line no-console
|
|
5193
5451
|
console.error("[AgentWidget] Failed to hydrate stored state:", error);
|
|
5194
5452
|
}
|
|
5195
|
-
})
|
|
5453
|
+
})
|
|
5454
|
+
.finally(() => maybeBootResume());
|
|
5455
|
+
} else {
|
|
5456
|
+
maybeBootResume();
|
|
5196
5457
|
}
|
|
5197
5458
|
|
|
5198
5459
|
// Centralized so both the default composer (`handleSubmit`) and the plugin
|
|
@@ -5940,10 +6201,14 @@ export const createAgentExperience = (
|
|
|
5940
6201
|
renderSuggestions();
|
|
5941
6202
|
updateCopy();
|
|
5942
6203
|
setComposerDisabled(session.isStreaming());
|
|
5943
|
-
|
|
5944
|
-
|
|
5945
|
-
|
|
5946
|
-
|
|
6204
|
+
// Reopen-where-left-off takes precedence when opted in (Principle 11);
|
|
6205
|
+
// otherwise fall back to the historical per-mode positioning.
|
|
6206
|
+
if (!restoreScrollPosition()) {
|
|
6207
|
+
if (getScrollMode() === "follow") {
|
|
6208
|
+
scheduleAutoScroll(true);
|
|
6209
|
+
} else {
|
|
6210
|
+
jumpToBottomInstant();
|
|
6211
|
+
}
|
|
5947
6212
|
}
|
|
5948
6213
|
maybeRestoreVoiceFromMetadata();
|
|
5949
6214
|
|
|
@@ -6099,9 +6364,9 @@ export const createAgentExperience = (
|
|
|
6099
6364
|
const bottomOffsetShrank = currentBottomOffset < lastBottomOffset;
|
|
6100
6365
|
lastBottomOffset = currentBottomOffset;
|
|
6101
6366
|
|
|
6102
|
-
if (
|
|
6103
|
-
// No follow state to manage: just keep the
|
|
6104
|
-
// affordance in sync with the user's position.
|
|
6367
|
+
if (!isFollowEffective()) {
|
|
6368
|
+
// No follow state to manage (anchored anchor-top / none): just keep the
|
|
6369
|
+
// scroll-to-bottom affordance in sync with the user's position.
|
|
6105
6370
|
lastScrollTop = scrollTop;
|
|
6106
6371
|
syncScrollToBottomButton();
|
|
6107
6372
|
return;
|
|
@@ -6159,7 +6424,7 @@ export const createAgentExperience = (
|
|
|
6159
6424
|
// left in the transcript fires no further events, so it can't re-pause
|
|
6160
6425
|
// after the user resumes following.
|
|
6161
6426
|
const handleSelectionChange = () => {
|
|
6162
|
-
if (
|
|
6427
|
+
if (!isFollowEffective()) return;
|
|
6163
6428
|
if (!autoFollow.isFollowing()) return;
|
|
6164
6429
|
if (hasActiveTranscriptSelection()) {
|
|
6165
6430
|
pauseAutoScroll();
|
|
@@ -6170,8 +6435,46 @@ export const createAgentExperience = (
|
|
|
6170
6435
|
destroyCallbacks.push(() => {
|
|
6171
6436
|
selectionDocument.removeEventListener("selectionchange", handleSelectionChange);
|
|
6172
6437
|
});
|
|
6438
|
+
|
|
6439
|
+
// Principle 3: every interaction is intent. Beyond wheel/scroll/selection,
|
|
6440
|
+
// opting into `pauseOnInteraction` also treats keyboard navigation within the
|
|
6441
|
+
// transcript and focusing an interactive element (a link, button, etc.) as
|
|
6442
|
+
// "the reader is doing something here" — pause auto-follow so the stream
|
|
6443
|
+
// doesn't move content out from under them. Opt-in; off by default.
|
|
6444
|
+
const NAV_KEYS = new Set([
|
|
6445
|
+
"PageUp",
|
|
6446
|
+
"PageDown",
|
|
6447
|
+
"Home",
|
|
6448
|
+
"End",
|
|
6449
|
+
"ArrowUp",
|
|
6450
|
+
"ArrowDown",
|
|
6451
|
+
]);
|
|
6452
|
+
const handleTranscriptKeydown = (event: KeyboardEvent) => {
|
|
6453
|
+
if (!isPauseOnInteractionEnabled()) return;
|
|
6454
|
+
if (!isFollowEffective()) return;
|
|
6455
|
+
if (!autoFollow.isFollowing()) return;
|
|
6456
|
+
if (NAV_KEYS.has(event.key)) {
|
|
6457
|
+
pauseAutoScroll();
|
|
6458
|
+
}
|
|
6459
|
+
};
|
|
6460
|
+
const handleTranscriptFocusIn = (event: FocusEvent) => {
|
|
6461
|
+
if (!isPauseOnInteractionEnabled()) return;
|
|
6462
|
+
if (!isFollowEffective()) return;
|
|
6463
|
+
if (!autoFollow.isFollowing()) return;
|
|
6464
|
+
const target = event.target as Element | null;
|
|
6465
|
+
if (target && target.closest("a, button, [tabindex], input, textarea, select")) {
|
|
6466
|
+
pauseAutoScroll();
|
|
6467
|
+
}
|
|
6468
|
+
};
|
|
6469
|
+
body.addEventListener("keydown", handleTranscriptKeydown);
|
|
6470
|
+
body.addEventListener("focusin", handleTranscriptFocusIn);
|
|
6471
|
+
destroyCallbacks.push(() => {
|
|
6472
|
+
body.removeEventListener("keydown", handleTranscriptKeydown);
|
|
6473
|
+
body.removeEventListener("focusin", handleTranscriptFocusIn);
|
|
6474
|
+
});
|
|
6475
|
+
|
|
6173
6476
|
const handleWheel = (event: WheelEvent) => {
|
|
6174
|
-
if (
|
|
6477
|
+
if (!isFollowEffective()) return;
|
|
6175
6478
|
const action = resolveFollowStateFromWheel({
|
|
6176
6479
|
following: autoFollow.isFollowing(),
|
|
6177
6480
|
deltaY: event.deltaY,
|
|
@@ -6423,6 +6726,7 @@ export const createAgentExperience = (
|
|
|
6423
6726
|
const previousShowToolCalls = config.features?.showToolCalls;
|
|
6424
6727
|
const previousToolCallDisplay = config.features?.toolCallDisplay;
|
|
6425
6728
|
const previousReasoningDisplay = config.features?.reasoningDisplay;
|
|
6729
|
+
const previousStreamAnimationType = config.features?.streamAnimation?.type;
|
|
6426
6730
|
config = { ...config, ...nextConfig };
|
|
6427
6731
|
// applyFullHeightStyles resets mount.style.cssText, so call it before applyThemeVariables
|
|
6428
6732
|
applyFullHeightStyles();
|
|
@@ -6672,6 +6976,29 @@ export const createAgentExperience = (
|
|
|
6672
6976
|
renderMessagesWithPlugins(messagesWrapper, session.getMessages(), postprocess);
|
|
6673
6977
|
}
|
|
6674
6978
|
|
|
6979
|
+
// Re-activate the stream-animation plugin when the type changes via
|
|
6980
|
+
// update(). Built-in animations (typewriter, word-fade, letter-rise,
|
|
6981
|
+
// pop-bubble) carry their CSS in widget.css, so swapping the type is
|
|
6982
|
+
// enough. Plugin animations (wipe, glyph-cycle, and custom plugins
|
|
6983
|
+
// registered via registerStreamAnimationPlugin) inject their styles and
|
|
6984
|
+
// run onAttach only through ensurePluginActive, which the initial mount
|
|
6985
|
+
// calls but update() otherwise skips. Without this, switching to a plugin
|
|
6986
|
+
// animation live sets the type but never injects the CSS, so it silently
|
|
6987
|
+
// does nothing. ensurePluginActive is idempotent (styles inject once per
|
|
6988
|
+
// root), so re-selecting a previously-activated plugin is a no-op.
|
|
6989
|
+
const nextStreamAnimationType = config.features?.streamAnimation?.type;
|
|
6990
|
+
if (
|
|
6991
|
+
nextStreamAnimationType !== previousStreamAnimationType &&
|
|
6992
|
+
nextStreamAnimationType &&
|
|
6993
|
+
nextStreamAnimationType !== "none"
|
|
6994
|
+
) {
|
|
6995
|
+
const streamAnimationPlugin = resolveStreamAnimationPlugin(
|
|
6996
|
+
nextStreamAnimationType,
|
|
6997
|
+
config.features?.streamAnimation?.plugins
|
|
6998
|
+
);
|
|
6999
|
+
if (streamAnimationPlugin) ensurePluginActive(streamAnimationPlugin, mount);
|
|
7000
|
+
}
|
|
7001
|
+
|
|
6675
7002
|
// Update panel icon sizes
|
|
6676
7003
|
const launcher = config.launcher ?? {};
|
|
6677
7004
|
const headerIconHidden = launcher.headerIconHidden ?? false;
|
|
@@ -7694,6 +8021,9 @@ export const createAgentExperience = (
|
|
|
7694
8021
|
if (!isPanelToggleable()) return;
|
|
7695
8022
|
setOpenState(!open, "api");
|
|
7696
8023
|
},
|
|
8024
|
+
reconnect() {
|
|
8025
|
+
session.reconnectNow();
|
|
8026
|
+
},
|
|
7697
8027
|
clearChat() {
|
|
7698
8028
|
// Clear messages in session (this will trigger onMessagesChanged which re-renders)
|
|
7699
8029
|
artifactsPaneUserHidden = false;
|
package/src/utils/constants.ts
CHANGED