mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-16 13:03:31 +08:00
fix: stop restored chats snapping to latest
Restored transcripts can still present stale running state while their final markdown and tool output continue to reflow. Resize-driven auto-follow now stays enabled only for live resizable output, so readers can scroll up after completion while active streams still remain pinned. Constraint: Old H5 sessions can reconnect with stale non-idle UI state even when the transcript has an end_turn. Rejected: Disable resize following entirely | active H5 streaming would regress when rendered content grows after tokens arrive. Confidence: high Scope-risk: narrow Directive: Do not broaden ResizeObserver auto-follow without a restored completed-session regression test. Tested: cd desktop && bun run test -- MessageList.test.tsx Tested: cd desktop && bun run lint Tested: cd desktop && bun run build Tested: Browser smoke on session 85862799-ec6b-43d0-b3b8-6e9fd9130954 kept scrollTop at 320 after content resize.
This commit is contained in:
parent
6ec8ae626b
commit
80291715b4
@ -1076,6 +1076,167 @@ describe('MessageList nested tool calls', () => {
|
||||
expect(screen.queryByRole('button', { name: 'Latest' })).toBeNull()
|
||||
})
|
||||
|
||||
it('does not pull a completed session back to the bottom when content resizes', async () => {
|
||||
const scrollIntoView = vi.fn()
|
||||
Object.defineProperty(HTMLElement.prototype, 'scrollIntoView', {
|
||||
configurable: true,
|
||||
value: scrollIntoView,
|
||||
})
|
||||
|
||||
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: 'idle',
|
||||
messages: [
|
||||
{
|
||||
id: 'user-1',
|
||||
type: 'user_text',
|
||||
content: '生成一个 todo app',
|
||||
timestamp: 1,
|
||||
},
|
||||
{
|
||||
id: 'assistant-1',
|
||||
type: 'assistant_text',
|
||||
content: [
|
||||
'已完成。',
|
||||
'',
|
||||
'```bash',
|
||||
'cd /private/tmp/todo-app',
|
||||
'npm run dev',
|
||||
'```',
|
||||
].join('\n'),
|
||||
timestamp: 2,
|
||||
},
|
||||
],
|
||||
}),
|
||||
},
|
||||
})
|
||||
|
||||
const { container } = render(<MessageList />)
|
||||
const scroller = container.querySelector('.overflow-y-auto') as HTMLDivElement
|
||||
let scrollTop = 180
|
||||
let scrollHeight = 1400
|
||||
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) => {
|
||||
scrollTop = value
|
||||
},
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(resizeCallback).not.toBeNull()
|
||||
})
|
||||
|
||||
scrollIntoView.mockClear()
|
||||
scrollHeight = 1600
|
||||
act(() => {
|
||||
resizeCallback?.([], {} as ResizeObserver)
|
||||
})
|
||||
|
||||
expect(scrollIntoView).not.toHaveBeenCalled()
|
||||
expect(scrollTop).toBe(180)
|
||||
})
|
||||
|
||||
it('does not pull a restored completed session back to the bottom from stale running state', async () => {
|
||||
const scrollIntoView = vi.fn()
|
||||
Object.defineProperty(HTMLElement.prototype, 'scrollIntoView', {
|
||||
configurable: true,
|
||||
value: scrollIntoView,
|
||||
})
|
||||
|
||||
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: null,
|
||||
messages: [
|
||||
{
|
||||
id: 'user-1',
|
||||
type: 'user_text',
|
||||
content: '复盘这个已完成会话',
|
||||
timestamp: 1,
|
||||
},
|
||||
{
|
||||
id: 'assistant-1',
|
||||
type: 'assistant_text',
|
||||
content: [
|
||||
'这个会话已经完成。',
|
||||
'',
|
||||
'```tsx',
|
||||
'export function TodoListView() {',
|
||||
' return <section>Done</section>',
|
||||
'}',
|
||||
'```',
|
||||
].join('\n'),
|
||||
timestamp: 2,
|
||||
},
|
||||
],
|
||||
streamingText: '',
|
||||
}),
|
||||
},
|
||||
})
|
||||
|
||||
const { container } = render(<MessageList />)
|
||||
const scroller = container.querySelector('.overflow-y-auto') as HTMLDivElement
|
||||
let scrollTop = 260
|
||||
let scrollHeight = 1800
|
||||
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) => {
|
||||
scrollTop = value
|
||||
},
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(resizeCallback).not.toBeNull()
|
||||
})
|
||||
|
||||
scrollIntoView.mockClear()
|
||||
scrollHeight = 2100
|
||||
act(() => {
|
||||
resizeCallback?.([], {} as ResizeObserver)
|
||||
})
|
||||
|
||||
expect(scrollIntoView).not.toHaveBeenCalled()
|
||||
expect(scrollTop).toBe(260)
|
||||
})
|
||||
|
||||
it('restores a session scroll position when switching back to a tab', async () => {
|
||||
const scrollIntoView = vi.fn()
|
||||
Object.defineProperty(HTMLElement.prototype, 'scrollIntoView', {
|
||||
|
||||
@ -540,6 +540,11 @@ export function MessageList({ sessionId, compact = false }: MessageListProps = {
|
||||
const streamingText = sessionState?.streamingText ?? ''
|
||||
const activeThinkingId = sessionState?.activeThinkingId ?? null
|
||||
const agentTaskNotifications = sessionState?.agentTaskNotifications ?? {}
|
||||
const shouldFollowContentResize =
|
||||
streamingText.trim().length > 0 ||
|
||||
chatState === 'streaming' ||
|
||||
chatState === 'tool_executing' ||
|
||||
(chatState === 'thinking' && Boolean(activeThinkingId))
|
||||
const scrollContainerRef = useRef<HTMLDivElement>(null)
|
||||
const scrollContentRef = useRef<HTMLDivElement>(null)
|
||||
const shouldAutoScrollRef = useRef(true)
|
||||
@ -669,13 +674,14 @@ export function MessageList({ sessionId, compact = false }: MessageListProps = {
|
||||
if (!content || typeof ResizeObserver === 'undefined') return
|
||||
|
||||
const observer = new ResizeObserver(() => {
|
||||
if (!shouldFollowContentResize) return
|
||||
if (!shouldAutoScrollRef.current) return
|
||||
scrollToBottom('auto')
|
||||
})
|
||||
observer.observe(content)
|
||||
|
||||
return () => observer.disconnect()
|
||||
}, [scrollToBottom])
|
||||
}, [scrollToBottom, shouldFollowContentResize])
|
||||
|
||||
const { toolResultMap, childToolCallsByParent, renderItems } = useMemo(
|
||||
() => buildRenderModel(messages),
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user