diff --git a/src/components/message-stream.tsx b/src/components/message-stream.tsx index 63686346..5223a1ea 100644 --- a/src/components/message-stream.tsx +++ b/src/components/message-stream.tsx @@ -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() // Calculate session tokens and cost from messagesInfo (matches TUI logic) function calculateSessionInfo(messagesInfo?: Map, 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() let toolItemCache = new Map() 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) { - +
- + + + + + +
diff --git a/src/styles/components.css b/src/styles/components.css index 3bb7d04c..10360e5d 100644 --- a/src/styles/components.css +++ b/src/styles/components.css @@ -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;