feat: auto-scroll message stream on change detection

This commit is contained in:
Shantur Rathore
2025-10-29 16:07:05 +00:00
parent e406c9f76d
commit a522e9f45b
2 changed files with 135 additions and 35 deletions

View File

@@ -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<string, any>, instanceId?: string) {
if (!messagesInfo || messagesInfo.size === 0)
@@ -159,6 +161,7 @@ export default function MessageStream(props: MessageStreamProps) {
let messageItemCache = new Map<string, MessageCacheEntry>()
let toolItemCache = new Map<string, ToolCacheEntry>()
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<string, MessageCacheEntry>()
const newToolCache = new Map<string, ToolCacheEntry>()
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) {
</div>
<Show when={showScrollButton()}>
<button class="scroll-to-bottom" onClick={scrollToBottom} aria-label="Scroll to bottom">
</button>
<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>
</div>
</Show>
</div>
)

View File

@@ -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;