fix: preserve per-conversation chat state

This commit is contained in:
mesalogo
2026-08-15 19:20:17 +08:00
parent 3f9defbad1
commit 1031ea618b
2 changed files with 210 additions and 46 deletions
+119
View File
@@ -1555,6 +1555,125 @@ describe('App', () => {
).toBeInTheDocument() ).toBeInTheDocument()
}) })
it('restores the reader position after activity continues on another page', async () => {
const { container } = render(<App />)
fireEvent.change(await screen.findByLabelText('向 GoodBuddy 提问'), {
target: { value: '离开页面后继续生成' }
})
fireEvent.click(screen.getByLabelText('发送'))
await waitFor(() => expect(run).toHaveBeenCalledOnce())
const request = run.mock.calls[0]?.[0]
if (!request) {
throw new Error('Missing request')
}
await act(
() =>
new Promise<void>((resolve) =>
requestAnimationFrame(() => resolve())
)
)
const chat = container.querySelector<HTMLElement>('.chat')
if (!chat) {
throw new Error('Missing chat scroll container')
}
Object.defineProperties(chat, {
clientHeight: { configurable: true, value: 400 },
scrollHeight: { configurable: true, value: 1_200 },
scrollTop: { configurable: true, writable: true, value: 175 }
})
fireEvent.scroll(chat)
fireEvent.click(screen.getByRole('button', { name: '知识库' }))
expect(container.querySelector('.chat')).not.toBeInTheDocument()
act(() => {
agentListener?.({
requestId: request.requestId,
type: 'text',
delta: '后台新增的回复内容'
})
})
fireEvent.click(screen.getByRole('button', { name: '对话' }))
expect(await screen.findByText('后台新增的回复内容')).toBeInTheDocument()
const restoredChat = container.querySelector<HTMLElement>('.chat')
expect(restoredChat).not.toBe(chat)
expect(restoredChat?.scrollTop).toBe(175)
expect(
screen.getByRole('button', { name: '到底部' })
).toBeInTheDocument()
})
it('keeps each conversation history window and reader position', async () => {
const firstConversationId =
'00000000-0000-4000-8000-000000000461'
const secondConversationId =
'00000000-0000-4000-8000-000000000462'
vi.mocked(api.conversations.list).mockResolvedValueOnce([
{
id: firstConversationId,
projectId,
title: '第一段长会话',
updatedAt: 1_775_000_000_002,
messages: Array.from({ length: 161 }, (_, index) => ({
id: `00000000-0000-4000-8100-${String(index).padStart(12, '0')}`,
role: index % 2 === 0 ? ('user' as const) : ('assistant' as const),
content: `第一段历史 ${String(index).padStart(3, '0')}`,
createdAt: 1_775_000_000_000 + index,
state: 'complete' as const
}))
},
{
id: secondConversationId,
projectId,
title: '第二段会话',
updatedAt: 1_775_000_000_001,
messages: [
{
id: '00000000-0000-4000-8200-000000000001',
role: 'assistant',
content: '第二段会话内容',
createdAt: 1_775_000_000_001,
state: 'complete'
}
]
}
])
const { container } = render(<App />)
expect(await screen.findByText('第一段历史 160')).toBeInTheDocument()
fireEvent.click(
screen.getByRole('button', {
name: '加载更早的消息(还剩 81 条)'
})
)
expect(container.querySelectorAll('.message')).toHaveLength(160)
const firstChat = container.querySelector<HTMLElement>('.chat')
if (!firstChat) {
throw new Error('Missing first chat scroll container')
}
Object.defineProperties(firstChat, {
clientHeight: { configurable: true, value: 400 },
scrollHeight: { configurable: true, value: 1_600 },
scrollTop: { configurable: true, writable: true, value: 225 }
})
fireEvent.scroll(firstChat)
fireEvent.click(
screen.getByText('第二段会话').closest('button')!
)
expect(await screen.findByText('第二段会话内容')).toBeInTheDocument()
fireEvent.click(
screen.getByText('第一段长会话').closest('button')!
)
expect(await screen.findByText('第一段历史 001')).toBeInTheDocument()
expect(container.querySelectorAll('.message')).toHaveLength(160)
expect(container.querySelector<HTMLElement>('.chat')?.scrollTop).toBe(
225
)
})
it('requires an accessible confirmation before permanently deleting a conversation', async () => { it('requires an accessible confirmation before permanently deleting a conversation', async () => {
render(<App />) render(<App />)
const menuTrigger = screen.getByLabelText( const menuTrigger = screen.getByLabelText(
+91 -46
View File
@@ -1578,7 +1578,19 @@ function App(): React.JSX.Element {
const inputRef = useRef<HTMLTextAreaElement>(null) const inputRef = useRef<HTMLTextAreaElement>(null)
const scrollRef = useRef<HTMLElement>(null) const scrollRef = useRef<HTMLElement>(null)
const chatPinnedToBottomRef = useRef(true) const chatPinnedToBottomRef = useRef(true)
const chatScrollContextRef = useRef(`${view}:${activeId}`) const chatScrollContextRef = useRef(activeId)
const chatScrollRestorePendingRef = useRef<string | undefined>(
undefined
)
const chatScrollSnapshotsRef = useRef(
new Map<
string,
{
pinnedToBottom: boolean
scrollTop: number
}
>()
)
const prependScrollPositionRef = useRef<{ const prependScrollPositionRef = useRef<{
conversationId: string conversationId: string
scrollHeight: number scrollHeight: number
@@ -1610,29 +1622,52 @@ function App(): React.JSX.Element {
), ),
[] []
) )
const [visibleMessageWindow, setVisibleMessageWindow] = useState(() => ({ const [visibleMessageCounts, setVisibleMessageCounts] = useState<
conversationId: activeId, Record<string, number>
count: messageRenderBatchSize >({})
}))
const [showScrollToBottom, setShowScrollToBottom] = useState(false) const [showScrollToBottom, setShowScrollToBottom] = useState(false)
const sidebarRef = useRef<HTMLElement>(null) const sidebarRef = useRef<HTMLElement>(null)
const sidebarToggleRef = useRef<HTMLButtonElement>(null) const sidebarToggleRef = useRef<HTMLButtonElement>(null)
const conversationActionTriggerRefs = useRef( const conversationActionTriggerRefs = useRef(
new Map<string, HTMLButtonElement>() new Map<string, HTMLButtonElement>()
) )
const saveChatScrollPosition = useCallback(
(conversationId: string, scrollContainer: HTMLElement): boolean => {
const distanceFromBottom =
scrollContainer.scrollHeight -
scrollContainer.scrollTop -
scrollContainer.clientHeight
const pinnedToBottom = distanceFromBottom <= chatBottomProximity
chatScrollSnapshotsRef.current.set(conversationId, {
pinnedToBottom,
scrollTop: scrollContainer.scrollTop
})
return pinnedToBottom
},
[]
)
const handleChatScrollRef = useCallback(
(element: HTMLElement | null): void => {
const previous = scrollRef.current
if (previous && previous !== element) {
saveChatScrollPosition(activeId, previous)
}
scrollRef.current = element
if (element) {
chatScrollRestorePendingRef.current = activeId
}
},
[activeId, saveChatScrollPosition]
)
const updateChatScrollPosition = useCallback((): void => { const updateChatScrollPosition = useCallback((): void => {
const scrollContainer = scrollRef.current const scrollContainer = scrollRef.current
if (!scrollContainer) { if (!scrollContainer || !activeId) {
return return
} }
const distanceFromBottom = const atBottom = saveChatScrollPosition(activeId, scrollContainer)
scrollContainer.scrollHeight -
scrollContainer.scrollTop -
scrollContainer.clientHeight
const atBottom = distanceFromBottom <= chatBottomProximity
chatPinnedToBottomRef.current = atBottom chatPinnedToBottomRef.current = atBottom
setShowScrollToBottom(!atBottom) setShowScrollToBottom(!atBottom)
}, []) }, [activeId, saveChatScrollPosition])
const scrollChatToBottom = useCallback((): void => { const scrollChatToBottom = useCallback((): void => {
const scrollContainer = scrollRef.current const scrollContainer = scrollRef.current
if (!scrollContainer) { if (!scrollContainer) {
@@ -1828,9 +1863,7 @@ function App(): React.JSX.Element {
[activeId, conversations] [activeId, conversations]
) )
const visibleMessageCount = const visibleMessageCount =
visibleMessageWindow.conversationId === activeId visibleMessageCounts[activeId] ?? messageRenderBatchSize
? visibleMessageWindow.count
: messageRenderBatchSize
const visibleMessageStartIndex = Math.max( const visibleMessageStartIndex = Math.max(
0, 0,
(activeConversation?.messages.length ?? 0) - visibleMessageCount (activeConversation?.messages.length ?? 0) - visibleMessageCount
@@ -1848,10 +1881,7 @@ function App(): React.JSX.Element {
scrollTop: scrollContainer.scrollTop scrollTop: scrollContainer.scrollTop
} }
} }
const currentCount = const currentCount = visibleMessageCount
visibleMessageWindow.conversationId === activeId
? visibleMessageWindow.count
: messageRenderBatchSize
if ( if (
activeConversation && activeConversation &&
currentCount + messageRenderBatchSize >= currentCount + messageRenderBatchSize >=
@@ -1860,11 +1890,11 @@ function App(): React.JSX.Element {
finalRevealedMessageIdRef.current = finalRevealedMessageIdRef.current =
activeConversation.messages[0]?.id activeConversation.messages[0]?.id
} }
setVisibleMessageWindow({ setVisibleMessageCounts((current) => ({
conversationId: activeId, ...current,
count: currentCount + messageRenderBatchSize [activeId]: currentCount + messageRenderBatchSize
}) }))
}, [activeConversation, activeId, visibleMessageWindow]) }, [activeConversation, activeId, visibleMessageCount])
useLayoutEffect(() => { useLayoutEffect(() => {
const previous = prependScrollPositionRef.current const previous = prependScrollPositionRef.current
@@ -1889,7 +1919,7 @@ function App(): React.JSX.Element {
.get(finalRevealedMessageId) .get(finalRevealedMessageId)
?.focus({ preventScroll: true }) ?.focus({ preventScroll: true })
} }
}, [activeId, visibleMessageWindow]) }, [activeId, visibleMessageCount])
const activeRuntimeSelection = useMemo( const activeRuntimeSelection = useMemo(
() => () =>
@@ -3872,32 +3902,47 @@ function App(): React.JSX.Element {
) )
}, [startNewConversation]) }, [startNewConversation])
useEffect(() => { useLayoutEffect(() => {
const frame = requestAnimationFrame(() => { if (view !== 'chat') {
const scrollContext = `${view}:${activeId}` return
if (chatScrollContextRef.current !== scrollContext) { }
chatScrollContextRef.current = scrollContext const scrollContainer = scrollRef.current
chatPinnedToBottomRef.current = true if (!scrollContainer) {
} return
const scrollContainer = scrollRef.current }
if (!scrollContainer) { const conversationChanged =
chatScrollContextRef.current !== activeId
if (conversationChanged) {
chatScrollContextRef.current = activeId
}
const shouldRestore =
conversationChanged ||
chatScrollRestorePendingRef.current === activeId
if (shouldRestore) {
chatScrollRestorePendingRef.current = undefined
const snapshot = chatScrollSnapshotsRef.current.get(activeId)
chatPinnedToBottomRef.current =
snapshot?.pinnedToBottom ?? true
if (snapshot && !snapshot.pinnedToBottom) {
scrollContainer.scrollTop = snapshot.scrollTop
setShowScrollToBottom(true)
return return
} }
if (chatPinnedToBottomRef.current) { }
scrollContainer.scrollTo({ if (chatPinnedToBottomRef.current) {
top: scrollContainer.scrollHeight, scrollContainer.scrollTo({
behavior: 'auto' top: scrollContainer.scrollHeight,
}) behavior: 'auto'
setShowScrollToBottom(false) })
return setShowScrollToBottom(false)
} return
updateChatScrollPosition() }
}) updateChatScrollPosition()
return () => cancelAnimationFrame(frame)
}, [ }, [
activeConversation?.messages, activeConversation?.messages,
activeId, activeId,
updateChatScrollPosition, updateChatScrollPosition,
visibleMessageCount,
view view
]) ])
@@ -5660,7 +5705,7 @@ function App(): React.JSX.Element {
className="chat" className="chat"
id="chat-message-list" id="chat-message-list"
onScroll={updateChatScrollPosition} onScroll={updateChatScrollPosition}
ref={scrollRef} ref={handleChatScrollRef}
> >
{activeProject?.kind === 'channel' && {activeProject?.kind === 'channel' &&
!activeConversation && ( !activeConversation && (