mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-17 13:13:35 +08:00
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:
parent
333271b159
commit
2db9a210b1
@ -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', {
|
||||
|
||||
@ -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')
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user