fix: preserve per-conversation chat state
This commit is contained in:
@@ -1555,6 +1555,125 @@ describe('App', () => {
|
|||||||
).toBeInTheDocument()
|
).toBeInTheDocument()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('restores the reader position after activity continues on another page', async () => {
|
||||||
|
const { container } = 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 = 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: 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<HTMLElement>('.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(<App />)
|
||||||
|
|
||||||
|
expect(await screen.findByText('第一段历史 160')).toBeInTheDocument()
|
||||||
|
fireEvent.click(
|
||||||
|
screen.getByRole('button', {
|
||||||
|
name: '加载更早的消息(还剩 81 条)'
|
||||||
|
})
|
||||||
|
)
|
||||||
|
expect(container.querySelectorAll('.message')).toHaveLength(160)
|
||||||
|
const firstChat = container.querySelector<HTMLElement>('.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<HTMLElement>('.chat')?.scrollTop).toBe(
|
||||||
|
225
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
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(
|
||||||
|
|||||||
+79
-34
@@ -1578,7 +1578,19 @@ function App(): React.JSX.Element {
|
|||||||
const inputRef = useRef<HTMLTextAreaElement>(null)
|
const inputRef = useRef<HTMLTextAreaElement>(null)
|
||||||
const scrollRef = useRef<HTMLElement>(null)
|
const scrollRef = useRef<HTMLElement>(null)
|
||||||
const chatPinnedToBottomRef = useRef(true)
|
const chatPinnedToBottomRef = useRef(true)
|
||||||
const chatScrollContextRef = useRef(`${view}:${activeId}`)
|
const chatScrollContextRef = useRef(activeId)
|
||||||
|
const chatScrollRestorePendingRef = useRef<string | undefined>(
|
||||||
|
undefined
|
||||||
|
)
|
||||||
|
const chatScrollSnapshotsRef = useRef(
|
||||||
|
new Map<
|
||||||
|
string,
|
||||||
|
{
|
||||||
|
pinnedToBottom: boolean
|
||||||
|
scrollTop: number
|
||||||
|
}
|
||||||
|
>()
|
||||||
|
)
|
||||||
const prependScrollPositionRef = useRef<{
|
const prependScrollPositionRef = useRef<{
|
||||||
conversationId: string
|
conversationId: string
|
||||||
scrollHeight: number
|
scrollHeight: number
|
||||||
@@ -1610,29 +1622,52 @@ function App(): React.JSX.Element {
|
|||||||
),
|
),
|
||||||
[]
|
[]
|
||||||
)
|
)
|
||||||
const [visibleMessageWindow, setVisibleMessageWindow] = useState(() => ({
|
const [visibleMessageCounts, setVisibleMessageCounts] = useState<
|
||||||
conversationId: activeId,
|
Record<string, number>
|
||||||
count: messageRenderBatchSize
|
>({})
|
||||||
}))
|
|
||||||
const [showScrollToBottom, setShowScrollToBottom] = useState(false)
|
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 saveChatScrollPosition = useCallback(
|
||||||
const scrollContainer = scrollRef.current
|
(conversationId: string, scrollContainer: HTMLElement): boolean => {
|
||||||
if (!scrollContainer) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const distanceFromBottom =
|
const distanceFromBottom =
|
||||||
scrollContainer.scrollHeight -
|
scrollContainer.scrollHeight -
|
||||||
scrollContainer.scrollTop -
|
scrollContainer.scrollTop -
|
||||||
scrollContainer.clientHeight
|
scrollContainer.clientHeight
|
||||||
const atBottom = distanceFromBottom <= chatBottomProximity
|
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 || !activeId) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const atBottom = saveChatScrollPosition(activeId, scrollContainer)
|
||||||
chatPinnedToBottomRef.current = atBottom
|
chatPinnedToBottomRef.current = atBottom
|
||||||
setShowScrollToBottom(!atBottom)
|
setShowScrollToBottom(!atBottom)
|
||||||
}, [])
|
}, [activeId, saveChatScrollPosition])
|
||||||
const scrollChatToBottom = useCallback((): void => {
|
const scrollChatToBottom = useCallback((): void => {
|
||||||
const scrollContainer = scrollRef.current
|
const scrollContainer = scrollRef.current
|
||||||
if (!scrollContainer) {
|
if (!scrollContainer) {
|
||||||
@@ -1828,9 +1863,7 @@ function App(): React.JSX.Element {
|
|||||||
[activeId, conversations]
|
[activeId, conversations]
|
||||||
)
|
)
|
||||||
const visibleMessageCount =
|
const visibleMessageCount =
|
||||||
visibleMessageWindow.conversationId === activeId
|
visibleMessageCounts[activeId] ?? messageRenderBatchSize
|
||||||
? visibleMessageWindow.count
|
|
||||||
: messageRenderBatchSize
|
|
||||||
const visibleMessageStartIndex = Math.max(
|
const visibleMessageStartIndex = Math.max(
|
||||||
0,
|
0,
|
||||||
(activeConversation?.messages.length ?? 0) - visibleMessageCount
|
(activeConversation?.messages.length ?? 0) - visibleMessageCount
|
||||||
@@ -1848,10 +1881,7 @@ function App(): React.JSX.Element {
|
|||||||
scrollTop: scrollContainer.scrollTop
|
scrollTop: scrollContainer.scrollTop
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const currentCount =
|
const currentCount = visibleMessageCount
|
||||||
visibleMessageWindow.conversationId === activeId
|
|
||||||
? visibleMessageWindow.count
|
|
||||||
: messageRenderBatchSize
|
|
||||||
if (
|
if (
|
||||||
activeConversation &&
|
activeConversation &&
|
||||||
currentCount + messageRenderBatchSize >=
|
currentCount + messageRenderBatchSize >=
|
||||||
@@ -1860,11 +1890,11 @@ function App(): React.JSX.Element {
|
|||||||
finalRevealedMessageIdRef.current =
|
finalRevealedMessageIdRef.current =
|
||||||
activeConversation.messages[0]?.id
|
activeConversation.messages[0]?.id
|
||||||
}
|
}
|
||||||
setVisibleMessageWindow({
|
setVisibleMessageCounts((current) => ({
|
||||||
conversationId: activeId,
|
...current,
|
||||||
count: currentCount + messageRenderBatchSize
|
[activeId]: currentCount + messageRenderBatchSize
|
||||||
})
|
}))
|
||||||
}, [activeConversation, activeId, visibleMessageWindow])
|
}, [activeConversation, activeId, visibleMessageCount])
|
||||||
|
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
const previous = prependScrollPositionRef.current
|
const previous = prependScrollPositionRef.current
|
||||||
@@ -1889,7 +1919,7 @@ function App(): React.JSX.Element {
|
|||||||
.get(finalRevealedMessageId)
|
.get(finalRevealedMessageId)
|
||||||
?.focus({ preventScroll: true })
|
?.focus({ preventScroll: true })
|
||||||
}
|
}
|
||||||
}, [activeId, visibleMessageWindow])
|
}, [activeId, visibleMessageCount])
|
||||||
|
|
||||||
const activeRuntimeSelection = useMemo(
|
const activeRuntimeSelection = useMemo(
|
||||||
() =>
|
() =>
|
||||||
@@ -3872,17 +3902,33 @@ function App(): React.JSX.Element {
|
|||||||
)
|
)
|
||||||
}, [startNewConversation])
|
}, [startNewConversation])
|
||||||
|
|
||||||
useEffect(() => {
|
useLayoutEffect(() => {
|
||||||
const frame = requestAnimationFrame(() => {
|
if (view !== 'chat') {
|
||||||
const scrollContext = `${view}:${activeId}`
|
return
|
||||||
if (chatScrollContextRef.current !== scrollContext) {
|
|
||||||
chatScrollContextRef.current = scrollContext
|
|
||||||
chatPinnedToBottomRef.current = true
|
|
||||||
}
|
}
|
||||||
const scrollContainer = scrollRef.current
|
const scrollContainer = scrollRef.current
|
||||||
if (!scrollContainer) {
|
if (!scrollContainer) {
|
||||||
return
|
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) {
|
if (chatPinnedToBottomRef.current) {
|
||||||
scrollContainer.scrollTo({
|
scrollContainer.scrollTo({
|
||||||
top: scrollContainer.scrollHeight,
|
top: scrollContainer.scrollHeight,
|
||||||
@@ -3892,12 +3938,11 @@ function App(): React.JSX.Element {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
updateChatScrollPosition()
|
updateChatScrollPosition()
|
||||||
})
|
|
||||||
return () => cancelAnimationFrame(frame)
|
|
||||||
}, [
|
}, [
|
||||||
activeConversation?.messages,
|
activeConversation?.messages,
|
||||||
activeId,
|
activeId,
|
||||||
updateChatScrollPosition,
|
updateChatScrollPosition,
|
||||||
|
visibleMessageCount,
|
||||||
view
|
view
|
||||||
])
|
])
|
||||||
|
|
||||||
@@ -5660,7 +5705,7 @@ function App(): React.JSX.Element {
|
|||||||
className="chat"
|
className="chat"
|
||||||
id="chat-message-list"
|
id="chat-message-list"
|
||||||
onScroll={updateChatScrollPosition}
|
onScroll={updateChatScrollPosition}
|
||||||
ref={scrollRef}
|
ref={handleChatScrollRef}
|
||||||
>
|
>
|
||||||
{activeProject?.kind === 'channel' &&
|
{activeProject?.kind === 'channel' &&
|
||||||
!activeConversation && (
|
!activeConversation && (
|
||||||
|
|||||||
Reference in New Issue
Block a user