mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-17 13:13:35 +08:00
Keep chat pinned to the newest turn during active output
H5 browsers can lag behind smooth scroll animations when streaming text grows, and sending a new prompt should always return the reader to the live turn even if they were browsing history. The message list now writes the scroll container directly while preserving the existing opt-out for manual history reading during assistant streaming. Constraint: H5 and desktop share MessageList behavior Rejected: Force-scroll on every streaming update | would break reading historical content during long replies Confidence: high Scope-risk: narrow Directive: Preserve manual history reading except when a new user prompt is appended Tested: cd desktop && bun run test -- MessageList.test.tsx Tested: cd desktop && bun run lint Tested: cd desktop && bun run build Not-tested: Full check:desktop remains blocked by the existing vite-config color-mix assertion in globals.css
This commit is contained in:
parent
8e8e596817
commit
7bc22e971a
@ -932,6 +932,77 @@ describe('MessageList nested tool calls', () => {
|
||||
expect(scrollIntoView).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('keeps mobile H5 streaming output pinned after the transcript height grows', async () => {
|
||||
const scrollIntoView = vi.fn()
|
||||
Object.defineProperty(HTMLElement.prototype, 'scrollIntoView', {
|
||||
configurable: true,
|
||||
value: scrollIntoView,
|
||||
})
|
||||
|
||||
useChatStore.setState({
|
||||
sessions: {
|
||||
[ACTIVE_TAB]: makeSessionState({
|
||||
chatState: 'streaming',
|
||||
messages: [
|
||||
{
|
||||
id: 'user-1',
|
||||
type: 'user_text',
|
||||
content: '移动端长回复',
|
||||
timestamp: 1,
|
||||
},
|
||||
],
|
||||
streamingText: 'streaming',
|
||||
}),
|
||||
},
|
||||
})
|
||||
|
||||
const { container } = render(<MessageList />)
|
||||
const scroller = container.querySelector('.overflow-y-auto') as HTMLDivElement
|
||||
let scrollTop = 552
|
||||
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) => {
|
||||
scrollTop = value
|
||||
},
|
||||
})
|
||||
|
||||
await waitForProgrammaticScrollReset()
|
||||
fireEvent.scroll(scroller)
|
||||
expect(screen.queryByRole('button', { name: 'Latest' })).toBeNull()
|
||||
|
||||
scrollIntoView.mockClear()
|
||||
scrollHeight = 1400
|
||||
act(() => {
|
||||
useChatStore.setState((state) => ({
|
||||
sessions: {
|
||||
...state.sessions,
|
||||
[ACTIVE_TAB]: {
|
||||
...state.sessions[ACTIVE_TAB]!,
|
||||
streamingText: 'streaming next token after height change',
|
||||
},
|
||||
},
|
||||
}))
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('streaming next token after height change')).toBeTruthy()
|
||||
})
|
||||
expect(scrollIntoView).toHaveBeenCalledWith({ behavior: 'smooth', block: 'end' })
|
||||
expect(scrollTop).toBe(1000)
|
||||
|
||||
await waitForProgrammaticScrollReset()
|
||||
fireEvent.scroll(scroller)
|
||||
|
||||
expect(screen.queryByRole('button', { name: 'Latest' })).toBeNull()
|
||||
})
|
||||
|
||||
it('restores a session scroll position when switching back to a tab', async () => {
|
||||
const scrollIntoView = vi.fn()
|
||||
Object.defineProperty(HTMLElement.prototype, 'scrollIntoView', {
|
||||
@ -1120,6 +1191,81 @@ describe('MessageList nested tool calls', () => {
|
||||
expect(scrollIntoView).toHaveBeenCalledWith({ behavior: 'smooth', block: 'end' })
|
||||
})
|
||||
|
||||
it('jumps to the latest message when the user sends a new prompt from history', async () => {
|
||||
const scrollIntoView = vi.fn()
|
||||
Object.defineProperty(HTMLElement.prototype, 'scrollIntoView', {
|
||||
configurable: true,
|
||||
value: scrollIntoView,
|
||||
})
|
||||
|
||||
useChatStore.setState({
|
||||
sessions: {
|
||||
[ACTIVE_TAB]: makeSessionState({
|
||||
messages: [
|
||||
{
|
||||
id: 'user-1',
|
||||
type: 'user_text',
|
||||
content: '历史消息',
|
||||
timestamp: 1,
|
||||
},
|
||||
{
|
||||
id: 'assistant-1',
|
||||
type: 'assistant_text',
|
||||
content: '历史回复',
|
||||
timestamp: 2,
|
||||
},
|
||||
],
|
||||
}),
|
||||
},
|
||||
})
|
||||
|
||||
const { container } = render(<MessageList />)
|
||||
const scroller = container.querySelector('.overflow-y-auto') as HTMLDivElement
|
||||
let scrollTop = 120
|
||||
Object.defineProperty(scroller, 'scrollHeight', { configurable: true, value: 1000 })
|
||||
Object.defineProperty(scroller, 'clientHeight', { configurable: true, value: 400 })
|
||||
Object.defineProperty(scroller, 'scrollTop', {
|
||||
configurable: true,
|
||||
get: () => scrollTop,
|
||||
set: (value) => {
|
||||
scrollTop = value
|
||||
},
|
||||
})
|
||||
|
||||
scrollIntoView.mockClear()
|
||||
await waitForProgrammaticScrollReset()
|
||||
fireEvent.scroll(scroller)
|
||||
expect(screen.getByRole('button', { name: 'Latest' })).toBeTruthy()
|
||||
|
||||
act(() => {
|
||||
useChatStore.setState((state) => ({
|
||||
sessions: {
|
||||
...state.sessions,
|
||||
[ACTIVE_TAB]: {
|
||||
...state.sessions[ACTIVE_TAB]!,
|
||||
chatState: 'thinking',
|
||||
messages: [
|
||||
...state.sessions[ACTIVE_TAB]!.messages,
|
||||
{
|
||||
id: 'user-2',
|
||||
type: 'user_text',
|
||||
content: '新的问题',
|
||||
timestamp: 3,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
}))
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('新的问题')).toBeTruthy()
|
||||
})
|
||||
expect(scrollIntoView).toHaveBeenCalledWith({ behavior: 'smooth', block: 'end' })
|
||||
expect(scrollTop).toBe(600)
|
||||
expect(screen.queryByRole('button', { name: 'Latest' })).toBeNull()
|
||||
})
|
||||
|
||||
it('keeps user actions anchored to the right bubble and assistant actions to the left bubble', () => {
|
||||
useChatStore.setState({
|
||||
sessions: {
|
||||
|
||||
@ -511,7 +511,11 @@ function rememberSessionScroll(sessionId: string, element: HTMLElement) {
|
||||
}
|
||||
|
||||
function clampScrollTop(element: HTMLElement, scrollTop: number) {
|
||||
return Math.max(0, Math.min(scrollTop, element.scrollHeight - element.clientHeight))
|
||||
return Math.max(0, Math.min(scrollTop, getBottomScrollTop(element)))
|
||||
}
|
||||
|
||||
function getBottomScrollTop(element: HTMLElement) {
|
||||
return Math.max(0, element.scrollHeight - element.clientHeight)
|
||||
}
|
||||
|
||||
function getRenderItemKey(item: RenderItem) {
|
||||
@ -541,6 +545,7 @@ export function MessageList({ sessionId, compact = false }: MessageListProps = {
|
||||
const shouldAutoScrollRef = useRef(true)
|
||||
const isProgrammaticScrollingRef = useRef(false)
|
||||
const lastSessionIdRef = useRef<string | null | undefined>(resolvedSessionId)
|
||||
const lastTailMessageIdBySessionRef = useRef(new Map<string, string | null>())
|
||||
const updateVirtualWindowRef = useRef<((element: HTMLElement) => void) | null>(null)
|
||||
const t = useTranslation()
|
||||
const [turnChangeCards, setTurnChangeCards] = useState<TurnChangeCardModel[]>([])
|
||||
@ -554,17 +559,39 @@ export function MessageList({ sessionId, compact = false }: MessageListProps = {
|
||||
const scrollToBottom = useCallback((behavior: ScrollBehavior) => {
|
||||
shouldAutoScrollRef.current = true
|
||||
isProgrammaticScrollingRef.current = true
|
||||
bottomRef.current?.scrollIntoView?.({ behavior, block: 'end' })
|
||||
const container = scrollContainerRef.current
|
||||
const targetScrollTop = container ? getBottomScrollTop(container) : null
|
||||
if (container) {
|
||||
container.scrollTop = targetScrollTop ?? 0
|
||||
}
|
||||
bottomRef.current?.scrollIntoView?.({ behavior, block: 'end' })
|
||||
if (container && resolvedSessionId) {
|
||||
sessionScrollSnapshots.set(resolvedSessionId, {
|
||||
scrollTop: Math.max(0, container.scrollHeight - container.clientHeight),
|
||||
scrollTop: getBottomScrollTop(container),
|
||||
wasAtBottom: true,
|
||||
})
|
||||
}
|
||||
setShowJumpToLatest(false)
|
||||
// Reset flag after the scroll event(s) from scrollIntoView have fired
|
||||
requestAnimationFrame(() => {
|
||||
const latestContainer = scrollContainerRef.current
|
||||
if (
|
||||
shouldAutoScrollRef.current &&
|
||||
latestContainer &&
|
||||
(
|
||||
targetScrollTop === null ||
|
||||
latestContainer.scrollTop === targetScrollTop ||
|
||||
isNearScrollBottom(latestContainer)
|
||||
)
|
||||
) {
|
||||
latestContainer.scrollTop = getBottomScrollTop(latestContainer)
|
||||
if (resolvedSessionId) {
|
||||
sessionScrollSnapshots.set(resolvedSessionId, {
|
||||
scrollTop: getBottomScrollTop(latestContainer),
|
||||
wasAtBottom: true,
|
||||
})
|
||||
}
|
||||
}
|
||||
isProgrammaticScrollingRef.current = false
|
||||
})
|
||||
}, [resolvedSessionId])
|
||||
@ -601,6 +628,22 @@ export function MessageList({ sessionId, compact = false }: MessageListProps = {
|
||||
}
|
||||
}, [resolvedSessionId, scrollToBottom])
|
||||
|
||||
const tailMessage = messages[messages.length - 1] ?? null
|
||||
const tailMessageId = tailMessage?.id ?? null
|
||||
const tailMessageType = tailMessage?.type ?? null
|
||||
|
||||
useEffect(() => {
|
||||
if (!resolvedSessionId) return
|
||||
|
||||
const previousTailMessageId = lastTailMessageIdBySessionRef.current.get(resolvedSessionId)
|
||||
lastTailMessageIdBySessionRef.current.set(resolvedSessionId, tailMessageId)
|
||||
if (previousTailMessageId === undefined || previousTailMessageId === tailMessageId) return
|
||||
|
||||
if (tailMessageType === 'user_text' && chatState !== 'idle') {
|
||||
scrollToBottom('smooth')
|
||||
}
|
||||
}, [chatState, resolvedSessionId, scrollToBottom, tailMessageId, tailMessageType])
|
||||
|
||||
useEffect(() => {
|
||||
if (!shouldAutoScrollRef.current) {
|
||||
setShowJumpToLatest(true)
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user