From 9aa555e00e44f02458a524135ab52494046ae201 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pascal=20Andr=C3=A9?= Date: Mon, 8 Jun 2026 20:13:13 +0200 Subject: [PATCH 1/4] fix: stabilize hold-mode assistant autoscroll Hold mode now latches after manual escape or when the Assistant answer text reaches the viewport top, and it remains held until the user manually reaches the true bottom. Content growth, hold-target changes, and stream completion no longer re-enable follow or snap the viewport while held. Hold target eligibility is restricted to user-readable Assistant answer text. Tool/status/output blocks and reflection/reasoning-only content are excluded from hold targeting, and held Assistant text anchors are restored across subsequent content renders. Verification: node --test packages/ui/src/components/virtual-follow-behavior.test.ts; npm run typecheck --workspace @codenomad/ui; npm run build --workspace @codenomad/ui. --- packages/ui/src/components/message-block.tsx | 1 + packages/ui/src/components/message-item.tsx | 8 ++ .../ui/src/components/message-section.tsx | 2 +- .../virtual-follow-behavior.test.ts | 70 +++++++++++++-- .../src/components/virtual-follow-behavior.ts | 41 +++++---- .../ui/src/components/virtual-follow-list.tsx | 88 ++++++++++++++++--- 6 files changed, 172 insertions(+), 38 deletions(-) diff --git a/packages/ui/src/components/message-block.tsx b/packages/ui/src/components/message-block.tsx index ac8497f73..e6c5780bf 100644 --- a/packages/ui/src/components/message-block.tsx +++ b/packages/ui/src/components/message-block.tsx @@ -391,6 +391,7 @@ function MessageContentItem(props: MessageContentItemProps) { parts={visibleParts()} instanceId={props.instanceId} sessionId={props.sessionId} + contentStartPartId={props.startPartId} isQueued={isQueued()} showAgentMeta={showAgentMeta()} showDeleteMessage={props.showDeleteMessage} diff --git a/packages/ui/src/components/message-item.tsx b/packages/ui/src/components/message-item.tsx index 0fc38c09e..f8b2078fa 100644 --- a/packages/ui/src/components/message-item.tsx +++ b/packages/ui/src/components/message-item.tsx @@ -37,6 +37,7 @@ interface MessageItemProps { onDeleteMessagesUpTo?: (messageId: string) => void | Promise onFork?: (messageId?: string) => void showAgentMeta?: boolean + contentStartPartId?: string onContentRendered?: () => void showDeleteMessage?: boolean onDeleteHoverChange?: (state: DeleteHoverState) => void @@ -158,6 +159,11 @@ export default function MessageItem(props: MessageItemProps) { } const messageParts = () => props.parts + const isAssistantTextBlock = () => + !isUser() && + messageParts().some( + (part) => part.type === "text" && !isHiddenSyntheticTextPart(part) && partHasRenderableText(part), + ) // User messages can temporarily include synthetic helper parts (e.g. tool traces / file reads). // We only want to display the primary prompt text for the user message; other synthetic text @@ -472,6 +478,8 @@ export default function MessageItem(props: MessageItemProps) { data-message-id={props.record.id} data-message-role={isUser() ? "user" : "assistant"} data-message-status={props.record.status} + data-message-content-start-part-id={props.contentStartPartId} + data-assistant-text-block={isAssistantTextBlock() ? "true" : undefined} >
(topRowEl = el)}> diff --git a/packages/ui/src/components/message-section.tsx b/packages/ui/src/components/message-section.tsx index 4c05cb8b6..2eec1ac1b 100644 --- a/packages/ui/src/components/message-section.tsx +++ b/packages/ui/src/components/message-section.tsx @@ -1341,7 +1341,7 @@ export default function MessageSection(props: MessageSectionProps) { autoPinHoldTargetKey={autoPinHoldTargetKey} autoPinHoldTopThresholdPx={STREAMING_TEXT_HOLD_TOP_THRESHOLD_PX} resolveAutoPinHoldElement={(itemWrapper, key) => { - const candidates = Array.from(itemWrapper.querySelectorAll(`.message-item-base[data-message-id="${key}"][data-message-role="assistant"]`)) + const candidates = Array.from(itemWrapper.querySelectorAll(`.message-item-base[data-message-id="${key}"][data-message-role="assistant"][data-assistant-text-block="true"]`)) return candidates[candidates.length - 1] ?? null }} onScroll={() => { diff --git a/packages/ui/src/components/virtual-follow-behavior.test.ts b/packages/ui/src/components/virtual-follow-behavior.test.ts index 1e20f0aa8..ae144098f 100644 --- a/packages/ui/src/components/virtual-follow-behavior.test.ts +++ b/packages/ui/src/components/virtual-follow-behavior.test.ts @@ -5,6 +5,7 @@ import { VirtualScrollController, isAtBottom, isAutoFollowing, + resolveAutoPinHoldElement, transitionFollowMode, type FollowMode, type ScrollControllerMetrics, @@ -51,10 +52,10 @@ describe("virtual follow behavior", () => { assert.deepEqual(next.effect, { type: "none" }) }) - it("releases hold when the user scrolls down above bottom", () => { + it("keeps hold latched when the user scrolls down above bottom", () => { const next = transitionFollowMode({ type: "holding", key: "message-1" }, userScroll("down", false, true)) - assert.deepEqual(next.mode, { type: "escaped" }) + assert.deepEqual(next.mode, { type: "holding", key: "message-1" }) assert.deepEqual(next.effect, { type: "none" }) }) @@ -72,10 +73,10 @@ describe("virtual follow behavior", () => { assert.deepEqual(next.effect, { type: "none" }) }) - it("releases hold for directionless user scroll away from bottom", () => { + it("keeps hold latched for directionless user scroll away from bottom", () => { const next = transitionFollowMode({ type: "holding", key: "message-1" }, userScroll(null, false, true)) - assert.deepEqual(next.mode, { type: "escaped" }) + assert.deepEqual(next.mode, { type: "holding", key: "message-1" }) assert.deepEqual(next.effect, { type: "none" }) }) @@ -87,11 +88,11 @@ describe("virtual follow behavior", () => { assert.deepEqual(following.effect, { type: "scroll-bottom", immediate: true, suppressHold: false }) }) - it("maintains hold alignment while held content grows", () => { + it("does not align or pin while held content grows", () => { const next = transitionFollowMode({ type: "holding", key: "message-1" }, { type: "content-grew", canPinToBottom: true }) assert.deepEqual(next.mode, { type: "holding", key: "message-1" }) - assert.deepEqual(next.effect, { type: "align-hold", key: "message-1" }) + assert.deepEqual(next.effect, { type: "none" }) }) it("enters hold mode for a valid hold candidate", () => { @@ -101,6 +102,20 @@ describe("virtual follow behavior", () => { assert.deepEqual(next.effect, { type: "align-hold", key: "message-1" }) }) + it("keeps hold latched when the hold target disappears", () => { + const next = transitionFollowMode({ type: "holding", key: "message-1" }, { type: "hold-target-changed", key: null, canPinToBottom: true }) + + assert.deepEqual(next.mode, { type: "holding", key: "message-1" }) + assert.deepEqual(next.effect, { type: "none" }) + }) + + it("keeps hold latched when a later hold target is reported", () => { + const next = transitionFollowMode({ type: "holding", key: "message-1" }, { type: "hold-target-changed", key: "message-2", canPinToBottom: true }) + + assert.deepEqual(next.mode, { type: "holding", key: "message-1" }) + assert.deepEqual(next.effect, { type: "none" }) + }) + it("explicit bottom jumps leave hold and suppress the next hold", () => { const next = transitionFollowMode({ type: "holding", key: "message-1" }, { type: "jump-bottom", immediate: true, explicit: true }) @@ -119,7 +134,7 @@ describe("virtual follow behavior", () => { it("derives auto-follow from modes", () => { const modes: Array<[FollowMode, boolean]> = [ [{ type: "following" }, true], - [{ type: "holding", key: "message-1" }, true], + [{ type: "holding", key: "message-1" }, false], [{ type: "escaped" }, false], ] @@ -138,14 +153,24 @@ describe("virtual follow behavior", () => { assert.deepEqual(result.effect, { type: "scroll-bottom", immediate: true, suppressHold: false }) }) - it("maintains hold alignment when held content renders", () => { + it("does not align or pin when held content renders", () => { const controller = new VirtualScrollController(true) controller.holdCandidate("message-1", true) const result = controller.contentRendered(metrics(2200), true) assert.deepEqual(result.state.mode, { type: "holding", key: "message-1" }) - assert.deepEqual(result.effect, { type: "align-hold", key: "message-1" }) + assert.deepEqual(result.effect, { type: "none" }) + }) + + it("does not resume or snap when a held target disappears", () => { + const controller = new VirtualScrollController(true) + controller.holdCandidate("message-1", true) + + const result = controller.holdTargetChanged(null, true) + + assert.deepEqual(result.state.mode, { type: "holding", key: "message-1" }) + assert.deepEqual(result.effect, { type: "none" }) }) it("lets fresh user upward movement escape even during a programmatic window", () => { @@ -200,6 +225,24 @@ describe("virtual follow behavior", () => { assert.deepEqual(result.effect, { type: "none" }) }) + it("keeps hold latched until downward movement reaches actual bottom", () => { + const controller = new VirtualScrollController(true) + controller.holdCandidate("message-1", true) + controller.recordProgrammaticOffset(2100, false) + controller.setUserIntent("down", 700) + + const nearBottom = controller.observeViewport(metrics(2220), 100, false, true) + + assert.deepEqual(nearBottom.state.mode, { type: "holding", key: "message-1" }) + assert.deepEqual(nearBottom.effect, { type: "none" }) + + controller.setUserIntent("down", 800) + const atBottom = controller.observeViewport(metrics(2400), 200, false, true) + + assert.deepEqual(atBottom.state.mode, { type: "following" }) + assert.deepEqual(atBottom.effect, { type: "none" }) + }) + it("still escapes follow on upward movement at bottom", () => { const controller = new VirtualScrollController(true) controller.recordProgrammaticOffset(1200, false) @@ -245,4 +288,13 @@ describe("virtual follow behavior", () => { assert.equal(isAtBottom(closeButNotAtBottom), false) }) + + it("excludes reasoning-only hold targets while preserving Assistant text eligibility", () => { + const itemWrapper = { id: "message-wrapper" } as unknown as HTMLElement + const assistantAnswerText = { id: "assistant-answer-text" } as unknown as HTMLElement + + assert.equal(resolveAutoPinHoldElement(itemWrapper, "message-1", () => null), null) + assert.equal(resolveAutoPinHoldElement(itemWrapper, "message-1", () => assistantAnswerText), assistantAnswerText) + assert.equal(resolveAutoPinHoldElement(itemWrapper, "message-1", () => undefined), itemWrapper) + }) }) diff --git a/packages/ui/src/components/virtual-follow-behavior.ts b/packages/ui/src/components/virtual-follow-behavior.ts index e2e6e446b..0cda402c4 100644 --- a/packages/ui/src/components/virtual-follow-behavior.ts +++ b/packages/ui/src/components/virtual-follow-behavior.ts @@ -58,26 +58,40 @@ export interface ScrollControllerSnapshot { restoring: boolean } +export type HoldTargetElementResolver = (itemWrapper: HTMLElement, key: string) => HTMLElement | null | undefined + const noFollowEffect: FollowEffect = { type: "none" } export function isAutoFollowing(mode: FollowMode) { - return mode.type === "following" || mode.type === "holding" + return mode.type === "following" } export function getHeldKey(mode: FollowMode) { return mode.type === "holding" ? mode.key : null } +export function resolveAutoPinHoldElement( + itemWrapper: HTMLElement | null | undefined, + key: string, + resolver?: HoldTargetElementResolver, +) { + if (!itemWrapper) return null + if (!resolver) return itemWrapper + + const resolved = resolver(itemWrapper, key) + return resolved === undefined ? itemWrapper : resolved +} + export function transitionFollowMode(mode: FollowMode, event: FollowEvent): FollowTransition { switch (event.type) { case "user-scroll": { - if (event.direction === "up") { - return { mode: { type: "escaped" }, effect: noFollowEffect } - } - if (mode.type === "holding" && event.direction === null) { - return { mode: { type: "escaped" }, effect: noFollowEffect } + if (mode.type === "holding") { + if (event.atBottom && event.direction !== "up") { + return { mode: { type: "following" }, effect: noFollowEffect } + } + return { mode, effect: noFollowEffect } } - if (mode.type === "holding" && event.direction === "down") { + if (event.direction === "up") { return { mode: { type: "escaped" }, effect: noFollowEffect } } if (mode.type === "escaped" && event.direction === "down" && event.canPinToBottom) { @@ -86,7 +100,7 @@ export function transitionFollowMode(mode: FollowMode, event: FollowEvent): Foll effect: { type: "scroll-bottom", immediate: true, suppressHold: false }, } } - if (event.atBottom && mode.type !== "holding") { + if (event.atBottom) { return { mode: { type: "following" }, effect: noFollowEffect } } return { mode, effect: noFollowEffect } @@ -111,9 +125,6 @@ export function transitionFollowMode(mode: FollowMode, event: FollowEvent): Foll if (mode.type === "following" && event.canPinToBottom) { return { mode, effect: { type: "scroll-bottom", immediate: true, suppressHold: false } } } - if (mode.type === "holding" && event.canPinToBottom) { - return { mode, effect: { type: "align-hold", key: mode.key } } - } return { mode, effect: noFollowEffect } case "hold-candidate": @@ -123,13 +134,7 @@ export function transitionFollowMode(mode: FollowMode, event: FollowEvent): Foll return { mode, effect: noFollowEffect } case "hold-target-changed": - if (mode.type !== "holding" || event.key === mode.key) { - return { mode, effect: noFollowEffect } - } - return { - mode: { type: "following" }, - effect: event.canPinToBottom ? { type: "scroll-bottom", immediate: false, suppressHold: false } : noFollowEffect, - } + return { mode, effect: noFollowEffect } case "set-follow": return { mode: event.enabled ? { type: "following" } : { type: "escaped" }, effect: noFollowEffect } diff --git a/packages/ui/src/components/virtual-follow-list.tsx b/packages/ui/src/components/virtual-follow-list.tsx index 86acfd735..37482fe34 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, VirtualScrollController, type FollowEffect, type FollowEvent, type FollowMode, type ScrollControllerMetrics, type ScrollControllerResult } from "./virtual-follow-behavior.ts" +import { getHeldKey, isAutoFollowing, isAtBottom, resolveAutoPinHoldElement, 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 @@ -108,9 +108,10 @@ export interface VirtualFollowListProps { /** * Optional resolver for the specific element inside an item wrapper that - * should be measured for hold-target geometry. + * should be measured for hold-target geometry. Return null when the item has + * no eligible hold target; return undefined to fall back to the item wrapper. */ - resolveAutoPinHoldElement?: (itemWrapper: HTMLDivElement, key: string) => HTMLElement | null | undefined + resolveAutoPinHoldElement?: HoldTargetElementResolver /** * Top-edge threshold for the hold target in pixels. @@ -180,7 +181,8 @@ export default function VirtualFollowList(props: VirtualFollowListProps) { const [activeKey, setActiveKey] = createSignal(null) const activeHoldTargetKey = createMemo(() => getHeldKey(followMode())) const [didTriggerHoldForCurrentTarget, setDidTriggerHoldForCurrentTarget] = createSignal(false) - const effectiveSuspendAutoPinToBottom = () => externalSuspendAutoPinToBottom() || activeHoldTargetKey() !== null + const holdLatchAwayFromBottom = () => holdTargetKey() !== null && !autoScroll() + const effectiveSuspendAutoPinToBottom = () => externalSuspendAutoPinToBottom() || activeHoldTargetKey() !== null || holdLatchAwayFromBottom() const scrollButtonsCount = createMemo(() => (showScrollTopButton() ? 1 : 0) + (showScrollBottomButton() ? 1 : 0)) const itemElements = new Map() @@ -208,6 +210,9 @@ export default function VirtualFollowList(props: VirtualFollowListProps) { let programmaticScrollUntil = 0 let pendingBottomRepinAfterHold = false let pendingContentRenderedFrame: number | null = null + let heldAnchorKey: string | null = null + let heldAnchorElement: HTMLElement | null = null + let heldAnchorOffset: number | null = null const state: VirtualFollowListState = { autoScroll, @@ -232,6 +237,11 @@ export default function VirtualFollowList(props: VirtualFollowListProps) { function syncControllerResult(result: ScrollControllerResult) { setFollowMode(result.state.mode) + if (result.state.mode.type !== "holding") { + clearHeldAnchor() + } else if (heldAnchorKey !== null && heldAnchorKey !== result.state.mode.key) { + clearHeldAnchor() + } applyFollowEffect(result.effect) } @@ -612,6 +622,9 @@ export default function VirtualFollowList(props: VirtualFollowListProps) { suppressHoldUntilTargetChanges = false } syncControllerResult(result) + if (result.state.mode.type === "holding") { + captureHeldAnchor(result.state.mode.key) + } } function performScrollToBottom(immediate = true) { @@ -707,12 +720,60 @@ export default function VirtualFollowList(props: VirtualFollowListProps) { return props.getAnchorId ? props.getAnchorId(key) : key } + function resolveHoldTargetElement(key: string) { + const itemWrapper = itemElements.get(key) + return resolveAutoPinHoldElement(itemWrapper, key, props.resolveAutoPinHoldElement) + } + + function clearHeldAnchor() { + heldAnchorKey = null + heldAnchorElement = null + heldAnchorOffset = null + } + + function captureHeldAnchor(key = activeHoldTargetKey(), target?: HTMLElement | null) { + const element = scrollElement() + if (!element || !key) return false + const resolvedTarget = target ?? ( + heldAnchorKey === key && heldAnchorElement && element.contains(heldAnchorElement) + ? heldAnchorElement + : resolveHoldTargetElement(key) + ) + if (!resolvedTarget || !element.contains(resolvedTarget)) return false + const containerRect = element.getBoundingClientRect() + const targetRect = resolvedTarget.getBoundingClientRect() + heldAnchorKey = key + heldAnchorElement = resolvedTarget + heldAnchorOffset = targetRect.top - containerRect.top + return true + } + + function restoreHeldAnchor() { + const key = activeHoldTargetKey() + const element = scrollElement() + if (!element || !key || heldAnchorKey !== key || heldAnchorOffset === null) return false + const target = heldAnchorElement + if (!target || !element.contains(target)) { + clearHeldAnchor() + return false + } + + const containerRect = element.getBoundingClientRect() + const targetRect = target.getBoundingClientRect() + const currentOffset = targetRect.top - containerRect.top + const delta = currentOffset - heldAnchorOffset + if (Math.abs(delta) > 1) { + scrollToOffset((virtuaHandle()?.scrollOffset ?? element.scrollTop) + delta, false) + } + captureHeldAnchor(key, target) + return true + } + function alignHoldTarget(key: string) { const element = scrollElement() if (!element) return - const itemWrapper = itemElements.get(key) - if (!itemWrapper) return - const target = props.resolveAutoPinHoldElement?.(itemWrapper, key) ?? itemWrapper + const target = resolveHoldTargetElement(key) + if (!target) return const containerRect = element.getBoundingClientRect() const targetRect = target.getBoundingClientRect() const relativeTop = targetRect.top - containerRect.top @@ -720,6 +781,8 @@ export default function VirtualFollowList(props: VirtualFollowListProps) { if (Math.abs(alignDelta) > 1) { scrollToOffset((virtuaHandle()?.scrollOffset ?? element.scrollTop) + alignDelta, false) } + captureHeldAnchor(key, target) + requestAnimationFrame(() => captureHeldAnchor(key, target)) } function updateAutoPinHold() { @@ -733,6 +796,9 @@ export default function VirtualFollowList(props: VirtualFollowListProps) { if (targetKey !== heldKey) { dispatchFollowEvent({ type: "hold-target-changed", key: targetKey, canPinToBottom: !externalSuspendAutoPinToBottom() }) } + if (heldAnchorKey !== heldKey || heldAnchorOffset === null) { + captureHeldAnchor(heldKey) + } return } @@ -745,15 +811,15 @@ export default function VirtualFollowList(props: VirtualFollowListProps) { if (suppressHoldUntilTargetChanges) return const itemWrapper = itemElements.get(targetKey) - if (!itemWrapper) return - const target = props.resolveAutoPinHoldElement?.(itemWrapper, targetKey) ?? itemWrapper + const target = resolveAutoPinHoldElement(itemWrapper, targetKey, props.resolveAutoPinHoldElement) + if (!target) return const containerRect = element.getBoundingClientRect() const targetRect = target.getBoundingClientRect() const relativeTop = targetRect.top - containerRect.top const exceedsViewport = targetRect.height > element.clientHeight - if (exceedsViewport && relativeTop < 0) { + if (exceedsViewport && relativeTop <= holdTargetTopThresholdPx()) { dispatchFollowEvent({ type: "hold-candidate", key: targetKey, shouldHold: true }) setDidTriggerHoldForCurrentTarget(true) } @@ -770,6 +836,7 @@ export default function VirtualFollowList(props: VirtualFollowListProps) { updateAutoPinHold() if (activeHoldTargetKey() !== null) { if (autoScroll() && streamingActive()) pendingBottomRepinAfterHold = true + restoreHeldAnchor() updateScrollButtons() return } @@ -818,6 +885,7 @@ export default function VirtualFollowList(props: VirtualFollowListProps) { setDidTriggerHoldForCurrentTarget(false) suppressHoldUntilTargetChanges = false pendingBottomRepinAfterHold = false + clearHeldAnchor() })) createEffect(on(holdTargetKey, (nextTargetKey, prevTargetKey) => { From 9342d3970613abb7cc1606755e2f77c938e24875 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pascal=20Andr=C3=A9?= Date: Mon, 8 Jun 2026 21:14:04 +0200 Subject: [PATCH 2/4] fix: restore bottom follow on hold prompt submit Prompt submission now performs an explicit bottom jump that suppresses stale Hold latches before and after sending, so the optimistic user prompt and the new exchange return to the true bottom after manual scrolling in Hold mode. The virtual follow controller also exposes a focused hold-clear transition for disabling Hold while held, and optimistic user rendering now keeps only the primary submitted text visible while preserving synthetic helper-part hiding. Verified with the virtual follow behavior node test, UI typecheck, UI build, and diff whitespace checks. --- packages/ui/src/components/message-part.tsx | 9 +++++++- .../ui/src/components/message-section.tsx | 1 + .../src/components/session/session-view.tsx | 9 ++++++-- .../virtual-follow-behavior.test.ts | 21 +++++++++++++++++ .../src/components/virtual-follow-behavior.ts | 19 +++++++++++++++ .../ui/src/components/virtual-follow-list.tsx | 23 +++++++++++++++++++ 6 files changed, 79 insertions(+), 3 deletions(-) diff --git a/packages/ui/src/components/message-part.tsx b/packages/ui/src/components/message-part.tsx index df3d911d6..c4a83077e 100644 --- a/packages/ui/src/components/message-part.tsx +++ b/packages/ui/src/components/message-part.tsx @@ -36,10 +36,17 @@ export default function MessagePart(props: MessagePartProps) { const textContainerClass = () => (isAssistantMessage() ? "message-text message-text-assistant" : "message-text") const markdownContainerClass = () => "message-text message-text-assistant" const textContainerRole = () => props.messageType || "assistant" + const isPrimaryUserTextPart = () => + props.messageType === "user" && + props.part?.type === "text" && + typeof props.primaryUserTextPartId === "string" && + props.primaryUserTextPartId.length > 0 && + props.part.id === props.primaryUserTextPartId const shouldHideTextPart = () => { const part = props.part if (!part || part.type !== "text") return false + if (isPrimaryUserTextPart()) return false return Boolean((part as any).synthetic) } @@ -213,7 +220,7 @@ export default function MessagePart(props: MessagePartProps) { return ( - +
props.sessionId} followToken={followToken} + autoPinHoldEnabled={holdLongAssistantRepliesEnabled} autoPinHoldTargetKey={autoPinHoldTargetKey} autoPinHoldTopThresholdPx={STREAMING_TEXT_HOLD_TOP_THRESHOLD_PX} resolveAutoPinHoldElement={(itemWrapper, key) => { diff --git a/packages/ui/src/components/session/session-view.tsx b/packages/ui/src/components/session/session-view.tsx index b8fef3b76..07a13b70d 100644 --- a/packages/ui/src/components/session/session-view.tsx +++ b/packages/ui/src/components/session/session-view.tsx @@ -99,6 +99,11 @@ export const SessionView: Component = (props) => { return true } + function forceSubmittedExchangeToBottom() { + scrollToBottomHandle?.() + scheduleScrollToBottom({ force: true }) + } + function getSeenIdleEntries(currentSession: Session, keepUnseenSubagentIdleStatus: boolean): Array<{ id: string; idleSince: number }> { const entries: Array<{ id: string; idleSince: number }> = [] @@ -277,10 +282,10 @@ export const SessionView: Component = (props) => { async function handleSendMessage(prompt: string, attachments: Attachment[]) { const messageCount = messageStore().getSessionMessageIds(props.sessionId).length setPendingSubmitBottomScrollTargetCount(messageCount + 2) - scheduleScrollToBottom({ force: true }) + forceSubmittedExchangeToBottom() try { await sendMessage(props.instanceId, props.sessionId, prompt, attachments) - scheduleScrollToBottom({ force: true }) + forceSubmittedExchangeToBottom() setPendingSubmitBottomScrollTargetCount(null) } catch (error) { setPendingSubmitBottomScrollTargetCount(null) diff --git a/packages/ui/src/components/virtual-follow-behavior.test.ts b/packages/ui/src/components/virtual-follow-behavior.test.ts index ae144098f..1303e1190 100644 --- a/packages/ui/src/components/virtual-follow-behavior.test.ts +++ b/packages/ui/src/components/virtual-follow-behavior.test.ts @@ -123,6 +123,27 @@ describe("virtual follow behavior", () => { assert.deepEqual(next.effect, { type: "scroll-bottom", immediate: true, suppressHold: true }) }) + it("prompt submission overrides a stale hold latch and returns to bottom follow", () => { + const controller = new VirtualScrollController(true) + controller.holdCandidate("old-assistant-answer", true) + + const result = controller.jumpBottom(true, true) + + assert.deepEqual(result.state.mode, { type: "following" }) + assert.deepEqual(result.effect, { type: "scroll-bottom", immediate: true, suppressHold: true }) + assert.equal(controller.isAutoFollowing(), true) + }) + + it("clears an existing hold latch when hold targeting is disabled", () => { + const controller = new VirtualScrollController(true) + controller.holdCandidate("old-assistant-answer", true) + + const result = controller.clearHold(true, true, true) + + assert.deepEqual(result.state.mode, { type: "following" }) + assert.deepEqual(result.effect, { type: "scroll-bottom", immediate: true, suppressHold: true }) + }) + 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 0cda402c4..357c11bff 100644 --- a/packages/ui/src/components/virtual-follow-behavior.ts +++ b/packages/ui/src/components/virtual-follow-behavior.ts @@ -18,6 +18,7 @@ export type FollowEvent = | { type: "content-grew"; canPinToBottom: boolean } | { type: "hold-candidate"; key: string; shouldHold: boolean } | { type: "hold-target-changed"; key: string | null; canPinToBottom: boolean } + | { type: "clear-hold"; follow: boolean; canPinToBottom: boolean; suppressHold: boolean } | { type: "set-follow"; enabled: boolean } | { type: "reset"; follow: boolean } @@ -136,6 +137,18 @@ export function transitionFollowMode(mode: FollowMode, event: FollowEvent): Foll case "hold-target-changed": return { mode, effect: noFollowEffect } + case "clear-hold": + if (mode.type !== "holding") { + return { mode, effect: noFollowEffect } + } + return { + mode: event.follow ? { type: "following" } : { type: "escaped" }, + effect: + event.follow && event.canPinToBottom + ? { type: "scroll-bottom", immediate: true, suppressHold: event.suppressHold } + : noFollowEffect, + } + case "set-follow": return { mode: event.enabled ? { type: "following" } : { type: "escaped" }, effect: noFollowEffect } @@ -240,6 +253,12 @@ export class VirtualScrollController { return this.result(next.effect) } + clearHold(follow: boolean, canPinToBottom: boolean, suppressHold: boolean): ScrollControllerResult { + const next = transitionFollowMode(this.state.mode, { type: "clear-hold", follow, canPinToBottom, suppressHold }) + this.state.mode = next.mode + return this.result(next.effect) + } + observeViewport(metrics: ScrollControllerMetrics, now: number, programmatic: boolean, canPinToBottom = false): ScrollControllerResult { const previousOffset = this.state.lastObservedOffset const offset = metrics.offset diff --git a/packages/ui/src/components/virtual-follow-list.tsx b/packages/ui/src/components/virtual-follow-list.tsx index 37482fe34..68c374e9d 100644 --- a/packages/ui/src/components/virtual-follow-list.tsx +++ b/packages/ui/src/components/virtual-follow-list.tsx @@ -65,6 +65,7 @@ export interface VirtualFollowListProps { suspendMeasurements?: Accessor streamingActive?: Accessor isActive?: Accessor + autoPinHoldEnabled?: Accessor /** * When switching back to an inactive (cached) pane, the list historically @@ -170,6 +171,7 @@ export default function VirtualFollowList(props: VirtualFollowListProps) { const initialAutoScroll = () => (props.initialAutoScroll ? props.initialAutoScroll() : true) const externalSuspendAutoPinToBottom = () => (props.suspendAutoPinToBottom ? props.suspendAutoPinToBottom() : false) const streamingActive = () => props.streamingActive?.() ?? false + const autoPinHoldEnabled = () => props.autoPinHoldEnabled?.() ?? true const holdTargetKey = () => (props.autoPinHoldTargetKey ? props.autoPinHoldTargetKey() : null) const holdTargetTopThresholdPx = () => props.autoPinHoldTopThresholdPx ?? DEFAULT_HOLD_TARGET_TOP_THRESHOLD_PX @@ -292,6 +294,9 @@ export default function VirtualFollowList(props: VirtualFollowListProps) { case "hold-target-changed": result = scrollController.holdTargetChanged(event.key, event.canPinToBottom) break + case "clear-hold": + result = scrollController.clearHold(event.follow, event.canPinToBottom, event.suppressHold) + break case "set-follow": result = scrollController.setFollow(event.enabled) break @@ -804,6 +809,7 @@ export default function VirtualFollowList(props: VirtualFollowListProps) { } if (!streamingActive()) return + if (!autoPinHoldEnabled()) return if (!autoScroll()) return if (externalSuspendAutoPinToBottom()) return if (!targetKey) return @@ -904,6 +910,23 @@ export default function VirtualFollowList(props: VirtualFollowListProps) { } }, { defer: true })) + createEffect(on(autoPinHoldEnabled, (enabled) => { + if (enabled) return + setDidTriggerHoldForCurrentTarget(false) + suppressHoldUntilTargetChanges = false + pendingBottomRepinAfterHold = false + if (activeHoldTargetKey() !== null) { + dispatchFollowEvent({ + type: "clear-hold", + follow: true, + canPinToBottom: !externalSuspendAutoPinToBottom(), + suppressHold: true, + }) + return + } + clearHeldAnchor() + }, { defer: true })) + // Handle autoScroll (Follow) on items change createEffect(on(() => props.items().length, (len, prevLen) => { if (pendingInitialScroll && isActive() && len > 0) { From b59426014874dc7e3f44f73021fe18fb483ce33d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pascal=20Andr=C3=A9?= Date: Mon, 8 Jun 2026 22:06:51 +0200 Subject: [PATCH 3/4] fix: keep hold submit anchored to new exchange Adds a durable submit/new-stream bottom-follow intent so Hold mode keeps the newly submitted prompt and assistant stream at the bottom until the new exchange is mounted and settled. Suppresses stale previous Assistant hold anchors and snapshot restores while that intent is active, then preserves suppression if the old target is still current so it cannot reclaim the viewport at stream start. Validated with node --test packages/ui/src/components/virtual-follow-behavior.test.ts, npm run typecheck --workspace @codenomad/ui, npm run build --workspace @codenomad/ui, and git diff --check. --- .../ui/src/components/message-section.tsx | 4 +- .../src/components/session/session-view.tsx | 45 ++----- .../virtual-follow-behavior.test.ts | 25 ++++ .../ui/src/components/virtual-follow-list.tsx | 127 +++++++++++++++++- 4 files changed, 168 insertions(+), 33 deletions(-) diff --git a/packages/ui/src/components/message-section.tsx b/packages/ui/src/components/message-section.tsx index 359be82d1..61c48f766 100644 --- a/packages/ui/src/components/message-section.tsx +++ b/packages/ui/src/components/message-section.tsx @@ -5,7 +5,7 @@ import BrandedEmptyState from "./branded-empty-state" import MessageBlock from "./message-block" import { getMessageAnchorId } from "./message-anchors" import MessageTimeline, { buildTimelineSegments, type TimelineSegment } from "./message-timeline" -import VirtualFollowList, { type VirtualFollowListApi, type VirtualFollowListState, type VirtualFollowScrollSnapshot } from "./virtual-follow-list" +import VirtualFollowList, { type VirtualFollowBottomIntent, type VirtualFollowListApi, type VirtualFollowListState, type VirtualFollowScrollSnapshot } from "./virtual-follow-list" import { useConfig } from "../stores/preferences" import { getSessionInfo } from "../stores/sessions" import { messageStoreBus } from "../stores/message-v2/bus" @@ -47,6 +47,7 @@ export interface MessageSectionProps { onReloadMessages?: () => void isActive?: boolean sessionStreamingActive?: boolean + bottomFollowIntent?: VirtualFollowBottomIntent | null } export default function MessageSection(props: MessageSectionProps) { @@ -1338,6 +1339,7 @@ export default function MessageSection(props: MessageSectionProps) { initialAutoScroll={initialAutoScroll} resetKey={() => props.sessionId} followToken={followToken} + forceBottomFollowIntent={() => props.bottomFollowIntent ?? null} autoPinHoldEnabled={holdLongAssistantRepliesEnabled} autoPinHoldTargetKey={autoPinHoldTargetKey} autoPinHoldTopThresholdPx={STREAMING_TEXT_HOLD_TOP_THRESHOLD_PX} diff --git a/packages/ui/src/components/session/session-view.tsx b/packages/ui/src/components/session/session-view.tsx index 07a13b70d..c93b280d2 100644 --- a/packages/ui/src/components/session/session-view.tsx +++ b/packages/ui/src/components/session/session-view.tsx @@ -79,7 +79,8 @@ export const SessionView: Component = (props) => { let scrollToBottomHandle: (() => void) | undefined let rootRef: HTMLDivElement | undefined const pendingIdleSeenTimers = new Set() - const [pendingSubmitBottomScrollTargetCount, setPendingSubmitBottomScrollTargetCount] = createSignal(null) + const [submitBottomFollowIntent, setSubmitBottomFollowIntent] = createSignal<{ token: number; minItemCount: number } | null>(null) + let submitBottomFollowIntentSequence = 0 function shouldScrollToBottomOnActivate() { const current = session() @@ -99,9 +100,14 @@ export const SessionView: Component = (props) => { return true } - function forceSubmittedExchangeToBottom() { + function startSubmitBottomFollowIntent(minItemCount: number) { + submitBottomFollowIntentSequence += 1 + setSubmitBottomFollowIntent({ token: submitBottomFollowIntentSequence, minItemCount }) + } + + function forceSubmittedExchangeToBottom(minItemCount: number) { + startSubmitBottomFollowIntent(minItemCount) scrollToBottomHandle?.() - scheduleScrollToBottom({ force: true }) } function getSeenIdleEntries(currentSession: Session, keepUnseenSubagentIdleStatus: boolean): Array<{ id: string; idleSince: number }> { @@ -221,21 +227,6 @@ export const SessionView: Component = (props) => { ) } - createEffect( - on( - () => messageStore().getSessionMessageIds(props.sessionId).length, - (messageCount) => { - const targetCount = pendingSubmitBottomScrollTargetCount() - if (targetCount === null) return - const didSchedule = scheduleScrollToBottom({ force: true }) - if (didSchedule && messageCount >= targetCount) { - setPendingSubmitBottomScrollTargetCount(null) - } - }, - { defer: true }, - ), - ) - function registerPromptInputApi(api: PromptInputApi) { promptInputApi = api props.registerSessionPromptApi?.(props.sessionId, api) @@ -281,14 +272,13 @@ export const SessionView: Component = (props) => { async function handleSendMessage(prompt: string, attachments: Attachment[]) { const messageCount = messageStore().getSessionMessageIds(props.sessionId).length - setPendingSubmitBottomScrollTargetCount(messageCount + 2) - forceSubmittedExchangeToBottom() + const submittedExchangeTargetCount = messageCount + 2 + forceSubmittedExchangeToBottom(submittedExchangeTargetCount) try { await sendMessage(props.instanceId, props.sessionId, prompt, attachments) - forceSubmittedExchangeToBottom() - setPendingSubmitBottomScrollTargetCount(null) + const latestMessageCount = messageStore().getSessionMessageIds(props.sessionId).length + forceSubmittedExchangeToBottom(Math.max(submittedExchangeTargetCount, latestMessageCount)) } catch (error) { - setPendingSubmitBottomScrollTargetCount(null) throw error } } @@ -453,20 +443,13 @@ export const SessionView: Component = (props) => { loadError={messagesLoadError()} onReloadMessages={handleReloadMessages} sessionStreamingActive={sessionStreamingActive()} + bottomFollowIntent={submitBottomFollowIntent()} onRevert={handleRevert} onDeleteMessagesUpTo={handleDeleteMessagesUpTo} onFork={handleFork} isActive={props.isActive} registerScrollToBottom={(fn) => { scrollToBottomHandle = fn ?? undefined - if (!fn) return - const targetCount = pendingSubmitBottomScrollTargetCount() - if (targetCount === null) return - const didSchedule = scheduleScrollToBottom({ force: true }) - const messageCount = messageStore().getSessionMessageIds(props.sessionId).length - if (didSchedule && messageCount >= targetCount) { - setPendingSubmitBottomScrollTargetCount(null) - } }} showSidebarToggle={props.showSidebarToggle} onSidebarToggle={props.onSidebarToggle} diff --git a/packages/ui/src/components/virtual-follow-behavior.test.ts b/packages/ui/src/components/virtual-follow-behavior.test.ts index 1303e1190..2454d5fb2 100644 --- a/packages/ui/src/components/virtual-follow-behavior.test.ts +++ b/packages/ui/src/components/virtual-follow-behavior.test.ts @@ -144,6 +144,31 @@ describe("virtual follow behavior", () => { assert.deepEqual(result.effect, { type: "scroll-bottom", immediate: true, suppressHold: true }) }) + it("keeps submitted prompt content growth in bottom-follow after clearing stale hold", () => { + const controller = new VirtualScrollController(true) + controller.holdCandidate("old-assistant-answer", true) + controller.jumpBottom(true, true) + + const result = controller.contentRendered(metrics(2400), true) + + assert.deepEqual(result.state.mode, { type: "following" }) + assert.deepEqual(result.effect, { type: "scroll-bottom", immediate: true, suppressHold: false }) + }) + + it("ignores stale previous assistant hold target changes after a submit bottom jump", () => { + const controller = new VirtualScrollController(true) + controller.holdCandidate("previous-assistant-answer", true) + controller.jumpBottom(true, true) + + const targetChanged = controller.holdTargetChanged("previous-assistant-answer", true) + const contentRendered = controller.contentRendered(metrics(2400), true) + + assert.deepEqual(targetChanged.state.mode, { type: "following" }) + assert.deepEqual(targetChanged.effect, { type: "none" }) + assert.deepEqual(contentRendered.state.mode, { type: "following" }) + assert.deepEqual(contentRendered.effect, { type: "scroll-bottom", immediate: true, suppressHold: false }) + }) + 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-list.tsx b/packages/ui/src/components/virtual-follow-list.tsx index 68c374e9d..cedcf3029 100644 --- a/packages/ui/src/components/virtual-follow-list.tsx +++ b/packages/ui/src/components/virtual-follow-list.tsx @@ -6,9 +6,16 @@ const DEFAULT_SCROLL_SENTINEL_MARGIN_PX = 48 const DEFAULT_HOLD_TARGET_TOP_THRESHOLD_PX = 8 const DEFAULT_REJOIN_LAST_ITEM_COUNT = 2 const USER_SCROLL_INTENT_WINDOW_MS = 600 +const BOTTOM_FOLLOW_INTENT_SETTLE_FRAMES = 4 +const BOTTOM_FOLLOW_INTENT_MAX_FRAMES = 60 const SCROLL_INTENT_KEYS = new Set(["ArrowUp", "ArrowDown", "PageUp", "PageDown", "Home", "End", " ", "Spacebar"]) const INTERACTIVE_KEY_TARGET_SELECTOR = "button, a, input, textarea, select, [contenteditable='true'], [role='button'], [role='textbox']" +export interface VirtualFollowBottomIntent { + token: string | number + minItemCount?: number +} + export interface VirtualFollowListApi { scrollToTop: (opts?: { immediate?: boolean }) => void scrollToBottom: (opts?: { immediate?: boolean; suppressAutoAnchor?: boolean; suppressHold?: boolean }) => void @@ -101,6 +108,12 @@ export interface VirtualFollowListProps { */ followToken?: Accessor + /** + * Explicit user intent to cancel stale hold/restore state and stay pinned to + * the bottom until a submitted exchange has rendered into the list. + */ + forceBottomFollowIntent?: Accessor + /** * Optional item key whose geometry can temporarily hold auto-follow when the * rendered item grows taller than the viewport and reaches the top edge. @@ -173,6 +186,7 @@ export default function VirtualFollowList(props: VirtualFollowListProps) { const streamingActive = () => props.streamingActive?.() ?? false const autoPinHoldEnabled = () => props.autoPinHoldEnabled?.() ?? true const holdTargetKey = () => (props.autoPinHoldTargetKey ? props.autoPinHoldTargetKey() : null) + const forceBottomFollowIntent = () => props.forceBottomFollowIntent?.() ?? null const holdTargetTopThresholdPx = () => props.autoPinHoldTopThresholdPx ?? DEFAULT_HOLD_TARGET_TOP_THRESHOLD_PX const scrollController = new VirtualScrollController(initialAutoScroll()) @@ -212,6 +226,12 @@ export default function VirtualFollowList(props: VirtualFollowListProps) { let programmaticScrollUntil = 0 let pendingBottomRepinAfterHold = false let pendingContentRenderedFrame: number | null = null + let pendingBottomFollowIntentFrame: number | null = null + let bottomFollowIntentToken: string | number | null = null + let bottomFollowIntentMinItemCount = 0 + let bottomFollowIntentStaleHoldTargetKey: string | null = null + let bottomFollowIntentSettleFrames = 0 + let bottomFollowIntentFramesRemaining = 0 let heldAnchorKey: string | null = null let heldAnchorElement: HTMLElement | null = null let heldAnchorOffset: number | null = null @@ -237,6 +257,77 @@ export default function VirtualFollowList(props: VirtualFollowListProps) { return performance.now() <= programmaticScrollUntil } + function hasActiveBottomFollowIntent() { + return bottomFollowIntentToken !== null + } + + function resetStaleHoldForBottomIntent() { + restoreToken += 1 + scrollController.setRestoring(false) + setDidTriggerHoldForCurrentTarget(false) + suppressHoldUntilTargetChanges = true + pendingBottomRepinAfterHold = false + clearHeldAnchor() + } + + function clearBottomFollowIntent() { + const staleTargetStillCurrent = bottomFollowIntentStaleHoldTargetKey !== null && holdTargetKey() === bottomFollowIntentStaleHoldTargetKey + bottomFollowIntentToken = null + bottomFollowIntentMinItemCount = 0 + bottomFollowIntentStaleHoldTargetKey = null + bottomFollowIntentSettleFrames = 0 + bottomFollowIntentFramesRemaining = 0 + if (!staleTargetStillCurrent) { + suppressHoldUntilTargetChanges = false + } + } + + function scheduleBottomFollowIntentFrame() { + if (!hasActiveBottomFollowIntent()) return + if (pendingBottomFollowIntentFrame !== null) return + if (typeof requestAnimationFrame !== "function") { + runBottomFollowIntentFrame() + return + } + pendingBottomFollowIntentFrame = requestAnimationFrame(() => runBottomFollowIntentFrame()) + } + + function runBottomFollowIntentFrame() { + pendingBottomFollowIntentFrame = null + if (!hasActiveBottomFollowIntent()) return + + resetStaleHoldForBottomIntent() + dispatchFollowEvent({ type: "jump-bottom", immediate: true, explicit: true }) + + const minItemCountReached = props.items().length >= bottomFollowIntentMinItemCount + if (minItemCountReached) { + bottomFollowIntentSettleFrames -= 1 + } else { + bottomFollowIntentSettleFrames = BOTTOM_FOLLOW_INTENT_SETTLE_FRAMES + } + bottomFollowIntentFramesRemaining -= 1 + + if ((minItemCountReached && bottomFollowIntentSettleFrames <= 0) || bottomFollowIntentFramesRemaining <= 0) { + clearBottomFollowIntent() + return + } + + scheduleBottomFollowIntentFrame() + } + + function startBottomFollowIntent(intent: VirtualFollowBottomIntent | null) { + if (!intent) return + if (!hasActiveBottomFollowIntent()) { + bottomFollowIntentStaleHoldTargetKey = activeHoldTargetKey() ?? holdTargetKey() + } + bottomFollowIntentToken = intent.token + bottomFollowIntentMinItemCount = Math.max(0, Math.floor(intent.minItemCount ?? 0)) + bottomFollowIntentSettleFrames = BOTTOM_FOLLOW_INTENT_SETTLE_FRAMES + bottomFollowIntentFramesRemaining = BOTTOM_FOLLOW_INTENT_MAX_FRAMES + resetStaleHoldForBottomIntent() + runBottomFollowIntentFrame() + } + function syncControllerResult(result: ScrollControllerResult) { setFollowMode(result.state.mode) if (result.state.mode.type !== "holding") { @@ -472,6 +563,12 @@ export default function VirtualFollowList(props: VirtualFollowListProps) { if (!element) return undefined const snapshot: VirtualFollowScrollSnapshot = getSnapshotMetrics(element, virtuaHandle()) + if (hasActiveBottomFollowIntent()) { + snapshot.atBottom = true + delete snapshot.anchorKey + delete snapshot.anchorOffset + return snapshot + } if (!snapshot.atBottom) { const anchor = findTopVisibleAnchor(element) if (anchor) { @@ -541,6 +638,12 @@ export default function VirtualFollowList(props: VirtualFollowListProps) { return } + if (hasActiveBottomFollowIntent()) { + scheduleBottomFollowIntentFrame() + opts?.onApplied?.() + return + } + const token = ++restoreToken const isRestoreCurrent = () => token === restoreToken && Boolean(scrollElement()) const behavior = opts?.behavior ?? "auto" @@ -793,6 +896,7 @@ export default function VirtualFollowList(props: VirtualFollowListProps) { function updateAutoPinHold() { const element = scrollElement() if (!element) return + if (hasActiveBottomFollowIntent()) return const targetKey = holdTargetKey() const heldKey = activeHoldTargetKey() @@ -834,6 +938,12 @@ export default function VirtualFollowList(props: VirtualFollowListProps) { function flushContentRendered() { pendingContentRenderedFrame = null + if (hasActiveBottomFollowIntent()) { + scheduleBottomFollowIntentFrame() + updateScrollButtons() + return + } + if (shouldHonorPrePinEscape() && escapeFollowIfDomMovedUp()) { updateScrollButtons() return @@ -898,7 +1008,7 @@ export default function VirtualFollowList(props: VirtualFollowListProps) { if (nextTargetKey !== prevTargetKey && didTriggerHoldForCurrentTarget()) { setDidTriggerHoldForCurrentTarget(false) } - if (nextTargetKey !== prevTargetKey) { + if (nextTargetKey !== prevTargetKey && !hasActiveBottomFollowIntent()) { suppressHoldUntilTargetChanges = false } if (activeHoldTargetKey() === null) return @@ -927,6 +1037,12 @@ export default function VirtualFollowList(props: VirtualFollowListProps) { clearHeldAnchor() }, { defer: true })) + createEffect(on(forceBottomFollowIntent, (intent) => { + if (!intent) return + if (intent.token === bottomFollowIntentToken) return + startBottomFollowIntent(intent) + }, { defer: true })) + // Handle autoScroll (Follow) on items change createEffect(on(() => props.items().length, (len, prevLen) => { if (pendingInitialScroll && isActive() && len > 0) { @@ -938,6 +1054,11 @@ export default function VirtualFollowList(props: VirtualFollowListProps) { return } if (len > (prevLen ?? 0) && autoScroll() && !effectiveSuspendAutoPinToBottom() && !suppressAutoScrollOnce) { + if (hasActiveBottomFollowIntent()) { + scheduleBottomFollowIntentFrame() + suppressAutoScrollOnce = false + return + } requestAnimationFrame(() => { dispatchFollowEvent({ type: "content-grew", canPinToBottom: autoScroll() && !effectiveSuspendAutoPinToBottom() }) }) @@ -979,6 +1100,10 @@ export default function VirtualFollowList(props: VirtualFollowListProps) { cancelAnimationFrame(pendingContentRenderedFrame) pendingContentRenderedFrame = null } + if (pendingBottomFollowIntentFrame !== null && typeof cancelAnimationFrame === "function") { + cancelAnimationFrame(pendingBottomFollowIntentFrame) + pendingBottomFollowIntentFrame = null + } detachScrollIntentListeners?.() detachScrollIntentListeners = undefined }) 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 4/4] 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 2454d5fb2..daaa0d7ab 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 357c11bff..84e111e31 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 cedcf3029..f1a0a257d 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()