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 && (