mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-17 13:13:35 +08:00
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:
parent
77552375e9
commit
0231e7b61d
@ -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', {
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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')
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user