fix: avoid rerendering chat history while typing
This commit is contained in:
@@ -44,6 +44,19 @@ const speechRecognitionMocks = vi.hoisted(() => ({
|
|||||||
startPcmRecording: vi.fn()
|
startPcmRecording: vi.fn()
|
||||||
}))
|
}))
|
||||||
|
|
||||||
|
const messageRenderProbe = vi.hoisted(() => vi.fn())
|
||||||
|
|
||||||
|
vi.mock('./MarkdownRenderer', async (importOriginal) => {
|
||||||
|
const original = await importOriginal<typeof import('./MarkdownRenderer')>()
|
||||||
|
return {
|
||||||
|
...original,
|
||||||
|
MarkdownRenderer: (props: { children: string; renderHtml?: boolean }) => {
|
||||||
|
messageRenderProbe(props.children)
|
||||||
|
return <original.MarkdownRenderer {...props} />
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
const lazyRouteMocks = vi.hoisted(() => {
|
const lazyRouteMocks = vi.hoisted(() => {
|
||||||
let pending: Promise<void> | undefined
|
let pending: Promise<void> | undefined
|
||||||
let releasePending: (() => void) | undefined
|
let releasePending: (() => void) | undefined
|
||||||
@@ -2527,6 +2540,28 @@ describe('App', () => {
|
|||||||
).not.toBeInTheDocument()
|
).not.toBeInTheDocument()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('measures composer height once per input and resizes after sending', async () => {
|
||||||
|
render(<App />)
|
||||||
|
const input = await screen.findByLabelText('向 GoodBuddy 提问')
|
||||||
|
const measureHeight = vi.fn(() =>
|
||||||
|
(input as HTMLTextAreaElement).value ? 300 : 0
|
||||||
|
)
|
||||||
|
Object.defineProperty(input, 'scrollHeight', {
|
||||||
|
configurable: true,
|
||||||
|
get: measureHeight
|
||||||
|
})
|
||||||
|
|
||||||
|
fireEvent.input(input, { target: { value: 'First line\nSecond line' } })
|
||||||
|
expect(measureHeight).toHaveBeenCalledTimes(1)
|
||||||
|
expect(input).toHaveStyle({ height: '220px' })
|
||||||
|
|
||||||
|
measureHeight.mockClear()
|
||||||
|
fireEvent.click(screen.getByLabelText('发送'))
|
||||||
|
await waitFor(() => expect(input).toHaveValue(''))
|
||||||
|
expect(measureHeight).toHaveBeenCalledTimes(1)
|
||||||
|
expect(input).toHaveStyle({ height: '72px' })
|
||||||
|
})
|
||||||
|
|
||||||
it('shows a red recording state until microphone capture stops', async () => {
|
it('shows a red recording state until microphone capture stops', async () => {
|
||||||
let resolveRecording!: (value: {
|
let resolveRecording!: (value: {
|
||||||
audio: ArrayBuffer
|
audio: ArrayBuffer
|
||||||
@@ -3325,9 +3360,12 @@ describe('App', () => {
|
|||||||
scrollTop: { configurable: true, writable: true, value: 225 }
|
scrollTop: { configurable: true, writable: true, value: 225 }
|
||||||
})
|
})
|
||||||
fireEvent.scroll(firstChat)
|
fireEvent.scroll(firstChat)
|
||||||
|
messageRenderProbe.mockClear()
|
||||||
fireEvent.change(screen.getByLabelText('向 GoodBuddy 提问'), {
|
fireEvent.change(screen.getByLabelText('向 GoodBuddy 提问'), {
|
||||||
target: { value: '第一段会话草稿' }
|
target: { value: '第一段会话草稿' }
|
||||||
})
|
})
|
||||||
|
expect(messageRenderProbe).not.toHaveBeenCalled()
|
||||||
|
expect(firstPane?.querySelectorAll('.message')).toHaveLength(160)
|
||||||
fireEvent.click(screen.getByLabelText('添加附件'))
|
fireEvent.click(screen.getByLabelText('添加附件'))
|
||||||
expect(
|
expect(
|
||||||
await screen.findByText(draftAttachment.name)
|
await screen.findByText(draftAttachment.name)
|
||||||
@@ -3341,9 +3379,12 @@ describe('App', () => {
|
|||||||
expect(
|
expect(
|
||||||
screen.queryByText(draftAttachment.name)
|
screen.queryByText(draftAttachment.name)
|
||||||
).not.toBeInTheDocument()
|
).not.toBeInTheDocument()
|
||||||
|
messageRenderProbe.mockClear()
|
||||||
fireEvent.change(screen.getByLabelText('向 GoodBuddy 提问'), {
|
fireEvent.change(screen.getByLabelText('向 GoodBuddy 提问'), {
|
||||||
target: { value: '第二段会话草稿' }
|
target: { value: '第二段会话草稿' }
|
||||||
})
|
})
|
||||||
|
expect(messageRenderProbe).not.toHaveBeenCalled()
|
||||||
|
expect(firstPane?.querySelectorAll('.message')).toHaveLength(160)
|
||||||
fireEvent.click(
|
fireEvent.click(
|
||||||
screen.getByText('第一段长会话').closest('button')!
|
screen.getByText('第一段长会话').closest('button')!
|
||||||
)
|
)
|
||||||
|
|||||||
+23
-15
@@ -931,11 +931,23 @@ function ChatHistoryPane({
|
|||||||
0,
|
0,
|
||||||
conversation.messages.length - visibleMessageCount
|
conversation.messages.length - visibleMessageCount
|
||||||
)
|
)
|
||||||
const visibleMessages = conversation.messages.slice(
|
const visibleMessages = useMemo(
|
||||||
visibleMessageStartIndex
|
() => conversation.messages.slice(visibleMessageStartIndex),
|
||||||
|
[conversation.messages, visibleMessageStartIndex]
|
||||||
)
|
)
|
||||||
const hiddenMessageCount = visibleMessageStartIndex
|
const hiddenMessageCount = visibleMessageStartIndex
|
||||||
|
|
||||||
|
const handleArticleRef = useCallback(
|
||||||
|
(messageId: string, element: HTMLElement | null): void => {
|
||||||
|
if (element) {
|
||||||
|
messageArticleRefs.current.set(messageId, element)
|
||||||
|
} else {
|
||||||
|
messageArticleRefs.current.delete(messageId)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[]
|
||||||
|
)
|
||||||
|
|
||||||
const saveScrollPosition = useCallback(
|
const saveScrollPosition = useCallback(
|
||||||
(scrollContainer: HTMLElement): boolean => {
|
(scrollContainer: HTMLElement): boolean => {
|
||||||
const distanceFromBottom =
|
const distanceFromBottom =
|
||||||
@@ -1068,7 +1080,7 @@ function ChatHistoryPane({
|
|||||||
}
|
}
|
||||||
}, [visibleMessageCount])
|
}, [visibleMessageCount])
|
||||||
|
|
||||||
const revealEarlierMessages = (): void => {
|
const revealEarlierMessages = useCallback((): void => {
|
||||||
const scrollContainer = scrollRef.current
|
const scrollContainer = scrollRef.current
|
||||||
if (scrollContainer) {
|
if (scrollContainer) {
|
||||||
prependScrollPositionRef.current = {
|
prependScrollPositionRef.current = {
|
||||||
@@ -1087,7 +1099,12 @@ function ChatHistoryPane({
|
|||||||
conversation.id,
|
conversation.id,
|
||||||
visibleMessageCount + messageRenderBatchSize
|
visibleMessageCount + messageRenderBatchSize
|
||||||
)
|
)
|
||||||
}
|
}, [
|
||||||
|
conversation.id,
|
||||||
|
conversation.messages,
|
||||||
|
onVisibleMessageCountChange,
|
||||||
|
visibleMessageCount
|
||||||
|
])
|
||||||
|
|
||||||
const scrollToBottom = (): void => {
|
const scrollToBottom = (): void => {
|
||||||
const scrollContainer = scrollRef.current
|
const scrollContainer = scrollRef.current
|
||||||
@@ -1156,13 +1173,7 @@ function ChatHistoryPane({
|
|||||||
locale={locale}
|
locale={locale}
|
||||||
messages={visibleMessages}
|
messages={visibleMessages}
|
||||||
messageStartIndex={visibleMessageStartIndex}
|
messageStartIndex={visibleMessageStartIndex}
|
||||||
onArticleRef={(messageId, element) => {
|
onArticleRef={handleArticleRef}
|
||||||
if (element) {
|
|
||||||
messageArticleRefs.current.set(messageId, element)
|
|
||||||
} else {
|
|
||||||
messageArticleRefs.current.delete(messageId)
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
onCopyMessage={onCopyMessage}
|
onCopyMessage={onCopyMessage}
|
||||||
onDownloadImage={onDownloadImage}
|
onDownloadImage={onDownloadImage}
|
||||||
onOpenCitationContext={onOpenCitationContext}
|
onOpenCitationContext={onOpenCitationContext}
|
||||||
@@ -3073,7 +3084,7 @@ function App(): React.JSX.Element {
|
|||||||
assistantTasksRef.current = assistantTasks
|
assistantTasksRef.current = assistantTasks
|
||||||
}, [assistantTasks])
|
}, [assistantTasks])
|
||||||
|
|
||||||
useEffect(() => {
|
useLayoutEffect(() => {
|
||||||
resizeComposerTextarea(inputRef.current)
|
resizeComposerTextarea(inputRef.current)
|
||||||
}, [input])
|
}, [input])
|
||||||
|
|
||||||
@@ -9679,9 +9690,6 @@ function App(): React.JSX.Element {
|
|||||||
title={t('composer.keyboardHint')}
|
title={t('composer.keyboardHint')}
|
||||||
value={input}
|
value={input}
|
||||||
onChange={(event) => setInput(event.target.value)}
|
onChange={(event) => setInput(event.target.value)}
|
||||||
onInput={(event) =>
|
|
||||||
resizeComposerTextarea(event.currentTarget)
|
|
||||||
}
|
|
||||||
onPaste={(event) => {
|
onPaste={(event) => {
|
||||||
const imageItem = Array.from(
|
const imageItem = Array.from(
|
||||||
event.clipboardData.items
|
event.clipboardData.items
|
||||||
|
|||||||
@@ -182,6 +182,12 @@ describe('ChatTimeline', () => {
|
|||||||
unchangedDetails!.open = true
|
unchangedDetails!.open = true
|
||||||
markdownRenderProbe.mockClear()
|
markdownRenderProbe.mockClear()
|
||||||
|
|
||||||
|
const mapMessages = vi.spyOn(messages, 'map')
|
||||||
|
rerender(<ChatTimeline {...props} messages={messages} />)
|
||||||
|
expect(mapMessages).not.toHaveBeenCalled()
|
||||||
|
expect(markdownRenderProbe).not.toHaveBeenCalled()
|
||||||
|
mapMessages.mockRestore()
|
||||||
|
|
||||||
const streamedMessages = messages.map((message, index) =>
|
const streamedMessages = messages.map((message, index) =>
|
||||||
index === messages.length - 1
|
index === messages.length - 1
|
||||||
? { ...message, content: `${message.content} delta` }
|
? { ...message, content: `${message.content} delta` }
|
||||||
|
|||||||
@@ -1163,7 +1163,7 @@ type ChatTimelineProps = {
|
|||||||
totalMessageCount: number
|
totalMessageCount: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ChatTimeline({
|
export const ChatTimeline = memo(function ChatTimeline({
|
||||||
artifactById,
|
artifactById,
|
||||||
conversationId,
|
conversationId,
|
||||||
hiddenMessageCount,
|
hiddenMessageCount,
|
||||||
@@ -1230,4 +1230,4 @@ export function ChatTimeline({
|
|||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user