@runtypelabs/persona 4.4.1 → 4.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/codegen.cjs +4 -4
- package/dist/codegen.js +2 -2
- package/dist/index.cjs +49 -49
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +58 -9
- package/dist/index.d.ts +58 -9
- package/dist/index.global.js +38 -38
- package/dist/index.global.js.map +1 -1
- package/dist/index.js +49 -49
- package/dist/index.js.map +1 -1
- package/dist/launcher.global.js.map +1 -1
- package/dist/smart-dom-reader.d.cts +57 -8
- package/dist/smart-dom-reader.d.ts +57 -8
- package/dist/theme-editor-preview.cjs +40 -40
- package/dist/theme-editor-preview.d.cts +57 -8
- package/dist/theme-editor-preview.d.ts +57 -8
- package/dist/theme-editor-preview.js +40 -40
- package/dist/theme-editor.cjs +1 -1
- package/dist/theme-editor.d.cts +57 -8
- package/dist/theme-editor.d.ts +57 -8
- package/dist/theme-editor.js +1 -1
- package/dist/widget.css +1 -4041
- package/package.json +3 -2
- package/src/defaults.ts +9 -1
- package/src/index-core.ts +5 -0
- package/src/types.ts +58 -8
- package/src/ui.scroll-additive.test.ts +451 -0
- package/src/ui.scroll.test.ts +8 -2
- package/src/ui.ts +300 -40
package/src/ui.ts
CHANGED
|
@@ -842,8 +842,27 @@ export const createAgentExperience = (
|
|
|
842
842
|
const getScrollToBottomLabel = () => scrollToBottomFeature.label ?? "";
|
|
843
843
|
const getScrollToBottomIconName = () => scrollToBottomFeature.iconName ?? "arrow-down";
|
|
844
844
|
const isScrollToBottomEnabled = () => scrollToBottomFeature.enabled !== false;
|
|
845
|
-
|
|
845
|
+
// Default is "anchor-top" (see DEFAULT_WIDGET_CONFIG). This `??` only applies
|
|
846
|
+
// when a partial config sets `scrollBehavior.mode` to undefined explicitly;
|
|
847
|
+
// it must agree with the declared default.
|
|
848
|
+
const getScrollMode = () => scrollBehaviorFeature.mode ?? "anchor-top";
|
|
849
|
+
// "Effectively following the bottom" for streaming auto-scroll: true in
|
|
850
|
+
// follow mode, and in anchor-top when the current turn has no anchor (the
|
|
851
|
+
// no-anchor fallback). Drives `scheduleAutoScroll`, `handleContentResize`,
|
|
852
|
+
// and `isAwayFromLatest` so a no-anchor anchor-top turn behaves like follow.
|
|
853
|
+
const isFollowEffective = () =>
|
|
854
|
+
getScrollMode() === "follow" ||
|
|
855
|
+
(getScrollMode() === "anchor-top" && followFallbackActive);
|
|
846
856
|
const getAnchorTopOffset = () => scrollBehaviorFeature.anchorTopOffset ?? 16;
|
|
857
|
+
const getScrollRestorePosition = () =>
|
|
858
|
+
scrollBehaviorFeature.restorePosition ?? "bottom";
|
|
859
|
+
const isPauseOnInteractionEnabled = () =>
|
|
860
|
+
scrollBehaviorFeature.pauseOnInteraction === true;
|
|
861
|
+
// Defaults on alongside the anchor-top default so the pinned-turn UX keeps the
|
|
862
|
+
// unread count + "streaming below" hint; opt out with `false`.
|
|
863
|
+
const isActivityWhilePinnedEnabled = () =>
|
|
864
|
+
scrollBehaviorFeature.showActivityWhilePinned !== false;
|
|
865
|
+
const isAnnounceEnabled = () => scrollBehaviorFeature.announce === true;
|
|
847
866
|
const scrollToBottomButton = createElement(
|
|
848
867
|
"button",
|
|
849
868
|
"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 +894,47 @@ export const createAgentExperience = (
|
|
|
875
894
|
anchorSpacer.style.height = "0px";
|
|
876
895
|
body.appendChild(anchorSpacer);
|
|
877
896
|
|
|
897
|
+
// Visually-hidden polite live region for screen-reader announcements
|
|
898
|
+
// (Principle 15: announce important events at a comfortable pace, never
|
|
899
|
+
// token-by-token). Created unconditionally but only written to when
|
|
900
|
+
// `features.scrollBehavior.announce` is opted in.
|
|
901
|
+
const liveRegion = createElement("div", "persona-sr-only");
|
|
902
|
+
liveRegion.setAttribute("aria-live", "polite");
|
|
903
|
+
liveRegion.setAttribute("aria-atomic", "true");
|
|
904
|
+
liveRegion.setAttribute("role", "status");
|
|
905
|
+
liveRegion.setAttribute("data-persona-live-region", "");
|
|
906
|
+
Object.assign(liveRegion.style, {
|
|
907
|
+
position: "absolute",
|
|
908
|
+
width: "1px",
|
|
909
|
+
height: "1px",
|
|
910
|
+
margin: "-1px",
|
|
911
|
+
padding: "0",
|
|
912
|
+
overflow: "hidden",
|
|
913
|
+
clip: "rect(0 0 0 0)",
|
|
914
|
+
clipPath: "inset(50%)",
|
|
915
|
+
whiteSpace: "nowrap",
|
|
916
|
+
border: "0",
|
|
917
|
+
} satisfies Partial<CSSStyleDeclaration>);
|
|
918
|
+
container.appendChild(liveRegion);
|
|
919
|
+
// Debounce announcements so a fast event sequence (e.g. several messages
|
|
920
|
+
// landing while away) collapses into one calm spoken update.
|
|
921
|
+
let announceTimer: ReturnType<typeof setTimeout> | null = null;
|
|
922
|
+
let pendingAnnouncement: string | null = null;
|
|
923
|
+
const announce = (message: string) => {
|
|
924
|
+
if (!isAnnounceEnabled() || !message) return;
|
|
925
|
+
pendingAnnouncement = message;
|
|
926
|
+
if (announceTimer !== null) return;
|
|
927
|
+
announceTimer = setTimeout(() => {
|
|
928
|
+
announceTimer = null;
|
|
929
|
+
// Re-check: `update()` may have disabled `announce` within the debounce
|
|
930
|
+
// window, and a stale message must not slip through to the live region.
|
|
931
|
+
if (pendingAnnouncement && isAnnounceEnabled()) {
|
|
932
|
+
liveRegion.textContent = pendingAnnouncement;
|
|
933
|
+
}
|
|
934
|
+
pendingAnnouncement = null;
|
|
935
|
+
}, 400);
|
|
936
|
+
};
|
|
937
|
+
|
|
878
938
|
const updateScrollToBottomButtonOffset = () => {
|
|
879
939
|
const footerHidden = footer.style.display === "none";
|
|
880
940
|
const footerHeight = footerHidden ? 0 : footer.offsetHeight;
|
|
@@ -2690,6 +2750,10 @@ export const createAgentExperience = (
|
|
|
2690
2750
|
destroyCallbacks.push(() => {
|
|
2691
2751
|
document.removeEventListener("keydown", handleAskUserDigitKey);
|
|
2692
2752
|
});
|
|
2753
|
+
// Clear any pending live-region announcement timer on teardown.
|
|
2754
|
+
destroyCallbacks.push(() => {
|
|
2755
|
+
if (announceTimer !== null) clearTimeout(announceTimer);
|
|
2756
|
+
});
|
|
2693
2757
|
|
|
2694
2758
|
let teardownHostStacking: (() => void) | null = null;
|
|
2695
2759
|
let releaseScrollLock: (() => void) | null = null;
|
|
@@ -2865,6 +2929,22 @@ export const createAgentExperience = (
|
|
|
2865
2929
|
let scrollSendSeeded = false;
|
|
2866
2930
|
let suppressScrollSend = false;
|
|
2867
2931
|
let lastSentUserMessageId: string | null = null;
|
|
2932
|
+
// anchor-top no-anchor fallback: anchor-top pins on a USER send. An assistant
|
|
2933
|
+
// message that streams when NO user send has anchored the conversation yet
|
|
2934
|
+
// (first-load / proactive-first streaming) has nothing to anchor to, so it
|
|
2935
|
+
// falls back to follow-to-bottom — otherwise its content streams in
|
|
2936
|
+
// off-screen. `true` by default (nothing anchored yet); a user send clears it
|
|
2937
|
+
// and the anchor takes over. Inert in follow/none mode (see
|
|
2938
|
+
// `isFollowEffective`).
|
|
2939
|
+
let followFallbackActive = true;
|
|
2940
|
+
// True once a user send has anchored the current conversation (until the chat
|
|
2941
|
+
// is cleared). While anchored, follow-on assistant content — the response, a
|
|
2942
|
+
// multi-part reply, an injected embed (tweet/image), a tool result — stays
|
|
2943
|
+
// pinned and never re-arms the fallback, so a late-loading embed can't yank
|
|
2944
|
+
// the viewport down to the bottom.
|
|
2945
|
+
let currentTurnAnchored = false;
|
|
2946
|
+
// Dedupes assistant-turn detection across token-by-token re-renders.
|
|
2947
|
+
let lastHandledAssistantId: string | null = null;
|
|
2868
2948
|
|
|
2869
2949
|
// Scroll events caused by layout, scroll anchoring, and smooth-scroll
|
|
2870
2950
|
// easing can easily move by a couple pixels. Keep manual wheel intent
|
|
@@ -2992,20 +3072,36 @@ export const createAgentExperience = (
|
|
|
2992
3072
|
cancelSmoothScroll();
|
|
2993
3073
|
};
|
|
2994
3074
|
|
|
3075
|
+
// True when a response is streaming in below the reader's current position,
|
|
3076
|
+
// i.e. content is arriving out of view. Drives the "still streaming" hint on
|
|
3077
|
+
// the scroll-to-bottom affordance (Principle 8: show what's happening out of
|
|
3078
|
+
// view). In anchor-top mode this is gated behind `showActivityWhilePinned`
|
|
3079
|
+
// so the historical "silent while pinned" behavior is preserved by default.
|
|
3080
|
+
const isStreamingOutOfView = () =>
|
|
3081
|
+
isStreaming &&
|
|
3082
|
+
isAwayFromLatest() &&
|
|
3083
|
+
(getScrollMode() !== "anchor-top" || isActivityWhilePinnedEnabled());
|
|
3084
|
+
|
|
2995
3085
|
const updateScrollToBottomCountBadge = () => {
|
|
3086
|
+
const base = getScrollToBottomLabel() || "Jump to latest";
|
|
3087
|
+
const streamingBelow = isStreamingOutOfView();
|
|
3088
|
+
scrollToBottomButton.toggleAttribute(
|
|
3089
|
+
"data-persona-scroll-to-bottom-streaming",
|
|
3090
|
+
streamingBelow
|
|
3091
|
+
);
|
|
2996
3092
|
if (newMessagesSincePause > 0) {
|
|
2997
3093
|
scrollToBottomCount.textContent = String(newMessagesSincePause);
|
|
2998
3094
|
scrollToBottomCount.style.display = "";
|
|
2999
3095
|
scrollToBottomButton.setAttribute(
|
|
3000
3096
|
"aria-label",
|
|
3001
|
-
`${
|
|
3097
|
+
`${base} (${newMessagesSincePause} new)`
|
|
3002
3098
|
);
|
|
3003
3099
|
} else {
|
|
3004
3100
|
scrollToBottomCount.textContent = "";
|
|
3005
3101
|
scrollToBottomCount.style.display = "none";
|
|
3006
3102
|
scrollToBottomButton.setAttribute(
|
|
3007
3103
|
"aria-label",
|
|
3008
|
-
|
|
3104
|
+
streamingBelow ? `${base} (response streaming below)` : base
|
|
3009
3105
|
);
|
|
3010
3106
|
}
|
|
3011
3107
|
};
|
|
@@ -3017,11 +3113,11 @@ export const createAgentExperience = (
|
|
|
3017
3113
|
};
|
|
3018
3114
|
|
|
3019
3115
|
// 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
|
-
//
|
|
3116
|
+
// the scroll-to-bottom affordance and the new-messages badge. When following
|
|
3117
|
+
// the bottom (follow mode, or a no-anchor anchor-top fallback turn) that's
|
|
3118
|
+
// "auto-follow paused"; otherwise it's simply "not near the bottom".
|
|
3023
3119
|
const isAwayFromLatest = () =>
|
|
3024
|
-
|
|
3120
|
+
isFollowEffective()
|
|
3025
3121
|
? !autoFollow.isFollowing()
|
|
3026
3122
|
: !isElementNearBottom(body, BOTTOM_THRESHOLD);
|
|
3027
3123
|
|
|
@@ -3041,6 +3137,9 @@ export const createAgentExperience = (
|
|
|
3041
3137
|
const show = hasOverflow && isAwayFromLatest();
|
|
3042
3138
|
if (!show) {
|
|
3043
3139
|
resetNewMessagesCount();
|
|
3140
|
+
} else {
|
|
3141
|
+
// Refresh the streaming-below hint while the affordance is visible.
|
|
3142
|
+
updateScrollToBottomCountBadge();
|
|
3044
3143
|
}
|
|
3045
3144
|
scrollToBottomButton.style.display = show ? "" : "none";
|
|
3046
3145
|
};
|
|
@@ -3058,9 +3157,10 @@ export const createAgentExperience = (
|
|
|
3058
3157
|
};
|
|
3059
3158
|
|
|
3060
3159
|
const scheduleAutoScroll = (force = false) => {
|
|
3061
|
-
// Auto-follow
|
|
3062
|
-
//
|
|
3063
|
-
|
|
3160
|
+
// Auto-follow applies in "follow" mode, and in anchor-top only for a
|
|
3161
|
+
// no-anchor fallback turn (see `isFollowEffective`). Anchored anchor-top
|
|
3162
|
+
// turns and "none" never chase the bottom during streaming.
|
|
3163
|
+
if (!isFollowEffective()) return;
|
|
3064
3164
|
|
|
3065
3165
|
if (!autoFollow.isFollowing()) return;
|
|
3066
3166
|
|
|
@@ -3108,6 +3208,17 @@ export const createAgentExperience = (
|
|
|
3108
3208
|
// Cancel any ongoing smooth scroll animation
|
|
3109
3209
|
cancelSmoothScroll();
|
|
3110
3210
|
|
|
3211
|
+
// Nothing to scroll: land exactly on target and skip the rAF loop. Avoids a
|
|
3212
|
+
// no-op animation when already in place (e.g. anchoring with zero overflow),
|
|
3213
|
+
// which also keeps environments with a synchronous rAF from spinning.
|
|
3214
|
+
if (Math.abs(distance) < 1) {
|
|
3215
|
+
isAutoScrolling = true;
|
|
3216
|
+
element.scrollTop = target;
|
|
3217
|
+
lastScrollTop = element.scrollTop;
|
|
3218
|
+
isAutoScrolling = false;
|
|
3219
|
+
return;
|
|
3220
|
+
}
|
|
3221
|
+
|
|
3111
3222
|
const startTime = performance.now();
|
|
3112
3223
|
isAutoScrolling = true;
|
|
3113
3224
|
|
|
@@ -3192,6 +3303,61 @@ export const createAgentExperience = (
|
|
|
3192
3303
|
syncScrollToBottomButton();
|
|
3193
3304
|
};
|
|
3194
3305
|
|
|
3306
|
+
// Walk offsetParents up to `body` (the positioned scroll ancestor) to get a
|
|
3307
|
+
// node's top relative to the scroll content. offsetTop avoids skew from any
|
|
3308
|
+
// in-flight entrance transforms. Mirrors the anchor-top geometry.
|
|
3309
|
+
const offsetTopWithinBody = (el: HTMLElement): number => {
|
|
3310
|
+
let top = 0;
|
|
3311
|
+
let node: HTMLElement | null = el;
|
|
3312
|
+
while (node && node !== body) {
|
|
3313
|
+
top += node.offsetTop;
|
|
3314
|
+
node = node.offsetParent as HTMLElement | null;
|
|
3315
|
+
}
|
|
3316
|
+
return top;
|
|
3317
|
+
};
|
|
3318
|
+
|
|
3319
|
+
// Principle 11: reopen where the reader left off. When `restorePosition` is
|
|
3320
|
+
// "last-user-turn" and there is pre-existing history, land with the last user
|
|
3321
|
+
// message pinned near the top of the viewport instead of jumping to the
|
|
3322
|
+
// absolute bottom. Returns true when it handled positioning. Opt-in; the
|
|
3323
|
+
// default ("bottom") returns false so callers fall back to jump-to-bottom.
|
|
3324
|
+
const restoreScrollPosition = (): boolean => {
|
|
3325
|
+
if (getScrollRestorePosition() !== "last-user-turn") return false;
|
|
3326
|
+
const messages = session?.getMessages() ?? [];
|
|
3327
|
+
// A *restore* only makes sense when reopening existing history; a fresh
|
|
3328
|
+
// (empty or single-turn) conversation should still start at the latest.
|
|
3329
|
+
if (messages.length < 2) return false;
|
|
3330
|
+
const lastUser = [...messages].reverse().find((m) => m.role === "user");
|
|
3331
|
+
if (!lastUser) return false;
|
|
3332
|
+
const escapedId =
|
|
3333
|
+
typeof CSS !== "undefined" && typeof CSS.escape === "function"
|
|
3334
|
+
? CSS.escape(lastUser.id)
|
|
3335
|
+
: lastUser.id.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
|
|
3336
|
+
const bubble = body.querySelector<HTMLElement>(
|
|
3337
|
+
`[data-message-id="${escapedId}"]`
|
|
3338
|
+
);
|
|
3339
|
+
if (!bubble) return false;
|
|
3340
|
+
const target = Math.min(
|
|
3341
|
+
Math.max(0, offsetTopWithinBody(bubble) - getAnchorTopOffset()),
|
|
3342
|
+
getScrollBottomOffset(body)
|
|
3343
|
+
);
|
|
3344
|
+
isAutoScrolling = true;
|
|
3345
|
+
body.scrollTop = target;
|
|
3346
|
+
lastScrollTop = body.scrollTop;
|
|
3347
|
+
isAutoScrolling = false;
|
|
3348
|
+
// In follow mode, deliberately landing above the bottom means we are not
|
|
3349
|
+
// following; pause so the first streamed token doesn't yank the reader
|
|
3350
|
+
// down. (In anchor-top/none there is no follow state to manage.)
|
|
3351
|
+
if (
|
|
3352
|
+
getScrollMode() === "follow" &&
|
|
3353
|
+
!isElementNearBottom(body, BOTTOM_THRESHOLD)
|
|
3354
|
+
) {
|
|
3355
|
+
autoFollow.pause();
|
|
3356
|
+
}
|
|
3357
|
+
syncScrollToBottomButton();
|
|
3358
|
+
return true;
|
|
3359
|
+
};
|
|
3360
|
+
|
|
3195
3361
|
const setAnchorSpacerHeight = (height: number) => {
|
|
3196
3362
|
anchorSpacer.style.height = `${Math.max(0, Math.round(height))}px`;
|
|
3197
3363
|
if (anchorState) {
|
|
@@ -3225,22 +3391,16 @@ export const createAgentExperience = (
|
|
|
3225
3391
|
const escapedId =
|
|
3226
3392
|
typeof CSS !== "undefined" && typeof CSS.escape === "function"
|
|
3227
3393
|
? CSS.escape(messageId)
|
|
3228
|
-
: messageId.replace(/"/g, '\\"');
|
|
3394
|
+
: messageId.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
|
|
3229
3395
|
const bubble = body.querySelector<HTMLElement>(
|
|
3230
3396
|
`[data-message-id="${escapedId}"]`
|
|
3231
3397
|
);
|
|
3232
3398
|
if (!bubble) return;
|
|
3233
3399
|
|
|
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
|
-
}
|
|
3400
|
+
// Bubble top relative to the scroll content. offsetTop is used instead
|
|
3401
|
+
// of getBoundingClientRect so in-flight entrance animations (transforms)
|
|
3402
|
+
// can't skew the target.
|
|
3403
|
+
const anchorOffsetTop = offsetTopWithinBody(bubble);
|
|
3244
3404
|
|
|
3245
3405
|
const previousSpacerHeight = anchorState?.spacerHeight ?? 0;
|
|
3246
3406
|
const contentHeight = body.scrollHeight - previousSpacerHeight;
|
|
@@ -3268,7 +3428,7 @@ export const createAgentExperience = (
|
|
|
3268
3428
|
// back as the streamed response grows (shrink-only, so total scroll height
|
|
3269
3429
|
// stays constant and nothing jumps).
|
|
3270
3430
|
const handleContentResize = () => {
|
|
3271
|
-
if (
|
|
3431
|
+
if (isFollowEffective()) {
|
|
3272
3432
|
if (!autoFollow.isFollowing()) return;
|
|
3273
3433
|
if (isElementNearBottom(body, 1)) return;
|
|
3274
3434
|
scheduleAutoScroll(!isStreaming);
|
|
@@ -3298,10 +3458,34 @@ export const createAgentExperience = (
|
|
|
3298
3458
|
resumeAutoScroll();
|
|
3299
3459
|
scheduleAutoScroll(true);
|
|
3300
3460
|
} else if (mode === "anchor-top") {
|
|
3461
|
+
// A real anchor now drives the conversation: disarm the no-anchor
|
|
3462
|
+
// fallback. Every follow-on assistant message stays anchored until the
|
|
3463
|
+
// next user send.
|
|
3464
|
+
followFallbackActive = false;
|
|
3465
|
+
currentTurnAnchored = true;
|
|
3301
3466
|
scheduleAnchorToUserMessage(messageId);
|
|
3302
3467
|
}
|
|
3303
3468
|
};
|
|
3304
3469
|
|
|
3470
|
+
// Reacts to a new assistant message that arrived without a fresh user send.
|
|
3471
|
+
// Only meaningful in anchor-top. While the conversation is anchored (a user
|
|
3472
|
+
// has sent at least once), follow-on assistant content — the response, a
|
|
3473
|
+
// multi-part reply, an injected embed, a tool result — keeps the anchor so a
|
|
3474
|
+
// late-loading embed never yanks the viewport. Only when nothing has anchored
|
|
3475
|
+
// yet (first-load / proactive-first streaming) does it fall back to
|
|
3476
|
+
// follow-to-bottom so the content isn't stranded off-screen.
|
|
3477
|
+
const handleAssistantTurnStarted = () => {
|
|
3478
|
+
if (getScrollMode() !== "anchor-top") return;
|
|
3479
|
+
if (currentTurnAnchored) {
|
|
3480
|
+
followFallbackActive = false;
|
|
3481
|
+
return;
|
|
3482
|
+
}
|
|
3483
|
+
followFallbackActive = true;
|
|
3484
|
+
resetAnchorState();
|
|
3485
|
+
resumeAutoScroll();
|
|
3486
|
+
scheduleAutoScroll(true);
|
|
3487
|
+
};
|
|
3488
|
+
|
|
3305
3489
|
const trackMessages = (messages: AgentWidgetMessage[]) => {
|
|
3306
3490
|
const nextState = new Map<
|
|
3307
3491
|
string,
|
|
@@ -3319,16 +3503,23 @@ export const createAgentExperience = (
|
|
|
3319
3503
|
eventBus.emit("assistant:message", message);
|
|
3320
3504
|
// Count messages the user hasn't seen for the scroll-to-bottom badge.
|
|
3321
3505
|
// 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").
|
|
3506
|
+
// from its top, so a "new" count would normally mislead) and during
|
|
3507
|
+
// history hydration (restored messages aren't "missed"). When
|
|
3508
|
+
// `showActivityWhilePinned` is opted in, anchor-top *does* count so the
|
|
3509
|
+
// reader is told content is arriving offscreen below (Principle 8).
|
|
3324
3510
|
if (
|
|
3325
3511
|
!suppressScrollSend &&
|
|
3326
|
-
getScrollMode() !== "anchor-top" &&
|
|
3512
|
+
(getScrollMode() !== "anchor-top" || isActivityWhilePinnedEnabled()) &&
|
|
3327
3513
|
isAwayFromLatest()
|
|
3328
3514
|
) {
|
|
3329
3515
|
newMessagesSincePause += 1;
|
|
3330
3516
|
updateScrollToBottomCountBadge();
|
|
3331
3517
|
syncScrollToBottomButton();
|
|
3518
|
+
announce(
|
|
3519
|
+
newMessagesSincePause === 1
|
|
3520
|
+
? "1 new message below."
|
|
3521
|
+
: `${newMessagesSincePause} new messages below.`
|
|
3522
|
+
);
|
|
3332
3523
|
}
|
|
3333
3524
|
}
|
|
3334
3525
|
|
|
@@ -4841,12 +5032,16 @@ export const createAgentExperience = (
|
|
|
4841
5032
|
|
|
4842
5033
|
if (open) {
|
|
4843
5034
|
recalcPanelHeight();
|
|
4844
|
-
|
|
4845
|
-
|
|
4846
|
-
|
|
4847
|
-
|
|
4848
|
-
|
|
4849
|
-
|
|
5035
|
+
// Reopen-where-left-off takes precedence when opted in (Principle 11);
|
|
5036
|
+
// otherwise fall back to the historical per-mode positioning.
|
|
5037
|
+
if (!restoreScrollPosition()) {
|
|
5038
|
+
if (getScrollMode() === "follow") {
|
|
5039
|
+
scheduleAutoScroll(true);
|
|
5040
|
+
} else {
|
|
5041
|
+
// Non-follow modes still start at the latest content when the panel
|
|
5042
|
+
// opens; they just never chase it during streaming.
|
|
5043
|
+
jumpToBottomInstant();
|
|
5044
|
+
}
|
|
4850
5045
|
}
|
|
4851
5046
|
}
|
|
4852
5047
|
|
|
@@ -4993,19 +5188,38 @@ export const createAgentExperience = (
|
|
|
4993
5188
|
const lastUserMessage = [...messages]
|
|
4994
5189
|
.reverse()
|
|
4995
5190
|
.find((msg) => msg.role === "user");
|
|
5191
|
+
const lastAssistantMessage = [...messages]
|
|
5192
|
+
.reverse()
|
|
5193
|
+
.find((msg) => msg.role === "assistant");
|
|
4996
5194
|
|
|
4997
5195
|
// Scroll-on-send / anchor-top. Seeded so restored history (constructor
|
|
4998
5196
|
// initialMessages and async storage hydration) never reads as a fresh
|
|
4999
5197
|
// send; clearing the chat resets any anchor spacer.
|
|
5000
5198
|
if (messages.length === 0) {
|
|
5001
5199
|
resetAnchorState();
|
|
5200
|
+
// Cleared: nothing anchored, so re-arm the no-anchor follow fallback.
|
|
5201
|
+
followFallbackActive = true;
|
|
5202
|
+
currentTurnAnchored = false;
|
|
5002
5203
|
}
|
|
5003
5204
|
if (!scrollSendSeeded || suppressScrollSend) {
|
|
5004
5205
|
scrollSendSeeded = true;
|
|
5005
5206
|
lastSentUserMessageId = lastUserMessage?.id ?? null;
|
|
5207
|
+
// Seed assistant-turn tracking too, so restored history doesn't read
|
|
5208
|
+
// as a fresh assistant turn and trigger the no-anchor fallback.
|
|
5209
|
+
lastHandledAssistantId = lastAssistantMessage?.id ?? null;
|
|
5006
5210
|
} else if (lastUserMessage && lastUserMessage.id !== lastSentUserMessageId) {
|
|
5007
5211
|
lastSentUserMessageId = lastUserMessage.id;
|
|
5008
5212
|
handleUserMessageSent(lastUserMessage.id);
|
|
5213
|
+
} else if (
|
|
5214
|
+
lastAssistantMessage &&
|
|
5215
|
+
lastAssistantMessage.id !== lastHandledAssistantId
|
|
5216
|
+
) {
|
|
5217
|
+
// A new assistant turn with no fresh user send: the anchor-top
|
|
5218
|
+
// no-anchor fallback (proactive/injected/resubmit/first-load streaming).
|
|
5219
|
+
handleAssistantTurnStarted();
|
|
5220
|
+
}
|
|
5221
|
+
if (lastAssistantMessage) {
|
|
5222
|
+
lastHandledAssistantId = lastAssistantMessage.id;
|
|
5009
5223
|
}
|
|
5010
5224
|
|
|
5011
5225
|
// Emit user:message event when a new user message is detected
|
|
@@ -5043,6 +5257,10 @@ export const createAgentExperience = (
|
|
|
5043
5257
|
if (!streaming) {
|
|
5044
5258
|
scheduleAutoScroll(true);
|
|
5045
5259
|
}
|
|
5260
|
+
// Keep the "streaming below" hint and its announcement in sync with the
|
|
5261
|
+
// streaming lifecycle (Principles 8 + 15).
|
|
5262
|
+
syncScrollToBottomButton();
|
|
5263
|
+
announce(streaming ? "Responding…" : "Response complete.");
|
|
5046
5264
|
// Composer-bar peek: streaming state is one of the two visibility
|
|
5047
5265
|
// triggers (the other is composer hover), so re-evaluate now.
|
|
5048
5266
|
syncComposerBarPeek();
|
|
@@ -5940,10 +6158,14 @@ export const createAgentExperience = (
|
|
|
5940
6158
|
renderSuggestions();
|
|
5941
6159
|
updateCopy();
|
|
5942
6160
|
setComposerDisabled(session.isStreaming());
|
|
5943
|
-
|
|
5944
|
-
|
|
5945
|
-
|
|
5946
|
-
|
|
6161
|
+
// Reopen-where-left-off takes precedence when opted in (Principle 11);
|
|
6162
|
+
// otherwise fall back to the historical per-mode positioning.
|
|
6163
|
+
if (!restoreScrollPosition()) {
|
|
6164
|
+
if (getScrollMode() === "follow") {
|
|
6165
|
+
scheduleAutoScroll(true);
|
|
6166
|
+
} else {
|
|
6167
|
+
jumpToBottomInstant();
|
|
6168
|
+
}
|
|
5947
6169
|
}
|
|
5948
6170
|
maybeRestoreVoiceFromMetadata();
|
|
5949
6171
|
|
|
@@ -6099,9 +6321,9 @@ export const createAgentExperience = (
|
|
|
6099
6321
|
const bottomOffsetShrank = currentBottomOffset < lastBottomOffset;
|
|
6100
6322
|
lastBottomOffset = currentBottomOffset;
|
|
6101
6323
|
|
|
6102
|
-
if (
|
|
6103
|
-
// No follow state to manage: just keep the
|
|
6104
|
-
// affordance in sync with the user's position.
|
|
6324
|
+
if (!isFollowEffective()) {
|
|
6325
|
+
// No follow state to manage (anchored anchor-top / none): just keep the
|
|
6326
|
+
// scroll-to-bottom affordance in sync with the user's position.
|
|
6105
6327
|
lastScrollTop = scrollTop;
|
|
6106
6328
|
syncScrollToBottomButton();
|
|
6107
6329
|
return;
|
|
@@ -6159,7 +6381,7 @@ export const createAgentExperience = (
|
|
|
6159
6381
|
// left in the transcript fires no further events, so it can't re-pause
|
|
6160
6382
|
// after the user resumes following.
|
|
6161
6383
|
const handleSelectionChange = () => {
|
|
6162
|
-
if (
|
|
6384
|
+
if (!isFollowEffective()) return;
|
|
6163
6385
|
if (!autoFollow.isFollowing()) return;
|
|
6164
6386
|
if (hasActiveTranscriptSelection()) {
|
|
6165
6387
|
pauseAutoScroll();
|
|
@@ -6170,8 +6392,46 @@ export const createAgentExperience = (
|
|
|
6170
6392
|
destroyCallbacks.push(() => {
|
|
6171
6393
|
selectionDocument.removeEventListener("selectionchange", handleSelectionChange);
|
|
6172
6394
|
});
|
|
6395
|
+
|
|
6396
|
+
// Principle 3: every interaction is intent. Beyond wheel/scroll/selection,
|
|
6397
|
+
// opting into `pauseOnInteraction` also treats keyboard navigation within the
|
|
6398
|
+
// transcript and focusing an interactive element (a link, button, etc.) as
|
|
6399
|
+
// "the reader is doing something here" — pause auto-follow so the stream
|
|
6400
|
+
// doesn't move content out from under them. Opt-in; off by default.
|
|
6401
|
+
const NAV_KEYS = new Set([
|
|
6402
|
+
"PageUp",
|
|
6403
|
+
"PageDown",
|
|
6404
|
+
"Home",
|
|
6405
|
+
"End",
|
|
6406
|
+
"ArrowUp",
|
|
6407
|
+
"ArrowDown",
|
|
6408
|
+
]);
|
|
6409
|
+
const handleTranscriptKeydown = (event: KeyboardEvent) => {
|
|
6410
|
+
if (!isPauseOnInteractionEnabled()) return;
|
|
6411
|
+
if (!isFollowEffective()) return;
|
|
6412
|
+
if (!autoFollow.isFollowing()) return;
|
|
6413
|
+
if (NAV_KEYS.has(event.key)) {
|
|
6414
|
+
pauseAutoScroll();
|
|
6415
|
+
}
|
|
6416
|
+
};
|
|
6417
|
+
const handleTranscriptFocusIn = (event: FocusEvent) => {
|
|
6418
|
+
if (!isPauseOnInteractionEnabled()) return;
|
|
6419
|
+
if (!isFollowEffective()) return;
|
|
6420
|
+
if (!autoFollow.isFollowing()) return;
|
|
6421
|
+
const target = event.target as Element | null;
|
|
6422
|
+
if (target && target.closest("a, button, [tabindex], input, textarea, select")) {
|
|
6423
|
+
pauseAutoScroll();
|
|
6424
|
+
}
|
|
6425
|
+
};
|
|
6426
|
+
body.addEventListener("keydown", handleTranscriptKeydown);
|
|
6427
|
+
body.addEventListener("focusin", handleTranscriptFocusIn);
|
|
6428
|
+
destroyCallbacks.push(() => {
|
|
6429
|
+
body.removeEventListener("keydown", handleTranscriptKeydown);
|
|
6430
|
+
body.removeEventListener("focusin", handleTranscriptFocusIn);
|
|
6431
|
+
});
|
|
6432
|
+
|
|
6173
6433
|
const handleWheel = (event: WheelEvent) => {
|
|
6174
|
-
if (
|
|
6434
|
+
if (!isFollowEffective()) return;
|
|
6175
6435
|
const action = resolveFollowStateFromWheel({
|
|
6176
6436
|
following: autoFollow.isFollowing(),
|
|
6177
6437
|
deltaY: event.deltaY,
|