fix(ui): hold auto-follow on oversized assistant replies
This commit is contained in:
@@ -16,12 +16,14 @@ import { showAlertDialog } from "../stores/alerts"
|
|||||||
import { deleteMessage, deleteMessagePart } from "../stores/session-actions"
|
import { deleteMessage, deleteMessagePart } from "../stores/session-actions"
|
||||||
import type { InstanceMessageStore } from "../stores/message-v2/instance-store"
|
import type { InstanceMessageStore } from "../stores/message-v2/instance-store"
|
||||||
import type { DeleteHoverState } from "../types/delete-hover"
|
import type { DeleteHoverState } from "../types/delete-hover"
|
||||||
|
import { partHasRenderableText } from "../types/message"
|
||||||
import { buildRecordDisplayData } from "../stores/message-v2/record-display-cache"
|
import { buildRecordDisplayData } from "../stores/message-v2/record-display-cache"
|
||||||
import { getPartCharCount } from "../lib/token-utils"
|
import { getPartCharCount } from "../lib/token-utils"
|
||||||
|
|
||||||
const SCROLL_SENTINEL_MARGIN_PX = 8
|
const SCROLL_SENTINEL_MARGIN_PX = 8
|
||||||
const MESSAGE_SCROLL_CACHE_SCOPE = "message-stream"
|
const MESSAGE_SCROLL_CACHE_SCOPE = "message-stream"
|
||||||
const QUOTE_SELECTION_MAX_LENGTH = 2000
|
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
|
const codeNomadLogo = new URL("../images/CodeNomad-Icon.png", import.meta.url).href
|
||||||
|
|
||||||
export interface MessageSectionProps {
|
export interface MessageSectionProps {
|
||||||
@@ -594,7 +596,10 @@ export default function MessageSection(props: MessageSectionProps) {
|
|||||||
const [streamElement, setStreamElement] = createSignal<HTMLDivElement | undefined>()
|
const [streamElement, setStreamElement] = createSignal<HTMLDivElement | undefined>()
|
||||||
const [streamShellElement, setStreamShellElement] = 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 initialScrollSnapshot = createMemo(() => store().getScrollSnapshot(props.sessionId, MESSAGE_SCROLL_CACHE_SCOPE))
|
||||||
const initialAutoScroll = createMemo(() => initialScrollSnapshot()?.atBottom ?? true)
|
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 [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(() => {
|
createEffect(() => {
|
||||||
const api = listApi()
|
const api = listApi()
|
||||||
if (!api) return
|
if (!api) return
|
||||||
@@ -1044,6 +1073,12 @@ export default function MessageSection(props: MessageSectionProps) {
|
|||||||
initialAutoScroll={initialAutoScroll}
|
initialAutoScroll={initialAutoScroll}
|
||||||
resetKey={() => props.sessionId}
|
resetKey={() => props.sessionId}
|
||||||
followToken={followToken}
|
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={() => {
|
onScroll={() => {
|
||||||
clearQuoteSelection()
|
clearQuoteSelection()
|
||||||
scrollCache.persist(streamElement())
|
scrollCache.persist(streamElement())
|
||||||
|
|||||||
@@ -79,11 +79,17 @@ export const SessionView: Component<SessionViewProps> = (props) => {
|
|||||||
requestAnimationFrame(() => scrollToBottomHandle?.())
|
requestAnimationFrame(() => scrollToBottomHandle?.())
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
createEffect(() => {
|
createEffect(
|
||||||
if (!props.isActive) return
|
on(
|
||||||
if (!shouldScrollToBottomOnActivate()) return
|
() => props.isActive,
|
||||||
scheduleScrollToBottom()
|
(isActive, wasActive) => {
|
||||||
})
|
if (!isActive) return
|
||||||
|
if (wasActive === true) return
|
||||||
|
if (!shouldScrollToBottomOnActivate()) return
|
||||||
|
scheduleScrollToBottom()
|
||||||
|
},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
createEffect(
|
createEffect(
|
||||||
on(
|
on(
|
||||||
@@ -332,16 +338,11 @@ export const SessionView: Component<SessionViewProps> = (props) => {
|
|||||||
loading={messagesLoading()}
|
loading={messagesLoading()}
|
||||||
onRevert={handleRevert}
|
onRevert={handleRevert}
|
||||||
onDeleteMessagesUpTo={handleDeleteMessagesUpTo}
|
onDeleteMessagesUpTo={handleDeleteMessagesUpTo}
|
||||||
onFork={handleFork}
|
onFork={handleFork}
|
||||||
isActive={props.isActive}
|
isActive={props.isActive}
|
||||||
registerScrollToBottom={(fn) => {
|
registerScrollToBottom={(fn) => {
|
||||||
scrollToBottomHandle = fn
|
scrollToBottomHandle = fn
|
||||||
if (props.isActive) {
|
}}
|
||||||
if (shouldScrollToBottomOnActivate()) {
|
|
||||||
scheduleScrollToBottom()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ import { Show, createEffect, createMemo, createSignal, onCleanup, type Accessor,
|
|||||||
import { Virtualizer, type VirtualizerHandle } from "virtua/solid"
|
import { Virtualizer, type VirtualizerHandle } from "virtua/solid"
|
||||||
|
|
||||||
const DEFAULT_SCROLL_SENTINEL_MARGIN_PX = 48
|
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 USER_SCROLL_INTENT_WINDOW_MS = 600
|
||||||
const SCROLL_INTENT_KEYS = new Set(["ArrowUp", "ArrowDown", "PageUp", "PageDown", "Home", "End", " ", "Spacebar"])
|
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>
|
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.
|
* Optional hooks to render content inside the scroll container.
|
||||||
* Useful for empty/loading states that should scroll with the list.
|
* 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 scrollToBottomOnActivate = () => (props.scrollToBottomOnActivate ? props.scrollToBottomOnActivate() : true)
|
||||||
const initialScrollToBottom = () => (props.initialScrollToBottom ? props.initialScrollToBottom() : true)
|
const initialScrollToBottom = () => (props.initialScrollToBottom ? props.initialScrollToBottom() : true)
|
||||||
const initialAutoScroll = () => (props.initialAutoScroll ? props.initialAutoScroll() : 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 [autoScroll, setAutoScroll] = createSignal(Boolean(initialAutoScroll()))
|
||||||
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 effectiveSuspendAutoPinToBottom = () => externalSuspendAutoPinToBottom() || heldItemCount() !== 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>()
|
||||||
|
|
||||||
let userScrollIntentUntil = 0
|
let userScrollIntentUntil = 0
|
||||||
let lastUserScrollIntentDirection: "up" | "down" | null = null
|
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
|
// Sync autoScroll state based on scroll position if it was a user scroll
|
||||||
if (hasUserScrollIntent()) {
|
if (hasUserScrollIntent()) {
|
||||||
|
if (atBottom && heldItemCount() !== null) {
|
||||||
|
setHeldItemCount(null)
|
||||||
|
}
|
||||||
if (atBottom && !autoScroll()) {
|
if (atBottom && !autoScroll()) {
|
||||||
setAutoScroll(true)
|
setAutoScroll(true)
|
||||||
} else if (!atBottom && autoScroll()) {
|
} else if (!atBottom && autoScroll()) {
|
||||||
@@ -253,6 +286,7 @@ 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)
|
||||||
@@ -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 = {
|
const api: VirtualFollowListApi = {
|
||||||
scrollToTop: (opts) => scrollToTop(opts?.immediate ?? true),
|
scrollToTop: (opts) => scrollToTop(opts?.immediate ?? true),
|
||||||
scrollToBottom: (opts) => scrollToBottom(opts?.immediate ?? true, { suppressAutoAnchor: opts?.suppressAutoAnchor }),
|
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" })
|
virtuaHandle()?.scrollToIndex(index, { align: opts?.block ?? "start", smooth: opts?.behavior === "smooth" })
|
||||||
},
|
},
|
||||||
notifyContentRendered: () => {
|
notifyContentRendered: () => {
|
||||||
if (autoScroll()) {
|
updateAutoPinHold()
|
||||||
|
if (heldItemCount() !== null) return
|
||||||
|
if (autoScroll() && !effectiveSuspendAutoPinToBottom()) {
|
||||||
scrollToBottom(true)
|
scrollToBottom(true)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -294,9 +392,15 @@ export default function VirtualFollowList<T>(props: VirtualFollowListProps<T>) {
|
|||||||
createEffect(() => props.registerApi?.(api))
|
createEffect(() => props.registerApi?.(api))
|
||||||
createEffect(() => props.registerState?.(state))
|
createEffect(() => props.registerState?.(state))
|
||||||
|
|
||||||
|
createEffect(on(() => props.resetKey?.(), () => {
|
||||||
|
itemElements.clear()
|
||||||
|
setHeldItemCount(null)
|
||||||
|
}))
|
||||||
|
|
||||||
// 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) => {
|
||||||
if (len > (prevLen ?? 0) && autoScroll() && !suppressAutoScrollOnce) {
|
updateAutoPinHold()
|
||||||
|
if (len > (prevLen ?? 0) && autoScroll() && !effectiveSuspendAutoPinToBottom() && !suppressAutoScrollOnce) {
|
||||||
requestAnimationFrame(() => scrollToBottom(true))
|
requestAnimationFrame(() => scrollToBottom(true))
|
||||||
}
|
}
|
||||||
suppressAutoScrollOnce = false
|
suppressAutoScrollOnce = false
|
||||||
@@ -304,11 +408,16 @@ export default function VirtualFollowList<T>(props: VirtualFollowListProps<T>) {
|
|||||||
|
|
||||||
// Handle followToken change
|
// Handle followToken change
|
||||||
createEffect(on(() => props.followToken?.(), () => {
|
createEffect(on(() => props.followToken?.(), () => {
|
||||||
if (autoScroll()) {
|
updateAutoPinHold()
|
||||||
|
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
|
||||||
@@ -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 (
|
return (
|
||||||
<div class="virtual-follow-list-shell" ref={shellElement => {
|
<div class="virtual-follow-list-shell" ref={shellElement => {
|
||||||
setShellElement(shellElement)
|
setShellElement(shellElement)
|
||||||
@@ -356,7 +472,15 @@ export default function VirtualFollowList<T>(props: VirtualFollowListProps<T>) {
|
|||||||
bufferSize={props.overscanPx ?? 400}
|
bufferSize={props.overscanPx ?? 400}
|
||||||
onScroll={handleScroll}
|
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>
|
</Virtualizer>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user