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')