Prevent cached session remeasurements and remove logs

This commit is contained in:
Shantur Rathore
2025-12-09 16:18:10 +00:00
parent 8204143810
commit 82ff1916b7
4 changed files with 89 additions and 53 deletions

View File

@@ -26,6 +26,7 @@ interface MessageBlockListProps {
onFork?: (messageId?: string) => void onFork?: (messageId?: string) => void
onContentRendered?: () => void onContentRendered?: () => void
setBottomSentinel: (element: HTMLDivElement | null) => void setBottomSentinel: (element: HTMLDivElement | null) => void
suspendMeasurements?: () => boolean
} }
export default function MessageBlockList(props: MessageBlockListProps) { export default function MessageBlockList(props: MessageBlockListProps) {
@@ -41,6 +42,7 @@ export default function MessageBlockList(props: MessageBlockListProps) {
threshold={VIRTUAL_ITEM_MARGIN_PX} threshold={VIRTUAL_ITEM_MARGIN_PX}
placeholderClass="message-stream-placeholder" placeholderClass="message-stream-placeholder"
virtualizationEnabled={() => !props.loading} virtualizationEnabled={() => !props.loading}
suspendMeasurements={props.suspendMeasurements}
> >
<MessageBlock <MessageBlock

View File

@@ -30,12 +30,11 @@ export interface MessageSectionProps {
onRevert?: (messageId: string) => void onRevert?: (messageId: string) => void
onFork?: (messageId?: string) => void onFork?: (messageId?: string) => void
registerScrollToBottom?: (fn: () => void) => void registerScrollToBottom?: (fn: () => void) => void
requestScrollToBottom?: () => void
isActive: boolean
showSidebarToggle?: boolean showSidebarToggle?: boolean
onSidebarToggle?: () => void onSidebarToggle?: () => void
forceCompactStatusLayout?: boolean forceCompactStatusLayout?: boolean
onQuoteSelection?: (text: string, mode: "quote" | "code") => void onQuoteSelection?: (text: string, mode: "quote" | "code") => void
isActive?: boolean
} }
export default function MessageSection(props: MessageSectionProps) { export default function MessageSection(props: MessageSectionProps) {
@@ -140,8 +139,8 @@ export default function MessageSection(props: MessageSectionProps) {
const bottomSentinel = () => bottomSentinelSignal() const bottomSentinel = () => bottomSentinelSignal()
const setBottomSentinel = (element: HTMLDivElement | null) => { const setBottomSentinel = (element: HTMLDivElement | null) => {
setBottomSentinelSignal(element) setBottomSentinelSignal(element)
resolvePendingActiveScroll()
} }
const [scrollToBottomRequest, setScrollToBottomRequest] = createSignal(false)
const [autoScroll, setAutoScroll] = createSignal(true) const [autoScroll, setAutoScroll] = createSignal(true)
const [showScrollTopButton, setShowScrollTopButton] = createSignal(false) const [showScrollTopButton, setShowScrollTopButton] = createSignal(false)
const [showScrollBottomButton, setShowScrollBottomButton] = createSignal(false) const [showScrollBottomButton, setShowScrollBottomButton] = createSignal(false)
@@ -160,6 +159,9 @@ export default function MessageSection(props: MessageSectionProps) {
let detachScrollIntentListeners: (() => void) | undefined let detachScrollIntentListeners: (() => void) | undefined
let hasRestoredScroll = false let hasRestoredScroll = false
let suppressAutoScrollOnce = false let suppressAutoScrollOnce = false
let pendingActiveScroll = false
let scrollToBottomFrame: number | null = null
let scrollToBottomDelayedFrame: number | null = null
function markUserScrollIntent() { function markUserScrollIntent() {
const now = typeof performance !== "undefined" ? performance.now() : Date.now() const now = typeof performance !== "undefined" ? performance.now() : Date.now()
@@ -197,14 +199,13 @@ export default function MessageSection(props: MessageSectionProps) {
function setContainerRef(element: HTMLDivElement | null) { function setContainerRef(element: HTMLDivElement | null) {
containerRef = element || undefined containerRef = element || undefined
if (import.meta.env?.DEV) {
console.debug("[MessageSection] setContainerRef", props.sessionId, Boolean(containerRef))
}
setScrollElement(containerRef) setScrollElement(containerRef)
attachScrollIntentListeners(containerRef) attachScrollIntentListeners(containerRef)
if (!containerRef) { if (!containerRef) {
clearQuoteSelection() clearQuoteSelection()
return
} }
resolvePendingActiveScroll()
} }
function setShellElement(element: HTMLDivElement | null) { function setShellElement(element: HTMLDivElement | null) {
@@ -219,9 +220,6 @@ export default function MessageSection(props: MessageSectionProps) {
const hasItems = messageIds().length > 0 const hasItems = messageIds().length > 0
const bottomVisible = bottomSentinelVisible() const bottomVisible = bottomSentinelVisible()
const topVisible = topSentinelVisible() const topVisible = topSentinelVisible()
if (import.meta.env?.DEV) {
console.debug("[MessageSection] sentinel visibility", props.sessionId, { bottomVisible, topVisible })
}
setShowScrollBottomButton(hasItems && !bottomVisible) setShowScrollBottomButton(hasItems && !bottomVisible)
setShowScrollTopButton(hasItems && !topVisible) setShowScrollTopButton(hasItems && !topVisible)
} }
@@ -238,13 +236,6 @@ export default function MessageSection(props: MessageSectionProps) {
function scrollToBottom(immediate = false) { function scrollToBottom(immediate = false) {
if (!containerRef) return if (!containerRef) return
const sentinel = bottomSentinel() const sentinel = bottomSentinel()
if (import.meta.env?.DEV) {
console.debug("[MessageSection] scrollToBottom", props.sessionId, {
immediate,
hasSentinel: Boolean(sentinel),
bottomVisible: bottomSentinelVisible(),
})
}
const behavior = immediate ? "auto" : "smooth" const behavior = immediate ? "auto" : "smooth"
if (!immediate) { if (!immediate) {
suppressAutoScrollOnce = true suppressAutoScrollOnce = true
@@ -254,12 +245,42 @@ export default function MessageSection(props: MessageSectionProps) {
scheduleScrollPersist() scheduleScrollPersist()
} }
function clearScrollToBottomFrames() {
if (scrollToBottomFrame !== null) {
cancelAnimationFrame(scrollToBottomFrame)
scrollToBottomFrame = null
}
if (scrollToBottomDelayedFrame !== null) {
cancelAnimationFrame(scrollToBottomDelayedFrame)
scrollToBottomDelayedFrame = null
}
}
function requestScrollToBottom(immediate = true) {
if (!containerRef || !bottomSentinel()) {
pendingActiveScroll = true
return
}
pendingActiveScroll = false
clearScrollToBottomFrames()
scrollToBottomFrame = requestAnimationFrame(() => {
scrollToBottomFrame = null
scrollToBottomDelayedFrame = requestAnimationFrame(() => {
scrollToBottomDelayedFrame = null
scrollToBottom(immediate)
})
})
}
function resolvePendingActiveScroll() {
if (!pendingActiveScroll) return
if (!props.isActive) return
requestScrollToBottom(true)
}
function scrollToTop(immediate = false) { function scrollToTop(immediate = false) {
if (!containerRef) return if (!containerRef) return
const behavior = immediate ? "auto" : "smooth" const behavior = immediate ? "auto" : "smooth"
if (import.meta.env?.DEV) {
console.debug("[MessageSection] scrollToTop", props.sessionId, { immediate })
}
setAutoScroll(false) setAutoScroll(false)
topSentinel()?.scrollIntoView({ block: "start", inline: "nearest", behavior }) topSentinel()?.scrollIntoView({ block: "start", inline: "nearest", behavior })
scheduleScrollPersist() scheduleScrollPersist()
@@ -378,19 +399,17 @@ export default function MessageSection(props: MessageSectionProps) {
createEffect(() => { createEffect(() => {
if (props.registerScrollToBottom) { if (props.registerScrollToBottom) {
props.registerScrollToBottom(() => scrollToBottom(true)) props.registerScrollToBottom(() => requestScrollToBottom(true))
} }
}) })
let lastActiveState = false
createEffect(() => { createEffect(() => {
const active = props.isActive const active = Boolean(props.isActive)
const container = containerRef if (active && !lastActiveState) {
if (!container) return requestScrollToBottom(true)
if (active) {
requestAnimationFrame(() => requestAnimationFrame(() => scrollToBottom(true)))
} else {
requestAnimationFrame(() => container.scrollTo({ top: 0, behavior: "auto" }))
} }
lastActiveState = active
}) })
createEffect(() => { createEffect(() => {
@@ -554,6 +573,7 @@ export default function MessageSection(props: MessageSectionProps) {
if (pendingAnchorScroll !== null) { if (pendingAnchorScroll !== null) {
cancelAnimationFrame(pendingAnchorScroll) cancelAnimationFrame(pendingAnchorScroll)
} }
clearScrollToBottomFrames()
if (detachScrollIntentListeners) { if (detachScrollIntentListeners) {
detachScrollIntentListeners() detachScrollIntentListeners()
} }
@@ -626,6 +646,7 @@ export default function MessageSection(props: MessageSectionProps) {
onFork={props.onFork} onFork={props.onFork}
onContentRendered={handleContentRendered} onContentRendered={handleContentRendered}
setBottomSentinel={setBottomSentinel} setBottomSentinel={setBottomSentinel}
suspendMeasurements={() => props.isActive === false}
/> />

View File

@@ -1,4 +1,4 @@
import { Show, createMemo, createEffect, createSignal, type Component } from "solid-js" import { Show, createMemo, createEffect, type Component } from "solid-js"
import type { Session } from "../../types/session" import type { Session } from "../../types/session"
import type { Attachment } from "../../types/attachment" import type { Attachment } from "../../types/attachment"
import type { ClientPart } from "../../types/message" import type { ClientPart } from "../../types/message"
@@ -26,25 +26,29 @@ interface SessionViewProps {
showSidebarToggle?: boolean showSidebarToggle?: boolean
onSidebarToggle?: () => void onSidebarToggle?: () => void
forceCompactStatusLayout?: boolean forceCompactStatusLayout?: boolean
isActive: boolean isActive?: boolean
} }
export const SessionView: Component<SessionViewProps> = (props) => { export const SessionView: Component<SessionViewProps> = (props) => {
const session = () => props.activeSessions.get(props.sessionId) const session = () => props.activeSessions.get(props.sessionId)
const messagesLoading = createMemo(() => isSessionMessagesLoading(props.instanceId, props.sessionId)) const messagesLoading = createMemo(() => isSessionMessagesLoading(props.instanceId, props.sessionId))
const messageStore = createMemo(() => messageStoreBus.getOrCreate(props.instanceId)) const messageStore = createMemo(() => messageStoreBus.getOrCreate(props.instanceId))
const [scrollToBottomHandle, setScrollToBottomHandle] = createSignal<(() => void) | null>(null)
createEffect(() => {
if (!props.isActive) return
const handler = scrollToBottomHandle()
if (!handler) return
requestAnimationFrame(() => requestAnimationFrame(() => handler()))
})
const sessionBusy = createMemo(() => { const sessionBusy = createMemo(() => {
const currentSession = session() const currentSession = session()
if (!currentSession) return false if (!currentSession) return false
return getSessionBusyStatus(props.instanceId, currentSession.id) return getSessionBusyStatus(props.instanceId, currentSession.id)
}) })
let scrollToBottomHandle: (() => void) | undefined
function scheduleScrollToBottom() {
if (!scrollToBottomHandle) return
requestAnimationFrame(() => {
requestAnimationFrame(() => scrollToBottomHandle?.())
})
}
createEffect(() => {
if (!props.isActive) return
scheduleScrollToBottom()
})
let quoteHandler: ((text: string, mode: "quote" | "code") => void) | null = null let quoteHandler: ((text: string, mode: "quote" | "code") => void) | null = null
createEffect(() => { createEffect(() => {
@@ -70,10 +74,10 @@ export const SessionView: Component<SessionViewProps> = (props) => {
} }
async function handleSendMessage(prompt: string, attachments: Attachment[]) { async function handleSendMessage(prompt: string, attachments: Attachment[]) {
const handler = scrollToBottomHandle() if (scrollToBottomHandle && import.meta.env?.DEV) {
if (handler) { console.debug("[SessionView] handleSendMessage scroll", props.sessionId)
handler()
} }
scheduleScrollToBottom()
await sendMessage(props.instanceId, props.sessionId, prompt, attachments) await sendMessage(props.instanceId, props.sessionId, prompt, attachments)
} }
@@ -201,12 +205,13 @@ export const SessionView: Component<SessionViewProps> = (props) => {
onRevert={handleRevert} onRevert={handleRevert}
onFork={handleFork} onFork={handleFork}
isActive={props.isActive} isActive={props.isActive}
registerScrollToBottom={(fn) => { registerScrollToBottom={(fn) => {
setScrollToBottomHandle(() => fn) scrollToBottomHandle = fn
if (props.isActive) { if (props.isActive) {
requestAnimationFrame(() => requestAnimationFrame(() => fn())) scheduleScrollToBottom()
} }
}} }}

View File

@@ -98,6 +98,7 @@ interface VirtualItemProps {
placeholderClass?: string placeholderClass?: string
virtualizationEnabled?: Accessor<boolean> virtualizationEnabled?: Accessor<boolean>
forceVisible?: Accessor<boolean> forceVisible?: Accessor<boolean>
suspendMeasurements?: Accessor<boolean>
onMeasured?: () => void onMeasured?: () => void
id?: string id?: string
} }
@@ -138,10 +139,12 @@ export default function VirtualItem(props: VirtualItemProps) {
if (!virtualizationEnabled()) return false if (!virtualizationEnabled()) return false
return !isIntersecting() return !isIntersecting()
}) })
const measurementsSuspended = () => Boolean(props.suspendMeasurements?.())
let wrapperRef: HTMLDivElement | undefined let wrapperRef: HTMLDivElement | undefined
let contentRef: HTMLDivElement | undefined let contentRef: HTMLDivElement | undefined
let resizeObserver: ResizeObserver | undefined let resizeObserver: ResizeObserver | undefined
let intersectionCleanup: (() => void) | undefined let intersectionCleanup: (() => void) | undefined
@@ -176,23 +179,27 @@ export default function VirtualItem(props: VirtualItemProps) {
} }
function updateMeasuredHeight() { function updateMeasuredHeight() {
if (!contentRef) return if (!contentRef || measurementsSuspended()) return
const next = contentRef.offsetHeight const next = contentRef.offsetHeight
if (next === measuredHeight()) return if (next === measuredHeight()) return
persistMeasurement(next) persistMeasurement(next)
} }
function setupResizeObserver() { function setupResizeObserver() {
if (!contentRef) return if (!contentRef || measurementsSuspended()) return
cleanupResizeObserver() cleanupResizeObserver()
if (typeof ResizeObserver === "undefined") { if (typeof ResizeObserver === "undefined") {
updateMeasuredHeight() updateMeasuredHeight()
return return
} }
resizeObserver = new ResizeObserver(() => updateMeasuredHeight()) resizeObserver = new ResizeObserver(() => {
if (measurementsSuspended()) return
updateMeasuredHeight()
})
resizeObserver.observe(contentRef) resizeObserver.observe(contentRef)
} }
function refreshIntersectionObserver(targetRoot: Element | Document | null) { function refreshIntersectionObserver(targetRoot: Element | Document | null) {
cleanupIntersectionObserver() cleanupIntersectionObserver()
if (!wrapperRef) { if (!wrapperRef) {
@@ -219,7 +226,7 @@ export default function VirtualItem(props: VirtualItemProps) {
contentRef = element ?? undefined contentRef = element ?? undefined
if (contentRef) { if (contentRef) {
queueMicrotask(() => { queueMicrotask(() => {
if (shouldHideContent()) return if (shouldHideContent() || measurementsSuspended()) return
updateMeasuredHeight() updateMeasuredHeight()
setupResizeObserver() setupResizeObserver()
}) })
@@ -230,7 +237,7 @@ export default function VirtualItem(props: VirtualItemProps) {
createEffect(() => { createEffect(() => {
if (shouldHideContent()) { if (shouldHideContent() || measurementsSuspended()) {
cleanupResizeObserver() cleanupResizeObserver()
} else if (contentRef) { } else if (contentRef) {
queueMicrotask(() => { queueMicrotask(() => {
@@ -240,6 +247,7 @@ export default function VirtualItem(props: VirtualItemProps) {
} }
}) })
createEffect(() => { createEffect(() => {
const key = props.cacheKey const key = props.cacheKey