From fb91d80325bb2159286663ace7f45f8405a80a16 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pascal=20Andr=C3=A9?= Date: Fri, 8 May 2026 14:37:41 +0200 Subject: [PATCH 1/4] fix(ui): stabilize virtual follow autoscroll Keep DOM and Virtua timing in VirtualFollowList while moving pure follow and hold transitions into a colocated behavior module with focused tests. The integration restores hidden session scroll snapshots safely, gates streaming magnetization on active streaming and hold state, and preserves explicit latest/submit bottom jumps without reintroducing fast-down heuristics. Use Virtua offsets for scroll snapshots and decide streaming rejoin from the item at the bottom of the viewport so long assistant messages can still magnetize near their tail without using stale top-of-viewport position. Remove the unused generic scroll cache hook now that message scroll restore is handled by the virtual follow integration. Validation: node --test packages/ui/src/components/virtual-follow-behavior.test.ts, npm run typecheck --workspace @codenomad/ui, npm run build --workspace @codenomad/tauri-app, and manual release-exe testing. --- packages/ui/src/components/message-block.tsx | 8 + .../ui/src/components/message-section.tsx | 131 +++- .../src/components/session/session-view.tsx | 172 ++++-- .../virtual-follow-behavior.test.ts | 248 ++++++++ .../src/components/virtual-follow-behavior.ts | 307 +++++++++ .../ui/src/components/virtual-follow-list.tsx | 583 +++++++++++++++--- packages/ui/src/lib/hooks/use-scroll-cache.ts | 102 --- packages/ui/src/stores/message-v2/types.ts | 4 + 8 files changed, 1255 insertions(+), 300 deletions(-) create mode 100644 packages/ui/src/components/virtual-follow-behavior.test.ts create mode 100644 packages/ui/src/components/virtual-follow-behavior.ts delete mode 100644 packages/ui/src/lib/hooks/use-scroll-cache.ts diff --git a/packages/ui/src/components/message-block.tsx b/packages/ui/src/components/message-block.tsx index 04c15e7ac..4b5e97f18 100644 --- a/packages/ui/src/components/message-block.tsx +++ b/packages/ui/src/components/message-block.tsx @@ -1004,6 +1004,7 @@ export default function MessageBlock(props: MessageBlockProps) { onDeleteMessagesUpTo={props.onDeleteMessagesUpTo} selectedMessageIds={props.selectedMessageIds} onToggleSelectedMessage={props.onToggleSelectedMessage} + onContentRendered={props.onContentRendered} /> @@ -1063,6 +1064,7 @@ interface StepCardProps { onDeleteMessagesUpTo?: (messageId: string) => void | Promise selectedMessageIds?: () => Set onToggleSelectedMessage?: (messageId: string, selected: boolean) => void + onContentRendered?: () => void } interface CompactionCardProps { @@ -1245,6 +1247,12 @@ function StepCard(props: StepCardProps) { const finishStyle = () => (props.borderColor ? { "border-left-color": props.borderColor } : undefined) + createEffect(() => { + if (props.kind !== "finish") return + if (!usageStats()) return + props.onContentRendered?.() + }) + const canDeleteMessage = () => Boolean(props.showDeleteMessage && props.instanceId && props.sessionId && props.messageId) && !deletingMessage() diff --git a/packages/ui/src/components/message-section.tsx b/packages/ui/src/components/message-section.tsx index f2cc7abbb..6a2e42551 100644 --- a/packages/ui/src/components/message-section.tsx +++ b/packages/ui/src/components/message-section.tsx @@ -4,12 +4,11 @@ import Kbd from "./kbd" import MessageBlock from "./message-block" import { getMessageAnchorId, getMessageIdFromAnchorId } from "./message-anchors" import MessageTimeline, { buildTimelineSegments, type TimelineSegment } from "./message-timeline" -import VirtualFollowList, { type VirtualFollowListApi, type VirtualFollowListState } from "./virtual-follow-list" +import VirtualFollowList, { type VirtualFollowListApi, type VirtualFollowListState, type VirtualFollowScrollSnapshot } from "./virtual-follow-list" import { useConfig } from "../stores/preferences" import { getSessionInfo } from "../stores/sessions" import { messageStoreBus } from "../stores/message-v2/bus" import { useI18n } from "../lib/i18n" -import { useScrollCache } from "../lib/hooks/use-scroll-cache" import { copyToClipboard } from "../lib/clipboard" import { showToastNotification } from "../lib/notifications" import { showAlertDialog } from "../stores/alerts" @@ -38,12 +37,13 @@ export interface MessageSectionProps { onRevert?: (messageId: string) => void onDeleteMessagesUpTo?: (messageId: string) => void | Promise onFork?: (messageId?: string) => void - registerScrollToBottom?: (fn: () => void) => void + registerScrollToBottom?: (fn: (() => void) | null) => void showSidebarToggle?: boolean onSidebarToggle?: () => void forceCompactStatusLayout?: boolean onQuoteSelection?: (text: string, mode: "quote" | "code") => void isActive?: boolean + sessionStreamingActive?: boolean } export default function MessageSection(props: MessageSectionProps) { @@ -82,12 +82,6 @@ export default function MessageSection(props: MessageSectionProps) { }) }) - const scrollCache = useScrollCache({ - instanceId: props.instanceId, - sessionId: props.sessionId, - scope: MESSAGE_SCROLL_CACHE_SCOPE, - }) - const sessionRevision = createMemo(() => store().getSessionRevision(props.sessionId)) const usageSnapshot = createMemo(() => store().getSessionUsage(props.sessionId)) const sessionInfo = createMemo(() => @@ -661,39 +655,101 @@ export default function MessageSection(props: MessageSectionProps) { const initialAutoScroll = createMemo(() => initialScrollSnapshot()?.atBottom ?? true) const [didRestoreScroll, setDidRestoreScroll] = createSignal(false) + let lastGoodScrollSnapshot: VirtualFollowScrollSnapshot | undefined + let restoringScrollSnapshot = false createEffect( on( () => props.sessionId, () => { setDidRestoreScroll(false) + lastGoodScrollSnapshot = store().getScrollSnapshot(props.sessionId, MESSAGE_SCROLL_CACHE_SCOPE) + }, + ), + ) + + createEffect( + on( + isActive, + (active, wasActive) => { + if (active) { + if (wasActive === false) { + setDidRestoreScroll(false) + } + return + } + persistMessageScrollSnapshot({ requireActive: false }) }, ), ) + function canCaptureScrollSnapshot(options?: { requireActive?: boolean }) { + const element = streamElement() + if (!element) return false + if ((options?.requireActive ?? true) && !isActive()) return false + if (restoringScrollSnapshot) return false + if (!element.isConnected) return false + if (element.clientHeight <= 0) return false + if (typeof getComputedStyle === "function" && getComputedStyle(element).display === "none") return false + return true + } + + function persistMessageScrollSnapshot(options?: { sessionId?: string; allowCapture?: boolean; requireActive?: boolean }) { + if (restoringScrollSnapshot) return + + const sessionId = options?.sessionId ?? props.sessionId + const allowCapture = options?.allowCapture ?? true + const canCapture = canCaptureScrollSnapshot({ requireActive: options?.requireActive }) + if (allowCapture && canCapture) { + const snapshot = listApi()?.captureScrollSnapshot() + if (snapshot) { + lastGoodScrollSnapshot = snapshot + store().setScrollSnapshot(sessionId, MESSAGE_SCROLL_CACHE_SCOPE, snapshot) + return + } + } + + if (lastGoodScrollSnapshot) { + store().setScrollSnapshot(sessionId, MESSAGE_SCROLL_CACHE_SCOPE, lastGoodScrollSnapshot) + 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 + lastGoodScrollSnapshot = { scrollTop, scrollRatio, maxScrollTop, atBottom } + store().setScrollSnapshot(sessionId, MESSAGE_SCROLL_CACHE_SCOPE, lastGoodScrollSnapshot) + } + // Persist scroll position when switching sessions. This effect's cleanup runs // when `props.sessionId` changes, before the next session is rendered. createEffect(() => { const sessionId = props.sessionId onCleanup(() => { - const element = streamElement() - if (!element) return - const scrollTop = element.scrollTop - const atBottom = element.scrollHeight - (element.scrollTop + element.clientHeight) <= 48 - store().setScrollSnapshot(sessionId, MESSAGE_SCROLL_CACHE_SCOPE, { scrollTop, atBottom }) + persistMessageScrollSnapshot({ sessionId, requireActive: false }) }) }) const [quoteSelection, setQuoteSelection] = createSignal<{ text: string; top: number; left: number } | null>(null) - const lastVisibleMessageId = createMemo(() => { - const ids = visibleMessageIds() - return ids[ids.length - 1] ?? null + const streamingAssistantTextMessageId = createMemo(() => { + const ids = messageIds() + for (let index = ids.length - 1; index >= 0; index -= 1) { + const messageId = ids[index] + if (isStreamingAssistantTextMessage(messageId)) return messageId + } + return null }) + const streamingActive = createMemo(() => Boolean(props.sessionStreamingActive) && streamingAssistantTextMessageId() !== null) + const autoPinHoldTargetKey = createMemo(() => { if (!holdLongAssistantRepliesEnabled()) return null - const messageId = lastVisibleMessageId() - return isStreamingAssistantTextMessage(messageId) ? messageId : null + if (!streamingActive()) return null + return streamingAssistantTextMessageId() }) function toggleHoldLongAssistantReplies() { @@ -708,10 +764,8 @@ export default function MessageSection(props: MessageSectionProps) { if (record.status !== "streaming") return false const info = resolvedStore.getMessageInfo(messageId) - if (!info) return false const timeInfo = info?.time as { end?: number } | undefined - const isStreaming = timeInfo?.end === undefined || timeInfo.end === 0 - if (!isStreaming) return false + if (typeof timeInfo?.end === "number" && timeInfo.end > 0) return false const { orderedParts } = buildRecordDisplayData(props.instanceId, record) return orderedParts.some((part) => { @@ -725,7 +779,8 @@ export default function MessageSection(props: MessageSectionProps) { const api = listApi() if (!api) return if (props.registerScrollToBottom) { - props.registerScrollToBottom(() => api.scrollToBottom({ immediate: true })) + props.registerScrollToBottom(() => api.scrollToBottom({ immediate: true, suppressHold: true })) + onCleanup(() => props.registerScrollToBottom?.(null)) } }) @@ -734,26 +789,38 @@ export default function MessageSection(props: MessageSectionProps) { const element = streamElement() const api = listApi() if (!element || !api) return + if (!isActive()) return if (props.loading) return if (visibleMessageIds().length === 0) return if (didRestoreScroll()) return - scrollCache.restore(element, { + const snapshot = store().getScrollSnapshot(props.sessionId, MESSAGE_SCROLL_CACHE_SCOPE) + if (!snapshot) { + api.setAutoScroll(true) + api.scrollToBottom({ immediate: true }) + setDidRestoreScroll(true) + return + } + + restoringScrollSnapshot = true + api.restoreScrollSnapshot(snapshot, { behavior: "auto", fallback: () => { api.setAutoScroll(true) api.scrollToBottom({ immediate: true }) }, - onApplied: (snapshot) => { + onApplied: () => { // Keep follow mode consistent with the restored state. - api.setAutoScroll(snapshot?.atBottom ?? true) + api.setAutoScroll(snapshot.atBottom) + restoringScrollSnapshot = false + lastGoodScrollSnapshot = snapshot setDidRestoreScroll(true) }, }) }) onCleanup(() => { - scrollCache.persist(streamElement()) + persistMessageScrollSnapshot({ requireActive: false }) }) function clearQuoteSelection() { @@ -865,7 +932,6 @@ export default function MessageSection(props: MessageSectionProps) { } function handleContentRendered() { - if (props.loading) return listApi()?.notifyContentRendered() } @@ -1241,11 +1307,11 @@ export default function MessageSection(props: MessageSectionProps) { items={visibleMessageIds} getKey={(messageId) => messageId} getAnchorId={getMessageAnchorId} - getKeyFromAnchorId={getMessageIdFromAnchorId} overscanPx={800} scrollSentinelMarginPx={SCROLL_SENTINEL_MARGIN_PX} suspendMeasurements={() => !isActive()} - loading={() => Boolean(props.loading)} + streamingActive={streamingActive} + autoPinHoldEnabled={holdLongAssistantRepliesEnabled} isActive={isActive} scrollToBottomOnActivate={() => false} initialScrollToBottom={() => false} @@ -1260,7 +1326,7 @@ export default function MessageSection(props: MessageSectionProps) { }} onScroll={() => { clearQuoteSelection() - scrollCache.persist(streamElement()) + persistMessageScrollSnapshot() }} onMouseUp={() => handleStreamMouseUp()} onClick={(e) => { @@ -1295,6 +1361,7 @@ export default function MessageSection(props: MessageSectionProps) { class="message-scroll-button" data-active={holdLongAssistantRepliesEnabled() ? "true" : "false"} onClick={toggleHoldLongAssistantReplies} + aria-pressed={holdLongAssistantRepliesEnabled()} aria-label={ holdLongAssistantRepliesEnabled() ? t("messageSection.scroll.disableHoldAriaLabel") @@ -1324,7 +1391,7 @@ export default function MessageSection(props: MessageSectionProps) { -