From c236b7f7e628eee17f397225c329411e40e536c2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pascal=20Andr=C3=A9?= Date: Mon, 8 Jun 2026 22:24:56 +0200 Subject: [PATCH] fix: preserve escaped streaming rejoin outside hold Limit the virtual follow bottom-pin suspension gate to external suspension or an actively latched Hold target, so escaped Assistant streaming can rejoin near the bottom while active Hold remains latched until true bottom. Adds behavior coverage for future eligible hold targets versus active hold latches. --- .../virtual-follow-behavior.test.ts | 29 +++++++++++++++++++ .../src/components/virtual-follow-behavior.ts | 8 +++++ .../ui/src/components/virtual-follow-list.tsx | 9 ++++-- 3 files changed, 43 insertions(+), 3 deletions(-) diff --git a/packages/ui/src/components/virtual-follow-behavior.test.ts b/packages/ui/src/components/virtual-follow-behavior.test.ts index 2454d5fb..daaa0d7a 100644 --- a/packages/ui/src/components/virtual-follow-behavior.test.ts +++ b/packages/ui/src/components/virtual-follow-behavior.test.ts @@ -6,6 +6,7 @@ import { isAtBottom, isAutoFollowing, resolveAutoPinHoldElement, + shouldSuspendAutoPinToBottomForHold, transitionFollowMode, type FollowMode, type ScrollControllerMetrics, @@ -169,6 +170,34 @@ describe("virtual follow behavior", () => { assert.deepEqual(contentRendered.effect, { type: "scroll-bottom", immediate: true, suppressHold: false }) }) + it("allows escaped-mode streaming rejoin when only a future hold target is eligible", () => { + const suspend = shouldSuspendAutoPinToBottomForHold({ + externalSuspend: false, + activeHoldTargetKey: null, + eligibleHoldTargetKey: "streaming-assistant-answer", + }) + + const next = transitionFollowMode({ type: "escaped" }, userScroll("down", false, !suspend)) + + assert.equal(suspend, false) + assert.deepEqual(next.mode, { type: "following" }) + assert.deepEqual(next.effect, { type: "scroll-bottom", immediate: true, suppressHold: false }) + }) + + it("keeps auto-pin suspended while a hold target is actively latched", () => { + const suspend = shouldSuspendAutoPinToBottomForHold({ + externalSuspend: false, + activeHoldTargetKey: "streaming-assistant-answer", + eligibleHoldTargetKey: "streaming-assistant-answer", + }) + + const next = transitionFollowMode({ type: "holding", key: "streaming-assistant-answer" }, userScroll("down", false, !suspend)) + + assert.equal(suspend, true) + assert.deepEqual(next.mode, { type: "holding", key: "streaming-assistant-answer" }) + assert.deepEqual(next.effect, { type: "none" }) + }) + it("key jumps can opt into follow or escape mode", () => { const follow = transitionFollowMode({ type: "escaped" }, { type: "jump-key", key: "a", block: "start", smooth: false, followAfter: true }) const escape = transitionFollowMode({ type: "following" }, { type: "jump-key", key: "b", block: "center", smooth: true, followAfter: false }) diff --git a/packages/ui/src/components/virtual-follow-behavior.ts b/packages/ui/src/components/virtual-follow-behavior.ts index 357c11bf..84e111e3 100644 --- a/packages/ui/src/components/virtual-follow-behavior.ts +++ b/packages/ui/src/components/virtual-follow-behavior.ts @@ -83,6 +83,14 @@ export function resolveAutoPinHoldElement( return resolved === undefined ? itemWrapper : resolved } +export function shouldSuspendAutoPinToBottomForHold(state: { + externalSuspend: boolean + activeHoldTargetKey: string | null + eligibleHoldTargetKey?: string | null +}) { + return state.externalSuspend || state.activeHoldTargetKey !== null +} + export function transitionFollowMode(mode: FollowMode, event: FollowEvent): FollowTransition { switch (event.type) { case "user-scroll": { diff --git a/packages/ui/src/components/virtual-follow-list.tsx b/packages/ui/src/components/virtual-follow-list.tsx index cedcf302..f1a0a257 100644 --- a/packages/ui/src/components/virtual-follow-list.tsx +++ b/packages/ui/src/components/virtual-follow-list.tsx @@ -1,6 +1,6 @@ import { Show, createEffect, createMemo, createSignal, type Accessor, type JSX, on, onCleanup } from "solid-js" import { Virtualizer, type VirtualizerHandle } from "virtua/solid" -import { getHeldKey, isAutoFollowing, isAtBottom, resolveAutoPinHoldElement, VirtualScrollController, type FollowEffect, type FollowEvent, type FollowMode, type HoldTargetElementResolver, type ScrollControllerMetrics, type ScrollControllerResult } from "./virtual-follow-behavior.ts" +import { getHeldKey, isAutoFollowing, isAtBottom, resolveAutoPinHoldElement, shouldSuspendAutoPinToBottomForHold, VirtualScrollController, type FollowEffect, type FollowEvent, type FollowMode, type HoldTargetElementResolver, type ScrollControllerMetrics, type ScrollControllerResult } from "./virtual-follow-behavior.ts" const DEFAULT_SCROLL_SENTINEL_MARGIN_PX = 48 const DEFAULT_HOLD_TARGET_TOP_THRESHOLD_PX = 8 @@ -197,8 +197,11 @@ export default function VirtualFollowList(props: VirtualFollowListProps) { const [activeKey, setActiveKey] = createSignal(null) const activeHoldTargetKey = createMemo(() => getHeldKey(followMode())) const [didTriggerHoldForCurrentTarget, setDidTriggerHoldForCurrentTarget] = createSignal(false) - const holdLatchAwayFromBottom = () => holdTargetKey() !== null && !autoScroll() - const effectiveSuspendAutoPinToBottom = () => externalSuspendAutoPinToBottom() || activeHoldTargetKey() !== null || holdLatchAwayFromBottom() + const effectiveSuspendAutoPinToBottom = () => shouldSuspendAutoPinToBottomForHold({ + externalSuspend: externalSuspendAutoPinToBottom(), + activeHoldTargetKey: activeHoldTargetKey(), + eligibleHoldTargetKey: holdTargetKey(), + }) const scrollButtonsCount = createMemo(() => (showScrollTopButton() ? 1 : 0) + (showScrollBottomButton() ? 1 : 0)) const itemElements = new Map()