fix(ui): stabilize long reply hold during streaming

This commit is contained in:
Shantur Rathore
2026-04-19 19:56:48 +01:00
parent b00aa7ef84
commit 623a09fd7e
3 changed files with 47 additions and 44 deletions

View File

@@ -638,18 +638,25 @@ export default function MessageSection(props: MessageSectionProps) {
const autoPinHoldTargetKey = createMemo(() => { const autoPinHoldTargetKey = createMemo(() => {
if (!holdLongAssistantRepliesEnabled()) return null if (!holdLongAssistantRepliesEnabled()) return null
const messageId = lastVisibleMessageId() const messageId = lastVisibleMessageId()
return isAssistantTextMessage(messageId) ? messageId : null return isStreamingAssistantTextMessage(messageId) ? messageId : null
}) })
function toggleHoldLongAssistantReplies() { function toggleHoldLongAssistantReplies() {
updatePreferences({ holdLongAssistantReplies: !holdLongAssistantRepliesEnabled() }) updatePreferences({ holdLongAssistantReplies: !holdLongAssistantRepliesEnabled() })
} }
function isAssistantTextMessage(messageId: string | null | undefined) { function isStreamingAssistantTextMessage(messageId: string | null | undefined) {
if (!messageId) return false if (!messageId) return false
const resolvedStore = store() const resolvedStore = store()
const record = resolvedStore.getMessage(messageId) const record = resolvedStore.getMessage(messageId)
if (!record || record.role !== "assistant") return false if (!record || record.role !== "assistant") return false
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
const { orderedParts } = buildRecordDisplayData(props.instanceId, record) const { orderedParts } = buildRecordDisplayData(props.instanceId, record)
return orderedParts.some((part) => { return orderedParts.some((part) => {

View File

@@ -161,8 +161,9 @@ export default function VirtualFollowList<T>(props: VirtualFollowListProps<T>) {
const [showScrollTopButton, setShowScrollTopButton] = createSignal(false) const [showScrollTopButton, setShowScrollTopButton] = createSignal(false)
const [showScrollBottomButton, setShowScrollBottomButton] = createSignal(false) const [showScrollBottomButton, setShowScrollBottomButton] = createSignal(false)
const [activeKey, setActiveKey] = createSignal<string | null>(null) const [activeKey, setActiveKey] = createSignal<string | null>(null)
const [heldItemCount, setHeldItemCount] = createSignal<number | null>(null) const [activeHoldTargetKey, setActiveHoldTargetKey] = createSignal<string | null>(null)
const effectiveSuspendAutoPinToBottom = () => externalSuspendAutoPinToBottom() || heldItemCount() !== null const [didTriggerHoldForCurrentTarget, setDidTriggerHoldForCurrentTarget] = createSignal(false)
const effectiveSuspendAutoPinToBottom = () => externalSuspendAutoPinToBottom() || activeHoldTargetKey() !== null
const scrollButtonsCount = createMemo(() => (showScrollTopButton() ? 1 : 0) + (showScrollBottomButton() ? 1 : 0)) const scrollButtonsCount = createMemo(() => (showScrollTopButton() ? 1 : 0) + (showScrollBottomButton() ? 1 : 0))
const itemElements = new Map<string, HTMLDivElement>() const itemElements = new Map<string, HTMLDivElement>()
@@ -196,6 +197,17 @@ export default function VirtualFollowList<T>(props: VirtualFollowListProps<T>) {
return performance.now() <= userScrollIntentUntil return performance.now() <= userScrollIntentUntil
} }
function clearAutoPinHold(options?: { resumeBottom?: boolean }) {
if (activeHoldTargetKey() === null) return
setActiveHoldTargetKey(null)
if (options?.resumeBottom && autoScroll()) {
requestAnimationFrame(() => {
if (!autoScroll() || activeHoldTargetKey() !== null) return
scrollToBottom(false)
})
}
}
function attachScrollIntentListeners(element: HTMLDivElement | undefined) { function attachScrollIntentListeners(element: HTMLDivElement | undefined) {
if (detachScrollIntentListeners) { if (detachScrollIntentListeners) {
detachScrollIntentListeners() detachScrollIntentListeners()
@@ -257,7 +269,7 @@ export default function VirtualFollowList<T>(props: VirtualFollowListProps<T>) {
// scrollbar). If follow mode stays enabled, the next render notification // scrollbar). If follow mode stays enabled, the next render notification
// snaps the list straight back to bottom. A real upward viewport move away // snaps the list straight back to bottom. A real upward viewport move away
// from bottom should always break follow unless a hold target is active. // from bottom should always break follow unless a hold target is active.
if (wasPinnedAtBottom && scrolledUp && autoScroll() && !atBottom && heldItemCount() === null) { if (wasPinnedAtBottom && scrolledUp && autoScroll() && !atBottom && activeHoldTargetKey() === null) {
setAutoScroll(false) setAutoScroll(false)
lastObservedPinnedAtBottom = false lastObservedPinnedAtBottom = false
return return
@@ -265,9 +277,7 @@ export default function VirtualFollowList<T>(props: VirtualFollowListProps<T>) {
// Sync autoScroll state based on scroll position if it was a user scroll // Sync autoScroll state based on scroll position if it was a user scroll
if (hasUserScrollIntent()) { if (hasUserScrollIntent()) {
if (atBottom && heldItemCount() !== null) { clearAutoPinHold()
setHeldItemCount(null)
}
if (atBottom && !autoScroll()) { if (atBottom && !autoScroll()) {
setAutoScroll(true) setAutoScroll(true)
} else if (!atBottom && autoScroll()) { } else if (!atBottom && autoScroll()) {
@@ -303,7 +313,6 @@ export default function VirtualFollowList<T>(props: VirtualFollowListProps<T>) {
} }
} }
updateScrollButtons() updateScrollButtons()
updateAutoPinHold()
props.onScroll?.() props.onScroll?.()
// Find active key (roughly the first visible item) // Find active key (roughly the first visible item)
@@ -335,25 +344,14 @@ export default function VirtualFollowList<T>(props: VirtualFollowListProps<T>) {
function updateAutoPinHold() { function updateAutoPinHold() {
const element = scrollElement() const element = scrollElement()
const itemCount = props.items().length
const heldCount = heldItemCount()
if (!element) return if (!element) return
if (heldCount !== null) { const targetKey = holdTargetKey()
if (itemCount > heldCount) { const heldKey = activeHoldTargetKey()
setHeldItemCount(null)
if (autoScroll()) {
requestAnimationFrame(() => {
if (!autoScroll()) return
scrollToBottom(false)
})
}
return
}
if (itemCount < heldCount) { if (heldKey !== null) {
setHeldItemCount(null) if (targetKey !== heldKey) {
return clearAutoPinHold({ resumeBottom: true })
} }
return return
@@ -361,9 +359,8 @@ export default function VirtualFollowList<T>(props: VirtualFollowListProps<T>) {
if (!autoScroll()) return if (!autoScroll()) return
if (externalSuspendAutoPinToBottom()) return if (externalSuspendAutoPinToBottom()) return
const targetKey = holdTargetKey()
if (!targetKey) return if (!targetKey) return
if (didTriggerHoldForCurrentTarget()) return
const itemWrapper = itemElements.get(targetKey) const itemWrapper = itemElements.get(targetKey)
if (!itemWrapper) return if (!itemWrapper) return
@@ -379,7 +376,8 @@ export default function VirtualFollowList<T>(props: VirtualFollowListProps<T>) {
if (Math.abs(alignDelta) > 1) { if (Math.abs(alignDelta) > 1) {
element.scrollTop = Math.max(0, element.scrollTop + alignDelta) element.scrollTop = Math.max(0, element.scrollTop + alignDelta)
} }
setHeldItemCount(itemCount) setActiveHoldTargetKey(targetKey)
setDidTriggerHoldForCurrentTarget(true)
} }
} }
@@ -395,7 +393,7 @@ export default function VirtualFollowList<T>(props: VirtualFollowListProps<T>) {
}, },
notifyContentRendered: () => { notifyContentRendered: () => {
updateAutoPinHold() updateAutoPinHold()
if (heldItemCount() !== null) return if (activeHoldTargetKey() !== null) return
if (autoScroll() && !effectiveSuspendAutoPinToBottom()) { if (autoScroll() && !effectiveSuspendAutoPinToBottom()) {
scrollToBottom(true) scrollToBottom(true)
} }
@@ -411,14 +409,23 @@ export default function VirtualFollowList<T>(props: VirtualFollowListProps<T>) {
createEffect(on(() => props.resetKey?.(), () => { createEffect(on(() => props.resetKey?.(), () => {
itemElements.clear() itemElements.clear()
setHeldItemCount(null) setActiveHoldTargetKey(null)
setDidTriggerHoldForCurrentTarget(false)
lastObservedScrollOffset = 0 lastObservedScrollOffset = 0
lastObservedPinnedAtBottom = false lastObservedPinnedAtBottom = false
})) }))
createEffect(on(holdTargetKey, (nextTargetKey, prevTargetKey) => {
if (nextTargetKey !== prevTargetKey && didTriggerHoldForCurrentTarget()) {
setDidTriggerHoldForCurrentTarget(false)
}
if (activeHoldTargetKey() === null) return
if (nextTargetKey === activeHoldTargetKey()) return
clearAutoPinHold({ resumeBottom: true })
}, { defer: true }))
// Handle autoScroll (Follow) on items change // Handle autoScroll (Follow) on items change
createEffect(on(() => props.items().length, (len, prevLen) => { createEffect(on(() => props.items().length, (len, prevLen) => {
updateAutoPinHold()
if (len > (prevLen ?? 0) && autoScroll() && !effectiveSuspendAutoPinToBottom() && !suppressAutoScrollOnce) { if (len > (prevLen ?? 0) && autoScroll() && !effectiveSuspendAutoPinToBottom() && !suppressAutoScrollOnce) {
requestAnimationFrame(() => scrollToBottom(true)) requestAnimationFrame(() => scrollToBottom(true))
} }
@@ -427,16 +434,11 @@ export default function VirtualFollowList<T>(props: VirtualFollowListProps<T>) {
// Handle followToken change // Handle followToken change
createEffect(on(() => props.followToken?.(), () => { createEffect(on(() => props.followToken?.(), () => {
updateAutoPinHold()
if (autoScroll() && !effectiveSuspendAutoPinToBottom()) { if (autoScroll() && !effectiveSuspendAutoPinToBottom()) {
scrollToBottom(true) scrollToBottom(true)
} }
}, { defer: true })) }, { defer: true }))
createEffect(on(() => holdTargetKey(), () => {
updateAutoPinHold()
}, { defer: true }))
// Reset state on resetKey change // Reset state on resetKey change
createEffect(on(() => props.resetKey?.(), (nextKey) => { createEffect(on(() => props.resetKey?.(), (nextKey) => {
if (nextKey === lastResetKey) return if (nextKey === lastResetKey) return
@@ -459,13 +461,6 @@ 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 ( return (
<div class="virtual-follow-list-shell" ref={shellElement => { <div class="virtual-follow-list-shell" ref={shellElement => {
setShellElement(shellElement) setShellElement(shellElement)

View File

@@ -397,7 +397,8 @@ function handleMessageUpdate(instanceId: string, event: MessageUpdateEvent | Mes
const role: MessageRole = info.role === "user" ? "user" : "assistant" const role: MessageRole = info.role === "user" ? "user" : "assistant"
const hasError = Boolean((info as any).error) const hasError = Boolean((info as any).error)
const status: MessageStatus = hasError ? "error" : "complete" const hasEnded = typeof timeInfo.end === "number" && timeInfo.end > 0
const status: MessageStatus = hasError ? "error" : hasEnded ? "complete" : "streaming"
let record = store.getMessage(messageId) let record = store.getMessage(messageId)
if (!record) { if (!record) {