@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.
Files changed (46) hide show
  1. package/dist/animations/glyph-cycle.d.cts +1 -1
  2. package/dist/animations/glyph-cycle.d.ts +1 -1
  3. package/dist/animations/{types-C6tFDxKy.d.cts → types-8RICZWQe.d.cts} +6 -0
  4. package/dist/animations/{types-C6tFDxKy.d.ts → types-8RICZWQe.d.ts} +6 -0
  5. package/dist/animations/wipe.d.cts +1 -1
  6. package/dist/animations/wipe.d.ts +1 -1
  7. package/dist/chunk-DFBSCFYN.js +1 -0
  8. package/dist/codegen.cjs +1 -1
  9. package/dist/codegen.js +1 -1
  10. package/dist/index.cjs +50 -50
  11. package/dist/index.cjs.map +1 -1
  12. package/dist/index.d.cts +286 -11
  13. package/dist/index.d.ts +286 -11
  14. package/dist/index.global.js +39 -39
  15. package/dist/index.global.js.map +1 -1
  16. package/dist/index.js +51 -51
  17. package/dist/index.js.map +1 -1
  18. package/dist/launcher.global.js.map +1 -1
  19. package/dist/markdown-parsers-entry-NVFT3TE6.js +1 -0
  20. package/dist/runtype-tts-entry-HFUV2UF7.js +1 -0
  21. package/dist/session-reconnect-U77QFUR7.js +1 -0
  22. package/dist/smart-dom-reader.d.cts +147 -8
  23. package/dist/smart-dom-reader.d.ts +147 -8
  24. package/dist/theme-editor-preview.cjs +48 -48
  25. package/dist/theme-editor-preview.d.cts +187 -9
  26. package/dist/theme-editor-preview.d.ts +187 -9
  27. package/dist/theme-editor-preview.js +48 -48
  28. package/dist/theme-editor.cjs +1 -1
  29. package/dist/theme-editor.d.cts +147 -8
  30. package/dist/theme-editor.d.ts +147 -8
  31. package/dist/theme-editor.js +1 -1
  32. package/package.json +2 -2
  33. package/src/client.ts +48 -7
  34. package/src/defaults.ts +9 -1
  35. package/src/generated/runtype-openapi-contract.ts +6 -0
  36. package/src/index-core.ts +5 -0
  37. package/src/reconnect-wake.test.ts +162 -0
  38. package/src/reconnect.test.ts +430 -0
  39. package/src/session-reconnect.ts +282 -0
  40. package/src/session.ts +408 -5
  41. package/src/types.ts +165 -9
  42. package/src/ui.scroll-additive.test.ts +451 -0
  43. package/src/ui.scroll.test.ts +8 -2
  44. package/src/ui.stream-animation-update.test.ts +99 -0
  45. package/src/ui.ts +371 -41
  46. 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
- const getScrollMode = () => scrollBehaviorFeature.mode ?? "follow";
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
- `${getScrollToBottomLabel() || "Jump to latest"} (${newMessagesSincePause} new)`
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
- getScrollToBottomLabel() || "Jump to latest"
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. In follow
3021
- // mode that's "auto-follow paused"; in anchor-top/none modes (where there
3022
- // is no follow state) it's simply "not near the bottom".
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
- getScrollMode() === "follow"
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 only applies in "follow" mode; anchor-top and none never
3062
- // chase the bottom during streaming.
3063
- if (getScrollMode() !== "follow") return;
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. `body` is the positioned
3235
- // ancestor, so walking offsetParents terminates there. offsetTop is
3236
- // used instead of getBoundingClientRect so in-flight entrance
3237
- // animations (transforms) can't skew the target.
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 (getScrollMode() === "follow") {
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 history
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
- if (getScrollMode() === "follow") {
4845
- scheduleAutoScroll(true);
4846
- } else {
4847
- // Non-follow modes still start at the latest content when the panel
4848
- // opens; they just never chase it during streaming.
4849
- jumpToBottomInstant();
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
- if (getScrollMode() === "follow") {
5944
- scheduleAutoScroll(true);
5945
- } else {
5946
- jumpToBottomInstant();
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 (getScrollMode() !== "follow") {
6103
- // No follow state to manage: just keep the scroll-to-bottom
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 (getScrollMode() !== "follow") return;
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 (getScrollMode() !== "follow") return;
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;
@@ -4,7 +4,9 @@ export const statusCopy: Record<AgentWidgetSessionStatus, string> = {
4
4
  idle: "Online",
5
5
  connecting: "Connecting…",
6
6
  connected: "Streaming…",
7
- error: "Offline"
7
+ error: "Offline",
8
+ paused: "Connection lost…",
9
+ resuming: "Reconnecting…"
8
10
  };
9
11
 
10
12
  /**