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()
})
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 () => {
render(<App />)
const menuTrigger = screen.getByLabelText(
+91 -46
View File
@@ -1578,7 +1578,19 @@ function App(): React.JSX.Element {
const inputRef = useRef<HTMLTextAreaElement>(null)
const scrollRef = useRef<HTMLElement>(null)
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<{
conversationId: string
scrollHeight: number
@@ -1610,29 +1622,52 @@ function App(): React.JSX.Element {
),
[]
)
const [visibleMessageWindow, setVisibleMessageWindow] = useState(() => ({
conversationId: activeId,
count: messageRenderBatchSize
}))
const [visibleMessageCounts, setVisibleMessageCounts] = useState<
Record<string, number>
>({})
const [showScrollToBottom, setShowScrollToBottom] = useState(false)
const sidebarRef = useRef<HTMLElement>(null)
const sidebarToggleRef = useRef<HTMLButtonElement>(null)
const conversationActionTriggerRefs = useRef(
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 scrollContainer = scrollRef.current
if (!scrollContainer) {
if (!scrollContainer || !activeId) {
return
}
const distanceFromBottom =
scrollContainer.scrollHeight -
scrollContainer.scrollTop -
scrollContainer.clientHeight
const atBottom = distanceFromBottom <= chatBottomProximity
const atBottom = saveChatScrollPosition(activeId, scrollContainer)
chatPinnedToBottomRef.current = atBottom
setShowScrollToBottom(!atBottom)
}, [])
}, [activeId, saveChatScrollPosition])
const scrollChatToBottom = useCallback((): void => {
const scrollContainer = scrollRef.current
if (!scrollContainer) {
@@ -1828,9 +1863,7 @@ function App(): React.JSX.Element {
[activeId, conversations]
)
const visibleMessageCount =
visibleMessageWindow.conversationId === activeId
? visibleMessageWindow.count
: messageRenderBatchSize
visibleMessageCounts[activeId] ?? messageRenderBatchSize
const visibleMessageStartIndex = Math.max(
0,
(activeConversation?.messages.length ?? 0) - visibleMessageCount
@@ -1848,10 +1881,7 @@ function App(): React.JSX.Element {
scrollTop: scrollContainer.scrollTop
}
}
const currentCount =
visibleMessageWindow.conversationId === activeId
? visibleMessageWindow.count
: messageRenderBatchSize
const currentCount = visibleMessageCount
if (
activeConversation &&
currentCount + messageRenderBatchSize >=
@@ -1860,11 +1890,11 @@ function App(): React.JSX.Element {
finalRevealedMessageIdRef.current =
activeConversation.messages[0]?.id
}
setVisibleMessageWindow({
conversationId: activeId,
count: currentCount + messageRenderBatchSize
})
}, [activeConversation, activeId, visibleMessageWindow])
setVisibleMessageCounts((current) => ({
...current,
[activeId]: currentCount + messageRenderBatchSize
}))
}, [activeConversation, activeId, visibleMessageCount])
useLayoutEffect(() => {
const previous = prependScrollPositionRef.current
@@ -1889,7 +1919,7 @@ function App(): React.JSX.Element {
.get(finalRevealedMessageId)
?.focus({ preventScroll: true })
}
}, [activeId, visibleMessageWindow])
}, [activeId, visibleMessageCount])
const activeRuntimeSelection = useMemo(
() =>
@@ -3872,32 +3902,47 @@ function App(): React.JSX.Element {
)
}, [startNewConversation])
useEffect(() => {
const frame = requestAnimationFrame(() => {
const scrollContext = `${view}:${activeId}`
if (chatScrollContextRef.current !== scrollContext) {
chatScrollContextRef.current = scrollContext
chatPinnedToBottomRef.current = true
}
const scrollContainer = scrollRef.current
if (!scrollContainer) {
useLayoutEffect(() => {
if (view !== 'chat') {
return
}
const scrollContainer = scrollRef.current
if (!scrollContainer) {
return
}
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
}
if (chatPinnedToBottomRef.current) {
scrollContainer.scrollTo({
top: scrollContainer.scrollHeight,
behavior: 'auto'
})
setShowScrollToBottom(false)
return
}
updateChatScrollPosition()
})
return () => cancelAnimationFrame(frame)
}
if (chatPinnedToBottomRef.current) {
scrollContainer.scrollTo({
top: scrollContainer.scrollHeight,
behavior: 'auto'
})
setShowScrollToBottom(false)
return
}
updateChatScrollPosition()
}, [
activeConversation?.messages,
activeId,
updateChatScrollPosition,
visibleMessageCount,
view
])
@@ -5660,7 +5705,7 @@ function App(): React.JSX.Element {
className="chat"
id="chat-message-list"
onScroll={updateChatScrollPosition}
ref={scrollRef}
ref={handleChatScrollRef}
>
{activeProject?.kind === 'channel' &&
!activeConversation && (