fix: prevent Windows chat content jitter (#603)

Windows WebView2 can report one-pixel content resize oscillations while a thinking or tool response is pinned to the bottom. That fed repeated bottom-scroll corrections back into the message list and made otherwise static chat content visibly move.

Ignore sub-2px content resize deltas before following live content growth, so real message growth still stays pinned without turning tiny layout jitter into scroll movement.

Constraint: Issue #603 reproduces on some Windows WebView2/display paths but not consistently across macOS or all Windows machines
Rejected: Disable bottom-following during thinking | would regress active response visibility when content actually grows
Confidence: medium
Scope-risk: narrow
Directive: Keep ResizeObserver follow behavior tolerant of sub-pixel or one-pixel WebView2 height jitter
Tested: cd desktop && bun run test -- --run src/components/chat/MessageList.test.tsx src/components/chat/virtualHeightCache.test.ts
Tested: cd desktop && bun run lint
Tested: bun run check:desktop
Tested: Browser smoke at http://127.0.0.1:17999 loaded Claude Code Companion with no frontend error logs
Not-tested: Live Windows WebView2 runtime retest on reporter machine
Related: #603
This commit is contained in:
程序员阿江(Relakkes) 2026-05-25 17:32:59 +08:00
parent 333271b159
commit 2db9a210b1
2 changed files with 105 additions and 1 deletions

View File

@ -2053,6 +2053,94 @@ describe('MessageList nested tool calls', () => {
expect(screen.queryByRole('button', { name: 'Latest' })).toBeNull()
})
it('ignores one-pixel content resize jitter while pinned to active thinking output', async () => {
let resizeCallback: ResizeObserverCallback | null = null
class TestResizeObserver {
observe = vi.fn()
unobserve = vi.fn()
disconnect = vi.fn()
constructor(callback: ResizeObserverCallback) {
resizeCallback = callback
}
}
vi.stubGlobal('ResizeObserver', TestResizeObserver)
useChatStore.setState({
sessions: {
[ACTIVE_TAB]: makeSessionState({
chatState: 'thinking',
activeThinkingId: 'thinking-1',
messages: [
{
id: 'user-1',
type: 'user_text',
content: '触发 Windows WebView2 细微重排',
timestamp: 1,
},
{
id: 'thinking-1',
type: 'thinking',
content: '正在分析一个静态问题',
timestamp: 2,
},
],
}),
},
})
const { container } = render(<MessageList />)
const scroller = container.querySelector('.overflow-y-auto') as HTMLDivElement
let scrollTop = 600
let scrollTopWriteCount = 0
let scrollHeight = 1000
Object.defineProperty(scroller, 'scrollHeight', {
configurable: true,
get: () => scrollHeight,
})
Object.defineProperty(scroller, 'clientHeight', { configurable: true, value: 400 })
Object.defineProperty(scroller, 'scrollTop', {
configurable: true,
get: () => scrollTop,
set: (value) => {
scrollTopWriteCount += 1
scrollTop = value
},
})
await waitFor(() => {
expect(resizeCallback).not.toBeNull()
})
await waitForProgrammaticScrollReset()
const makeResizeEntry = (height: number) => ([{
contentRect: { height },
} as ResizeObserverEntry])
act(() => {
resizeCallback?.(makeResizeEntry(400), {} as ResizeObserver)
})
expect(scrollTop).toBe(600)
scrollTopWriteCount = 0
act(() => {
resizeCallback?.(makeResizeEntry(401), {} as ResizeObserver)
})
act(() => {
resizeCallback?.(makeResizeEntry(400), {} as ResizeObserver)
})
expect(scrollTopWriteCount).toBe(0)
expect(scrollTop).toBe(600)
scrollHeight = 1040
act(() => {
resizeCallback?.(makeResizeEntry(420), {} as ResizeObserver)
})
expect(scrollTop).toBe(640)
})
it('does not pull a completed session back to the bottom when content resizes', async () => {
const scrollIntoView = vi.fn()
Object.defineProperty(HTMLElement.prototype, 'scrollIntoView', {

View File

@ -795,6 +795,9 @@ const VIRTUAL_OVERSCAN_PX = 1200
const VIRTUAL_DEFAULT_VIEWPORT_HEIGHT = 720
const VIRTUAL_MIN_ITEM_HEIGHT = 48
const VIRTUAL_MAX_ITEM_HEIGHT = 24_000
// Windows WebView2 can report 1px oscillations for live chat content; don't
// convert those into bottom-scroll corrections.
const CONTENT_RESIZE_FOLLOW_MIN_DELTA_PX = 2
const EMPTY_MESSAGES: UIMessage[] = []
const CHAT_SCROLL_AREA_CLASS = [
'chat-scroll-area',
@ -1241,6 +1244,7 @@ export function MessageList({ sessionId, compact = false }: MessageListProps = {
const pendingMeasuredHeightsRef = useRef(false)
const measureFlushFrameRef = useRef<number | null>(null)
const lastAutoScrollAtRef = useRef(0)
const lastContentResizeFollowHeightRef = useRef<number | null>(null)
const shouldAutoScrollRef = useRef(true)
const isProgrammaticScrollingRef = useRef(false)
const ignoreProgrammaticScrollUntilRef = useRef(0)
@ -1397,6 +1401,7 @@ export function MessageList({ sessionId, compact = false }: MessageListProps = {
? getMetricsForSession(resolvedSessionId)
: new Map<string, VirtualRenderItemMetric>()
pendingMeasuredHeightsRef.current = false
lastContentResizeFollowHeightRef.current = null
if (measureFlushFrameRef.current !== null) {
cancelAnimationFrame(measureFlushFrameRef.current)
measureFlushFrameRef.current = null
@ -1474,7 +1479,18 @@ export function MessageList({ sessionId, compact = false }: MessageListProps = {
const content = scrollContentRef.current
if (!content || typeof ResizeObserver === 'undefined') return
const observer = new ResizeObserver(() => {
const observer = new ResizeObserver((entries) => {
const nextHeight = entries[0]?.contentRect.height
if (typeof nextHeight === 'number' && Number.isFinite(nextHeight)) {
const previousFollowHeight = lastContentResizeFollowHeightRef.current
if (
previousFollowHeight !== null &&
Math.abs(nextHeight - previousFollowHeight) < CONTENT_RESIZE_FOLLOW_MIN_DELTA_PX
) {
return
}
lastContentResizeFollowHeightRef.current = nextHeight
}
if (!shouldFollowContentResize) return
if (!shouldAutoScrollRef.current) return
scrollToBottom('auto')