fix: reduce desktop jank for long sessions

Long transcripts were triggering avoidable layout work during tab switches and auto-scroll because MessageList read scroll geometry while the browser still had a large message tree to lay out. The tab bar also subscribed to full chat session state, so streaming payload churn could invalidate tab chrome even when running status did not change.

This keeps variable-height transcript rows fully mounted, adds browser offscreen rendering hints, scrolls to bottom through clamped large offsets instead of synchronous geometry reads, and narrows TabBar's chat-store subscription to running-state ids.

Constraint: Prior fixed-height virtualization left blank spacer gaps for variable-height chat rows.
Rejected: Reintroduce fixed-height virtualization | would regress existing long transcript rendering behavior.
Confidence: high
Scope-risk: moderate
Directive: Do not replace this with fixed row-height virtualization without measured row heights and scroll-restore coverage.
Tested: bun run check:desktop
Tested: Chrome DevTools trace on real session 055015e4-6093-418e-9944-19bba25ecb69 with INP 84ms to 43ms and ForcedReflow insight removed.
Not-tested: Packaged Tauri WebView runtime beyond the local web UI trace.
This commit is contained in:
程序员阿江(Relakkes) 2026-05-23 17:56:16 +08:00
parent 77552375e9
commit 0231e7b61d
4 changed files with 207 additions and 26 deletions

View File

@ -153,6 +153,39 @@ describe('MessageList nested tool calls', () => {
expect(container.querySelector('[data-virtual-message-item]')).toBeNull()
})
it('marks transcript items as offscreen-renderable without virtualizing variable-height rows', () => {
useChatStore.setState({
sessions: {
[ACTIVE_TAB]: makeSessionState({
messages: [
{
id: 'assistant-1',
type: 'assistant_text',
content: 'first assistant reply',
timestamp: 1,
},
{
id: 'assistant-2',
type: 'assistant_text',
content: 'second assistant reply',
timestamp: 2,
},
],
}),
},
})
const { container } = render(<MessageList />)
const renderItems = container.querySelectorAll('.chat-render-item')
expect(renderItems).toHaveLength(2)
for (const item of renderItems) {
expect(item.className).toContain('[content-visibility:auto]')
expect(item.className).toContain('[contain-intrinsic-size:auto_180px]')
}
expect(container.querySelector('[data-virtual-message-item]')).toBeNull()
})
it('filters duplicate unresolved AskUserQuestion cards while a matching permission is pending', () => {
const messages: UIMessage[] = [
{
@ -1734,6 +1767,78 @@ describe('MessageList nested tool calls', () => {
expect(scrollTop).toBe(600)
})
it('keeps auto-scrolling without reading scroll geometry synchronously', async () => {
useChatStore.setState({
sessions: {
[ACTIVE_TAB]: makeSessionState({
chatState: 'streaming',
messages: [
{
id: 'user-1',
type: 'user_text',
content: 'latest prompt',
timestamp: 1,
},
],
streamingText: 'streaming',
}),
},
})
const { container } = render(<MessageList />)
const scroller = container.querySelector('.overflow-y-auto') as HTMLDivElement
const readScrollHeight = vi.fn(() => {
throw new Error('scrollHeight should not be read while pinning to bottom')
})
const readClientHeight = vi.fn(() => {
throw new Error('clientHeight should not be read while pinning to bottom')
})
let scrollTop = 552
Object.defineProperty(scroller, 'scrollHeight', {
configurable: true,
get: readScrollHeight,
})
Object.defineProperty(scroller, 'clientHeight', {
configurable: true,
get: readClientHeight,
})
Object.defineProperty(scroller, 'scrollTop', {
configurable: true,
get: () => scrollTop,
set: (value) => {
scrollTop = value >= 1_000_000_000 ? 600 : value
},
})
Object.defineProperty(scroller, 'scrollTo', {
configurable: true,
value: vi.fn((options: ScrollToOptions | number, y?: number) => {
scroller.scrollTop = typeof options === 'number' ? y ?? 0 : options.top ?? 0
}),
})
await waitForProgrammaticScrollReset()
act(() => {
useChatStore.setState((state) => ({
sessions: {
...state.sessions,
[ACTIVE_TAB]: {
...state.sessions[ACTIVE_TAB]!,
streamingText: 'streaming next token',
},
},
}))
})
await waitFor(() => {
expect(screen.getByText('streaming next token')).toBeTruthy()
})
await waitForProgrammaticScrollReset()
expect(scrollTop).toBe(600)
expect(readScrollHeight).not.toHaveBeenCalled()
expect(readClientHeight).not.toHaveBeenCalled()
})
it('keeps mobile H5 streaming output pinned after the transcript height grows', async () => {
const scrollIntoView = vi.fn()
Object.defineProperty(HTMLElement.prototype, 'scrollIntoView', {

View File

@ -782,6 +782,7 @@ type MessageListProps = {
}
const AUTO_SCROLL_BOTTOM_THRESHOLD_PX = 48
const SCROLL_BOTTOM_SENTINEL = 1_000_000_000
const MAX_SCROLL_SNAPSHOTS = 100
const EMPTY_MESSAGES: UIMessage[] = []
const CHAT_SCROLL_AREA_CLASS = [
@ -798,6 +799,11 @@ const CHAT_SCROLL_AREA_CLASS = [
'[&::-webkit-scrollbar-thumb:hover]:border-2',
'[&::-webkit-scrollbar-thumb:hover]:bg-[color-mix(in_srgb,var(--color-outline)_90%,transparent)]',
].join(' ')
const CHAT_RENDER_ITEM_CLASS = [
'chat-render-item',
'[content-visibility:auto]',
'[contain-intrinsic-size:auto_180px]',
].join(' ')
type SessionScrollSnapshot = {
scrollTop: number
@ -827,14 +833,32 @@ function rememberSessionScroll(sessionId: string, element: HTMLElement) {
})
}
function clampScrollTop(element: HTMLElement, scrollTop: number) {
return Math.max(0, Math.min(scrollTop, getBottomScrollTop(element)))
}
function getBottomScrollTop(element: HTMLElement) {
return Math.max(0, element.scrollHeight - element.clientHeight)
}
function setScrollTopWithoutLayoutRead(element: HTMLElement, scrollTop: number) {
element.scrollTop = Math.max(0, scrollTop)
}
function setScrollToBottomWithoutLayoutRead(element: HTMLElement, behavior: ScrollBehavior) {
if (typeof element.scrollTo === 'function') {
try {
element.scrollTo({ top: SCROLL_BOTTOM_SENTINEL, behavior })
} catch {
element.scrollTo(0, SCROLL_BOTTOM_SENTINEL)
}
}
element.scrollTop = SCROLL_BOTTOM_SENTINEL
// Browsers clamp the large value to the true bottom without needing us to
// synchronously read layout metrics. JSDOM test doubles do not clamp, so keep
// the old numeric behavior there as a fallback.
if (element.scrollTop === SCROLL_BOTTOM_SENTINEL) {
element.scrollTop = getBottomScrollTop(element)
}
}
export function MessageList({ sessionId, compact = false }: MessageListProps = {}) {
const activeTabId = useTabStore((s) => s.activeTabId)
const resolvedSessionId = sessionId ?? activeTabId
@ -893,21 +917,14 @@ export function MessageList({ sessionId, compact = false }: MessageListProps = {
shouldAutoScrollRef.current = true
isProgrammaticScrollingRef.current = true
const container = scrollContainerRef.current
const targetScrollTop = container ? getBottomScrollTop(container) : null
let requestedScrollTop: number | null = null
if (container) {
const nextScrollTop = targetScrollTop ?? 0
if (typeof container.scrollTo === 'function') {
try {
container.scrollTo({ top: nextScrollTop, behavior })
} catch {
container.scrollTo(0, nextScrollTop)
}
}
container.scrollTop = nextScrollTop
setScrollToBottomWithoutLayoutRead(container, behavior)
requestedScrollTop = container.scrollTop
}
if (container && resolvedSessionId) {
sessionScrollSnapshots.set(resolvedSessionId, {
scrollTop: getBottomScrollTop(container),
scrollTop: container.scrollTop,
wasAtBottom: true,
})
}
@ -919,15 +936,14 @@ export function MessageList({ sessionId, compact = false }: MessageListProps = {
shouldAutoScrollRef.current &&
latestContainer &&
(
targetScrollTop === null ||
latestContainer.scrollTop === targetScrollTop ||
isNearScrollBottom(latestContainer)
requestedScrollTop === null ||
latestContainer.scrollTop === requestedScrollTop
)
) {
latestContainer.scrollTop = getBottomScrollTop(latestContainer)
setScrollToBottomWithoutLayoutRead(latestContainer, 'auto')
if (resolvedSessionId) {
sessionScrollSnapshots.set(resolvedSessionId, {
scrollTop: getBottomScrollTop(latestContainer),
scrollTop: latestContainer.scrollTop,
wasAtBottom: true,
})
}
@ -959,7 +975,7 @@ export function MessageList({ sessionId, compact = false }: MessageListProps = {
const container = scrollContainerRef.current
if (container && snapshot && !snapshot.wasAtBottom) {
container.scrollTop = clampScrollTop(container, snapshot.scrollTop)
setScrollTopWithoutLayoutRead(container, snapshot.scrollTop)
setShowJumpToLatest(true)
} else {
scrollToBottom('auto')
@ -1197,7 +1213,7 @@ export function MessageList({ sessionId, compact = false }: MessageListProps = {
const cardsForItem = turnCardsByRenderIndex.get(index) ?? []
return (
<div key={itemKey}>
<div key={itemKey} className={CHAT_RENDER_ITEM_CLASS}>
{item.kind === 'tool_group' ? (
<ToolCallGroup
toolCalls={item.toolCalls}

View File

@ -357,6 +357,59 @@ describe('TabBar', () => {
expect(openProjectMenuMock.paths[openProjectMenuMock.paths.length - 1]).toBe('/repo/worktree')
})
it('does not rerender for chat payload changes when tab running state is unchanged', async () => {
const { TabBar } = await import('./TabBar')
const { useTabStore } = await import('../../stores/tabStore')
const { useChatStore } = await import('../../stores/chatStore')
const { useSessionStore } = await import('../../stores/sessionStore')
useTabStore.setState({
tabs: [
{ sessionId: 'tab-1', title: 'Workspace Session', type: 'session', status: 'idle' },
],
activeTabId: 'tab-1',
})
useChatStore.setState({
sessions: {
'tab-1': makeChatSession('idle'),
},
disconnectSession: vi.fn(),
} as Partial<ReturnType<typeof useChatStore.getState>>)
useSessionStore.setState({
sessions: [{
id: 'tab-1',
title: 'Workspace Session',
createdAt: '2026-05-13T00:00:00.000Z',
modifiedAt: '2026-05-13T00:00:00.000Z',
messageCount: 0,
projectPath: '/repo',
workDir: '/repo/worktree',
workDirExists: true,
}],
activeSessionId: 'tab-1',
})
await act(async () => {
render(<TabBar />)
})
expect(openProjectMenuMock.paths[openProjectMenuMock.paths.length - 1]).toBe('/repo/worktree')
openProjectMenuMock.paths = []
await act(async () => {
useChatStore.setState((state) => ({
sessions: {
...state.sessions,
'tab-1': {
...state.sessions['tab-1']!,
streamingText: 'token churn should not affect tab chrome',
},
},
}))
})
expect(openProjectMenuMock.paths).toEqual([])
})
it('hides the open-project control when the active session workdir is unavailable', async () => {
const { TabBar } = await import('./TabBar')
const { useTabStore } = await import('../../stores/tabStore')

View File

@ -1,4 +1,5 @@
import { forwardRef, useMemo, useRef, useState, useEffect, useCallback } from 'react'
import { useShallow } from 'zustand/react/shallow'
import {
SCHEDULED_TAB_ID,
SETTINGS_TAB_ID,
@ -44,7 +45,13 @@ export function TabBar() {
const activeTabId = useTabStore((s) => s.activeTabId)
const setActiveTab = useTabStore((s) => s.setActiveTab)
const closeTab = useTabStore((s) => s.closeTab)
const chatSessions = useChatStore((s) => s.sessions)
const sessionTabIds = useMemo(
() => tabs.filter((tab) => isSessionTab(tab)).map((tab) => tab.sessionId),
[tabs],
)
const activeChatSessionIds = useChatStore(useShallow((s) =>
sessionTabIds.filter((sessionId) => s.sessions[sessionId]?.chatState !== 'idle')
))
const disconnectSession = useChatStore((s) => s.disconnectSession)
const activeTab = tabs.find((tab) => tab.sessionId === activeTabId) ?? null
const isActiveSessionTab = isSessionTab(activeTab) || isSessionTabId(activeTabId)
@ -81,11 +88,11 @@ export function TabBar() {
for (const tab of tabs) {
if (isSessionTab(tab) && tab.status === 'running') ids.add(tab.sessionId)
}
for (const [sessionId, sessionState] of Object.entries(chatSessions)) {
if (sessionState.chatState !== 'idle') ids.add(sessionId)
for (const sessionId of activeChatSessionIds) {
ids.add(sessionId)
}
return ids
}, [chatSessions, tabs])
}, [activeChatSessionIds, tabs])
useEffect(() => {
if (!isTauri) return