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 type { Message, MessageDisplayParts } from "../types/message"
|
||||||
import MessageItem from "./message-item"
|
import MessageItem from "./message-item"
|
||||||
import ToolCall from "./tool-call"
|
import ToolCall from "./tool-call"
|
||||||
@@ -7,6 +7,8 @@ import Kbd from "./kbd"
|
|||||||
import { preferences } from "../stores/preferences"
|
import { preferences } from "../stores/preferences"
|
||||||
import { providers, getSessionInfo, computeDisplayParts } from "../stores/sessions"
|
import { providers, getSessionInfo, computeDisplayParts } from "../stores/sessions"
|
||||||
|
|
||||||
|
const SCROLL_BOTTOM_OFFSET = 64
|
||||||
|
|
||||||
// Calculate session tokens and cost from messagesInfo (matches TUI logic)
|
// Calculate session tokens and cost from messagesInfo (matches TUI logic)
|
||||||
function calculateSessionInfo(messagesInfo?: Map<string, any>, instanceId?: string) {
|
function calculateSessionInfo(messagesInfo?: Map<string, any>, instanceId?: string) {
|
||||||
if (!messagesInfo || messagesInfo.size === 0)
|
if (!messagesInfo || messagesInfo.size === 0)
|
||||||
@@ -159,6 +161,7 @@ export default function MessageStream(props: MessageStreamProps) {
|
|||||||
|
|
||||||
let messageItemCache = new Map<string, MessageCacheEntry>()
|
let messageItemCache = new Map<string, MessageCacheEntry>()
|
||||||
let toolItemCache = new Map<string, ToolCacheEntry>()
|
let toolItemCache = new Map<string, ToolCacheEntry>()
|
||||||
|
let scrollAnimationFrame: number | null = null
|
||||||
|
|
||||||
const connectionStatus = () => sseManager.getStatus(props.instanceId)
|
const connectionStatus = () => sseManager.getStatus(props.instanceId)
|
||||||
|
|
||||||
@@ -188,32 +191,50 @@ export default function MessageStream(props: MessageStreamProps) {
|
|||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
function scrollToBottom() {
|
function isNearBottom(element: HTMLDivElement, offset = SCROLL_BOTTOM_OFFSET) {
|
||||||
if (containerRef) {
|
const { scrollTop, scrollHeight, clientHeight } = element
|
||||||
containerRef.scrollTop = containerRef.scrollHeight
|
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)
|
setAutoScroll(true)
|
||||||
setShowScrollButton(false)
|
setShowScrollButton(false)
|
||||||
}
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleScroll() {
|
function handleScroll() {
|
||||||
// Scroll handling temporarily disabled during testing
|
if (!containerRef) return
|
||||||
// if (!containerRef) return
|
|
||||||
//
|
if (scrollAnimationFrame !== null) {
|
||||||
// const { scrollTop, scrollHeight, clientHeight } = containerRef
|
cancelAnimationFrame(scrollAnimationFrame)
|
||||||
// const isAtBottom = scrollHeight - scrollTop - clientHeight < 50
|
}
|
||||||
//
|
|
||||||
// setAutoScroll(isAtBottom)
|
scrollAnimationFrame = requestAnimationFrame(() => {
|
||||||
// setShowScrollButton(!isAtBottom)
|
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
|
// Ensure memo reacts to preference changes
|
||||||
const showThinking = preferences().showThinkingBlocks
|
const showThinking = preferences().showThinkingBlocks
|
||||||
|
|
||||||
const items: DisplayItem[] = []
|
const items: DisplayItem[] = []
|
||||||
const newMessageCache = new Map<string, MessageCacheEntry>()
|
const newMessageCache = new Map<string, MessageCacheEntry>()
|
||||||
const newToolCache = new Map<string, ToolCacheEntry>()
|
const newToolCache = new Map<string, ToolCacheEntry>()
|
||||||
|
const tokenSegments: string[] = []
|
||||||
|
|
||||||
let lastAssistantIndex = -1
|
let lastAssistantIndex = -1
|
||||||
for (let i = props.messages.length - 1; i >= 0; i--) {
|
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++) {
|
for (let index = 0; index < props.messages.length; index++) {
|
||||||
const message = props.messages[index]
|
const message = props.messages[index]
|
||||||
const messageInfo = props.messagesInfo?.get(message.id)
|
const messageInfo = props.messagesInfo?.get(message.id)
|
||||||
@@ -232,6 +257,8 @@ export default function MessageStream(props: MessageStreamProps) {
|
|||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|
||||||
|
tokenSegments.push(`${message.id}:${message.version ?? 0}:${message.status}:${message.parts.length}`)
|
||||||
|
|
||||||
const baseDisplayParts = message.displayParts
|
const baseDisplayParts = message.displayParts
|
||||||
const displayParts: MessageDisplayParts =
|
const displayParts: MessageDisplayParts =
|
||||||
baseDisplayParts && baseDisplayParts.showThinking === showThinking
|
baseDisplayParts && baseDisplayParts.showThinking === showThinking
|
||||||
@@ -308,16 +335,52 @@ export default function MessageStream(props: MessageStreamProps) {
|
|||||||
messageItemCache = newMessageCache
|
messageItemCache = newMessageCache
|
||||||
toolItemCache = newToolCache
|
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(() => {
|
createEffect(() => {
|
||||||
// Scroll handling temporarily disabled during testing
|
const token = changeToken()
|
||||||
itemsLength()
|
const shouldScroll = autoScroll()
|
||||||
// if (autoScroll()) {
|
|
||||||
// setTimeout(scrollToBottom, 0)
|
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 (
|
return (
|
||||||
@@ -409,9 +472,17 @@ export default function MessageStream(props: MessageStreamProps) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Show when={showScrollButton()}>
|
<Show when={showScrollButton()}>
|
||||||
<button class="scroll-to-bottom" onClick={scrollToBottom} aria-label="Scroll to bottom">
|
<div class="message-scroll-button-wrapper">
|
||||||
↓
|
<button
|
||||||
</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>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -532,6 +532,46 @@ button.button-primary {
|
|||||||
color: inherit;
|
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 wrapper */
|
||||||
.tool-call-message {
|
.tool-call-message {
|
||||||
@apply flex flex-col gap-2 p-3 rounded-lg w-full;
|
@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);
|
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 */
|
||||||
.empty-state {
|
.empty-state {
|
||||||
@apply flex-1 flex items-center justify-center p-12;
|
@apply flex-1 flex items-center justify-center p-12;
|
||||||
|
|||||||
Reference in New Issue
Block a user