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}

View File

@@ -618,6 +618,13 @@ function mutateToolPartPermission(
draft.updatedAt = Date.now()
}),
)
// Permission attachment/removal can change the rendered height of the
// message list (e.g., permission blocks or diffs), so bump the
// session revision to ensure auto-scroll reacts.
if (messageRecord.sessionId) {
store.setState("sessionRevisions", messageRecord.sessionId, (value: number = 0) => value + 1)
}
}
function attachPermissionToToolPart(instanceId: string, permission: Permission, active: boolean): void {

View File

@@ -439,10 +439,10 @@ export function createInstanceMessageStore(instanceId: string): InstanceMessageS
bufferPendingPart({ messageId: input.messageId, part: input.part, receivedAt: Date.now() })
return
}
const partId = ensurePartId(input.messageId, input.part, message.partIds.length)
const cloned = clonePart(input.part)
setState(
"messages",
input.messageId,
@@ -463,8 +463,13 @@ export function createInstanceMessageStore(instanceId: string): InstanceMessageS
}
}),
)
// Any part update can change the rendered height of the message
// list, so we treat it as a session revision for scroll purposes.
bumpSessionRevision(message.sessionId)
}
function flushPendingParts(messageId: string) {
const pending = state.pendingParts[messageId]
if (!pending || pending.length === 0) {

View File

@@ -81,20 +81,25 @@ function handleMessageUpdate(instanceId: string, event: MessageUpdateEvent | Mes
if (event.type === "message.part.updated") {
const rawPart = event.properties?.part
if (!rawPart) return
const part = normalizeMessagePart(rawPart)
const sessionId = typeof part.sessionID === "string" ? part.sessionID : undefined
const messageId = typeof part.messageID === "string" ? part.messageID : undefined
const messageInfo = (event as any)?.properties?.message as MessageInfo | undefined
const fallbackSessionId = typeof messageInfo?.sessionID === "string" ? messageInfo.sessionID : undefined
const fallbackMessageId = typeof messageInfo?.id === "string" ? messageInfo.id : undefined
const sessionId = typeof part.sessionID === "string" ? part.sessionID : fallbackSessionId
const messageId = typeof part.messageID === "string" ? part.messageID : fallbackMessageId
if (!sessionId || !messageId) return
const session = instanceSessions.get(sessionId)
if (!session) return
const store = messageStoreBus.getOrCreate(instanceId)
const messageInfo = (event as any)?.properties?.message as MessageInfo | undefined
const role: MessageRole = resolveMessageRole(messageInfo)
const createdAt = typeof messageInfo?.time?.created === "number" ? messageInfo.time.created : Date.now()
let record = store.getMessage(messageId)
if (!record) {
const pendingId = findPendingMessageId(store, sessionId, role)
@@ -119,8 +124,9 @@ function handleMessageUpdate(instanceId: string, event: MessageUpdateEvent | Mes
if (messageInfo) {
upsertMessageInfoV2(instanceId, messageInfo, { status: "streaming" })
}
applyPartUpdateV2(instanceId, { ...part, sessionID: sessionId, messageID: messageId })
applyPartUpdateV2(instanceId, part)
updateSessionInfo(instanceId, sessionId)
refreshPermissionsForSession(instanceId, sessionId)