feat: preserve chat reading position

This commit is contained in:
lofyer
2026-08-14 18:27:55 +08:00
parent 57c57d232c
commit a5f1e31900
5 changed files with 232 additions and 7 deletions
+109
View File
@@ -1168,6 +1168,115 @@ describe('App', () => {
expect(await screen.findByRole('status')).toBeVisible() 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(<App />)
expect(await screen.findByText('较早的会话内容')).toBeInTheDocument()
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: 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(<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 = document.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: 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<void>((resolve) =>
requestAnimationFrame(() => resolve())
)
)
expect(scrollTo).not.toHaveBeenCalled()
expect(
screen.getByRole('button', { name: '到底部' })
).toBeInTheDocument()
})
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(
+77 -5
View File
@@ -1,4 +1,5 @@
import { import {
ArrowDown,
Bot, Bot,
Check, Check,
CheckCircle2, CheckCircle2,
@@ -525,6 +526,8 @@ function groupMessageBlocks(
return items return items
} }
const chatBottomProximity = 96
function MessageReasoning({ function MessageReasoning({
content, content,
streaming streaming
@@ -1593,12 +1596,42 @@ function App(): React.JSX.Element {
const hydratingArtifactIds = useRef(new Set<string>()) const hydratingArtifactIds = useRef(new Set<string>())
const knowledgeScopeInitialized = useRef(false) const knowledgeScopeInitialized = useRef(false)
const inputRef = useRef<HTMLTextAreaElement>(null) const inputRef = useRef<HTMLTextAreaElement>(null)
const scrollRef = useRef<HTMLDivElement>(null) const scrollRef = useRef<HTMLElement>(null)
const chatPinnedToBottomRef = useRef(true)
const chatScrollContextRef = useRef(`${view}:${activeId}`)
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 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 => { const closeNarrowSidebar = useCallback((): void => {
setSidebarOpen(false) setSidebarOpen(false)
requestAnimationFrame(() => sidebarToggleRef.current?.focus()) requestAnimationFrame(() => sidebarToggleRef.current?.focus())
@@ -3572,13 +3605,32 @@ function App(): React.JSX.Element {
useEffect(() => { useEffect(() => {
const frame = requestAnimationFrame(() => { const frame = requestAnimationFrame(() => {
scrollRef.current?.scrollTo({ const scrollContext = `${view}:${activeId}`
top: scrollRef.current.scrollHeight, 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' behavior: 'auto'
}) })
setShowScrollToBottom(false)
return
}
updateChatScrollPosition()
}) })
return () => cancelAnimationFrame(frame) return () => cancelAnimationFrame(frame)
}, [activeConversation?.messages]) }, [
activeConversation?.messages,
activeId,
updateChatScrollPosition,
view
])
const selectProject = (projectId: string): void => { const selectProject = (projectId: string): void => {
const project = projects.find((candidate) => candidate.id === projectId) const project = projects.find((candidate) => candidate.id === projectId)
@@ -4096,6 +4148,7 @@ function App(): React.JSX.Element {
runtime.capability === 'image-generation' ? '' : selectedExpertId runtime.capability === 'image-generation' ? '' : selectedExpertId
const workModeSnapshot = effectiveWorkMode const workModeSnapshot = effectiveWorkMode
preparingConversations.current.add(conversationId) preparingConversations.current.add(conversationId)
chatPinnedToBottomRef.current = true
setInput('') setInput('')
updateAttachments([]) updateAttachments([])
const userMessage: Message = { const userMessage: Message = {
@@ -5266,7 +5319,13 @@ function App(): React.JSX.Element {
{view === 'chat' ? ( {view === 'chat' ? (
<PageShell variant="reading"> <PageShell variant="reading">
<section className="chat" ref={scrollRef}> <div className="chat-scroll-region">
<section
className="chat"
id="chat-message-list"
onScroll={updateChatScrollPosition}
ref={scrollRef}
>
{activeProject?.kind === 'channel' && {activeProject?.kind === 'channel' &&
!activeConversation && ( !activeConversation && (
<EmptyState <EmptyState
@@ -5876,6 +5935,19 @@ function App(): React.JSX.Element {
))} ))}
</div> </div>
</section> </section>
{showScrollToBottom && (
<button
aria-controls="chat-message-list"
aria-label={t('chat.scrollToBottom')}
className="chat-scroll-to-bottom"
onClick={scrollChatToBottom}
title={t('chat.scrollToBottom')}
type="button"
>
<ArrowDown aria-hidden="true" size={18} />
</button>
)}
</div>
<footer className="composer-wrap"> <footer className="composer-wrap">
{activeProject?.kind === 'channel' ? ( {activeProject?.kind === 'channel' ? (
@@ -226,6 +226,7 @@ export const app = {
} }
}, },
retry: 'Edit and send again', retry: 'Edit and send again',
scrollToBottom: 'Scroll to bottom',
status: { status: {
responseTruncated: 'The response was too long and was truncated locally', responseTruncated: 'The response was too long and was truncated locally',
savingImage: 'Image generated; saving the result', savingImage: 'Image generated; saving the result',
@@ -219,6 +219,7 @@ export const app = {
} }
}, },
retry: '重新编辑并发送', retry: '重新编辑并发送',
scrollToBottom: '到底部',
status: { status: {
responseTruncated: '回答过长,已在本地截断显示', responseTruncated: '回答过长,已在本地截断显示',
savingImage: '图片已生成,正在保存结果', savingImage: '图片已生成,正在保存结果',
+42
View File
@@ -2968,7 +2968,15 @@ button > svg {
background: var(--success); background: var(--success);
} }
.chat-scroll-region {
position: relative;
min-height: 0;
overflow: hidden;
background: var(--surface-raised);
}
.chat { .chat {
height: 100%;
min-height: 0; min-height: 0;
padding: padding:
var(--page-gutter) var(--page-gutter)
@@ -2980,6 +2988,40 @@ button > svg {
scrollbar-width: thin; scrollbar-width: thin;
} }
.chat-scroll-to-bottom {
position: absolute;
z-index: 2;
bottom: var(--space-3);
left: 50%;
display: grid;
width: 40px;
height: 40px;
padding: 0;
border: 1px solid var(--border-control);
border-radius: 50%;
background: var(--surface-raised);
box-shadow: var(--shadow-card);
color: var(--accent);
cursor: pointer;
place-items: center;
transform: translateX(-50%);
transition:
border-color var(--motion-fast) ease-out,
background var(--motion-fast) ease-out,
color var(--motion-fast) ease-out,
opacity var(--motion-fast) ease-out;
}
.chat-scroll-to-bottom:hover {
border-color: var(--accent-selected);
background: var(--accent-subtle);
}
.chat-scroll-to-bottom:active {
background: var(--accent-selected);
opacity: 0.82;
}
.welcome { .welcome {
padding: 26px 0 30px; padding: 26px 0 30px;
text-align: center; text-align: center;