From 2db9a210b1a503e26ce6d902a86303bc3641110c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A8=8B=E5=BA=8F=E5=91=98=E9=98=BF=E6=B1=9F=28Relakkes?= =?UTF-8?q?=29?= Date: Mon, 25 May 2026 17:32:59 +0800 Subject: [PATCH] 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 --- .../src/components/chat/MessageList.test.tsx | 88 +++++++++++++++++++ desktop/src/components/chat/MessageList.tsx | 18 +++- 2 files changed, 105 insertions(+), 1 deletion(-) diff --git a/desktop/src/components/chat/MessageList.test.tsx b/desktop/src/components/chat/MessageList.test.tsx index e7f8af85..33cd7827 100644 --- a/desktop/src/components/chat/MessageList.test.tsx +++ b/desktop/src/components/chat/MessageList.test.tsx @@ -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() + 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', { diff --git a/desktop/src/components/chat/MessageList.tsx b/desktop/src/components/chat/MessageList.tsx index 97c08a86..d301f755 100644 --- a/desktop/src/components/chat/MessageList.tsx +++ b/desktop/src/components/chat/MessageList.tsx @@ -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(null) const lastAutoScrollAtRef = useRef(0) + const lastContentResizeFollowHeightRef = useRef(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() 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')