feat: auto-scroll message stream on change detection
This commit is contained in:
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user