diff --git a/src/renderer/src/App.test.tsx b/src/renderer/src/App.test.tsx
index 9dd2393..4a1fd07 100644
--- a/src/renderer/src/App.test.tsx
+++ b/src/renderer/src/App.test.tsx
@@ -1555,6 +1555,125 @@ describe('App', () => {
).toBeInTheDocument()
})
+ it('restores the reader position after activity continues on another page', async () => {
+ const { container } = render()
+ 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((resolve) =>
+ requestAnimationFrame(() => resolve())
+ )
+ )
+
+ const chat = container.querySelector('.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('.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()
+
+ expect(await screen.findByText('第一段历史 160')).toBeInTheDocument()
+ fireEvent.click(
+ screen.getByRole('button', {
+ name: '加载更早的消息(还剩 81 条)'
+ })
+ )
+ expect(container.querySelectorAll('.message')).toHaveLength(160)
+ const firstChat = container.querySelector('.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('.chat')?.scrollTop).toBe(
+ 225
+ )
+ })
+
it('requires an accessible confirmation before permanently deleting a conversation', async () => {
render()
const menuTrigger = screen.getByLabelText(
diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx
index 006c50e..580b5f4 100644
--- a/src/renderer/src/App.tsx
+++ b/src/renderer/src/App.tsx
@@ -1578,7 +1578,19 @@ function App(): React.JSX.Element {
const inputRef = useRef(null)
const scrollRef = useRef(null)
const chatPinnedToBottomRef = useRef(true)
- const chatScrollContextRef = useRef(`${view}:${activeId}`)
+ const chatScrollContextRef = useRef(activeId)
+ const chatScrollRestorePendingRef = useRef(
+ 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
+ >({})
const [showScrollToBottom, setShowScrollToBottom] = useState(false)
const sidebarRef = useRef(null)
const sidebarToggleRef = useRef(null)
const conversationActionTriggerRefs = useRef(
new Map()
)
+ 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 && (