fix(ui): stabilize long reply hold during streaming
This commit is contained in:
@@ -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) => {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
Reference in New Issue
Block a user