fix(ui): stabilize strict follow autoscroll (#576)

## Summary
- replace message stream follow behavior with a strict following/escaped
state model
- keep explicit submit bottom pinning deterministic while preserving
user scroll-up escape intent
- prevent repeated pending question/permission polling from rewriting
identical store entries and snapping back to bottom
- isolate pending request dedupe logic in a raw-node-testable helper

Fixes #574

## Validation
- node --test
"packages/ui/src/components/virtual-follow-behavior.test.ts"
"packages/ui/src/components/session/session-bottom-pin-intent.test.ts"
"packages/ui/src/stores/message-v2/pending-request-dedupe.test.ts"
- npm run typecheck --workspace @codenomad/ui
- npm run build --workspace @codenomad/ui
- git diff --check
This commit is contained in:
Pascal André 2026-07-08 18:46:55 +02:00 committed by GitHub
parent 275770b51b
commit bce975d940
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
17 changed files with 890 additions and 1552 deletions

View file

@ -33,7 +33,6 @@ function DeleteUpToIcon() {
const USER_BORDER_COLOR = "var(--message-user-border)"
const ASSISTANT_BORDER_COLOR = "var(--message-assistant-border)"
const NO_STEP_BORDER = "none"
const REASONING_SCROLL_SENTINEL_MARGIN_PX = 48
const LazyToolCall = lazy(() => import("./tool-call"))
@ -1419,7 +1418,6 @@ function ReasoningStreamOutput(props: {
const followScroll = createFollowScroll({
getScrollTopSnapshot: props.scrollTopSnapshot,
setScrollTopSnapshot: props.setScrollTopSnapshot,
sentinelMarginPx: REASONING_SCROLL_SENTINEL_MARGIN_PX,
sentinelClassName: "reasoning-scroll-sentinel",
})

View file

@ -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 VirtualFollowBottomIntent, type VirtualFollowListApi, type VirtualFollowListState, type VirtualFollowScrollSnapshot } from "./virtual-follow-list"
import VirtualFollowList, { type VirtualExplicitBottomPinIntent, type VirtualFollowListApi, type VirtualFollowListState, type VirtualFollowScrollSnapshot } from "./virtual-follow-list"
import { isSnapshotAutoFollowing } from "./virtual-follow-behavior"
import { useConfig } from "../stores/preferences"
import { getSessionInfo } from "../stores/sessions"
@ -24,7 +24,6 @@ import { buildSessionSearchMatches } from "../lib/session-search"
import type { SessionSearchMatch } from "../lib/session-search"
import { resolveThinkingExpansionDefault } from "./tool-call/tool-registry"
const SCROLL_SENTINEL_MARGIN_PX = 8
const MESSAGE_SCROLL_CACHE_SCOPE = "message-stream"
const QUOTE_SELECTION_MAX_LENGTH = 2000
const STREAMING_TEXT_HOLD_TOP_THRESHOLD_PX = 8
@ -49,7 +48,8 @@ export interface MessageSectionProps {
onReloadMessages?: () => void
isActive?: boolean
sessionStreamingActive?: boolean
bottomFollowIntent?: VirtualFollowBottomIntent | null
explicitBottomPinIntent?: VirtualExplicitBottomPinIntent | null
onExplicitBottomPinCancelled?: () => void
}
export default function MessageSection(props: MessageSectionProps) {
@ -735,16 +735,6 @@ export default function MessageSection(props: MessageSectionProps) {
return
}
const element = streamElement()
if (!allowCapture || !canCapture) return
if (!element) return
const scrollTop = element.scrollTop
const maxScrollTop = Math.max(element.scrollHeight - element.clientHeight, 0)
const scrollRatio = maxScrollTop > 0 ? scrollTop / maxScrollTop : 0
const atBottom = element.scrollHeight - (element.scrollTop + element.clientHeight) <= 48
const snapshot = { scrollTop, scrollRatio, maxScrollTop, atBottom }
setLastGoodScrollSnapshot(sessionId, snapshot)
store().setScrollSnapshot(sessionId, MESSAGE_SCROLL_CACHE_SCOPE, snapshot)
}
// Persist scroll position when switching sessions. This effect's cleanup runs
@ -783,7 +773,7 @@ export default function MessageSection(props: MessageSectionProps) {
setScrollControlsOpen(false)
}
function openScrollControlsFromTrigger(event: PointerEvent) {
function openScrollControlsFromTrigger(event: MouseEvent) {
event.preventDefault()
event.stopPropagation()
if (scrollControlsOpen()) return
@ -795,7 +785,7 @@ export default function MessageSection(props: MessageSectionProps) {
event.preventDefault()
event.stopPropagation()
action()
setScrollControlsHoverSuppressed(event.pointerType !== "mouse")
setScrollControlsHoverSuppressed(false)
closeScrollControls()
}
@ -836,7 +826,7 @@ export default function MessageSection(props: MessageSectionProps) {
const api = listApi()
if (!api) return
if (props.registerScrollToBottom) {
props.registerScrollToBottom(() => api.scrollToBottom({ immediate: true, suppressHold: true }))
props.registerScrollToBottom(() => api.scrollToBottom({ immediate: true }))
onCleanup(() => props.registerScrollToBottom?.(null))
}
})
@ -1264,7 +1254,7 @@ export default function MessageSection(props: MessageSectionProps) {
if (!match || !isSearchOpen()) return
if (match.id === lastScrolledSearchMatchId) return
lastScrolledSearchMatchId = match.id
listApi()?.scrollToKey(match.messageId, { behavior: "smooth", block: "start", setAutoScroll: false })
listApi()?.scrollToKey(match.messageId, { behavior: "smooth", block: "start" })
})
@ -1367,8 +1357,6 @@ export default function MessageSection(props: MessageSectionProps) {
getKey={(messageId) => messageId}
getAnchorId={getMessageAnchorId}
overscanPx={800}
scrollSentinelMarginPx={SCROLL_SENTINEL_MARGIN_PX}
suspendMeasurements={() => !isActive()}
streamingActive={streamingActive}
isActive={isActive}
scrollToBottomOnActivate={() => false}
@ -1376,7 +1364,8 @@ export default function MessageSection(props: MessageSectionProps) {
initialAutoScroll={initialAutoScroll}
resetKey={() => props.sessionId}
followToken={followToken}
forceBottomFollowIntent={() => props.bottomFollowIntent ?? null}
explicitBottomPinIntent={() => props.explicitBottomPinIntent ?? null}
onExplicitBottomPinCancelled={props.onExplicitBottomPinCancelled}
autoPinHoldEnabled={holdLongAssistantRepliesEnabled}
autoPinHoldTargetKey={autoPinHoldTargetKey}
autoPinHoldTopThresholdPx={STREAMING_TEXT_HOLD_TOP_THRESHOLD_PX}
@ -1429,7 +1418,7 @@ export default function MessageSection(props: MessageSectionProps) {
<button
type="button"
class="message-scroll-button message-scroll-controls-trigger"
onPointerUp={openScrollControlsFromTrigger}
onClick={openScrollControlsFromTrigger}
aria-label={t("messageSection.scroll.showControlsAriaLabel")}
title={t("messageSection.scroll.showControlsAriaLabel")}
>
@ -1472,7 +1461,7 @@ export default function MessageSection(props: MessageSectionProps) {
<button
type="button"
class="message-scroll-button"
onPointerUp={(event) => runScrollControlAction(event, () => api.scrollToBottom({ suppressHold: true }))}
onPointerUp={(event) => runScrollControlAction(event, () => api.scrollToBottom())}
aria-label={t("messageSection.scroll.toLatestAriaLabel")}
>
<span class="message-scroll-icon" aria-hidden="true">

View file

@ -1,56 +0,0 @@
import assert from "node:assert/strict"
import { describe, it } from "node:test"
import { resolveSessionBottomFollowIntent, shouldClearSessionBottomFollowIntent } from "./session-bottom-follow-intent.ts"
describe("session bottom follow intent", () => {
it("only exposes submit follow intent to the matching session", () => {
const intent = { sessionId: "session-a", token: 3, minItemCount: 12 }
assert.deepEqual(resolveSessionBottomFollowIntent(intent, "session-a"), {
token: 3,
minItemCount: 12,
})
assert.equal(resolveSessionBottomFollowIntent(intent, "session-b"), null)
})
it("clears submit follow intent only after the submitted exchange has rendered and stopped streaming", () => {
const intent = { sessionId: "session-a", token: 3, minItemCount: 12 }
assert.equal(
shouldClearSessionBottomFollowIntent(intent, {
sessionId: "session-a",
messageCount: 11,
streamingActive: false,
}),
false,
)
assert.equal(
shouldClearSessionBottomFollowIntent(intent, {
sessionId: "session-a",
messageCount: 12,
streamingActive: true,
}),
false,
)
assert.equal(
shouldClearSessionBottomFollowIntent(intent, {
sessionId: "session-a",
messageCount: 12,
streamingActive: false,
}),
true,
)
assert.equal(
shouldClearSessionBottomFollowIntent(intent, {
sessionId: "session-b",
messageCount: 12,
streamingActive: false,
}),
false,
)
})
})

View file

@ -1,22 +0,0 @@
import type { VirtualFollowBottomIntent } from "../virtual-follow-list"
export interface SessionBottomFollowIntent extends VirtualFollowBottomIntent {
sessionId: string
}
export function resolveSessionBottomFollowIntent(
intent: SessionBottomFollowIntent | null,
sessionId: string,
): VirtualFollowBottomIntent | null {
if (!intent || intent.sessionId !== sessionId) return null
return { token: intent.token, minItemCount: intent.minItemCount }
}
export function shouldClearSessionBottomFollowIntent(
intent: SessionBottomFollowIntent | null,
state: { sessionId: string; messageCount: number; streamingActive: boolean },
) {
if (!intent) return false
if (intent.sessionId !== state.sessionId) return false
return state.messageCount >= (intent.minItemCount ?? 0) && !state.streamingActive
}

View file

@ -0,0 +1,74 @@
import assert from "node:assert/strict"
import { describe, it } from "node:test"
import { getSubmitBottomPinTargetCount, resolveSessionBottomPinIntent, shouldClearSessionBottomPinIntent } from "./session-bottom-pin-intent.ts"
describe("session bottom pin intent", () => {
it("targets only the queued user prompt when submitting during active streaming", () => {
assert.equal(getSubmitBottomPinTargetCount(10, true), 11)
assert.equal(getSubmitBottomPinTargetCount(10, false), 12)
})
it("only exposes submit bottom pin intent to the matching session", () => {
const intent = { sessionId: "session-a", token: 3, minItemCount: 12, createdMessageCount: 10, observedStreaming: false }
assert.deepEqual(resolveSessionBottomPinIntent(intent, "session-a"), {
token: 3,
minItemCount: 12,
})
assert.equal(resolveSessionBottomPinIntent(intent, "session-b"), null)
})
it("clears submit bottom pin intent after the submitted exchange has rendered and stopped streaming", () => {
const intent = { sessionId: "session-a", token: 3, minItemCount: 12, createdMessageCount: 10, observedStreaming: false }
assert.equal(
shouldClearSessionBottomPinIntent(intent, {
sessionId: "session-a",
messageCount: 11,
streamingActive: false,
}),
false,
)
assert.equal(
shouldClearSessionBottomPinIntent(intent, {
sessionId: "session-a",
messageCount: 12,
streamingActive: true,
}),
false,
)
assert.equal(
shouldClearSessionBottomPinIntent(intent, {
sessionId: "session-a",
messageCount: 12,
streamingActive: false,
}),
true,
)
assert.equal(
shouldClearSessionBottomPinIntent(intent, {
sessionId: "session-b",
messageCount: 12,
streamingActive: false,
}),
false,
)
})
it("clears a submitted turn that stopped streaming before the optimistic count was reached", () => {
const intent = { sessionId: "session-a", token: 3, minItemCount: 12, createdMessageCount: 10, observedStreaming: true }
assert.equal(
shouldClearSessionBottomPinIntent(intent, {
sessionId: "session-a",
messageCount: 11,
streamingActive: false,
}),
true,
)
})
})

View file

@ -0,0 +1,30 @@
import type { VirtualExplicitBottomPinIntent } from "../virtual-follow-list"
export interface SessionBottomPinIntent extends VirtualExplicitBottomPinIntent {
sessionId: string
createdMessageCount: number
observedStreaming: boolean
}
export function getSubmitBottomPinTargetCount(messageCount: number, streamingActive: boolean) {
return messageCount + (streamingActive ? 1 : 2)
}
export function resolveSessionBottomPinIntent(
intent: SessionBottomPinIntent | null,
sessionId: string,
): VirtualExplicitBottomPinIntent | null {
if (!intent || intent.sessionId !== sessionId) return null
return { token: intent.token, minItemCount: intent.minItemCount }
}
export function shouldClearSessionBottomPinIntent(
intent: SessionBottomPinIntent | null,
state: { sessionId: string; messageCount: number; streamingActive: boolean },
) {
if (!intent) return false
if (intent.sessionId !== state.sessionId) return false
if (state.streamingActive) return false
if (state.messageCount >= (intent.minItemCount ?? 0)) return true
return intent.observedStreaming && state.messageCount > intent.createdMessageCount
}

View file

@ -21,7 +21,7 @@ import { useConfig } from "../../stores/preferences"
import { closeSessionPreview, getSessionPreview, showSessionChat } from "../../stores/session-previews"
import { SessionPreviewView } from "../session-preview-view"
import { isSnapshotAutoFollowing } from "../virtual-follow-behavior"
import { resolveSessionBottomFollowIntent, shouldClearSessionBottomFollowIntent, type SessionBottomFollowIntent } from "./session-bottom-follow-intent"
import { getSubmitBottomPinTargetCount, resolveSessionBottomPinIntent, shouldClearSessionBottomPinIntent, type SessionBottomPinIntent } from "./session-bottom-pin-intent"
const log = getLogger("session")
@ -81,8 +81,8 @@ export const SessionView: Component<SessionViewProps> = (props) => {
let scrollToBottomHandle: (() => void) | undefined
let rootRef: HTMLDivElement | undefined
const pendingIdleSeenTimers = new Set<string>()
const [submitBottomFollowIntent, setSubmitBottomFollowIntent] = createSignal<SessionBottomFollowIntent | null>(null)
let submitBottomFollowIntentSequence = 0
const [submitBottomPinIntent, setSubmitBottomPinIntent] = createSignal<SessionBottomPinIntent | null>(null)
let submitBottomPinIntentSequence = 0
function shouldScrollToBottomOnActivate() {
const current = session()
@ -105,23 +105,45 @@ export const SessionView: Component<SessionViewProps> = (props) => {
return true
}
function startSubmitBottomFollowIntent(minItemCount: number) {
submitBottomFollowIntentSequence += 1
setSubmitBottomFollowIntent({ sessionId: props.sessionId, token: submitBottomFollowIntentSequence, minItemCount })
function startSubmitBottomPinIntent(
minItemCount: number,
options?: { createdMessageCount?: number; preserveObservedStreaming?: boolean },
) {
submitBottomPinIntentSequence += 1
const previous = submitBottomPinIntent()
const createdMessageCount = options?.createdMessageCount ?? messageStore().getSessionMessageIds(props.sessionId).length
const shouldPreserveObservedStreaming = Boolean(
options?.preserveObservedStreaming &&
previous?.sessionId === props.sessionId &&
previous.createdMessageCount === createdMessageCount,
)
const intent: SessionBottomPinIntent = {
sessionId: props.sessionId,
token: submitBottomPinIntentSequence,
minItemCount,
createdMessageCount,
observedStreaming: shouldPreserveObservedStreaming ? previous?.observedStreaming === true : false,
}
setSubmitBottomPinIntent(intent)
return intent
}
function forceSubmittedExchangeToBottom(minItemCount: number) {
startSubmitBottomFollowIntent(minItemCount)
function forceSubmittedExchangeToBottom(
minItemCount: number,
options?: { createdMessageCount?: number; preserveObservedStreaming?: boolean },
) {
const intent = startSubmitBottomPinIntent(minItemCount, options)
scrollToBottomHandle?.()
return intent
}
const activeSubmitBottomFollowIntent = createMemo(() => {
const intent = submitBottomFollowIntent()
const activeSubmitBottomPinIntent = createMemo(() => {
const intent = submitBottomPinIntent()
const currentSession = session()
if (!intent || !currentSession) return null
const messageCount = messageStore().getSessionMessageIds(currentSession.id).length
if (shouldClearSessionBottomFollowIntent(intent, {
if (shouldClearSessionBottomPinIntent(intent, {
sessionId: currentSession.id,
messageCount,
streamingActive: sessionStreamingActive(),
@ -129,7 +151,7 @@ export const SessionView: Component<SessionViewProps> = (props) => {
return null
}
return resolveSessionBottomFollowIntent(intent, currentSession.id)
return resolveSessionBottomPinIntent(intent, currentSession.id)
})
function getSeenIdleEntries(currentSession: Session, keepUnseenSubagentIdleStatus: boolean): Array<{ id: string; idleSince: number }> {
@ -151,6 +173,14 @@ export const SessionView: Component<SessionViewProps> = (props) => {
return entries
}
createEffect(
on(
() => props.sessionId,
() => setSubmitBottomPinIntent(null),
{ defer: true },
),
)
createEffect(
on(
() => props.isActive,
@ -164,17 +194,22 @@ export const SessionView: Component<SessionViewProps> = (props) => {
)
createEffect(() => {
const intent = submitBottomFollowIntent()
const intent = submitBottomPinIntent()
const currentSession = session()
if (!intent || !currentSession) return
if (sessionStreamingActive() && intent.sessionId === currentSession.id && !intent.observedStreaming) {
setSubmitBottomPinIntent({ ...intent, observedStreaming: true })
return
}
const messageCount = messageStore().getSessionMessageIds(currentSession.id).length
if (shouldClearSessionBottomFollowIntent(intent, {
if (shouldClearSessionBottomPinIntent(intent, {
sessionId: currentSession.id,
messageCount,
streamingActive: sessionStreamingActive(),
})) {
setSubmitBottomFollowIntent(null)
setSubmitBottomPinIntent(null)
}
})
@ -309,14 +344,21 @@ export const SessionView: Component<SessionViewProps> = (props) => {
async function handleSendMessage(prompt: string, attachments: Attachment[]) {
const messageCount = messageStore().getSessionMessageIds(props.sessionId).length
const submittedExchangeTargetCount = messageCount + 2
forceSubmittedExchangeToBottom(submittedExchangeTargetCount)
const submittedExchangeTargetCount = getSubmitBottomPinTargetCount(messageCount, sessionStreamingActive())
const initialPinIntent = forceSubmittedExchangeToBottom(submittedExchangeTargetCount, { createdMessageCount: messageCount })
try {
await sendMessage(props.instanceId, props.sessionId, prompt, attachments)
const latestMessageCount = messageStore().getSessionMessageIds(props.sessionId).length
forceSubmittedExchangeToBottom(Math.max(submittedExchangeTargetCount, latestMessageCount))
if (latestMessageCount < submittedExchangeTargetCount && !sessionStreamingActive()) {
setSubmitBottomPinIntent(null)
} else if (submitBottomPinIntent()?.token === initialPinIntent.token) {
forceSubmittedExchangeToBottom(Math.max(submittedExchangeTargetCount, latestMessageCount), {
createdMessageCount: messageCount,
preserveObservedStreaming: true,
})
}
} catch (error) {
setSubmitBottomFollowIntent(null)
setSubmitBottomPinIntent(null)
throw error
}
}
@ -481,7 +523,8 @@ export const SessionView: Component<SessionViewProps> = (props) => {
loadError={messagesLoadError()}
onReloadMessages={handleReloadMessages}
sessionStreamingActive={sessionStreamingActive()}
bottomFollowIntent={activeSubmitBottomFollowIntent()}
explicitBottomPinIntent={activeSubmitBottomPinIntent()}
onExplicitBottomPinCancelled={() => setSubmitBottomPinIntent(null)}
onRevert={handleRevert}
onDeleteMessagesUpTo={handleDeleteMessagesUpTo}
onFork={handleFork}

View file

@ -52,7 +52,6 @@ const log = getLogger("session")
type ToolState = import("@opencode-ai/sdk/v2").ToolState
const TOOL_CALL_CACHE_SCOPE = "tool-call"
const TOOL_SCROLL_SENTINEL_MARGIN_PX = 48
function makeRenderCacheKey(
toolCallId?: string | null,
@ -194,7 +193,6 @@ function ToolCallDetails(props: {
const followScroll = createFollowScroll({
getScrollTopSnapshot: props.scrollTopSnapshot,
setScrollTopSnapshot: props.setScrollTopSnapshot,
sentinelMarginPx: TOOL_SCROLL_SENTINEL_MARGIN_PX,
sentinelClassName: "tool-call-scroll-sentinel",
})

View file

@ -2,214 +2,92 @@ import assert from "node:assert/strict"
import { describe, it } from "node:test"
import {
BOTTOM_FOLLOW_EPSILON_PX,
VirtualScrollController,
isAtBottom,
isAutoFollowing,
isSnapshotAutoFollowing,
resolveAutoPinHoldElement,
shouldSuspendAutoPinToBottomForHold,
restoreFollowModeFromSnapshot,
transitionFollowMode,
type FollowMode,
type ScrollControllerMetrics,
} from "./virtual-follow-behavior.ts"
const userScroll = (direction: "up" | "down" | null, atBottom: boolean, canPinToBottom = false) =>
({ type: "user-scroll", direction, atBottom, canPinToBottom }) as const
const userScroll = (direction: "up" | "down" | null, atBottom: boolean) =>
({ type: "user-scroll", direction, atBottom }) as const
function metrics(offset: number, scrollHeight = 3000, clientHeight = 600): ScrollControllerMetrics {
return {
offset,
scrollHeight,
clientHeight,
sentinelMarginPx: 48,
}
function metrics(offset: number, scrollHeight = 3000, clientHeight = 600, sentinelMarginPx = BOTTOM_FOLLOW_EPSILON_PX): ScrollControllerMetrics {
return { offset, scrollHeight, clientHeight, sentinelMarginPx }
}
describe("virtual follow behavior", () => {
it("escapes follow on upward user scroll", () => {
const next = transitionFollowMode({ type: "following" }, userScroll("up", false))
it("escapes follow on any upward user intent", () => {
const next = transitionFollowMode({ type: "following" }, userScroll("up", true))
assert.deepEqual(next.mode, { type: "escaped" })
assert.deepEqual(next.effect, { type: "none" })
})
it("does not rejoin follow when escaped user scrolls down above bottom without pin permission", () => {
it("does not rejoin follow from downward movement above the exact bottom", () => {
const next = transitionFollowMode({ type: "escaped" }, userScroll("down", false))
assert.deepEqual(next.mode, { type: "escaped" })
assert.deepEqual(next.effect, { type: "none" })
})
it("does not rejoin follow above bottom even with pin permission", () => {
const next = transitionFollowMode({ type: "escaped" }, userScroll("down", false, true))
assert.deepEqual(next.mode, { type: "escaped" })
assert.deepEqual(next.effect, { type: "none" })
})
it("rejoins follow when escaped user scrolls to the bottom", () => {
it("rejoins follow only at the exact bottom", () => {
const next = transitionFollowMode({ type: "escaped" }, userScroll("down", true))
assert.deepEqual(next.mode, { type: "following" })
assert.deepEqual(next.effect, { type: "none" })
})
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: "holding", key: "message-1" })
assert.deepEqual(next.effect, { type: "none" })
})
it("does not rejoin follow for directionless scroll above bottom", () => {
const next = transitionFollowMode({ type: "escaped" }, userScroll(null, false, true))
assert.deepEqual(next.mode, { type: "escaped" })
assert.deepEqual(next.effect, { type: "none" })
})
it("does not rejoin follow on upward scroll at bottom", () => {
const next = transitionFollowMode({ type: "escaped" }, userScroll("up", true, true))
assert.deepEqual(next.mode, { type: "escaped" })
assert.deepEqual(next.effect, { type: "none" })
})
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: "holding", key: "message-1" })
assert.deepEqual(next.effect, { type: "none" })
})
it("pins content growth while following but not while escaped", () => {
const escaped = transitionFollowMode({ type: "escaped" }, { type: "content-grew", canPinToBottom: true })
const following = transitionFollowMode({ type: "following" }, { type: "content-grew", canPinToBottom: true })
assert.deepEqual(escaped.effect, { type: "none" })
assert.deepEqual(following.effect, { type: "scroll-bottom", immediate: true, suppressHold: false })
assert.deepEqual(following.effect, { type: "scroll-bottom", immediate: true })
})
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: "none" })
})
it("enters hold mode for a valid hold candidate", () => {
const next = transitionFollowMode({ type: "following" }, { type: "hold-candidate", key: "message-1", shouldHold: true })
assert.deepEqual(next.mode, { type: "holding", key: "message-1" })
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 })
it("does not pin content growth when the integration gate is closed", () => {
const next = transitionFollowMode({ type: "following" }, { type: "content-grew", canPinToBottom: false })
assert.deepEqual(next.mode, { type: "following" })
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("keeps escaped-mode streaming detached until actual bottom", () => {
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: "escaped" })
assert.deepEqual(next.effect, { type: "none" })
})
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",
})
it("explicit bottom jumps enter follow mode", () => {
const next = transitionFollowMode({ type: "escaped" }, { type: "jump-bottom", immediate: true, explicit: true })
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" })
assert.deepEqual(next.mode, { type: "following" })
assert.deepEqual(next.effect, { type: "scroll-bottom", immediate: 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 })
it("explicit bottom jumps override stale upward user intent", () => {
const controller = new VirtualScrollController(false)
controller.recordProgrammaticOffset(2200, false)
controller.setUserIntent("up", 700)
assert.deepEqual(follow.mode, { type: "following" })
assert.deepEqual(escape.mode, { type: "escaped" })
const jump = controller.jumpBottom(true, true)
const observed = controller.observeViewport(metrics(2400), 100, true)
assert.deepEqual(jump.state.mode, { type: "following" })
assert.deepEqual(observed.state.mode, { type: "following" })
})
it("derives auto-follow from modes", () => {
it("key jumps always escape follow mode", () => {
const fromEscaped = transitionFollowMode({ type: "escaped" }, { type: "jump-key", key: "a", block: "start", smooth: false })
const fromFollowing = transitionFollowMode({ type: "following" }, { type: "jump-key", key: "b", block: "center", smooth: true })
assert.deepEqual(fromEscaped.mode, { type: "escaped" })
assert.deepEqual(fromFollowing.mode, { type: "escaped" })
})
it("derives auto-follow from the two modes", () => {
const modes: Array<[FollowMode, boolean]> = [
[{ type: "following" }, true],
[{ type: "holding", key: "message-1" }, false],
[{ type: "escaped" }, false],
]
@ -218,56 +96,6 @@ describe("virtual follow behavior", () => {
}
})
it("pins content growth instead of escaping on transient upward render movement", () => {
const controller = new VirtualScrollController(true)
controller.recordProgrammaticOffset(2400, true)
const result = controller.contentRendered(metrics(2200), true)
assert.deepEqual(result.state.mode, { type: "following" })
assert.deepEqual(result.effect, { type: "scroll-bottom", immediate: true, suppressHold: false })
})
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: "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", () => {
const controller = new VirtualScrollController(true)
controller.recordProgrammaticOffset(2400, true)
controller.setUserIntent("up", 700)
const result = controller.observeViewport(metrics(2200), 100, true)
assert.deepEqual(result.state.mode, { type: "escaped" })
})
it("does not escape for owned programmatic upward movement", () => {
const controller = new VirtualScrollController(true)
controller.recordProgrammaticOffset(2400, true)
const result = controller.observeViewport(metrics(2200), 100, true)
assert.deepEqual(result.state.mode, { type: "following" })
assert.deepEqual(result.effect, { type: "none" })
})
it("does not resume follow on directionless scroll above bottom", () => {
const controller = new VirtualScrollController(false)
controller.recordProgrammaticOffset(2220, false)
@ -289,18 +117,7 @@ describe("virtual follow behavior", () => {
assert.deepEqual(result.effect, { type: "none" })
})
it("does not magnet to bottom above bottom even with integration pin permission", () => {
const controller = new VirtualScrollController(false)
controller.recordProgrammaticOffset(2100, false)
controller.setUserIntent("down", 700)
const result = controller.observeViewport(metrics(2220), 100, false, true)
assert.deepEqual(result.state.mode, { type: "escaped" })
assert.deepEqual(result.effect, { type: "none" })
})
it("resumes follow only when downward movement reaches actual bottom", () => {
it("resumes follow only when downward movement reaches exact bottom", () => {
const controller = new VirtualScrollController(false)
controller.recordProgrammaticOffset(2300, false)
controller.setUserIntent("down", 700)
@ -311,31 +128,33 @@ describe("virtual follow behavior", () => {
assert.deepEqual(result.effect, { type: "none" })
})
it("keeps hold latched until downward movement reaches actual bottom", () => {
it("lets fresh user upward movement escape even during a programmatic window", () => {
const controller = new VirtualScrollController(true)
controller.holdCandidate("message-1", true)
controller.recordProgrammaticOffset(2100, false)
controller.setUserIntent("down", 700)
controller.recordProgrammaticOffset(2400, true)
controller.setUserIntent("up", 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)
const result = controller.observeViewport(metrics(1100), 100, false)
const result = controller.observeViewport(metrics(2200), 100, true)
assert.deepEqual(result.state.mode, { type: "escaped" })
})
it("keeps fresh upward intent escaped even if a programmatic scroll later moves down to bottom", () => {
const controller = new VirtualScrollController(false)
controller.recordProgrammaticOffset(2200, false)
controller.setUserIntent("up", 700)
const result = controller.observeViewport(metrics(2400), 100, true)
assert.deepEqual(result.state.mode, { type: "escaped" })
})
it("does not escape for owned programmatic upward movement", () => {
const controller = new VirtualScrollController(true)
controller.recordProgrammaticOffset(2400, true)
const result = controller.observeViewport(metrics(2200), 100, true)
assert.deepEqual(result.state.mode, { type: "following" })
assert.deepEqual(result.effect, { type: "none" })
})
@ -349,33 +168,27 @@ describe("virtual follow behavior", () => {
assert.deepEqual(result.effect, { type: "none" })
})
it("does not pin content growth when the integration gate is closed", () => {
const controller = new VirtualScrollController(true)
it("uses a small bottom follow tolerance", () => {
const bottomOffset = 2400
const result = controller.contentRendered(metrics(2400), false)
assert.deepEqual(result.state.mode, { type: "following" })
assert.deepEqual(result.effect, { type: "none" })
assert.equal(isAtBottom(metrics(bottomOffset - BOTTOM_FOLLOW_EPSILON_PX - 0.1)), false)
assert.equal(isAtBottom(metrics(bottomOffset - BOTTOM_FOLLOW_EPSILON_PX)), true)
assert.equal(isAtBottom(metrics(bottomOffset)), true)
})
it("blocks pre-pin upward reconciliation while restoring", () => {
const controller = new VirtualScrollController(true)
controller.recordProgrammaticOffset(2400, true)
controller.setRestoring(true)
it("treats fractional distance inside the tolerance as at-bottom", () => {
const bottomOffset = 2400
const result = controller.beforeBottomPin(metrics(2200))
assert.deepEqual(result.state.mode, { type: "following" })
assert.deepEqual(result.effect, { type: "none" })
assert.equal(isAtBottom(metrics(bottomOffset - BOTTOM_FOLLOW_EPSILON_PX - 0.5)), false)
assert.equal(isAtBottom(metrics(bottomOffset - 0.5)), true)
})
it("distinguishes close-to-bottom from at-bottom metrics", () => {
const closeButNotAtBottom = metrics(2351)
assert.equal(isAtBottom(closeButNotAtBottom), false)
it("does not restore follow from an off-bottom snapshot", () => {
assert.equal(isSnapshotAutoFollowing({ atBottom: false, followModeType: "following" }), false)
assert.deepEqual(restoreFollowModeFromSnapshot({ atBottom: false, followModeType: "following" }), { type: "escaped" })
})
it("excludes reasoning-only hold targets while preserving Assistant text eligibility", () => {
it("keeps hold element resolution as a DOM concern", () => {
const itemWrapper = { id: "message-wrapper" } as unknown as HTMLElement
const assistantAnswerText = { id: "assistant-answer-text" } as unknown as HTMLElement

View file

@ -1,24 +1,19 @@
export type FollowMode =
| { type: "following" }
| { type: "escaped" }
| { type: "holding"; key: string }
export type FollowMode = { type: "following" } | { type: "escaped" }
export const BOTTOM_FOLLOW_EPSILON_PX = 48
export type FollowEffect =
| { type: "none" }
| { type: "scroll-top"; immediate: boolean }
| { type: "scroll-bottom"; immediate: boolean; suppressHold: boolean }
| { type: "scroll-bottom"; immediate: boolean }
| { type: "scroll-key"; key: string; block: ScrollLogicalPosition; smooth: boolean }
| { type: "align-hold"; key: string }
export type FollowEvent =
| { type: "user-scroll"; direction: "up" | "down" | null; atBottom: boolean; canPinToBottom: boolean }
| { type: "user-scroll"; direction: "up" | "down" | null; atBottom: boolean }
| { type: "jump-top"; immediate: boolean }
| { type: "jump-bottom"; immediate: boolean; explicit: boolean }
| { type: "jump-key"; key: string; block: ScrollLogicalPosition; smooth: boolean; followAfter: boolean }
| { type: "jump-key"; key: string; block: ScrollLogicalPosition; smooth: boolean }
| { 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 }
@ -61,8 +56,6 @@ export interface ScrollControllerSnapshot {
export interface FollowSnapshotState {
followModeType?: FollowMode["type"]
heldKey?: string
holdAnchorSuspended?: boolean
}
export type HoldTargetElementResolver = (itemWrapper: HTMLElement, key: string) => HTMLElement | null | undefined
@ -73,10 +66,6 @@ export function isAutoFollowing(mode: FollowMode) {
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,
@ -89,79 +78,25 @@ 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 shouldTrackHeldAnchor(state: {
activeHoldTargetKey: string | null
eligibleHoldTargetKey: string | null
suspendedByUser: boolean
}) {
return !state.suspendedByUser && state.activeHoldTargetKey !== null && state.activeHoldTargetKey === state.eligibleHoldTargetKey
}
export function isSnapshotAutoFollowing(snapshot: { atBottom: boolean; followModeType?: FollowMode["type"] } | null | undefined) {
if (!snapshot) return true
if (snapshot.followModeType) return snapshot.followModeType === "following"
return snapshot.atBottom
return snapshot.atBottom && snapshot.followModeType !== "escaped"
}
export function getFollowSnapshotState(mode: FollowMode, holdAnchorSuspended: boolean): FollowSnapshotState {
if (mode.type === "holding") {
return {
followModeType: "holding",
heldKey: mode.key,
holdAnchorSuspended: holdAnchorSuspended || undefined,
}
}
export function getFollowSnapshotState(mode: FollowMode): FollowSnapshotState {
return { followModeType: mode.type }
}
export function restoreFollowModeFromSnapshot(state: {
atBottom: boolean
followModeType?: FollowMode["type"]
heldKey?: string
hasHeldKeyItem?: boolean
}): FollowMode {
if (state.followModeType === "holding" && state.heldKey && state.hasHeldKeyItem) {
return { type: "holding", key: state.heldKey }
}
if (state.followModeType === "following") {
return { type: "following" }
}
if (state.followModeType === "escaped") {
return { type: "escaped" }
}
export function restoreFollowModeFromSnapshot(state: { atBottom: boolean; followModeType?: FollowMode["type"] }): FollowMode {
if (state.followModeType === "escaped") return { type: "escaped" }
return state.atBottom ? { type: "following" } : { type: "escaped" }
}
export function transitionFollowMode(mode: FollowMode, event: FollowEvent): FollowTransition {
switch (event.type) {
case "user-scroll": {
if (mode.type === "holding") {
if (event.atBottom && event.direction !== "up") {
return { mode: { type: "following" }, effect: noFollowEffect }
}
return { mode, effect: noFollowEffect }
}
if (event.direction === "up") {
return { mode: { type: "escaped" }, effect: noFollowEffect }
}
if (mode.type === "escaped" && event.direction === "down" && event.atBottom && event.canPinToBottom) {
return {
mode: { type: "following" },
effect: { type: "scroll-bottom", immediate: true, suppressHold: false },
}
}
if (event.atBottom) {
return { mode: { type: "following" }, effect: noFollowEffect }
}
if (event.direction === "up") return { mode: { type: "escaped" }, effect: noFollowEffect }
if (event.atBottom) return { mode: { type: "following" }, effect: noFollowEffect }
return { mode, effect: noFollowEffect }
}
@ -171,42 +106,21 @@ export function transitionFollowMode(mode: FollowMode, event: FollowEvent): Foll
case "jump-bottom":
return {
mode: { type: "following" },
effect: { type: "scroll-bottom", immediate: event.immediate, suppressHold: event.explicit },
effect: { type: "scroll-bottom", immediate: event.immediate },
}
case "jump-key":
return {
mode: event.followAfter ? { type: "following" } : { type: "escaped" },
mode: { type: "escaped" },
effect: { type: "scroll-key", key: event.key, block: event.block, smooth: event.smooth },
}
case "content-grew":
if (mode.type === "following" && event.canPinToBottom) {
return { mode, effect: { type: "scroll-bottom", immediate: true, suppressHold: false } }
return { mode, effect: { type: "scroll-bottom", immediate: true } }
}
return { mode, effect: noFollowEffect }
case "hold-candidate":
if (mode.type === "following" && event.shouldHold) {
return { mode: { type: "holding", key: event.key }, effect: { type: "align-hold", key: event.key } }
}
return { mode, effect: noFollowEffect }
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 }
@ -245,10 +159,6 @@ export class VirtualScrollController {
return isAutoFollowing(this.state.mode)
}
heldKey() {
return this.state.mode.type === "holding" ? this.state.mode.key : null
}
setUserIntent(direction: ScrollDirection, until: number) {
this.state.userIntentDirection = direction
this.state.userIntentUntil = until
@ -293,42 +203,22 @@ export class VirtualScrollController {
}
jumpBottom(immediate: boolean, explicit: boolean): ScrollControllerResult {
if (explicit) {
this.state.userIntentDirection = null
this.state.userIntentUntil = 0
}
const next = transitionFollowMode(this.state.mode, { type: "jump-bottom", immediate, explicit })
this.state.mode = next.mode
return this.result(next.effect)
}
jumpKey(key: string, block: ScrollLogicalPosition, smooth: boolean, followAfter: boolean): ScrollControllerResult {
const next = transitionFollowMode(this.state.mode, { type: "jump-key", key, block, smooth, followAfter })
jumpKey(key: string, block: ScrollLogicalPosition, smooth: boolean): ScrollControllerResult {
const next = transitionFollowMode(this.state.mode, { type: "jump-key", key, block, smooth })
this.state.mode = next.mode
return this.result(next.effect)
}
holdCandidate(key: string, shouldHold: boolean): ScrollControllerResult {
const next = transitionFollowMode(this.state.mode, { type: "hold-candidate", key, shouldHold })
this.state.mode = next.mode
return this.result(next.effect)
}
holdTargetChanged(key: string | null, canPinToBottom: boolean): ScrollControllerResult {
const next = transitionFollowMode(this.state.mode, { type: "hold-target-changed", key, canPinToBottom })
this.state.mode = next.mode
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,
forceEscapeFromUpScroll = false,
): ScrollControllerResult {
observeViewport(metrics: ScrollControllerMetrics, now: number, programmatic: boolean): ScrollControllerResult {
const previousOffset = this.state.lastObservedOffset
const offset = metrics.offset
const scrolledUp = offset < previousOffset - 1
@ -340,66 +230,39 @@ export class VirtualScrollController {
this.clearExpiredUserIntent(now)
const hasFreshIntent = now <= this.state.userIntentUntil
if (
scrolledUp &&
this.isAutoFollowing() &&
(forceEscapeFromUpScroll || !atBottom) &&
this.heldKey() === null &&
(!programmatic || hasFreshIntent)
) {
const actualDirection: ScrollDirection = scrolledUp ? "up" : scrolledDown ? "down" : null
if (hasFreshIntent && this.state.userIntentDirection === "up") {
return this.setFollow(false)
}
const actualDirection: ScrollDirection = scrolledUp ? "up" : scrolledDown ? "down" : null
const direction = actualDirection ?? this.state.userIntentDirection
if (!hasFreshIntent && (!actualDirection || programmatic)) {
return this.result(noFollowEffect)
}
const direction = actualDirection ?? this.state.userIntentDirection
const canMagnetToBottom = hasFreshIntent && direction === "down" && canPinToBottom
const next = transitionFollowMode(this.state.mode, {
type: "user-scroll",
direction,
atBottom,
canPinToBottom: canMagnetToBottom,
})
if (direction === "up" && (!programmatic || hasFreshIntent)) {
return this.setFollow(false)
}
const next = transitionFollowMode(this.state.mode, { type: "user-scroll", direction, atBottom })
this.state.mode = next.mode
this.state.lastObservedAtBottom = this.isAutoFollowing() && atBottom
return this.result(next.effect)
}
contentRendered(metrics: ScrollControllerMetrics, canPinToBottom: boolean): ScrollControllerResult {
contentRendered(_metrics: ScrollControllerMetrics, canPinToBottom: boolean): ScrollControllerResult {
if (this.state.restoring) return this.result(noFollowEffect)
if (!canPinToBottom || !this.isAutoFollowing()) {
const reconcile = this.reconcileUpwardDomMovement(metrics)
if (reconcile.effect.type !== "none") return reconcile
}
const next = transitionFollowMode(this.state.mode, { type: "content-grew", canPinToBottom })
this.state.mode = next.mode
return this.result(next.effect)
}
beforeBottomPin(metrics: ScrollControllerMetrics): ScrollControllerResult {
if (this.state.restoring) return this.result(noFollowEffect)
return this.reconcileUpwardDomMovement(metrics)
}
recordProgrammaticOffset(offset: number, atBottom: boolean) {
this.state.lastObservedOffset = offset
this.state.lastObservedAtBottom = this.isAutoFollowing() && atBottom
}
private reconcileUpwardDomMovement(metrics: ScrollControllerMetrics): ScrollControllerResult {
if (!this.isAutoFollowing()) return this.result(noFollowEffect)
if (this.heldKey() !== null) return this.result(noFollowEffect)
if (isAtBottom(metrics)) return this.result(noFollowEffect)
if (metrics.offset >= this.state.lastObservedOffset - 1) return this.result(noFollowEffect)
this.state.lastObservedOffset = metrics.offset
this.state.lastObservedAtBottom = false
return this.setFollow(false)
}
private result(effect: FollowEffect): ScrollControllerResult {
return { effect, state: this.snapshot() }
}

File diff suppressed because it is too large Load diff

View file

@ -1,4 +1,5 @@
import { createEffect, createSignal, onCleanup, type Accessor, type JSXElement } from "solid-js"
import { BOTTOM_FOLLOW_EPSILON_PX, VirtualScrollController, isAutoFollowing, type ScrollControllerMetrics } from "../components/virtual-follow-behavior"
const DEFAULT_SCROLL_INTENT_WINDOW_MS = 600
const DEFAULT_SCROLL_INTENT_KEYS = new Set(["ArrowUp", "ArrowDown", "PageUp", "PageDown", "Home", "End", " ", "Spacebar"])
@ -6,7 +7,6 @@ const DEFAULT_SCROLL_INTENT_KEYS = new Set(["ArrowUp", "ArrowDown", "PageUp", "P
interface FollowScrollOptions {
getScrollTopSnapshot: Accessor<number>
setScrollTopSnapshot: (next: number) => void
sentinelMarginPx: number
sentinelClassName: string
intentWindowMs?: number
intentKeys?: ReadonlySet<string>
@ -24,16 +24,14 @@ export function createFollowScroll(options: FollowScrollOptions): FollowScrollHe
const [scrollContainer, setScrollContainer] = createSignal<HTMLDivElement | undefined>()
const [bottomSentinel, setBottomSentinel] = createSignal<HTMLDivElement | null>(null)
const [autoScroll, setAutoScroll] = createSignal(true)
const [bottomSentinelVisible, setBottomSentinelVisible] = createSignal(true)
const scrollController = new VirtualScrollController(true)
let scrollContainerRef: HTMLDivElement | undefined
let detachScrollIntentListeners: (() => void) | undefined
let pendingScrollFrame: number | null = null
let pendingAnchorScroll: number | null = null
let userScrollIntentUntil = 0
let lastKnownScrollTop = options.getScrollTopSnapshot()
let pointerInteractionActive = false
let suppressNextScrollHandling = false
function restoreScrollPosition(forceBottom = false) {
@ -44,8 +42,10 @@ export function createFollowScroll(options: FollowScrollOptions): FollowScrollHe
container.scrollTop = container.scrollHeight
lastKnownScrollTop = container.scrollTop
options.setScrollTopSnapshot(lastKnownScrollTop)
scrollController.recordProgrammaticOffset(lastKnownScrollTop, true)
} else {
container.scrollTop = lastKnownScrollTop
scrollController.recordProgrammaticOffset(lastKnownScrollTop, isAtBottom(container))
}
}
@ -55,17 +55,9 @@ export function createFollowScroll(options: FollowScrollOptions): FollowScrollHe
options.setScrollTopSnapshot(lastKnownScrollTop)
}
function markUserScrollIntent() {
function markUserScrollIntent(direction: "up" | "down" | null) {
const now = typeof performance !== "undefined" ? performance.now() : Date.now()
userScrollIntentUntil = now + (options.intentWindowMs ?? DEFAULT_SCROLL_INTENT_WINDOW_MS)
}
function hasUserScrollIntent() {
if (pointerInteractionActive) {
return true
}
const now = typeof performance !== "undefined" ? performance.now() : Date.now()
return now <= userScrollIntentUntil
scrollController.setUserIntent(direction, now + (options.intentWindowMs ?? DEFAULT_SCROLL_INTENT_WINDOW_MS))
}
function attachScrollIntentListeners(element: HTMLDivElement) {
@ -74,42 +66,27 @@ export function createFollowScroll(options: FollowScrollOptions): FollowScrollHe
detachScrollIntentListeners = undefined
}
const intentKeys = options.intentKeys ?? DEFAULT_SCROLL_INTENT_KEYS
const handlePointerIntent = () => {
pointerInteractionActive = true
markUserScrollIntent()
}
const clearPointerIntent = () => {
pointerInteractionActive = false
const handlePointerIntent = (event: WheelEvent | PointerEvent | TouchEvent) => {
markUserScrollIntent(event instanceof WheelEvent ? (event.deltaY < 0 ? "up" : event.deltaY > 0 ? "down" : null) : null)
}
const handleKeyIntent = (event: KeyboardEvent) => {
if (intentKeys.has(event.key)) {
markUserScrollIntent()
const direction =
event.key === "ArrowUp" || event.key === "PageUp" || event.key === "Home" || (event.shiftKey && (event.key === " " || event.key === "Spacebar"))
? "up"
: "down"
markUserScrollIntent(direction)
}
}
element.addEventListener("wheel", handlePointerIntent, { passive: true })
element.addEventListener("pointerdown", handlePointerIntent)
element.addEventListener("touchstart", handlePointerIntent, { passive: true })
element.addEventListener("keydown", handleKeyIntent)
if (typeof window !== "undefined") {
window.addEventListener("pointerup", clearPointerIntent)
window.addEventListener("pointercancel", clearPointerIntent)
window.addEventListener("mouseup", clearPointerIntent)
window.addEventListener("touchend", clearPointerIntent)
window.addEventListener("touchcancel", clearPointerIntent)
}
detachScrollIntentListeners = () => {
element.removeEventListener("wheel", handlePointerIntent)
element.removeEventListener("pointerdown", handlePointerIntent)
element.removeEventListener("touchstart", handlePointerIntent)
element.removeEventListener("keydown", handleKeyIntent)
if (typeof window !== "undefined") {
window.removeEventListener("pointerup", clearPointerIntent)
window.removeEventListener("pointercancel", clearPointerIntent)
window.removeEventListener("mouseup", clearPointerIntent)
window.removeEventListener("touchend", clearPointerIntent)
window.removeEventListener("touchcancel", clearPointerIntent)
}
pointerInteractionActive = false
}
}
@ -126,18 +103,28 @@ export function createFollowScroll(options: FollowScrollOptions): FollowScrollHe
pendingAnchorScroll = null
const containerRect = container.getBoundingClientRect()
const sentinelRect = sentinel.getBoundingClientRect()
const delta = sentinelRect.bottom - containerRect.bottom + options.sentinelMarginPx
const delta = sentinelRect.bottom - containerRect.bottom
if (Math.abs(delta) > 1) {
suppressNextScrollHandling = true
container.scrollBy({ top: delta, behavior: immediate ? "auto" : "smooth" })
}
lastKnownScrollTop = container.scrollTop
options.setScrollTopSnapshot(lastKnownScrollTop)
scrollController.recordProgrammaticOffset(lastKnownScrollTop, isAtBottom(container))
})
}
function getMetrics(container: HTMLDivElement): ScrollControllerMetrics {
return {
offset: container.scrollTop,
scrollHeight: container.scrollHeight,
clientHeight: container.clientHeight,
sentinelMarginPx: BOTTOM_FOLLOW_EPSILON_PX,
}
}
function isAtBottom(container: HTMLDivElement) {
return container.scrollHeight - (container.scrollTop + container.clientHeight) <= options.sentinelMarginPx
return container.scrollHeight - (container.scrollTop + container.clientHeight) <= BOTTOM_FOLLOW_EPSILON_PX
}
function updateFollowModeFromScroll(containerOverride?: HTMLDivElement) {
@ -147,17 +134,8 @@ export function createFollowScroll(options: FollowScrollOptions): FollowScrollHe
suppressNextScrollHandling = false
return
}
const isUserScroll = hasUserScrollIntent()
const atBottomFromScroll = isAtBottom(container)
const atBottom = atBottomFromScroll || bottomSentinelVisible()
if (isUserScroll || !atBottom) {
if (atBottom) {
if (!autoScroll()) setAutoScroll(true)
} else if (autoScroll()) {
setAutoScroll(false)
}
}
const result = scrollController.observeViewport(getMetrics(container), typeof performance !== "undefined" ? performance.now() : Date.now(), false)
setAutoScroll(isAutoFollowing(result.state.mode))
}
const handleScroll = (event: Event & { currentTarget: HTMLDivElement }) => {
@ -166,7 +144,7 @@ export function createFollowScroll(options: FollowScrollOptions): FollowScrollHe
}
const registerContainer = (element: HTMLDivElement | null | undefined, config?: { disableTracking?: boolean }) => {
const next = element || undefined
const next = config?.disableTracking ? undefined : element || undefined
if (next === scrollContainerRef) {
return
}
@ -185,10 +163,13 @@ export function createFollowScroll(options: FollowScrollOptions): FollowScrollHe
const restoreAfterRender = () => {
const container = scrollContainerRef
if (container && hasUserScrollIntent() && !isAtBottom(container)) {
if (autoScroll()) {
setAutoScroll(false)
}
if (!container) return
const now = typeof performance !== "undefined" ? performance.now() : Date.now()
const result = scrollController.observeViewport(getMetrics(container), now, false)
setAutoScroll(isAutoFollowing(result.state.mode))
const hasFreshUpwardEscape = now <= result.state.userIntentUntil && result.state.userIntentDirection === "up" && result.state.mode.type === "escaped"
if (hasFreshUpwardEscape) {
requestAnimationFrame(() => {
restoreScrollPosition(false)
})
@ -219,24 +200,6 @@ export function createFollowScroll(options: FollowScrollOptions): FollowScrollHe
})
})
createEffect(() => {
const container = scrollContainer()
const sentinel = bottomSentinel()
if (!container || !sentinel) return
const observer = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
if (entry.target === sentinel) {
setBottomSentinelVisible(entry.isIntersecting)
}
})
},
{ root: container, threshold: 0, rootMargin: `0px 0px ${options.sentinelMarginPx}px 0px` },
)
observer.observe(sentinel)
onCleanup(() => observer.disconnect())
})
onCleanup(() => {
if (pendingScrollFrame !== null) {
cancelAnimationFrame(pendingScrollFrame)

View file

@ -40,4 +40,5 @@ describe("message-v2 permission state", () => {
assert.equal(store.state.permissions.active?.permission.id, "permission-2")
assert.equal(store.getPermissionState(undefined, "permission-2")?.active, true)
})
})

View file

@ -13,6 +13,7 @@ import {
import type { ClientPart, MessageInfo } from "../../types/message"
import { mergePermissionRequest } from "../../types/permission"
import { clearRecordDisplayCacheForMessages } from "./record-display-cache"
import { mergePendingRequestEntry, shouldSkipPendingRequestUpsert } from "./pending-request-dedupe"
import type {
InstanceMessageState,
LatestTodoSnapshot,
@ -958,6 +959,23 @@ export function createInstanceMessageStore(instanceId: string, hooks?: MessageSt
const entry = mergePermissionEntry(input)
const messageKey = entry.messageId ?? "__global__"
const partKey = entry.partId ?? entry.permission?.id ?? "__global__"
const existing = state.permissions.queue.find((item) => item.permission.id === entry.permission.id)
const existingAtLocation = state.permissions.byMessage[messageKey]?.[partKey]
const expectedActiveId = state.permissions.queue[0]?.permission.id
if (shouldSkipPendingRequestUpsert({
existing,
existingAtLocationId: existingAtLocation?.permission.id,
expectedActiveId,
activeId: state.permissions.active?.permission.id,
incomingId: entry.permission.id,
incomingMessageId: entry.messageId,
incomingPartId: entry.partId,
incomingEnqueuedAt: entry.enqueuedAt,
existingValue: existing?.permission,
incomingValue: entry.permission,
})) {
return
}
setState(
"permissions",
@ -1019,13 +1037,47 @@ export function createInstanceMessageStore(instanceId: string, hooks?: MessageSt
return { entry, active }
}
function upsertQuestion(entry: QuestionEntry) {
function mergeQuestionEntry(entry: QuestionEntry): QuestionEntry {
const existing = state.questions.queue.find((item) => item.request.id === entry.request.id)
return mergePendingRequestEntry(entry, existing)
}
function upsertQuestion(input: QuestionEntry) {
const entry = mergeQuestionEntry(input)
const messageKey = entry.messageId ?? "__global__"
const partKey = entry.partId ?? entry.request?.id ?? "__global__"
const existing = state.questions.queue.find((item) => item.request.id === entry.request.id)
const existingAtLocation = state.questions.byMessage[messageKey]?.[partKey]
const expectedActiveId = state.questions.queue[0]?.request.id
if (shouldSkipPendingRequestUpsert({
existing,
existingAtLocationId: existingAtLocation?.request.id,
expectedActiveId,
activeId: state.questions.active?.request.id,
incomingId: entry.request.id,
incomingMessageId: entry.messageId,
incomingPartId: entry.partId,
incomingEnqueuedAt: entry.enqueuedAt,
existingValue: existing?.request,
incomingValue: entry.request,
})) {
return
}
setState(
"questions",
produce((draft) => {
Object.keys(draft.byMessage).forEach((existingMessageKey) => {
const partEntries = draft.byMessage[existingMessageKey]
Object.keys(partEntries).forEach((existingPartKey) => {
if (partEntries[existingPartKey].request.id === entry.request.id) {
delete partEntries[existingPartKey]
}
})
if (Object.keys(partEntries).length === 0) {
delete draft.byMessage[existingMessageKey]
}
})
draft.byMessage[messageKey] = draft.byMessage[messageKey] ?? {}
draft.byMessage[messageKey][partKey] = entry
const existingIndex = draft.queue.findIndex((item) => item.request.id === entry.request.id)

View file

@ -0,0 +1,57 @@
import assert from "node:assert/strict"
import { describe, it } from "node:test"
import { mergePendingRequestEntry, shouldSkipPendingRequestUpsert } from "./pending-request-dedupe.ts"
describe("pending request dedupe", () => {
it("skips unchanged polling updates at the same attachment location", () => {
const existing = {
messageId: "message-1",
partId: "part-1",
enqueuedAt: 1_000,
}
const request = { id: "question-1", sessionID: "session-1", questions: [{ header: "Confirm", question: "Continue?", options: [] }] }
assert.equal(shouldSkipPendingRequestUpsert({
existing,
existingAtLocationId: "question-1",
expectedActiveId: "question-1",
activeId: "question-1",
incomingId: "question-1",
incomingMessageId: "message-1",
incomingPartId: "part-1",
incomingEnqueuedAt: 1_000,
existingValue: request,
incomingValue: { ...request, questions: [...request.questions] },
}), true)
})
it("does not skip when polling resolves a request from global state to a tool part", () => {
const existing = {
enqueuedAt: 1_000,
}
const request = { id: "question-1", sessionID: "session-1", questions: [{ header: "Confirm", question: "Continue?", options: [] }] }
assert.equal(shouldSkipPendingRequestUpsert({
existing,
existingAtLocationId: undefined,
expectedActiveId: "question-1",
activeId: "question-1",
incomingId: "question-1",
incomingMessageId: "message-1",
incomingPartId: "part-1",
incomingEnqueuedAt: 1_000,
existingValue: request,
incomingValue: request,
}), false)
})
it("keeps the earliest queue time while preserving resolved attachment ids", () => {
const merged = mergePendingRequestEntry(
{ messageId: "message-1", partId: "part-1", enqueuedAt: 3_000 },
{ enqueuedAt: 1_000 },
)
assert.deepEqual(merged, { messageId: "message-1", partId: "part-1", enqueuedAt: 1_000 })
})
})

View file

@ -0,0 +1,50 @@
export interface PendingRequestEntryLike {
messageId?: string
partId?: string
enqueuedAt: number
}
export interface PendingRequestSkipInput {
existing: PendingRequestEntryLike | undefined
existingAtLocationId: string | undefined
expectedActiveId: string | undefined
activeId: string | undefined
incomingId: string
incomingMessageId: string | undefined
incomingPartId: string | undefined
incomingEnqueuedAt: number
existingValue: unknown
incomingValue: unknown
}
export function areStructuredValuesEqual(left: unknown, right: unknown): boolean {
if (left === right) return true
try {
return JSON.stringify(left) === JSON.stringify(right)
} catch {
return false
}
}
export function mergePendingRequestEntry<T extends PendingRequestEntryLike>(entry: T, existing: T | undefined): T {
if (!existing) return entry
return {
...entry,
messageId: entry.messageId ?? existing.messageId,
partId: entry.partId ?? existing.partId,
enqueuedAt: Math.min(existing.enqueuedAt, entry.enqueuedAt),
}
}
export function shouldSkipPendingRequestUpsert(input: PendingRequestSkipInput): boolean {
const existing = input.existing
return Boolean(
existing &&
input.existingAtLocationId === input.incomingId &&
input.activeId === input.expectedActiveId &&
existing.messageId === input.incomingMessageId &&
existing.partId === input.incomingPartId &&
existing.enqueuedAt === input.incomingEnqueuedAt &&
areStructuredValuesEqual(input.existingValue, input.incomingValue),
)
}

View file

@ -82,9 +82,7 @@ export interface ScrollSnapshot {
anchorKey?: string
anchorOffset?: number
atBottom: boolean
followModeType?: "following" | "escaped" | "holding"
heldKey?: string
holdAnchorSuspended?: boolean
followModeType?: "following" | "escaped"
updatedAt: number
}