From a522e9f45b870f573bc888a36f82ae656ec401ad Mon Sep 17 00:00:00 2001 From: Shantur Rathore Date: Wed, 29 Oct 2025 16:07:05 +0000 Subject: [PATCH] feat: auto-scroll message stream on change detection --- src/components/message-stream.tsx | 119 ++++++++++++++++++++++++------ src/styles/components.css | 51 ++++++++++--- 2 files changed, 135 insertions(+), 35 deletions(-) diff --git a/src/components/message-stream.tsx b/src/components/message-stream.tsx index d5a0d156..63686346 100644 --- a/src/components/message-stream.tsx +++ b/src/components/message-stream.tsx @@ -1,4 +1,4 @@ -import { For, Show, createSignal, createEffect, createMemo } from "solid-js" +import { For, Show, createSignal, createEffect, createMemo, onCleanup } from "solid-js" import type { Message, MessageDisplayParts } from "../types/message" import MessageItem from "./message-item" import ToolCall from "./tool-call" @@ -7,6 +7,8 @@ import Kbd from "./kbd" import { preferences } from "../stores/preferences" import { providers, getSessionInfo, computeDisplayParts } from "../stores/sessions" +const SCROLL_BOTTOM_OFFSET = 64 + // Calculate session tokens and cost from messagesInfo (matches TUI logic) function calculateSessionInfo(messagesInfo?: Map, instanceId?: string) { if (!messagesInfo || messagesInfo.size === 0) @@ -159,6 +161,7 @@ export default function MessageStream(props: MessageStreamProps) { let messageItemCache = new Map() let toolItemCache = new Map() + let scrollAnimationFrame: number | null = null const connectionStatus = () => sseManager.getStatus(props.instanceId) @@ -188,32 +191,50 @@ export default function MessageStream(props: MessageStreamProps) { ) }) - function scrollToBottom() { - if (containerRef) { - containerRef.scrollTop = containerRef.scrollHeight + function isNearBottom(element: HTMLDivElement, offset = SCROLL_BOTTOM_OFFSET) { + const { scrollTop, scrollHeight, clientHeight } = element + const distance = scrollHeight - (scrollTop + clientHeight) + return distance <= offset + } + + function scrollToBottom(options: { smooth?: boolean } = {}) { + if (!containerRef) return + + const behavior = options.smooth ? "smooth" : "auto" + + requestAnimationFrame(() => { + if (!containerRef) return + containerRef.scrollTo({ top: containerRef.scrollHeight, behavior }) setAutoScroll(true) setShowScrollButton(false) - } + }) } function handleScroll() { - // Scroll handling temporarily disabled during testing - // if (!containerRef) return - // - // const { scrollTop, scrollHeight, clientHeight } = containerRef - // const isAtBottom = scrollHeight - scrollTop - clientHeight < 50 - // - // setAutoScroll(isAtBottom) - // setShowScrollButton(!isAtBottom) + if (!containerRef) return + + if (scrollAnimationFrame !== null) { + cancelAnimationFrame(scrollAnimationFrame) + } + + scrollAnimationFrame = requestAnimationFrame(() => { + if (!containerRef) return + + const atBottom = isNearBottom(containerRef) + setAutoScroll(atBottom) + setShowScrollButton(!atBottom && displayItems().length > 0) + scrollAnimationFrame = null + }) } - const displayItems = createMemo(() => { + const messageView = createMemo(() => { // Ensure memo reacts to preference changes const showThinking = preferences().showThinkingBlocks const items: DisplayItem[] = [] const newMessageCache = new Map() const newToolCache = new Map() + const tokenSegments: string[] = [] let lastAssistantIndex = -1 for (let i = props.messages.length - 1; i >= 0; i--) { @@ -223,6 +244,10 @@ export default function MessageStream(props: MessageStreamProps) { } } + tokenSegments.push(`count:${props.messages.length}`) + tokenSegments.push(`revert:${props.revert?.messageID ?? ""}`) + tokenSegments.push(`thinking:${showThinking ? 1 : 0}`) + for (let index = 0; index < props.messages.length; index++) { const message = props.messages[index] const messageInfo = props.messagesInfo?.get(message.id) @@ -232,6 +257,8 @@ export default function MessageStream(props: MessageStreamProps) { break } + tokenSegments.push(`${message.id}:${message.version ?? 0}:${message.status}:${message.parts.length}`) + const baseDisplayParts = message.displayParts const displayParts: MessageDisplayParts = baseDisplayParts && baseDisplayParts.showThinking === showThinking @@ -308,16 +335,52 @@ export default function MessageStream(props: MessageStreamProps) { messageItemCache = newMessageCache toolItemCache = newToolCache - return items + tokenSegments.push(`items:${items.length}`) + + if (items.length > 0) { + const tail = items[items.length - 1] + if (tail.type === "message") { + tokenSegments.push(`tail:${tail.message.id}:${tail.message.version ?? 0}`) + } else { + tokenSegments.push(`tail:${tail.key}`) + } + } + + return { items, token: tokenSegments.join("|") } }) - const itemsLength = () => displayItems().length + const displayItems = () => messageView().items + const changeToken = () => messageView().token + + let previousToken: string | undefined createEffect(() => { - // Scroll handling temporarily disabled during testing - itemsLength() - // if (autoScroll()) { - // setTimeout(scrollToBottom, 0) - // } + const token = changeToken() + const shouldScroll = autoScroll() + + if (!token || token === previousToken) { + return + } + + previousToken = token + + if (!shouldScroll) { + return + } + + scrollToBottom() + }) + + createEffect(() => { + if (displayItems().length === 0) { + setShowScrollButton(false) + setAutoScroll(true) + } + }) + + onCleanup(() => { + if (scrollAnimationFrame !== null) { + cancelAnimationFrame(scrollAnimationFrame) + } }) return ( @@ -409,9 +472,17 @@ export default function MessageStream(props: MessageStreamProps) { - +
+ +
) diff --git a/src/styles/components.css b/src/styles/components.css index 2175973f..3bb7d04c 100644 --- a/src/styles/components.css +++ b/src/styles/components.css @@ -532,6 +532,46 @@ button.button-primary { color: inherit; } +.message-scroll-button-wrapper { + position: absolute; + right: 1rem; + bottom: 1rem; + display: flex; + justify-content: flex-end; +} + +.message-scroll-button { + @apply inline-flex items-center gap-2 font-medium; + padding: 0.5rem 0.875rem; + 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; +} + +.message-scroll-button:hover { + background-color: var(--surface-hover); + transform: translateY(-1px); +} + +.message-scroll-button:focus-visible { + outline: none; + box-shadow: 0 0 0 2px var(--surface-base), 0 0 0 4px var(--accent-primary); +} + +.message-scroll-icon { + font-size: var(--font-size-base); + 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; @@ -844,17 +884,6 @@ button.button-primary { font-weight: var(--font-weight-semibold); } -/* Scroll to bottom button */ -.scroll-to-bottom { - @apply absolute bottom-4 right-4 w-10 h-10 rounded-full border-none shadow-lg cursor-pointer text-xl flex items-center justify-center transition-transform; - background-color: var(--accent-primary); - color: white; -} - -.scroll-to-bottom:hover { - transform: scale(1.1); -} - /* Empty state */ .empty-state { @apply flex-1 flex items-center justify-center p-12;