From a5f1e319008256126066a39b1ca70de808f311bb Mon Sep 17 00:00:00 2001 From: lofyer Date: Fri, 14 Aug 2026 18:27:55 +0800 Subject: [PATCH] feat: preserve chat reading position --- src/renderer/src/App.test.tsx | 109 +++++++++++++++++++++ src/renderer/src/App.tsx | 86 ++++++++++++++-- src/renderer/src/i18n/locales/en-US/app.ts | 1 + src/renderer/src/i18n/locales/zh-CN/app.ts | 1 + src/renderer/src/styles.css | 42 ++++++++ 5 files changed, 232 insertions(+), 7 deletions(-) diff --git a/src/renderer/src/App.test.tsx b/src/renderer/src/App.test.tsx index 8b94b26..2d4d782 100644 --- a/src/renderer/src/App.test.tsx +++ b/src/renderer/src/App.test.tsx @@ -1168,6 +1168,115 @@ describe('App', () => { expect(await screen.findByRole('status')).toBeVisible() }) + it('offers a floating control when more messages remain below', async () => { + vi.mocked(api.conversations.list).mockResolvedValueOnce([ + { + id: '00000000-0000-4000-8000-000000000401', + projectId, + title: '长会话', + updatedAt: 1_775_000_000_000, + messages: [ + { + id: '00000000-0000-4000-8000-000000000402', + role: 'assistant', + content: '较早的会话内容', + createdAt: 1_775_000_000_000, + state: 'complete' + } + ] + } + ]) + const { container } = render() + + expect(await screen.findByText('较早的会话内容')).toBeInTheDocument() + 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: 100 } + }) + const scrollTo = vi.fn() + chat.scrollTo = scrollTo + + fireEvent.scroll(chat) + const scrollButton = screen.getByRole('button', { + name: '到底部' + }) + expect(scrollButton).toHaveAttribute( + 'aria-controls', + 'chat-message-list' + ) + expect(scrollButton).toHaveAttribute('title', '到底部') + expect(scrollButton).toHaveTextContent('') + + fireEvent.click(scrollButton) + expect(scrollTo).toHaveBeenLastCalledWith({ + top: 1_200, + behavior: 'smooth' + }) + + chat.scrollTop = 750 + fireEvent.scroll(chat) + expect( + screen.queryByRole('button', { name: '到底部' }) + ).not.toBeInTheDocument() + }) + + it('keeps the reader position while a response continues below', async () => { + 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 = document.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: 100 } + }) + const scrollTo = vi.fn() + chat.scrollTo = scrollTo + fireEvent.scroll(chat) + + act(() => { + agentListener?.({ + requestId: request.requestId, + type: 'text', + delta: '新增的回复内容' + }) + }) + expect(await screen.findByText('新增的回复内容')).toBeInTheDocument() + await act( + () => + new Promise((resolve) => + requestAnimationFrame(() => resolve()) + ) + ) + + expect(scrollTo).not.toHaveBeenCalled() + expect( + screen.getByRole('button', { name: '到底部' }) + ).toBeInTheDocument() + }) + 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 caac0f4..bb9ba5a 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -1,4 +1,5 @@ import { + ArrowDown, Bot, Check, CheckCircle2, @@ -525,6 +526,8 @@ function groupMessageBlocks( return items } +const chatBottomProximity = 96 + function MessageReasoning({ content, streaming @@ -1593,12 +1596,42 @@ function App(): React.JSX.Element { const hydratingArtifactIds = useRef(new Set()) const knowledgeScopeInitialized = useRef(false) const inputRef = useRef(null) - const scrollRef = useRef(null) + const scrollRef = useRef(null) + const chatPinnedToBottomRef = useRef(true) + const chatScrollContextRef = useRef(`${view}:${activeId}`) + const [showScrollToBottom, setShowScrollToBottom] = useState(false) const sidebarRef = useRef(null) const sidebarToggleRef = useRef(null) const conversationActionTriggerRefs = useRef( new Map() ) + const updateChatScrollPosition = useCallback((): void => { + const scrollContainer = scrollRef.current + if (!scrollContainer) { + return + } + const distanceFromBottom = + scrollContainer.scrollHeight - + scrollContainer.scrollTop - + scrollContainer.clientHeight + const atBottom = distanceFromBottom <= chatBottomProximity + chatPinnedToBottomRef.current = atBottom + setShowScrollToBottom(!atBottom) + }, []) + const scrollChatToBottom = useCallback((): void => { + const scrollContainer = scrollRef.current + if (!scrollContainer) { + return + } + chatPinnedToBottomRef.current = true + const reduceMotion = + typeof window.matchMedia === 'function' && + window.matchMedia('(prefers-reduced-motion: reduce)').matches + scrollContainer.scrollTo({ + top: scrollContainer.scrollHeight, + behavior: reduceMotion ? 'auto' : 'smooth' + }) + }, []) const closeNarrowSidebar = useCallback((): void => { setSidebarOpen(false) requestAnimationFrame(() => sidebarToggleRef.current?.focus()) @@ -3572,13 +3605,32 @@ function App(): React.JSX.Element { useEffect(() => { const frame = requestAnimationFrame(() => { - scrollRef.current?.scrollTo({ - top: scrollRef.current.scrollHeight, - behavior: 'auto' - }) + const scrollContext = `${view}:${activeId}` + if (chatScrollContextRef.current !== scrollContext) { + chatScrollContextRef.current = scrollContext + chatPinnedToBottomRef.current = true + } + const scrollContainer = scrollRef.current + if (!scrollContainer) { + return + } + if (chatPinnedToBottomRef.current) { + scrollContainer.scrollTo({ + top: scrollContainer.scrollHeight, + behavior: 'auto' + }) + setShowScrollToBottom(false) + return + } + updateChatScrollPosition() }) return () => cancelAnimationFrame(frame) - }, [activeConversation?.messages]) + }, [ + activeConversation?.messages, + activeId, + updateChatScrollPosition, + view + ]) const selectProject = (projectId: string): void => { const project = projects.find((candidate) => candidate.id === projectId) @@ -4096,6 +4148,7 @@ function App(): React.JSX.Element { runtime.capability === 'image-generation' ? '' : selectedExpertId const workModeSnapshot = effectiveWorkMode preparingConversations.current.add(conversationId) + chatPinnedToBottomRef.current = true setInput('') updateAttachments([]) const userMessage: Message = { @@ -5266,7 +5319,13 @@ function App(): React.JSX.Element { {view === 'chat' ? ( -
+
+
{activeProject?.kind === 'channel' && !activeConversation && (
+ {showScrollToBottom && ( + + )} +