Merge desktop long-session performance fix into local main

Bring the validated detached-worktree desktop performance patch into the canonical local main checkout so long-session tab and scroll fixes are available from the main working copy.

Constraint: Local main had advanced independently beyond the detached worktree base, so fast-forward was not the correct landing shape.
Confidence: high
Scope-risk: moderate
Directive: Verify ancestry for detached-worktree landings because main may advance after the feature worktree is created.
Tested: bun run check:desktop in detached worktree before merge.
Not-tested: Post-merge desktop gate before this merge commit was created.
This commit is contained in:
程序员阿江(Relakkes) 2026-05-23 17:56:39 +08:00
commit 385435a04d
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