Restore reliable long chat scrolling

The fixed-height virtual window introduced after v0.2.6 assumed every rendered chat item was 96px tall. Real desktop messages include markdown, math, code blocks, tool groups, and agent cards with highly variable heights, so scrolling history could replace real content with incorrectly sized blank spacer regions and make tab restoration janky.

Remove that unsafe virtualization layer and keep the existing direct scroll restoration behavior. The regression tests now keep long mixed-height transcripts and tool-call histories mounted so variable-height rows cannot disappear behind fake spacer math again.

Constraint: Desktop chat rows have unbounded variable height from markdown, KaTeX, code, tool, and agent output.
Rejected: Tune the 96px estimate | any constant row height still fails for mixed markdown and tool output.
Rejected: Add measured virtualization now | larger change surface than needed for the production white-gap regression.
Confidence: high
Scope-risk: narrow
Directive: Do not reintroduce chat virtualization without real row-height measurement and scroll restore coverage.
Tested: cd desktop && bun run test -- MessageList.test.tsx
Tested: cd desktop && bun run lint
Tested: cd desktop && bun run build
Tested: git diff --check
This commit is contained in:
程序员阿江(Relakkes) 2026-05-16 16:45:59 +08:00
parent 7f74f1bde7
commit 5e899bb1a1
2 changed files with 19 additions and 86 deletions

View File

@ -120,14 +120,22 @@ describe('MessageList nested tool calls', () => {
})
})
it('window-renders long transcripts while keeping the latest messages visible', () => {
it('keeps full long transcripts mounted so variable-height messages cannot leave spacer gaps', () => {
useChatStore.setState({
sessions: {
[ACTIVE_TAB]: makeSessionState({
messages: Array.from({ length: 220 }, (_, index) => ({
id: `assistant-${index}`,
type: 'assistant_text',
content: `assistant transcript line ${index}`,
content: index % 25 === 0
? [
`assistant transcript line ${index}`,
'',
'```ts',
'const value = "this intentionally makes the row much taller"',
'```',
].join('\n')
: `assistant transcript line ${index}`,
timestamp: index,
})),
}),
@ -136,9 +144,10 @@ describe('MessageList nested tool calls', () => {
const { container } = render(<MessageList />)
expect(screen.queryByText('assistant transcript line 0')).toBeNull()
expect(screen.getByText('assistant transcript line 0')).toBeTruthy()
expect(screen.getByText('assistant transcript line 219')).toBeTruthy()
expect(container.querySelectorAll('[data-message-shell="assistant"]').length).toBeLessThan(140)
expect(container.querySelectorAll('[data-message-shell="assistant"]').length).toBe(220)
expect(container.querySelector('[data-virtual-message-item]')).toBeNull()
})
it('renders goal events as visible status cards', () => {
@ -221,7 +230,7 @@ describe('MessageList nested tool calls', () => {
expect(container.querySelectorAll('[data-message-shell="assistant"]').length).toBe(220)
})
it('window-renders long histories that include tool-call groups', async () => {
it('keeps long histories with tool-call groups mounted while scrolling history', async () => {
useChatStore.setState({
sessions: {
[ACTIVE_TAB]: makeSessionState({
@ -259,7 +268,7 @@ describe('MessageList nested tool calls', () => {
Object.defineProperty(scrollArea, 'scrollHeight', { configurable: true, value: 222 * 112 })
await waitForProgrammaticScrollReset()
expect(screen.queryByText('Read')).toBeNull()
expect(screen.getByText('Read')).toBeTruthy()
expect(screen.getByText('assistant transcript line 219')).toBeTruthy()
scrollArea.scrollTop = 0
@ -269,7 +278,7 @@ describe('MessageList nested tool calls', () => {
expect(screen.getByText('Read')).toBeTruthy()
expect(screen.getByText('assistant transcript line 219')).toBeTruthy()
expect(container.querySelectorAll('[data-virtual-message-item]').length).toBe(221)
expect(container.querySelector('[data-virtual-message-item]')).toBeNull()
})
it('renders sub-agent tool calls inline beneath the parent agent tool call', () => {

View File

@ -537,8 +537,6 @@ type MessageListProps = {
const AUTO_SCROLL_BOTTOM_THRESHOLD_PX = 48
const MAX_SCROLL_SNAPSHOTS = 100
const VIRTUALIZE_MIN_ITEMS = 160
const VIRTUAL_INITIAL_ITEMS = 96
const CHAT_SCROLL_AREA_CLASS = [
'chat-scroll-area',
'[scrollbar-width:auto]',
@ -590,10 +588,6 @@ function getBottomScrollTop(element: HTMLElement) {
return Math.max(0, element.scrollHeight - element.clientHeight)
}
function getRenderItemKey(item: RenderItem) {
return item.kind === 'tool_group' ? item.id : item.message.id
}
export function MessageList({ sessionId, compact = false }: MessageListProps = {}) {
const activeTabId = useTabStore((s) => s.activeTabId)
const resolvedSessionId = sessionId ?? activeTabId
@ -623,7 +617,6 @@ export function MessageList({ sessionId, compact = false }: MessageListProps = {
const isProgrammaticScrollingRef = useRef(false)
const lastSessionIdRef = useRef<string | null | undefined>(resolvedSessionId)
const lastTailMessageIdBySessionRef = useRef(new Map<string, string | null>())
const updateVirtualWindowRef = useRef<((element: HTMLElement) => void) | null>(null)
const t = useTranslation()
const [turnChangeCards, setTurnChangeCards] = useState<TurnChangeCardModel[]>([])
const [turnChangeLoadError, setTurnChangeLoadError] = useState<string | null>(null)
@ -693,7 +686,6 @@ export function MessageList({ sessionId, compact = false }: MessageListProps = {
if (resolvedSessionId) {
rememberSessionScroll(resolvedSessionId, container)
}
updateVirtualWindowRef.current?.(container)
}, [resolvedSessionId])
useLayoutEffect(() => {
@ -759,58 +751,6 @@ export function MessageList({ sessionId, compact = false }: MessageListProps = {
() => buildRenderModel(messages),
[messages],
)
const shouldVirtualize = renderItems.length > VIRTUALIZE_MIN_ITEMS
const [virtualRange, setVirtualRange] = useState(() => ({ start: 0, end: 0 }))
const updateVirtualWindow = useCallback((container: HTMLElement | null) => {
if (!shouldVirtualize) {
setVirtualRange((current) =>
current.start === 0 && current.end === renderItems.length
? current
: { start: 0, end: renderItems.length },
)
return
}
if (!container || shouldAutoScrollRef.current) {
const start = Math.max(0, renderItems.length - VIRTUAL_INITIAL_ITEMS)
setVirtualRange((current) =>
current.start === start && current.end === renderItems.length
? current
: { start, end: renderItems.length },
)
return
}
setVirtualRange((current) =>
current.start === 0 && current.end === renderItems.length
? current
: { start: 0, end: renderItems.length },
)
}, [renderItems.length, shouldVirtualize])
useEffect(() => {
updateVirtualWindowRef.current = updateVirtualWindow
return () => {
if (updateVirtualWindowRef.current === updateVirtualWindow) {
updateVirtualWindowRef.current = null
}
}
}, [updateVirtualWindow])
useLayoutEffect(() => {
updateVirtualWindow(scrollContainerRef.current)
}, [renderItems.length, resolvedSessionId, updateVirtualWindow])
const visibleRenderItems = shouldVirtualize
? renderItems.slice(virtualRange.start, virtualRange.end)
: renderItems
const topVirtualSpacerHeight = shouldVirtualize
? Math.max(0, virtualRange.start * 96)
: 0
const bottomVirtualSpacerHeight = shouldVirtualize
? Math.max(0, renderItems.length - virtualRange.end) * 96
: 0
const completedTurnTargets = useMemo(() => getCompletedTurnTargets(messages), [messages])
const latestCompletedTurnId =
completedTurnTargets.length > 0
@ -961,25 +901,13 @@ export function MessageList({ sessionId, compact = false }: MessageListProps = {
<div
ref={scrollContentRef}
className={compact ? 'mx-auto max-w-full' : 'mx-auto max-w-[860px]'}
role={shouldVirtualize ? 'list' : undefined}
>
{topVirtualSpacerHeight > 0 && (
<div aria-hidden="true" style={{ height: topVirtualSpacerHeight }} />
)}
{visibleRenderItems.map((item, visibleIndex) => {
const index = shouldVirtualize ? virtualRange.start + visibleIndex : visibleIndex
const itemKey = getRenderItemKey(item)
{renderItems.map((item, index) => {
const itemKey = item.kind === 'tool_group' ? item.id : item.message.id
const cardsForItem = turnCardsByRenderIndex.get(index) ?? []
return (
<div
key={itemKey}
data-virtual-message-item={shouldVirtualize ? itemKey : undefined}
role={shouldVirtualize ? 'listitem' : undefined}
aria-posinset={shouldVirtualize ? index + 1 : undefined}
aria-setsize={shouldVirtualize ? renderItems.length : undefined}
>
<div key={itemKey}>
{item.kind === 'tool_group' ? (
<ToolCallGroup
toolCalls={item.toolCalls}
@ -1027,10 +955,6 @@ export function MessageList({ sessionId, compact = false }: MessageListProps = {
)
})}
{bottomVirtualSpacerHeight > 0 && (
<div aria-hidden="true" style={{ height: bottomVirtualSpacerHeight }} />
)}
{streamingText.trim() && (
<AssistantMessage content={streamingText} isStreaming={chatState === 'streaming'} />
)}