Always reveal newly sent chat prompts

Sending from history must move the transcript to the new turn even if session state has not yet advanced from idle. The previous guard tied the jump to chatState, so a user_text tail that landed before thinking/streaming would be skipped permanently because the tail id no longer changed.

Constraint: The same MessageList path serves desktop and H5 chat.

Rejected: Wait for chatState to become thinking before scrolling | that misses updates where the message id is already recorded.

Confidence: high

Scope-risk: narrow

Directive: New user_text tail messages are intentional navigation events and must override historical scroll position.

Tested: cd desktop && bun run test -- MessageList.test.tsx

Tested: cd desktop && bun run lint

Tested: cd desktop && bun run build

Tested: git diff --check
This commit is contained in:
程序员阿江(Relakkes) 2026-05-15 20:41:28 +08:00
parent 76d472376b
commit 5871af11d4
2 changed files with 94 additions and 2 deletions

View File

@ -1342,6 +1342,98 @@ describe('MessageList nested tool calls', () => {
expect(screen.queryByRole('button', { name: 'Latest' })).toBeNull()
})
it('jumps to the latest message when a sent prompt lands before chat state changes', 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: 'idle',
messages: [
...state.sessions[ACTIVE_TAB]!.messages,
{
id: 'user-2',
type: 'user_text',
content: '刚发送的问题',
timestamp: 3,
},
],
},
},
}))
})
await waitFor(() => {
expect(screen.getByText('刚发送的问题')).toBeTruthy()
})
expect(scrollIntoView).not.toHaveBeenCalled()
expect(scrollTop).toBe(600)
expect(screen.queryByRole('button', { name: 'Latest' })).toBeNull()
act(() => {
useChatStore.setState((state) => ({
sessions: {
...state.sessions,
[ACTIVE_TAB]: {
...state.sessions[ACTIVE_TAB]!,
chatState: 'thinking',
},
},
}))
})
await waitFor(() => {
expect(screen.getByText('刚发送的问题')).toBeTruthy()
})
expect(scrollTop).toBe(600)
})
it('keeps user actions anchored to the right bubble and assistant actions to the left bubble', () => {
useChatStore.setState({
sessions: {

View File

@ -646,10 +646,10 @@ export function MessageList({ sessionId, compact = false }: MessageListProps = {
lastTailMessageIdBySessionRef.current.set(resolvedSessionId, tailMessageId)
if (previousTailMessageId === undefined || previousTailMessageId === tailMessageId) return
if (tailMessageType === 'user_text' && chatState !== 'idle') {
if (tailMessageType === 'user_text') {
scrollToBottom('auto')
}
}, [chatState, resolvedSessionId, scrollToBottom, tailMessageId, tailMessageType])
}, [resolvedSessionId, scrollToBottom, tailMessageId, tailMessageType])
useEffect(() => {
if (!shouldAutoScrollRef.current) {