diff --git a/packages/ui/src/components/message-section.tsx b/packages/ui/src/components/message-section.tsx index 159efbc1..51ef2b26 100644 --- a/packages/ui/src/components/message-section.tsx +++ b/packages/ui/src/components/message-section.tsx @@ -16,12 +16,14 @@ import { showAlertDialog } from "../stores/alerts" import { deleteMessage, deleteMessagePart } from "../stores/session-actions" import type { InstanceMessageStore } from "../stores/message-v2/instance-store" import type { DeleteHoverState } from "../types/delete-hover" +import { partHasRenderableText } from "../types/message" import { buildRecordDisplayData } from "../stores/message-v2/record-display-cache" import { getPartCharCount } from "../lib/token-utils" const SCROLL_SENTINEL_MARGIN_PX = 8 const MESSAGE_SCROLL_CACHE_SCOPE = "message-stream" 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 export interface MessageSectionProps { @@ -594,7 +596,10 @@ export default function MessageSection(props: MessageSectionProps) { const [streamElement, setStreamElement] = createSignal() const [streamShellElement, setStreamShellElement] = createSignal() - 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 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 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(() => { const api = listApi() if (!api) return @@ -1044,6 +1073,12 @@ export default function MessageSection(props: MessageSectionProps) { initialAutoScroll={initialAutoScroll} resetKey={() => props.sessionId} followToken={followToken} + autoPinHoldTargetKey={autoPinHoldTargetKey} + autoPinHoldTopThresholdPx={STREAMING_TEXT_HOLD_TOP_THRESHOLD_PX} + resolveAutoPinHoldElement={(itemWrapper, key) => { + const candidates = Array.from(itemWrapper.querySelectorAll(`.message-item-base[data-message-id="${key}"][data-message-role="assistant"]`)) + return candidates[candidates.length - 1] ?? null + }} onScroll={() => { clearQuoteSelection() scrollCache.persist(streamElement()) diff --git a/packages/ui/src/components/session/session-view.tsx b/packages/ui/src/components/session/session-view.tsx index 04a32613..bcc2b9cc 100644 --- a/packages/ui/src/components/session/session-view.tsx +++ b/packages/ui/src/components/session/session-view.tsx @@ -79,11 +79,17 @@ export const SessionView: Component = (props) => { requestAnimationFrame(() => scrollToBottomHandle?.()) }) } - createEffect(() => { - if (!props.isActive) return - if (!shouldScrollToBottomOnActivate()) return - scheduleScrollToBottom() - }) + createEffect( + on( + () => props.isActive, + (isActive, wasActive) => { + if (!isActive) return + if (wasActive === true) return + if (!shouldScrollToBottomOnActivate()) return + scheduleScrollToBottom() + }, + ), + ) createEffect( on( @@ -332,16 +338,11 @@ export const SessionView: Component = (props) => { loading={messagesLoading()} onRevert={handleRevert} onDeleteMessagesUpTo={handleDeleteMessagesUpTo} - onFork={handleFork} - isActive={props.isActive} - registerScrollToBottom={(fn) => { - scrollToBottomHandle = fn - if (props.isActive) { - if (shouldScrollToBottomOnActivate()) { - scheduleScrollToBottom() - } - } - }} + onFork={handleFork} + isActive={props.isActive} + registerScrollToBottom={(fn) => { + scrollToBottomHandle = fn + }} diff --git a/packages/ui/src/components/virtual-follow-list.tsx b/packages/ui/src/components/virtual-follow-list.tsx index a0dce708..c26ad97d 100644 --- a/packages/ui/src/components/virtual-follow-list.tsx +++ b/packages/ui/src/components/virtual-follow-list.tsx @@ -2,6 +2,8 @@ import { Show, createEffect, createMemo, createSignal, onCleanup, type Accessor, import { Virtualizer, type VirtualizerHandle } from "virtua/solid" 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 SCROLL_INTENT_KEYS = new Set(["ArrowUp", "ArrowDown", "PageUp", "PageDown", "Home", "End", " ", "Spacebar"]) @@ -85,6 +87,28 @@ export interface VirtualFollowListProps { */ followToken?: Accessor + /** + * 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 + + /** + * 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 + /** * Optional hooks to render content inside the scroll container. * Useful for empty/loading states that should scroll with the list. @@ -130,13 +154,19 @@ export default function VirtualFollowList(props: VirtualFollowListProps) { const scrollToBottomOnActivate = () => (props.scrollToBottomOnActivate ? props.scrollToBottomOnActivate() : true) const initialScrollToBottom = () => (props.initialScrollToBottom ? props.initialScrollToBottom() : 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 [showScrollTopButton, setShowScrollTopButton] = createSignal(false) const [showScrollBottomButton, setShowScrollBottomButton] = createSignal(false) const [activeKey, setActiveKey] = createSignal(null) + const [heldItemCount, setHeldItemCount] = createSignal(null) + const effectiveSuspendAutoPinToBottom = () => externalSuspendAutoPinToBottom() || heldItemCount() !== null const scrollButtonsCount = createMemo(() => (showScrollTopButton() ? 1 : 0) + (showScrollBottomButton() ? 1 : 0)) + const itemElements = new Map() let userScrollIntentUntil = 0 let lastUserScrollIntentDirection: "up" | "down" | null = null @@ -220,6 +250,9 @@ export default function VirtualFollowList(props: VirtualFollowListProps) { // Sync autoScroll state based on scroll position if it was a user scroll if (hasUserScrollIntent()) { + if (atBottom && heldItemCount() !== null) { + setHeldItemCount(null) + } if (atBottom && !autoScroll()) { setAutoScroll(true) } else if (!atBottom && autoScroll()) { @@ -253,6 +286,7 @@ export default function VirtualFollowList(props: VirtualFollowListProps) { } } updateScrollButtons() + updateAutoPinHold() props.onScroll?.() // Find active key (roughly the first visible item) @@ -270,6 +304,68 @@ export default function VirtualFollowList(props: VirtualFollowListProps) { } } + 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 = { scrollToTop: (opts) => scrollToTop(opts?.immediate ?? true), scrollToBottom: (opts) => scrollToBottom(opts?.immediate ?? true, { suppressAutoAnchor: opts?.suppressAutoAnchor }), @@ -281,7 +377,9 @@ export default function VirtualFollowList(props: VirtualFollowListProps) { virtuaHandle()?.scrollToIndex(index, { align: opts?.block ?? "start", smooth: opts?.behavior === "smooth" }) }, notifyContentRendered: () => { - if (autoScroll()) { + updateAutoPinHold() + if (heldItemCount() !== null) return + if (autoScroll() && !effectiveSuspendAutoPinToBottom()) { scrollToBottom(true) } }, @@ -294,9 +392,15 @@ export default function VirtualFollowList(props: VirtualFollowListProps) { createEffect(() => props.registerApi?.(api)) createEffect(() => props.registerState?.(state)) + createEffect(on(() => props.resetKey?.(), () => { + itemElements.clear() + setHeldItemCount(null) + })) + // Handle autoScroll (Follow) on items change createEffect(on(() => props.items().length, (len, prevLen) => { - if (len > (prevLen ?? 0) && autoScroll() && !suppressAutoScrollOnce) { + updateAutoPinHold() + if (len > (prevLen ?? 0) && autoScroll() && !effectiveSuspendAutoPinToBottom() && !suppressAutoScrollOnce) { requestAnimationFrame(() => scrollToBottom(true)) } suppressAutoScrollOnce = false @@ -304,11 +408,16 @@ export default function VirtualFollowList(props: VirtualFollowListProps) { // Handle followToken change createEffect(on(() => props.followToken?.(), () => { - if (autoScroll()) { + updateAutoPinHold() + if (autoScroll() && !effectiveSuspendAutoPinToBottom()) { scrollToBottom(true) } }, { defer: true })) + createEffect(on(() => holdTargetKey(), () => { + updateAutoPinHold() + }, { defer: true })) + // Reset state on resetKey change createEffect(on(() => props.resetKey?.(), (nextKey) => { if (nextKey === lastResetKey) return @@ -331,6 +440,13 @@ export default function VirtualFollowList(props: VirtualFollowListProps) { } }) + createEffect(() => { + if (typeof window === "undefined") return + const handleResize = () => updateAutoPinHold() + window.addEventListener("resize", handleResize) + onCleanup(() => window.removeEventListener("resize", handleResize)) + }) + return (