fix: persist session scroll state across tabs

This commit is contained in:
Shantur Rathore
2025-10-29 19:12:18 +00:00
parent a522e9f45b
commit c3504266fa
2 changed files with 120 additions and 26 deletions

View File

@@ -7,7 +7,9 @@ import Kbd from "./kbd"
import { preferences } from "../stores/preferences"
import { providers, getSessionInfo, computeDisplayParts } from "../stores/sessions"
const SCROLL_BOTTOM_OFFSET = 64
const SCROLL_OFFSET = 64
const messageScrollState = new Map<string, { scrollTop: number; autoScroll: boolean }>()
// Calculate session tokens and cost from messagesInfo (matches TUI logic)
function calculateSessionInfo(messagesInfo?: Map<string, any>, instanceId?: string) {
@@ -157,14 +159,18 @@ interface ToolCacheEntry {
export default function MessageStream(props: MessageStreamProps) {
let containerRef: HTMLDivElement | undefined
const [autoScroll, setAutoScroll] = createSignal(true)
const [showScrollButton, setShowScrollButton] = createSignal(false)
const [showScrollBottomButton, setShowScrollBottomButton] = createSignal(false)
const [showScrollTopButton, setShowScrollTopButton] = createSignal(false)
let messageItemCache = new Map<string, MessageCacheEntry>()
let toolItemCache = new Map<string, ToolCacheEntry>()
let scrollAnimationFrame: number | null = null
const makeScrollKey = (instanceId: string, sessionId: string) => `${instanceId}:${sessionId}`
const scrollStateKey = () => makeScrollKey(props.instanceId, props.sessionId)
const connectionStatus = () => sseManager.getStatus(props.instanceId)
const sessionInfo = createMemo(() => {
return (
getSessionInfo(props.instanceId, props.sessionId) || {
@@ -191,12 +197,16 @@ export default function MessageStream(props: MessageStreamProps) {
)
})
function isNearBottom(element: HTMLDivElement, offset = SCROLL_BOTTOM_OFFSET) {
function isNearBottom(element: HTMLDivElement, offset = SCROLL_OFFSET) {
const { scrollTop, scrollHeight, clientHeight } = element
const distance = scrollHeight - (scrollTop + clientHeight)
return distance <= offset
}
function isNearTop(element: HTMLDivElement, offset = SCROLL_OFFSET) {
return element.scrollTop <= offset
}
function scrollToBottom(options: { smooth?: boolean } = {}) {
if (!containerRef) return
@@ -206,7 +216,22 @@ export default function MessageStream(props: MessageStreamProps) {
if (!containerRef) return
containerRef.scrollTo({ top: containerRef.scrollHeight, behavior })
setAutoScroll(true)
setShowScrollButton(false)
updateScrollIndicators(containerRef)
})
}
function scrollToTop(options: { smooth?: boolean } = {}) {
if (!containerRef) return
const behavior = options.smooth ? "smooth" : "auto"
setAutoScroll(false)
requestAnimationFrame(() => {
if (!containerRef) return
containerRef.scrollTo({ top: 0, behavior })
setShowScrollTopButton(false)
updateScrollIndicators(containerRef)
})
}
@@ -222,7 +247,7 @@ export default function MessageStream(props: MessageStreamProps) {
const atBottom = isNearBottom(containerRef)
setAutoScroll(atBottom)
setShowScrollButton(!atBottom && displayItems().length > 0)
updateScrollIndicators(containerRef)
scrollAnimationFrame = null
})
}
@@ -352,6 +377,65 @@ export default function MessageStream(props: MessageStreamProps) {
const displayItems = () => messageView().items
const changeToken = () => messageView().token
function updateScrollIndicators(element: HTMLDivElement) {
const itemsLength = displayItems().length
setShowScrollBottomButton(!isNearBottom(element) && itemsLength > 0)
setShowScrollTopButton(!isNearTop(element) && itemsLength > 0)
persistScrollState()
}
function getActiveScrollKey() {
return containerRef?.dataset.scrollKey || scrollStateKey()
}
function persistScrollState() {
if (!containerRef) return
const key = getActiveScrollKey()
messageScrollState.set(key, {
scrollTop: containerRef.scrollTop,
autoScroll: autoScroll(),
})
}
createEffect(() => {
const key = scrollStateKey()
if (containerRef) {
containerRef.dataset.scrollKey = key
}
const savedState = messageScrollState.get(key)
const shouldAutoScroll = savedState?.autoScroll ?? true
setAutoScroll(shouldAutoScroll)
requestAnimationFrame(() => {
if (!containerRef) return
if (savedState) {
if (shouldAutoScroll) {
scrollToBottom({ smooth: false })
} else {
const maxScrollTop = Math.max(containerRef.scrollHeight - containerRef.clientHeight, 0)
containerRef.scrollTop = Math.min(savedState.scrollTop, maxScrollTop)
updateScrollIndicators(containerRef)
}
} else {
scrollToBottom({ smooth: false })
}
})
onCleanup(() => {
if (containerRef) {
messageScrollState.set(key, {
scrollTop: containerRef.scrollTop,
autoScroll: autoScroll(),
})
if (containerRef.dataset.scrollKey === key) {
delete containerRef.dataset.scrollKey
}
}
})
})
let previousToken: string | undefined
createEffect(() => {
const token = changeToken()
@@ -372,8 +456,10 @@ export default function MessageStream(props: MessageStreamProps) {
createEffect(() => {
if (displayItems().length === 0) {
setShowScrollButton(false)
setShowScrollBottomButton(false)
setShowScrollTopButton(false)
setAutoScroll(true)
persistScrollState()
}
})
@@ -471,17 +557,28 @@ export default function MessageStream(props: MessageStreamProps) {
</For>
</div>
<Show when={showScrollButton()}>
<Show when={showScrollTopButton() || showScrollBottomButton()}>
<div class="message-scroll-button-wrapper">
<button
type="button"
class="message-scroll-button"
onClick={() => scrollToBottom({ smooth: true })}
aria-label="Scroll to latest message"
>
<span class="message-scroll-icon"></span>
<span class="message-scroll-label">Jump to latest</span>
</button>
<Show when={showScrollTopButton()}>
<button
type="button"
class="message-scroll-button"
onClick={() => scrollToTop({ smooth: true })}
aria-label="Scroll to first message"
>
<span class="message-scroll-icon" aria-hidden="true"></span>
</button>
</Show>
<Show when={showScrollBottomButton()}>
<button
type="button"
class="message-scroll-button"
onClick={() => scrollToBottom({ smooth: true })}
aria-label="Scroll to latest message"
>
<span class="message-scroll-icon" aria-hidden="true"></span>
</button>
</Show>
</div>
</Show>
</div>

View File

@@ -537,17 +537,19 @@ button.button-primary {
right: 1rem;
bottom: 1rem;
display: flex;
justify-content: flex-end;
flex-direction: column;
gap: 0.5rem;
align-items: flex-end;
}
.message-scroll-button {
@apply inline-flex items-center gap-2 font-medium;
padding: 0.5rem 0.875rem;
@apply inline-flex items-center justify-center;
width: 2.75rem;
height: 2.75rem;
border-radius: 9999px;
border: 1px solid var(--border-base);
background-color: var(--surface-secondary);
color: var(--text-primary);
font-size: var(--font-size-sm);
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.08);
transition: background-color 0.2s ease, color 0.2s ease, transform 0.2s ease, box-shadow 0.2s ease;
}
@@ -563,15 +565,10 @@ button.button-primary {
}
.message-scroll-icon {
font-size: var(--font-size-base);
font-size: var(--font-size-lg);
color: var(--accent-primary);
}
.message-scroll-label {
line-height: var(--line-height-tight);
color: var(--text-primary);
}
/* Tool call message wrapper */
.tool-call-message {
@apply flex flex-col gap-2 p-3 rounded-lg w-full;