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-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 ( - +
void isActive?: boolean sessionStreamingActive?: boolean + bottomFollowIntent?: VirtualFollowBottomIntent | null } export default function MessageSection(props: MessageSectionProps) { @@ -1338,10 +1339,12 @@ 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} 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/session/session-view.tsx b/packages/ui/src/components/session/session-view.tsx index b8fef3b76..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,6 +100,16 @@ export const SessionView: Component = (props) => { return true } + function startSubmitBottomFollowIntent(minItemCount: number) { + submitBottomFollowIntentSequence += 1 + setSubmitBottomFollowIntent({ token: submitBottomFollowIntentSequence, minItemCount }) + } + + function forceSubmittedExchangeToBottom(minItemCount: number) { + startSubmitBottomFollowIntent(minItemCount) + scrollToBottomHandle?.() + } + function getSeenIdleEntries(currentSession: Session, keepUnseenSubagentIdleStatus: boolean): Array<{ id: string; idleSince: number }> { const entries: Array<{ id: string; idleSince: number }> = [] @@ -216,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) @@ -276,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) - scheduleScrollToBottom({ force: true }) + const submittedExchangeTargetCount = messageCount + 2 + forceSubmittedExchangeToBottom(submittedExchangeTargetCount) try { await sendMessage(props.instanceId, props.sessionId, prompt, attachments) - scheduleScrollToBottom({ force: true }) - setPendingSubmitBottomScrollTargetCount(null) + const latestMessageCount = messageStore().getSessionMessageIds(props.sessionId).length + forceSubmittedExchangeToBottom(Math.max(submittedExchangeTargetCount, latestMessageCount)) } catch (error) { - setPendingSubmitBottomScrollTargetCount(null) throw error } } @@ -448,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 1e20f0aa8..daaa0d7ab 100644 --- a/packages/ui/src/components/virtual-follow-behavior.test.ts +++ b/packages/ui/src/components/virtual-follow-behavior.test.ts @@ -5,6 +5,8 @@ import { VirtualScrollController, isAtBottom, isAutoFollowing, + resolveAutoPinHoldElement, + shouldSuspendAutoPinToBottomForHold, transitionFollowMode, type FollowMode, type ScrollControllerMetrics, @@ -51,10 +53,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 +74,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 +89,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 +103,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 }) @@ -108,6 +124,80 @@ 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("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("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 }) @@ -119,7 +209,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 +228,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 +300,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 +363,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..84e111e31 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 } @@ -58,26 +59,48 @@ 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 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": { - 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 +109,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 +134,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,12 +143,18 @@ 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 } + + case "clear-hold": + if (mode.type !== "holding") { return { mode, effect: noFollowEffect } } return { - mode: { type: "following" }, - effect: event.canPinToBottom ? { type: "scroll-bottom", immediate: false, suppressHold: false } : noFollowEffect, + mode: event.follow ? { type: "following" } : { type: "escaped" }, + effect: + event.follow && event.canPinToBottom + ? { type: "scroll-bottom", immediate: true, suppressHold: event.suppressHold } + : noFollowEffect, } case "set-follow": @@ -235,6 +261,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 86acfd735..f1a0a257d 100644 --- a/packages/ui/src/components/virtual-follow-list.tsx +++ b/packages/ui/src/components/virtual-follow-list.tsx @@ -1,14 +1,21 @@ 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, 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 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 @@ -65,6 +72,7 @@ export interface VirtualFollowListProps { suspendMeasurements?: Accessor streamingActive?: Accessor isActive?: Accessor + autoPinHoldEnabled?: Accessor /** * When switching back to an inactive (cached) pane, the list historically @@ -100,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. @@ -108,9 +122,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. @@ -169,7 +184,9 @@ 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 forceBottomFollowIntent = () => props.forceBottomFollowIntent?.() ?? null const holdTargetTopThresholdPx = () => props.autoPinHoldTopThresholdPx ?? DEFAULT_HOLD_TARGET_TOP_THRESHOLD_PX const scrollController = new VirtualScrollController(initialAutoScroll()) @@ -180,7 +197,11 @@ 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 effectiveSuspendAutoPinToBottom = () => shouldSuspendAutoPinToBottomForHold({ + externalSuspend: externalSuspendAutoPinToBottom(), + activeHoldTargetKey: activeHoldTargetKey(), + eligibleHoldTargetKey: holdTargetKey(), + }) const scrollButtonsCount = createMemo(() => (showScrollTopButton() ? 1 : 0) + (showScrollBottomButton() ? 1 : 0)) const itemElements = new Map() @@ -208,6 +229,15 @@ 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 const state: VirtualFollowListState = { autoScroll, @@ -230,8 +260,84 @@ 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") { + clearHeldAnchor() + } else if (heldAnchorKey !== null && heldAnchorKey !== result.state.mode.key) { + clearHeldAnchor() + } applyFollowEffect(result.effect) } @@ -282,6 +388,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 @@ -457,6 +566,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) { @@ -526,6 +641,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" @@ -612,6 +733,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 +831,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,11 +892,14 @@ 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() { const element = scrollElement() if (!element) return + if (hasActiveBottomFollowIntent()) return const targetKey = holdTargetKey() const heldKey = activeHoldTargetKey() @@ -733,11 +908,15 @@ 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 } if (!streamingActive()) return + if (!autoPinHoldEnabled()) return if (!autoScroll()) return if (externalSuspendAutoPinToBottom()) return if (!targetKey) return @@ -745,15 +924,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) } @@ -762,6 +941,12 @@ export default function VirtualFollowList(props: VirtualFollowListProps) { function flushContentRendered() { pendingContentRenderedFrame = null + if (hasActiveBottomFollowIntent()) { + scheduleBottomFollowIntentFrame() + updateScrollButtons() + return + } + if (shouldHonorPrePinEscape() && escapeFollowIfDomMovedUp()) { updateScrollButtons() return @@ -770,6 +955,7 @@ export default function VirtualFollowList(props: VirtualFollowListProps) { updateAutoPinHold() if (activeHoldTargetKey() !== null) { if (autoScroll() && streamingActive()) pendingBottomRepinAfterHold = true + restoreHeldAnchor() updateScrollButtons() return } @@ -818,13 +1004,14 @@ export default function VirtualFollowList(props: VirtualFollowListProps) { setDidTriggerHoldForCurrentTarget(false) suppressHoldUntilTargetChanges = false pendingBottomRepinAfterHold = false + clearHeldAnchor() })) createEffect(on(holdTargetKey, (nextTargetKey, prevTargetKey) => { if (nextTargetKey !== prevTargetKey && didTriggerHoldForCurrentTarget()) { setDidTriggerHoldForCurrentTarget(false) } - if (nextTargetKey !== prevTargetKey) { + if (nextTargetKey !== prevTargetKey && !hasActiveBottomFollowIntent()) { suppressHoldUntilTargetChanges = false } if (activeHoldTargetKey() === null) return @@ -836,6 +1023,29 @@ 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 })) + + 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) { @@ -847,6 +1057,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() }) }) @@ -888,6 +1103,10 @@ export default function VirtualFollowList(props: VirtualFollowListProps) { cancelAnimationFrame(pendingContentRenderedFrame) pendingContentRenderedFrame = null } + if (pendingBottomFollowIntentFrame !== null && typeof cancelAnimationFrame === "function") { + cancelAnimationFrame(pendingBottomFollowIntentFrame) + pendingBottomFollowIntentFrame = null + } detachScrollIntentListeners?.() detachScrollIntentListeners = undefined })