Improve sidebar default width and message autoscroll

This commit is contained in:
Shantur Rathore
2025-11-27 18:24:45 +00:00
parent d9b149a7cb
commit 91fb351a63
2 changed files with 139 additions and 34 deletions

View File

@@ -527,6 +527,22 @@ export default function MessageStreamV2(props: MessageStreamV2Props) {
const [showScrollTopButton, setShowScrollTopButton] = createSignal(false) const [showScrollTopButton, setShowScrollTopButton] = createSignal(false)
const [showScrollBottomButton, setShowScrollBottomButton] = createSignal(false) const [showScrollBottomButton, setShowScrollBottomButton] = createSignal(false)
let containerRef: HTMLDivElement | undefined let containerRef: HTMLDivElement | undefined
let resizeObserver: ResizeObserver | null = null
let lastScrollHeight = 0
let autoScrollLocked = true
function setAutoScrollState(enabled: boolean) {
autoScrollLocked = enabled
setAutoScroll(enabled)
}
function lockAutoScroll() {
setAutoScrollState(true)
}
function unlockAutoScroll() {
setAutoScrollState(false)
}
function isNearBottom(element: HTMLDivElement, offset = 48) { function isNearBottom(element: HTMLDivElement, offset = 48) {
const { scrollTop, scrollHeight, clientHeight } = element const { scrollTop, scrollHeight, clientHeight } = element
@@ -543,11 +559,89 @@ export default function MessageStreamV2(props: MessageStreamV2Props) {
setShowScrollTopButton(hasItems && !isNearTop(element)) setShowScrollTopButton(hasItems && !isNearTop(element))
} }
function scrollToBottom(immediate = false) { function detachResizeObserver() {
if (resizeObserver) {
resizeObserver.disconnect()
resizeObserver = null
}
}
function handleResizeEvent() {
if (!containerRef) return if (!containerRef) return
const currentHeight = containerRef.scrollHeight
const heightDecreased = currentHeight < lastScrollHeight
lastScrollHeight = currentHeight
if (heightDecreased && shouldMaintainAutoScroll()) {
containerRef.scrollTop = Math.max(currentHeight - containerRef.clientHeight, 0)
lockAutoScroll()
queueAutoScroll(true)
return
}
if (shouldMaintainAutoScroll()) {
queueAutoScroll(true)
} else {
updateScrollIndicators(containerRef)
scheduleScrollPersist()
}
}
function attachResizeObserver(element: HTMLDivElement | undefined) {
detachResizeObserver()
if (!element) return
resizeObserver = new ResizeObserver(() => {
handleResizeEvent()
})
resizeObserver.observe(element)
}
function setContainerRef(element: HTMLDivElement | null) {
containerRef = element || undefined
if (containerRef) {
lastScrollHeight = containerRef.scrollHeight
}
attachResizeObserver(containerRef)
}
function shouldMaintainAutoScroll() {
return autoScrollLocked
}
function applyScrollToBottom(immediate: boolean, options?: { preserveAuto?: boolean }) {
if (!containerRef) return
const preserveAuto = options?.preserveAuto ?? false
if (preserveAuto && !shouldMaintainAutoScroll()) {
return
}
if (!preserveAuto) {
lockAutoScroll()
}
const behavior = immediate ? "auto" : "smooth" const behavior = immediate ? "auto" : "smooth"
containerRef.scrollTo({ top: containerRef.scrollHeight, behavior }) containerRef.scrollTo({ top: containerRef.scrollHeight, behavior })
setAutoScroll(true) requestAnimationFrame(() => {
if (!containerRef) return
if (preserveAuto && !shouldMaintainAutoScroll()) {
updateScrollIndicators(containerRef)
scheduleScrollPersist()
return
}
if (!isNearBottom(containerRef)) {
containerRef.scrollTo({ top: containerRef.scrollHeight, behavior: "auto" })
}
updateScrollIndicators(containerRef)
scheduleScrollPersist()
})
}
function scrollToBottom(immediate = false) {
applyScrollToBottom(immediate, { preserveAuto: false })
}
function scrollToTop(immediate = false) {
if (!containerRef) return
const behavior = immediate ? "auto" : "smooth"
unlockAutoScroll()
containerRef.scrollTo({ top: 0, behavior })
requestAnimationFrame(() => { requestAnimationFrame(() => {
if (!containerRef) return if (!containerRef) return
updateScrollIndicators(containerRef) updateScrollIndicators(containerRef)
@@ -555,28 +649,37 @@ export default function MessageStreamV2(props: MessageStreamV2Props) {
}) })
} }
function scrollToTop(immediate = false) { let pendingAutoScrollId: number | null = null
if (!containerRef) return function cancelPendingAutoScroll() {
const behavior = immediate ? "auto" : "smooth" if (pendingAutoScrollId !== null) {
setAutoScroll(false) cancelAnimationFrame(pendingAutoScrollId)
containerRef.scrollTo({ top: 0, behavior }) pendingAutoScrollId = null
requestAnimationFrame(() => { }
if (!containerRef) return }
updateScrollIndicators(containerRef)
scheduleScrollPersist() function queueAutoScroll(immediate = true) {
}) cancelPendingAutoScroll()
} if (!shouldMaintainAutoScroll()) {
return
}
pendingAutoScrollId = requestAnimationFrame(() => {
pendingAutoScrollId = null
applyScrollToBottom(immediate, { preserveAuto: true })
})
}
let pendingScrollPersist: number | null = null
function scheduleScrollPersist() {
if (pendingScrollPersist !== null) return
pendingScrollPersist = requestAnimationFrame(() => {
pendingScrollPersist = null
if (!containerRef) return
lastScrollHeight = containerRef.scrollHeight
scrollCache.persist(containerRef, { atBottomOffset: 48 })
})
}
let pendingScrollPersist: number | null = null
function scheduleScrollPersist() {
if (pendingScrollPersist !== null) return
pendingScrollPersist = requestAnimationFrame(() => {
pendingScrollPersist = null
if (!containerRef) return
scrollCache.persist(containerRef, { atBottomOffset: 48 })
})
}
function handleScroll(event: Event) { function handleScroll(event: Event) {
if (!containerRef) return if (!containerRef) return
@@ -584,9 +687,9 @@ export default function MessageStreamV2(props: MessageStreamV2Props) {
if (event.isTrusted) { if (event.isTrusted) {
const atBottom = isNearBottom(containerRef) const atBottom = isNearBottom(containerRef)
if (!atBottom) { if (!atBottom) {
setAutoScroll(false) unlockAutoScroll()
} else { } else {
setAutoScroll(true) lockAutoScroll()
} }
} }
scheduleScrollPersist() scheduleScrollPersist()
@@ -599,10 +702,10 @@ export default function MessageStreamV2(props: MessageStreamV2Props) {
fallback: () => scrollToBottom(true), fallback: () => scrollToBottom(true),
onApplied: (snapshot) => { onApplied: (snapshot) => {
if (snapshot) { if (snapshot) {
setAutoScroll(snapshot.atBottom) setAutoScrollState(snapshot.atBottom)
} else { } else {
const atBottom = isNearBottom(target) const atBottom = isNearBottom(target)
setAutoScroll(atBottom) setAutoScrollState(atBottom)
} }
updateScrollIndicators(target) updateScrollIndicators(target)
}, },
@@ -619,7 +722,7 @@ export default function MessageStreamV2(props: MessageStreamV2Props) {
} }
previousToken = token previousToken = token
if (autoScroll()) { if (autoScroll()) {
requestAnimationFrame(() => scrollToBottom(true)) queueAutoScroll(true)
} }
}) })
@@ -627,11 +730,14 @@ export default function MessageStreamV2(props: MessageStreamV2Props) {
if (messageRecords().length === 0) { if (messageRecords().length === 0) {
setShowScrollTopButton(false) setShowScrollTopButton(false)
setShowScrollBottomButton(false) setShowScrollBottomButton(false)
setAutoScroll(true) lockAutoScroll()
cancelPendingAutoScroll()
} }
}) })
onCleanup(() => { onCleanup(() => {
detachResizeObserver()
cancelPendingAutoScroll()
if (pendingScrollPersist !== null) { if (pendingScrollPersist !== null) {
cancelAnimationFrame(pendingScrollPersist) cancelAnimationFrame(pendingScrollPersist)
pendingScrollPersist = null pendingScrollPersist = null
@@ -641,6 +747,7 @@ export default function MessageStreamV2(props: MessageStreamV2Props) {
} }
}) })
return ( return (
<div class="message-stream-container"> <div class="message-stream-container">
<div class="connection-status"> <div class="connection-status">
@@ -691,9 +798,7 @@ export default function MessageStreamV2(props: MessageStreamV2Props) {
<div <div
class="message-stream" class="message-stream"
ref={(element) => { ref={setContainerRef}
containerRef = element || undefined
}}
onScroll={handleScroll} onScroll={handleScroll}
> >
<Show when={!props.loading && displayBlocks().length === 0}> <Show when={!props.loading && displayBlocks().length === 0}>

View File

@@ -25,7 +25,7 @@ interface SessionListProps {
const MIN_WIDTH = 200 const MIN_WIDTH = 200
const MAX_WIDTH = 520 const MAX_WIDTH = 520
const DEFAULT_WIDTH = 350 const DEFAULT_WIDTH = 360
const STORAGE_KEY = "opencode-session-sidebar-width-v7" const STORAGE_KEY = "opencode-session-sidebar-width-v7"
function formatSessionStatus(status: SessionStatus): string { function formatSessionStatus(status: SessionStatus): string {