feat: preserve chat reading position
This commit is contained in:
@@ -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(
|
||||||
|
|||||||
@@ -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: '图片已生成,正在保存结果',
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user