Restore tool navigation and balanced scroll controls

This commit is contained in:
Shantur Rathore
2025-11-26 15:28:48 +00:00
parent fad2809299
commit 4e0e5dcdca
3 changed files with 210 additions and 319 deletions

View File

@@ -3,7 +3,7 @@ import MessageItem from "./message-item"
import ToolCall from "./tool-call"
import Kbd from "./kbd"
import type { MessageInfo, ClientPart } from "../types/message"
import { getSessionInfo } from "../stores/sessions"
import { getSessionInfo, sessions, setActiveParentSession, setActiveSession } from "../stores/sessions"
import { showCommandPalette } from "../stores/command-palette"
import { messageStoreBus } from "../stores/message-v2/bus"
import type { MessageRecord } from "../stores/message-v2/types"
@@ -12,24 +12,81 @@ import { useConfig } from "../stores/preferences"
import { sseManager } from "../lib/sse-manager"
import { formatTokenTotal } from "../lib/formatters"
import { useScrollCache } from "../lib/hooks/use-scroll-cache"
import { setActiveInstanceId } from "../stores/instances"
const SCROLL_SCOPE = "session"
const TOOL_ICON = "🔧"
const codeNomadLogo = new URL("../images/CodeNomad-Icon.png", import.meta.url).href
const INITIAL_BATCH_COUNT = 150
const PREPEND_CHUNK_COUNT = 50
const LOAD_MORE_THRESHOLD_PX = 320
const ESTIMATED_MESSAGE_HEIGHT = 120
const messageItemCache = new Map<string, MessageDisplayItem>()
const toolItemCache = new Map<string, ToolDisplayItem>()
type ToolState = import("@opencode-ai/sdk").ToolState
type ToolStateRunning = import("@opencode-ai/sdk").ToolStateRunning
type ToolStateCompleted = import("@opencode-ai/sdk").ToolStateCompleted
type ToolStateError = import("@opencode-ai/sdk").ToolStateError
function isToolStateRunning(state: ToolState | undefined): state is ToolStateRunning {
return Boolean(state && state.status === "running")
}
function isToolStateCompleted(state: ToolState | undefined): state is ToolStateCompleted {
return Boolean(state && state.status === "completed")
}
function isToolStateError(state: ToolState | undefined): state is ToolStateError {
return Boolean(state && state.status === "error")
}
function extractTaskSessionId(state: ToolState | undefined): string {
if (!state) return ""
const metadata = (state as unknown as { metadata?: Record<string, unknown> }).metadata ?? {}
const directId = metadata?.sessionId ?? metadata?.sessionID
return typeof directId === "string" ? directId : ""
}
interface TaskSessionLocation {
sessionId: string
instanceId: string
parentId: string | null
}
function findTaskSessionLocation(sessionId: string): TaskSessionLocation | null {
if (!sessionId) return null
const allSessions = sessions()
for (const [instanceId, sessionMap] of allSessions) {
const session = sessionMap?.get(sessionId)
if (session) {
return {
sessionId: session.id,
instanceId,
parentId: session.parentId ?? null,
}
}
}
return null
}
function navigateToTaskSession(location: TaskSessionLocation) {
setActiveInstanceId(location.instanceId)
const parentToActivate = location.parentId ?? location.sessionId
setActiveParentSession(location.instanceId, parentToActivate)
if (location.parentId) {
setActiveSession(location.instanceId, location.sessionId)
}
}
function formatTokens(tokens: number): string {
return formatTokenTotal(tokens)
}
function makeInstanceCacheKey(instanceId: string, id: string) {
return `${instanceId}:${id}`
}
function clearInstanceCaches(instanceId: string) {
clearRecordDisplayCacheForInstance(instanceId)
const prefix = `${instanceId}:`
for (const key of messageItemCache.keys()) {
@@ -46,11 +103,6 @@ function clearInstanceCaches(instanceId: string) {
messageStoreBus.onInstanceDestroyed(clearInstanceCaches)
function formatTokens(tokens: number): string {
return formatTokenTotal(tokens)
}
interface MessageStreamV2Props {
instanceId: string
sessionId: string
@@ -84,11 +136,6 @@ interface MessageDisplayBlock {
toolItems: ToolDisplayItem[]
}
interface MeasurementEntry {
revision: number
height: number
}
function hasRenderableContent(record: MessageRecord, combinedParts: ClientPart[], info?: MessageInfo): boolean {
if (record.role !== "assistant" && record.role !== "user") {
return false
@@ -112,62 +159,6 @@ export default function MessageStreamV2(props: MessageStreamV2Props) {
.filter((record): record is MessageRecord => Boolean(record)),
)
const [visibleRange, setVisibleRange] = createSignal({ start: 0, end: 0 })
const [rangeInitialized, setRangeInitialized] = createSignal(false)
const [forceFullHistory, setForceFullHistory] = createSignal(false)
const messageMeasurements = new Map<string, MeasurementEntry>()
const [measurementVersion, setMeasurementVersion] = createSignal(0)
const [virtualPadding, setVirtualPadding] = createSignal(0)
const [reachedAbsoluteTop, setReachedAbsoluteTop] = createSignal(false)
const showLoadOlderButton = createMemo(() => visibleRange().start > 0 && reachedAbsoluteTop())
function updateMeasurementCache(messageId: string, revision: number, height: number) {
const safeHeight = Math.max(0, height)
const existing = messageMeasurements.get(messageId)
if (existing && existing.revision === revision && Math.abs(existing.height - safeHeight) < 1) {
return
}
messageMeasurements.set(messageId, { revision, height: safeHeight })
setMeasurementVersion((value) => value + 1)
}
function getAverageMeasuredHeight() {
if (messageMeasurements.size === 0) {
return ESTIMATED_MESSAGE_HEIGHT
}
let total = 0
for (const entry of messageMeasurements.values()) {
total += entry.height
}
return total / messageMeasurements.size
}
const messageIndexMap = createMemo(() => {
const map = new Map<string, number>()
const records = messageRecords()
records.forEach((record, index) => map.set(record.id, index))
return map
})
const lastAssistantIndex = createMemo(() => {
const records = messageRecords()
for (let index = records.length - 1; index >= 0; index--) {
if (records[index].role === "assistant") {
return index
}
}
return -1
})
const visibleRecords = createMemo(() => {
const records = messageRecords()
const range = visibleRange()
if (range.end === 0) {
return records
}
return records.slice(range.start, range.end)
})
const sessionRevision = createMemo(() => store().getSessionRevision(props.sessionId))
const usageSnapshot = createMemo(() => store().getSessionUsage(props.sessionId))
const sessionInfo = createMemo(() =>
@@ -199,8 +190,7 @@ export default function MessageStreamV2(props: MessageStreamV2Props) {
const messageInfoMap = createMemo(() => {
const map = new Map<string, MessageInfo>()
const records = visibleRecords()
records.forEach((record) => {
messageRecords().forEach((record) => {
const info = store().getMessageInfo(record.id)
if (info) {
map.set(record.id, info)
@@ -210,26 +200,21 @@ export default function MessageStreamV2(props: MessageStreamV2Props) {
})
const revertTarget = createMemo(() => store().getSessionRevert(props.sessionId))
const scrollCache = useScrollCache({
instanceId: () => props.instanceId,
sessionId: () => props.sessionId,
scope: SCROLL_SCOPE,
const messageIndexMap = createMemo(() => {
const map = new Map<string, number>()
const records = messageRecords()
records.forEach((record, index) => map.set(record.id, index))
return map
})
let previousToken: string | undefined
createEffect(() => {
const sessionId = props.sessionId
store()
messageMeasurements.clear()
setMeasurementVersion((value) => value + 1)
setVirtualPadding(0)
setVisibleRange({ start: 0, end: 0 })
setRangeInitialized(false)
setReachedAbsoluteTop(false)
const snapshot = store().getScrollSnapshot(sessionId, SCROLL_SCOPE)
setForceFullHistory(Boolean(snapshot && !snapshot.atBottom))
previousToken = undefined
const lastAssistantIndex = createMemo(() => {
const records = messageRecords()
for (let index = records.length - 1; index >= 0; index--) {
if (records[index].role === "assistant") {
return index
}
}
return -1
})
const displayBlocks = createMemo<MessageDisplayBlock[]>(() => {
@@ -240,12 +225,11 @@ export default function MessageStreamV2(props: MessageStreamV2Props) {
const blocks: MessageDisplayBlock[] = []
const usedMessageKeys = new Set<string>()
const usedToolKeys = new Set<string>()
const records = visibleRecords()
const globalAssistantIndex = lastAssistantIndex()
const records = messageRecords()
const assistantIndex = lastAssistantIndex()
const indexMap = messageIndexMap()
for (let index = 0; index < records.length; index++) {
const record = records[index]
for (const record of records) {
if (revert?.messageID && record.id === revert.messageID) {
break
}
@@ -254,7 +238,7 @@ export default function MessageStreamV2(props: MessageStreamV2Props) {
const messageInfo = infoMap.get(record.id)
const recordCacheKey = makeInstanceCacheKey(instanceId, record.id)
const recordIndex = indexMap.get(record.id) ?? 0
const isQueued = record.role === "user" && (globalAssistantIndex === -1 || recordIndex > globalAssistantIndex)
const isQueued = record.role === "user" && (assistantIndex === -1 || recordIndex > assistantIndex)
let messageItem: MessageDisplayItem | null = null
if (hasRenderableContent(record, textAndReasoningParts, messageInfo)) {
@@ -285,8 +269,8 @@ export default function MessageStreamV2(props: MessageStreamV2Props) {
const partVersion = typeof toolPart.version === "number" ? toolPart.version : 0
const messageVersion = record.revision
const key = `${record.id}:${toolPart.id ?? toolIndex}`
const toolCacheKey = makeInstanceCacheKey(instanceId, key)
let toolItem = toolItemCache.get(toolCacheKey)
const cacheKey = makeInstanceCacheKey(instanceId, key)
let toolItem = toolItemCache.get(cacheKey)
if (!toolItem) {
toolItem = {
type: "tool",
@@ -297,7 +281,7 @@ export default function MessageStreamV2(props: MessageStreamV2Props) {
messageVersion,
partVersion,
}
toolItemCache.set(toolCacheKey, toolItem)
toolItemCache.set(cacheKey, toolItem)
} else {
toolItem.key = key
toolItem.toolPart = toolPart
@@ -307,7 +291,7 @@ export default function MessageStreamV2(props: MessageStreamV2Props) {
toolItem.partVersion = partVersion
}
toolItems.push(toolItem)
usedToolKeys.add(toolCacheKey)
usedToolKeys.add(cacheKey)
})
if (!messageItem && toolItems.length === 0) {
@@ -322,7 +306,6 @@ export default function MessageStreamV2(props: MessageStreamV2Props) {
messageItemCache.delete(key)
}
}
for (const key of toolItemCache.keys()) {
if (!usedToolKeys.has(key)) {
toolItemCache.delete(key)
@@ -332,126 +315,68 @@ export default function MessageStreamV2(props: MessageStreamV2Props) {
return blocks
})
createEffect(() => {
const records = messageRecords()
const total = records.length
const requireFullHistory = forceFullHistory()
if (total === 0) {
setVisibleRange({ start: 0, end: 0 })
setRangeInitialized(false)
return
}
setVisibleRange((current) => {
if (!rangeInitialized() || requireFullHistory) {
const start = requireFullHistory ? 0 : Math.max(0, total - INITIAL_BATCH_COUNT)
if (!rangeInitialized()) {
setRangeInitialized(true)
}
if (requireFullHistory) {
setForceFullHistory(false)
}
return { start, end: total }
}
const nextEnd = total
let nextStart = current.start
if (nextStart > nextEnd) {
nextStart = Math.max(0, nextEnd - INITIAL_BATCH_COUNT)
}
return { start: nextStart, end: nextEnd }
})
})
createEffect(() => {
measurementVersion()
const range = visibleRange()
if (range.start <= 0) {
setVirtualPadding(0)
return
}
const records = messageRecords()
const trimmed = records.slice(0, range.start)
if (trimmed.length === 0) {
setVirtualPadding(0)
return
}
const fallback = getAverageMeasuredHeight()
let total = 0
for (const record of trimmed) {
const entry = messageMeasurements.get(record.id)
total += entry?.height ?? fallback
}
setVirtualPadding(total)
})
const changeToken = createMemo(() => {
const revisionValue = sessionRevision()
const range = visibleRange()
const blocks = displayBlocks()
if (blocks.length === 0) {
return `${revisionValue}:${range.start}:${range.end}:empty`
return `${revisionValue}:empty`
}
const lastBlock = blocks[blocks.length - 1]
const lastTool = lastBlock.toolItems[lastBlock.toolItems.length - 1]
const tailSignature = lastTool
? `tool:${lastTool.key}:${lastTool.partVersion}`
: `msg:${lastBlock.record.id}:${lastBlock.record.revision}`
return `${revisionValue}:${range.start}:${range.end}:${tailSignature}`
return `${revisionValue}:${tailSignature}`
})
const scrollCache = useScrollCache({
instanceId: () => props.instanceId,
sessionId: () => props.sessionId,
scope: SCROLL_SCOPE,
})
const [autoScroll, setAutoScroll] = createSignal(true)
const [showScrollButton, setShowScrollButton] = createSignal(false)
const [showScrollTopButton, setShowScrollTopButton] = createSignal(false)
const [showScrollBottomButton, setShowScrollBottomButton] = createSignal(false)
let containerRef: HTMLDivElement | undefined
function captureScrollSnapshot() {
if (!containerRef) return { height: 0, top: 0 }
return { height: containerRef.scrollHeight, top: containerRef.scrollTop }
}
function restoreScrollSnapshot(snapshot?: { height: number; top: number }) {
if (!containerRef || !snapshot) return
requestAnimationFrame(() => {
if (!containerRef) return
const delta = containerRef.scrollHeight - snapshot.height
containerRef.scrollTop = snapshot.top + delta
})
}
function prependChunk(amount = PREPEND_CHUNK_COUNT) {
if (visibleRange().start === 0) {
return
}
const snapshot = captureScrollSnapshot()
setVisibleRange((range) => {
if (range.start === 0) {
return range
}
const nextStart = Math.max(0, range.start - amount)
return { start: nextStart, end: range.end }
})
restoreScrollSnapshot(snapshot)
}
function loadAllOlderMessages() {
if (visibleRange().start === 0) {
return
}
const snapshot = captureScrollSnapshot()
setVisibleRange((range) => ({ start: 0, end: range.end }))
restoreScrollSnapshot(snapshot)
}
function isNearBottom(element: HTMLDivElement, offset = 48) {
const { scrollTop, scrollHeight, clientHeight } = element
return scrollHeight - (scrollTop + clientHeight) <= offset
}
function isNearTop(element: HTMLDivElement, offset = 48) {
return element.scrollTop <= offset
}
function updateScrollIndicators(element: HTMLDivElement) {
const hasItems = displayBlocks().length > 0
setShowScrollBottomButton(hasItems && !isNearBottom(element))
setShowScrollTopButton(hasItems && !isNearTop(element))
}
function scrollToBottom(immediate = false) {
if (!containerRef) return
const behavior = immediate ? "auto" : "smooth"
containerRef.scrollTo({ top: containerRef.scrollHeight, behavior })
setAutoScroll(true)
persistScrollState()
requestAnimationFrame(() => {
if (!containerRef) return
updateScrollIndicators(containerRef)
persistScrollState()
})
}
function scrollToTop(immediate = false) {
if (!containerRef) return
const behavior = immediate ? "auto" : "smooth"
setAutoScroll(false)
containerRef.scrollTo({ top: 0, behavior })
requestAnimationFrame(() => {
if (!containerRef) return
updateScrollIndicators(containerRef)
persistScrollState()
})
}
function persistScrollState() {
@@ -461,22 +386,19 @@ export default function MessageStreamV2(props: MessageStreamV2Props) {
function handleScroll(event: Event) {
if (!containerRef) return
const atBottom = isNearBottom(containerRef)
setShowScrollButton(!atBottom)
const atAbsoluteTop = containerRef.scrollTop <= 4
setReachedAbsoluteTop(atAbsoluteTop)
updateScrollIndicators(containerRef)
if (event.isTrusted) {
setAutoScroll(atBottom)
if (containerRef.scrollTop <= LOAD_MORE_THRESHOLD_PX && visibleRange().start > 0) {
prependChunk()
const atBottom = isNearBottom(containerRef)
if (!atBottom) {
setAutoScroll(false)
} else {
setAutoScroll(true)
}
}
persistScrollState()
}
createEffect(() => {
const sessionId = props.sessionId
store()
const target = containerRef
if (!target) return
scrollCache.restore(target, {
@@ -484,17 +406,16 @@ export default function MessageStreamV2(props: MessageStreamV2Props) {
onApplied: (snapshot) => {
if (snapshot) {
setAutoScroll(snapshot.atBottom)
setShowScrollButton(!snapshot.atBottom)
} else {
const atBottom = isNearBottom(target)
setAutoScroll(atBottom)
setShowScrollButton(!atBottom)
}
updateScrollIndicators(target)
},
})
void sessionId
})
let previousToken: string | undefined
createEffect(() => {
const token = changeToken()
if (!token || token === previousToken) {
@@ -508,7 +429,8 @@ export default function MessageStreamV2(props: MessageStreamV2Props) {
createEffect(() => {
if (messageRecords().length === 0) {
setShowScrollButton(false)
setShowScrollTopButton(false)
setShowScrollBottomButton(false)
setAutoScroll(true)
}
})
@@ -572,7 +494,7 @@ export default function MessageStreamV2(props: MessageStreamV2Props) {
}}
onScroll={handleScroll}
>
<Show when={!props.loading && messageRecords().length === 0}>
<Show when={!props.loading && displayBlocks().length === 0}>
<div class="empty-state">
<div class="empty-state-content">
<div class="flex flex-col items-center gap-3 mb-6">
@@ -602,64 +524,40 @@ export default function MessageStreamV2(props: MessageStreamV2Props) {
</div>
</Show>
<Show when={virtualPadding() > 0}>
<div class="message-stream-virtual-padding" style={{ height: `${virtualPadding()}px` }} aria-hidden="true" />
</Show>
<Show when={showLoadOlderButton()}>
<div class="message-stream-load-older">
<button type="button" class="message-stream-load-older-button" onClick={loadAllOlderMessages}>
Load older messages
</button>
</div>
</Show>
<For each={displayBlocks()}>
{(block) => {
let blockRef: HTMLDivElement | undefined
{(block) => (
<div class="message-stream-block" data-message-id={block.record.id}>
<Show when={block.messageItem} keyed>
{(message) => (
<MessageItem
record={message.record}
messageInfo={message.messageInfo}
combinedParts={message.combinedParts}
orderedParts={message.orderedParts}
instanceId={props.instanceId}
sessionId={props.sessionId}
isQueued={message.isQueued}
onRevert={props.onRevert}
onFork={props.onFork}
/>
)}
</Show>
const scheduleMeasurement = () => {
if (!blockRef) return
requestAnimationFrame(() => {
if (!blockRef) return
updateMeasurementCache(block.record.id, block.record.revision, blockRef.clientHeight)
})
}
createEffect(() => {
void block.record.revision
scheduleMeasurement()
})
return (
<div
class="message-stream-block"
data-message-id={block.record.id}
ref={(element) => {
blockRef = element || undefined
if (element) {
scheduleMeasurement()
<For each={block.toolItems}>
{(item) => {
const toolState = item.toolPart.state as ToolState | undefined
const hasToolState =
Boolean(toolState) && (isToolStateRunning(toolState) || isToolStateCompleted(toolState) || isToolStateError(toolState))
const taskSessionId = hasToolState ? extractTaskSessionId(toolState) : ""
const taskLocation = taskSessionId ? findTaskSessionLocation(taskSessionId) : null
const handleGoToTaskSession = (event: MouseEvent) => {
event.preventDefault()
event.stopPropagation()
if (!taskLocation) return
navigateToTaskSession(taskLocation)
}
}}
>
<Show when={block.messageItem} keyed>
{(message) => (
<MessageItem
record={message.record}
messageInfo={message.messageInfo}
combinedParts={message.combinedParts}
orderedParts={message.orderedParts}
instanceId={props.instanceId}
sessionId={props.sessionId}
isQueued={message.isQueued}
onRevert={props.onRevert}
onFork={props.onFork}
/>
)}
</Show>
<For each={block.toolItems}>
{(item) => (
return (
<div class="tool-call-message" data-key={item.key}>
<div class="tool-call-header-label">
<div class="tool-call-header-meta">
@@ -667,6 +565,17 @@ export default function MessageStreamV2(props: MessageStreamV2Props) {
<span>Tool Call</span>
<span class="tool-name">{item.toolPart.tool || "unknown"}</span>
</div>
<Show when={taskSessionId}>
<button
class="tool-call-header-button"
type="button"
disabled={!taskLocation}
onClick={handleGoToTaskSession}
title={!taskLocation ? "Session not available yet" : "Go to session"}
>
Go to Session
</button>
</Show>
</div>
<ToolCall
toolCall={item.toolPart}
@@ -678,21 +587,40 @@ export default function MessageStreamV2(props: MessageStreamV2Props) {
sessionId={props.sessionId}
/>
</div>
)}
</For>
</div>
)
}}
)
}}
</For>
</div>
)}
</For>
</div>
<Show when={showScrollButton()}>
<Show when={showScrollTopButton() || showScrollBottomButton()}>
<div class="message-scroll-button-wrapper">
<button type="button" class="message-scroll-button" onClick={() => scrollToBottom()} aria-label="Scroll to latest message">
<span class="message-scroll-icon" aria-hidden="true">
</span>
</button>
<Show when={showScrollTopButton()}>
<button
type="button"
class="message-scroll-button"
onClick={() => scrollToTop()}
aria-label="Scroll to first message"
>
<span class="message-scroll-icon" aria-hidden="true">
</span>
</button>
</Show>
<Show when={showScrollBottomButton()}>
<button
type="button"
class="message-scroll-button"
onClick={() => scrollToBottom()}
aria-label="Scroll to latest message"
>
<span class="message-scroll-icon" aria-hidden="true">
</span>
</button>
</Show>
</div>
</Show>
</div>

View File

@@ -40,14 +40,12 @@ const TOOL_CALL_CACHE_SCOPE = "tool-call"
function makeRenderCacheKey(
toolCallId?: string | null,
messageId?: string,
messageVersion?: number,
partVersion?: number,
partId?: string | null,
variant = "default",
) {
const messageComponent = messageId ?? "unknown-message"
const toolCallComponent = toolCallId ?? "unknown-tool-call"
const versionComponent = `${messageVersion ?? 0}:${partVersion ?? 0}`
return `${messageComponent}:${toolCallComponent}:${versionComponent}:${variant}`
const toolCallComponent = partId ?? toolCallId ?? "unknown-tool-call"
return `${messageComponent}:${toolCallComponent}:${variant}`
}
@@ -326,8 +324,7 @@ export default function ToolCall(props: ToolCallProps) {
const cacheContext = createMemo(() => ({
toolCallId: toolCallId(),
messageId: props.messageId,
messageVersion: props.messageVersion ?? 0,
partVersion: props.partVersion ?? 0,
partId: props.toolCall?.id ?? null,
}))
const createVariantCache = (variant: string) =>
@@ -337,20 +334,14 @@ export default function ToolCall(props: ToolCallProps) {
scope: TOOL_CALL_CACHE_SCOPE,
key: () => {
const context = cacheContext()
return makeRenderCacheKey(
context.toolCallId || undefined,
context.messageId,
context.messageVersion,
context.partVersion,
variant,
)
return makeRenderCacheKey(context.toolCallId || undefined, context.messageId, context.partId, variant)
},
})
const diffCache = createVariantCache("diff")
const permissionDiffCache = createVariantCache("permission-diff")
const markdownCache = createVariantCache("markdown")
const permissionState = createMemo(() => store().getPermissionState(props.messageId, toolCallId() || props.toolCall?.id))
const permissionState = createMemo(() => store().getPermissionState(props.messageId, props.toolCall?.id))
const pendingPermission = createMemo(() => {
const state = permissionState()
if (state) {

View File

@@ -70,40 +70,12 @@
color: inherit;
}
.message-stream-virtual-padding {
width: 100%;
flex-shrink: 0;
}
.message-stream-block {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.message-stream-load-older {
display: flex;
justify-content: center;
padding: 0.5rem 0;
}
.message-stream-load-older-button {
@apply inline-flex items-center justify-center rounded-md border text-sm font-medium px-3 py-1.5 transition-colors;
border-color: var(--border-base);
background-color: var(--surface-base);
color: var(--text-secondary);
}
.message-stream-load-older-button:hover {
background-color: var(--surface-hover);
color: var(--text-primary);
}
.message-stream-load-older-button:focus-visible {
outline: none;
box-shadow: 0 0 0 2px var(--surface-base), 0 0 0 4px var(--accent-primary);
}
.message-scroll-button-wrapper {
position: absolute;
right: 1rem;