Improve message stream auto-scroll during streaming

This commit is contained in:
Shantur Rathore
2025-12-01 19:30:14 +00:00
parent fd23ea54b6
commit e91923ad99
7 changed files with 145 additions and 76 deletions

View File

@@ -15,9 +15,11 @@ interface MessageItemProps {
onRevert?: (messageId: string) => void
onFork?: (messageId?: string) => void
showAgentMeta?: boolean
}
onContentRendered?: () => void
}
export default function MessageItem(props: MessageItemProps) {
export default function MessageItem(props: MessageItemProps) {
const isUser = () => props.record.role === "user"
const timestamp = () => {
const createdTime = props.messageInfo?.time?.created ?? props.record.createdAt
@@ -234,6 +236,7 @@ export default function MessageItem(props: MessageItemProps) {
messageType={props.record.role}
instanceId={props.instanceId}
sessionId={props.sessionId}
onRendered={props.onContentRendered}
/>
)}
</For>

View File

@@ -13,8 +13,10 @@ interface MessagePartProps {
messageType?: "user" | "assistant"
instanceId: string
sessionId: string
}
export default function MessagePart(props: MessagePartProps) {
onRendered?: () => void
}
export default function MessagePart(props: MessagePartProps) {
const { isDark } = useTheme()
const { preferences } = useConfig()
const partType = () => props.part?.type || ""
@@ -95,11 +97,17 @@ export default function MessagePart(props: MessagePartProps) {
<Show when={!(props.part.type === "text" && props.part.synthetic) && partHasRenderableText(props.part)}>
<div class={textContainerClass()}>
<Show
when={isAssistantMessage()}
fallback={<span>{plainTextContent()}</span>}
>
<Markdown part={createTextPartForMarkdown()} isDark={isDark()} size={isAssistantMessage() ? "tight" : "base"} />
</Show>
when={isAssistantMessage()}
fallback={<span>{plainTextContent()}</span>}
>
<Markdown
part={createTextPartForMarkdown()}
isDark={isDark()}
size={isAssistantMessage() ? "tight" : "base"}
onRendered={props.onRendered}
/>
</Show>
</div>
</Show>
</Match>

View File

@@ -17,7 +17,6 @@ import { useScrollCache } from "../lib/hooks/use-scroll-cache"
import { setActiveInstanceId } from "../stores/instances"
const SCROLL_SCOPE = "session"
const SCROLL_DIRECTION_THRESHOLD = 10
const USER_SCROLL_INTENT_WINDOW_MS = 600
const SCROLL_INTENT_KEYS = new Set(["ArrowUp", "ArrowDown", "PageUp", "PageDown", "Home", "End", " ", "Spacebar"])
@@ -174,6 +173,7 @@ interface MessageStreamV2Props {
loading?: boolean
onRevert?: (messageId: string) => void
onFork?: (messageId?: string) => void
registerScrollToBottom?: (fn: () => void) => void
}
interface ContentDisplayItem {
@@ -284,15 +284,10 @@ export default function MessageStreamV2(props: MessageStreamV2Props) {
})
const changeToken = createMemo(() => {
const revisionValue = sessionRevision()
const ids = messageIds()
if (ids.length === 0) {
return `${revisionValue}:empty`
}
const lastId = ids[ids.length - 1]
const lastRecord = store().getMessage(lastId)
const tailSignature = lastRecord ? `msg:${lastRecord.id}:${lastRecord.revision}` : `msg:${lastId}:missing`
return `${revisionValue}:${tailSignature}`
// Any change that can affect layout (new message, part update, revert,
// etc.) should bump the session revision. We use this as the primary
// signal for auto-scroll decisions.
return String(sessionRevision())
})
const scrollCache = useScrollCache({
@@ -312,6 +307,10 @@ export default function MessageStreamV2(props: MessageStreamV2Props) {
let detachScrollIntentListeners: (() => void) | undefined
let hasRestoredScroll = false
let hasInitialScroll = false
// When the user explicitly clicks "scroll to bottom", we want the
// smooth scroll animation to complete without being immediately
// overridden by the auto-scroll effects that react to new messages.
let suppressAutoScrollOnce = false
function markUserScrollIntent() {
const now = typeof performance !== "undefined" ? performance.now() : Date.now()
@@ -372,15 +371,18 @@ export default function MessageStreamV2(props: MessageStreamV2Props) {
function scrollToBottom(immediate = false) {
if (!containerRef) return
const behavior = immediate ? "auto" : "smooth"
requestAnimationFrame(() => {
if (!containerRef) return
containerRef.scrollTo({ top: containerRef.scrollHeight, behavior })
setAutoScroll(true)
lastMeasuredScrollHeight = containerRef.scrollHeight
lastKnownScrollTop = containerRef.scrollTop
updateScrollIndicators(containerRef)
scheduleScrollPersist()
})
if (!immediate) {
// We initiated this scroll (e.g., via the button). Skip the
// next auto-scroll reaction so the smooth animation isn't
// overridden by changeToken/preference effects.
suppressAutoScrollOnce = true
}
containerRef.scrollTo({ top: containerRef.scrollHeight, behavior })
setAutoScroll(true)
lastMeasuredScrollHeight = containerRef.scrollHeight
lastKnownScrollTop = containerRef.scrollTop
updateScrollIndicators(containerRef)
scheduleScrollPersist()
}
function scrollToBottomAndClamp(immediate = false) {
@@ -396,17 +398,28 @@ export default function MessageStreamV2(props: MessageStreamV2Props) {
if (!containerRef) return
const behavior = immediate ? "auto" : "smooth"
setAutoScroll(false)
requestAnimationFrame(() => {
if (!containerRef) return
containerRef.scrollTo({ top: 0, behavior })
lastMeasuredScrollHeight = containerRef.scrollHeight
lastKnownScrollTop = containerRef.scrollTop
updateScrollIndicators(containerRef)
scheduleScrollPersist()
})
containerRef.scrollTo({ top: 0, behavior })
lastMeasuredScrollHeight = containerRef.scrollHeight
lastKnownScrollTop = containerRef.scrollTop
updateScrollIndicators(containerRef)
scheduleScrollPersist()
}
function handleContentRendered() {
if (!containerRef) return
if (!autoScroll()) return
scrollToBottomAndClamp(true)
}
createEffect(() => {
if (props.registerScrollToBottom) {
props.registerScrollToBottom(() => scrollToBottomAndClamp(true))
}
})
let pendingScrollPersist: number | null = null
let pendingScrollPersist: number | null = null
function scheduleScrollPersist() {
if (pendingScrollPersist !== null) return
pendingScrollPersist = requestAnimationFrame(() => {
@@ -439,20 +452,22 @@ export default function MessageStreamV2(props: MessageStreamV2Props) {
pendingScrollFrame = requestAnimationFrame(() => {
pendingScrollFrame = null
if (!containerRef) return
const previousTop = lastKnownScrollTop
const currentTop = containerRef.scrollTop
const movingUp = currentTop < previousTop - SCROLL_DIRECTION_THRESHOLD
const movingDown = currentTop > previousTop + SCROLL_DIRECTION_THRESHOLD
lastKnownScrollTop = currentTop
lastMeasuredScrollHeight = containerRef.scrollHeight
const atBottom = isNearBottom(containerRef)
if (isUserScroll) {
if (movingUp && !atBottom && autoScroll()) {
setAutoScroll(false)
} else if (movingDown && atBottom && !autoScroll()) {
setAutoScroll(true)
// If the user scrolls and ends near the bottom, enable auto-scroll.
// If they scroll away from the bottom by more than our threshold,
// disable auto-scroll until they explicitly return.
if (atBottom) {
if (!autoScroll()) setAutoScroll(true)
} else {
if (autoScroll()) setAutoScroll(false)
}
}
updateScrollIndicators(containerRef)
scheduleScrollPersist()
})
@@ -465,7 +480,7 @@ export default function MessageStreamV2(props: MessageStreamV2Props) {
if (!target) return
if (loading) return
if (hasRestoredScroll) return
scrollCache.restore(target, {
onApplied: (snapshot) => {
if (snapshot) {
@@ -478,12 +493,12 @@ export default function MessageStreamV2(props: MessageStreamV2Props) {
updateScrollIndicators(target)
},
})
hasRestoredScroll = true
})
let previousToken: string | undefined
createEffect(() => {
const token = changeToken()
const loading = props.loading
@@ -493,19 +508,28 @@ export default function MessageStreamV2(props: MessageStreamV2Props) {
return
}
previousToken = token
if (suppressAutoScrollOnce) {
suppressAutoScrollOnce = false
return
}
if (autoScroll()) {
scrollToBottomAndClamp(true)
}
})
createEffect(() => {
preferenceSignature()
if (props.loading) return
if (!autoScroll()) {
return
}
if (suppressAutoScrollOnce) {
suppressAutoScrollOnce = false
return
}
scrollToBottomAndClamp(true)
})
createEffect(() => {
if (messageIds().length === 0) {
@@ -621,18 +645,21 @@ export default function MessageStreamV2(props: MessageStreamV2Props) {
<Index each={messageIds()}>
{(messageId) => (
<MessageBlock
messageId={messageId()}
instanceId={props.instanceId}
sessionId={props.sessionId}
store={store}
messageIndexMap={messageIndexMap}
lastAssistantIndex={lastAssistantIndex}
showThinking={() => preferences().showThinkingBlocks}
thinkingDefaultExpanded={() => (preferences().thinkingBlocksExpansion ?? "expanded") === "expanded"}
showUsageMetrics={showUsagePreference}
onRevert={props.onRevert}
onFork={props.onFork}
/>
messageId={messageId()}
instanceId={props.instanceId}
sessionId={props.sessionId}
store={store}
messageIndexMap={messageIndexMap}
lastAssistantIndex={lastAssistantIndex}
showThinking={() => preferences().showThinkingBlocks}
thinkingDefaultExpanded={() => (preferences().thinkingBlocksExpansion ?? "expanded") === "expanded"}
showUsageMetrics={showUsagePreference}
onRevert={props.onRevert}
onFork={props.onFork}
onContentRendered={handleContentRendered}
/>
)}
</Index>
</div>
@@ -681,8 +708,10 @@ interface MessageBlockProps {
showUsageMetrics: () => boolean
onRevert?: (messageId: string) => void
onFork?: (messageId?: string) => void
onContentRendered?: () => void
}
function MessageBlock(props: MessageBlockProps) {
const record = createMemo(() => props.store().getMessage(props.messageId))
const messageInfo = createMemo(() => props.store().getMessageInfo(props.messageId))
@@ -882,7 +911,9 @@ function MessageBlock(props: MessageBlockProps) {
showAgentMeta={(item as ContentDisplayItem).showAgentMeta}
onRevert={props.onRevert}
onFork={props.onFork}
onContentRendered={props.onContentRendered}
/>
</Match>
<Match when={item.type === "tool"}>
{(() => {

View File

@@ -25,8 +25,10 @@ export const SessionView: Component<SessionViewProps> = (props) => {
const session = () => props.activeSessions.get(props.sessionId)
const messagesLoading = createMemo(() => isSessionMessagesLoading(props.instanceId, props.sessionId))
const messageStore = createMemo(() => messageStoreBus.getOrCreate(props.instanceId))
let scrollToBottomHandle: (() => void) | undefined
createEffect(() => {
createEffect(() => {
const currentSession = session()
if (currentSession) {
loadMessages(props.instanceId, currentSession.id).catch(console.error)
@@ -34,6 +36,9 @@ export const SessionView: Component<SessionViewProps> = (props) => {
})
async function handleSendMessage(prompt: string, attachments: Attachment[]) {
if (scrollToBottomHandle) {
scrollToBottomHandle()
}
await sendMessage(props.instanceId, props.sessionId, prompt, attachments)
}
@@ -137,12 +142,16 @@ export const SessionView: Component<SessionViewProps> = (props) => {
return (
<div class="session-view">
<MessageStreamV2
instanceId={props.instanceId}
sessionId={activeSession.id}
loading={messagesLoading()}
onRevert={handleRevert}
onFork={handleFork}
/>
instanceId={props.instanceId}
sessionId={activeSession.id}
loading={messagesLoading()}
onRevert={handleRevert}
onFork={handleFork}
registerScrollToBottom={(fn) => {
scrollToBottomHandle = fn
}}
/>
<PromptInput
instanceId={props.instanceId}