fix(ui): hold auto-follow on oversized assistant replies

This commit is contained in:
Shantur Rathore
2026-04-11 19:28:27 +01:00
parent c9c1cf21f0
commit 2a3329b5ed
3 changed files with 180 additions and 20 deletions

View File

@@ -16,12 +16,14 @@ import { showAlertDialog } from "../stores/alerts"
import { deleteMessage, deleteMessagePart } from "../stores/session-actions"
import type { InstanceMessageStore } from "../stores/message-v2/instance-store"
import type { DeleteHoverState } from "../types/delete-hover"
import { partHasRenderableText } from "../types/message"
import { buildRecordDisplayData } from "../stores/message-v2/record-display-cache"
import { getPartCharCount } from "../lib/token-utils"
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
const codeNomadLogo = new URL("../images/CodeNomad-Icon.png", import.meta.url).href
export interface MessageSectionProps {
@@ -594,7 +596,10 @@ export default function MessageSection(props: MessageSectionProps) {
const [streamElement, setStreamElement] = createSignal<HTMLDivElement | undefined>()
const [streamShellElement, setStreamShellElement] = createSignal<HTMLDivElement | undefined>()
const followToken = createMemo(() => `${sessionRevision()}|${preferenceSignature()}`)
// Only preferences should force a follow-token re-anchor. Message/session
// revision churn at the end of a turn (message.updated, session.idle, etc.)
// should not trigger an immediate scroll-to-bottom.
const followToken = createMemo(() => preferenceSignature())
const initialScrollSnapshot = createMemo(() => store().getScrollSnapshot(props.sessionId, MESSAGE_SCROLL_CACHE_SCOPE))
const initialAutoScroll = createMemo(() => initialScrollSnapshot()?.atBottom ?? true)
@@ -624,6 +629,30 @@ export default function MessageSection(props: MessageSectionProps) {
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 autoPinHoldTargetKey = createMemo(() => {
const messageId = lastVisibleMessageId()
return isAssistantTextMessage(messageId) ? messageId : null
})
function isAssistantTextMessage(messageId: string | null | undefined) {
if (!messageId) return false
const resolvedStore = store()
const record = resolvedStore.getMessage(messageId)
if (!record || record.role !== "assistant") return false
const { orderedParts } = buildRecordDisplayData(props.instanceId, record)
return orderedParts.some((part) => {
if ((part as any)?.type !== "text") return false
if (partHasRenderableText(part)) return true
return typeof (part as { text?: unknown }).text === "string"
})
}
createEffect(() => {
const api = listApi()
if (!api) return
@@ -1044,6 +1073,12 @@ export default function MessageSection(props: MessageSectionProps) {
initialAutoScroll={initialAutoScroll}
resetKey={() => props.sessionId}
followToken={followToken}
autoPinHoldTargetKey={autoPinHoldTargetKey}
autoPinHoldTopThresholdPx={STREAMING_TEXT_HOLD_TOP_THRESHOLD_PX}
resolveAutoPinHoldElement={(itemWrapper, key) => {
const candidates = Array.from(itemWrapper.querySelectorAll<HTMLElement>(`.message-item-base[data-message-id="${key}"][data-message-role="assistant"]`))
return candidates[candidates.length - 1] ?? null
}}
onScroll={() => {
clearQuoteSelection()
scrollCache.persist(streamElement())

View File

@@ -79,11 +79,17 @@ export const SessionView: Component<SessionViewProps> = (props) => {
requestAnimationFrame(() => scrollToBottomHandle?.())
})
}
createEffect(() => {
if (!props.isActive) return
if (!shouldScrollToBottomOnActivate()) return
scheduleScrollToBottom()
})
createEffect(
on(
() => props.isActive,
(isActive, wasActive) => {
if (!isActive) return
if (wasActive === true) return
if (!shouldScrollToBottomOnActivate()) return
scheduleScrollToBottom()
},
),
)
createEffect(
on(
@@ -332,16 +338,11 @@ export const SessionView: Component<SessionViewProps> = (props) => {
loading={messagesLoading()}
onRevert={handleRevert}
onDeleteMessagesUpTo={handleDeleteMessagesUpTo}
onFork={handleFork}
isActive={props.isActive}
registerScrollToBottom={(fn) => {
scrollToBottomHandle = fn
if (props.isActive) {
if (shouldScrollToBottomOnActivate()) {
scheduleScrollToBottom()
}
}
}}
onFork={handleFork}
isActive={props.isActive}
registerScrollToBottom={(fn) => {
scrollToBottomHandle = fn
}}

View File

@@ -2,6 +2,8 @@ import { Show, createEffect, createMemo, createSignal, onCleanup, type Accessor,
import { Virtualizer, type VirtualizerHandle } from "virtua/solid"
const DEFAULT_SCROLL_SENTINEL_MARGIN_PX = 48
const DEFAULT_HOLD_TARGET_TOP_THRESHOLD_PX = 8
const DEFAULT_HOLD_TARGET_TOP_OVERSHOOT_PX = 128
const USER_SCROLL_INTENT_WINDOW_MS = 600
const SCROLL_INTENT_KEYS = new Set(["ArrowUp", "ArrowDown", "PageUp", "PageDown", "Home", "End", " ", "Spacebar"])
@@ -85,6 +87,28 @@ export interface VirtualFollowListProps<T> {
*/
followToken?: Accessor<string | number>
/**
* Optional item key whose geometry can temporarily hold auto-follow when the
* rendered item grows taller than the viewport and reaches the top edge.
*/
autoPinHoldTargetKey?: Accessor<string | null>
/**
* Optional resolver for the specific element inside an item wrapper that
* should be measured for hold-target geometry.
*/
resolveAutoPinHoldElement?: (itemWrapper: HTMLDivElement, key: string) => HTMLElement | null | undefined
/**
* Top-edge threshold for the hold target in pixels.
*/
autoPinHoldTopThresholdPx?: number
/**
* Temporarily suppress automatic bottom pinning while keeping follow mode enabled.
*/
suspendAutoPinToBottom?: Accessor<boolean>
/**
* Optional hooks to render content inside the scroll container.
* Useful for empty/loading states that should scroll with the list.
@@ -130,13 +154,19 @@ export default function VirtualFollowList<T>(props: VirtualFollowListProps<T>) {
const scrollToBottomOnActivate = () => (props.scrollToBottomOnActivate ? props.scrollToBottomOnActivate() : true)
const initialScrollToBottom = () => (props.initialScrollToBottom ? props.initialScrollToBottom() : true)
const initialAutoScroll = () => (props.initialAutoScroll ? props.initialAutoScroll() : true)
const externalSuspendAutoPinToBottom = () => (props.suspendAutoPinToBottom ? props.suspendAutoPinToBottom() : false)
const holdTargetKey = () => (props.autoPinHoldTargetKey ? props.autoPinHoldTargetKey() : null)
const holdTargetTopThresholdPx = () => props.autoPinHoldTopThresholdPx ?? DEFAULT_HOLD_TARGET_TOP_THRESHOLD_PX
const [autoScroll, setAutoScroll] = createSignal(Boolean(initialAutoScroll()))
const [showScrollTopButton, setShowScrollTopButton] = createSignal(false)
const [showScrollBottomButton, setShowScrollBottomButton] = createSignal(false)
const [activeKey, setActiveKey] = createSignal<string | null>(null)
const [heldItemCount, setHeldItemCount] = createSignal<number | null>(null)
const effectiveSuspendAutoPinToBottom = () => externalSuspendAutoPinToBottom() || heldItemCount() !== null
const scrollButtonsCount = createMemo(() => (showScrollTopButton() ? 1 : 0) + (showScrollBottomButton() ? 1 : 0))
const itemElements = new Map<string, HTMLDivElement>()
let userScrollIntentUntil = 0
let lastUserScrollIntentDirection: "up" | "down" | null = null
@@ -220,6 +250,9 @@ export default function VirtualFollowList<T>(props: VirtualFollowListProps<T>) {
// Sync autoScroll state based on scroll position if it was a user scroll
if (hasUserScrollIntent()) {
if (atBottom && heldItemCount() !== null) {
setHeldItemCount(null)
}
if (atBottom && !autoScroll()) {
setAutoScroll(true)
} else if (!atBottom && autoScroll()) {
@@ -253,6 +286,7 @@ export default function VirtualFollowList<T>(props: VirtualFollowListProps<T>) {
}
}
updateScrollButtons()
updateAutoPinHold()
props.onScroll?.()
// Find active key (roughly the first visible item)
@@ -270,6 +304,68 @@ export default function VirtualFollowList<T>(props: VirtualFollowListProps<T>) {
}
}
function registerItemElement(key: string, element: HTMLDivElement | null | undefined) {
if (!element) {
itemElements.delete(key)
return
}
itemElements.set(key, element)
}
function getAnchorIdForKey(key: string) {
return props.getAnchorId ? props.getAnchorId(key) : key
}
function updateAutoPinHold() {
const element = scrollElement()
const itemCount = props.items().length
const heldCount = heldItemCount()
if (!element) return
if (heldCount !== null) {
if (itemCount > heldCount) {
setHeldItemCount(null)
if (autoScroll()) {
requestAnimationFrame(() => {
if (!autoScroll()) return
scrollToBottom(false)
})
}
return
}
if (itemCount < heldCount) {
setHeldItemCount(null)
return
}
return
}
if (!autoScroll()) return
if (externalSuspendAutoPinToBottom()) return
const targetKey = holdTargetKey()
if (!targetKey) return
const itemWrapper = itemElements.get(targetKey)
if (!itemWrapper) return
const target = props.resolveAutoPinHoldElement?.(itemWrapper, targetKey) ?? itemWrapper
const containerRect = element.getBoundingClientRect()
const targetRect = target.getBoundingClientRect()
const relativeTop = targetRect.top - containerRect.top
const exceedsViewport = targetRect.height > element.clientHeight
if (
exceedsViewport &&
relativeTop <= holdTargetTopThresholdPx() &&
relativeTop >= holdTargetTopThresholdPx() - DEFAULT_HOLD_TARGET_TOP_OVERSHOOT_PX
) {
setHeldItemCount(itemCount)
}
}
const api: VirtualFollowListApi = {
scrollToTop: (opts) => scrollToTop(opts?.immediate ?? true),
scrollToBottom: (opts) => scrollToBottom(opts?.immediate ?? true, { suppressAutoAnchor: opts?.suppressAutoAnchor }),
@@ -281,7 +377,9 @@ export default function VirtualFollowList<T>(props: VirtualFollowListProps<T>) {
virtuaHandle()?.scrollToIndex(index, { align: opts?.block ?? "start", smooth: opts?.behavior === "smooth" })
},
notifyContentRendered: () => {
if (autoScroll()) {
updateAutoPinHold()
if (heldItemCount() !== null) return
if (autoScroll() && !effectiveSuspendAutoPinToBottom()) {
scrollToBottom(true)
}
},
@@ -294,9 +392,15 @@ export default function VirtualFollowList<T>(props: VirtualFollowListProps<T>) {
createEffect(() => props.registerApi?.(api))
createEffect(() => props.registerState?.(state))
createEffect(on(() => props.resetKey?.(), () => {
itemElements.clear()
setHeldItemCount(null)
}))
// Handle autoScroll (Follow) on items change
createEffect(on(() => props.items().length, (len, prevLen) => {
if (len > (prevLen ?? 0) && autoScroll() && !suppressAutoScrollOnce) {
updateAutoPinHold()
if (len > (prevLen ?? 0) && autoScroll() && !effectiveSuspendAutoPinToBottom() && !suppressAutoScrollOnce) {
requestAnimationFrame(() => scrollToBottom(true))
}
suppressAutoScrollOnce = false
@@ -304,11 +408,16 @@ export default function VirtualFollowList<T>(props: VirtualFollowListProps<T>) {
// Handle followToken change
createEffect(on(() => props.followToken?.(), () => {
if (autoScroll()) {
updateAutoPinHold()
if (autoScroll() && !effectiveSuspendAutoPinToBottom()) {
scrollToBottom(true)
}
}, { defer: true }))
createEffect(on(() => holdTargetKey(), () => {
updateAutoPinHold()
}, { defer: true }))
// Reset state on resetKey change
createEffect(on(() => props.resetKey?.(), (nextKey) => {
if (nextKey === lastResetKey) return
@@ -331,6 +440,13 @@ export default function VirtualFollowList<T>(props: VirtualFollowListProps<T>) {
}
})
createEffect(() => {
if (typeof window === "undefined") return
const handleResize = () => updateAutoPinHold()
window.addEventListener("resize", handleResize)
onCleanup(() => window.removeEventListener("resize", handleResize))
})
return (
<div class="virtual-follow-list-shell" ref={shellElement => {
setShellElement(shellElement)
@@ -356,7 +472,15 @@ export default function VirtualFollowList<T>(props: VirtualFollowListProps<T>) {
bufferSize={props.overscanPx ?? 400}
onScroll={handleScroll}
>
{(item, index) => props.renderItem(item, index())}
{(item, index) => {
const key = props.getKey(item, index())
const anchorId = getAnchorIdForKey(key)
return (
<div id={anchorId} data-virtual-follow-key={key} ref={(element) => registerItemElement(key, element)}>
{props.renderItem(item, index())}
</div>
)
}}
</Virtualizer>
</div>