From 0883af2d182f19dde4ac72558dc2fd9501bdb7fa Mon Sep 17 00:00:00 2001
From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com>
Date: Tue, 3 Nov 2026 14:25:27 -0800
Subject: [PATCH] Avoid copying chat history on preview-only updates (#37922)
* Avoid copying chat history on preview-only updates
* Use fixed timestamps in preview input regression test
---
...eChatResolvedView.streaming-input.test.tsx | 163 ++++++++++++++++++
.../native-chat/NativeChatResolvedView.tsx | 17 +-
2 files changed, 172 insertions(+), 8 deletions(-)
create mode 100644 src/renderer/src/components/native-chat/NativeChatResolvedView.streaming-input.test.tsx
diff --git a/src/renderer/src/components/native-chat/NativeChatResolvedView.streaming-input.test.tsx b/src/renderer/src/components/native-chat/NativeChatResolvedView.streaming-input.test.tsx
new file mode 100644
index 00000000000..1fd302d51f8
--- /dev/null
+++ b/src/renderer/src/components/native-chat/NativeChatResolvedView.streaming-input.test.tsx
@@ -0,0 +1,163 @@
+// @vitest-environment happy-dom
+
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'
+import type { NativeChatMessage } from '../../../../shared/native-chat/native-chat-types'
+import type { NativeChatLiveSession } from './use-native-chat-live-session'
+import type { NativeChatComposerProps } from './native-chat-composer-types'
+import type { AgentStatusPayload } from '../../../../shared/agent-status-types'
+import type * as StreamingModule from '../../../../shared/native-chat/native-chat-streaming'
+import { clearPendingSendCacheForTests } from './native-chat-pending'
+
+const probe = vi.hoisted<{
+ session: NativeChatLiveSession | null
+ inputs: (readonly NativeChatMessage[])[]
+ shown: NativeChatMessage[]
+}>(() => ({ session: null, inputs: [], shown: [] }))
+vi.mock('./use-native-chat-retained-session', () => ({
+ useNativeChatRetainedSession: () => probe.session
+}))
+vi.mock('./NativeChatMessageList', () => ({
+ NativeChatMessageList: ({ session }: { session: NativeChatLiveSession }) => {
+ probe.shown = session.messages
+ return null
+ }
+}))
+vi.mock('./NativeChatComposer', () => ({
+ NativeChatComposer: ({ onOptimisticSend }: NativeChatComposerProps) => (
+
+ )
+}))
+vi.mock('../../../../shared/native-chat/native-chat-streaming', async (importOriginal) => {
+ const original = await importOriginal()
+ return {
+ ...original,
+ deriveNativeChatStreamingText: (
+ args: Parameters[0]
+ ) => {
+ probe.inputs.push(args.messages)
+ return original.deriveNativeChatStreamingText(args)
+ }
+ }
+})
+
+const { NativeChatResolvedView } = await import('./NativeChatResolvedView')
+const { TooltipProvider } = await import('@/components/ui/tooltip')
+const { useAppStore } = await import('../../store')
+const paneKey = 'preview-perf:leaf'
+const message = (id: string, role: 'user' | 'assistant', text: string): NativeChatMessage => ({
+ id,
+ role,
+ blocks: [{ type: 'text', text }],
+ timestamp: 1,
+ source: 'transcript'
+})
+
+function setPreview(text: string, extra: Partial = {}): void {
+ useAppStore.getState().setAgentStatus(paneKey, {
+ prompt: 'Prompt',
+ agentType: 'claude',
+ state: 'working',
+ lastAssistantMessage: text,
+ ...extra
+ })
+}
+
+function previewTicks(expected: readonly NativeChatMessage[], extra = {}): void {
+ probe.inputs = []
+ for (let tick = 0; tick < 20; tick += 1) {
+ act(() => setPreview(`Current reply ${tick}`, extra))
+ }
+ expect(probe.inputs.length).toBeGreaterThanOrEqual(20)
+ expect(new Set(probe.inputs).size).toBe(1)
+ expect(probe.inputs[0]).toBe(expected)
+}
+
+beforeEach(() => {
+ vi.useFakeTimers({ toFake: ['Date'] })
+ vi.setSystemTime(1_000)
+ clearPendingSendCacheForTests()
+ probe.inputs = []
+ probe.shown = []
+ useAppStore.setState({ agentStatusByPaneKey: {}, nativeChatLaunchPromptByTabId: {} })
+})
+afterEach(() => {
+ cleanup()
+ vi.useRealTimers()
+ clearPendingSendCacheForTests()
+ useAppStore.setState({ agentStatusByPaneKey: {}, nativeChatLaunchPromptByTabId: {} })
+})
+
+describe('preview input allocation at realistic history sizes', () => {
+ it.each([1_000, 10_000])('reuses input across ticks with %i loaded messages', (size) => {
+ const transcript = Array.from({ length: size }, (_, index) =>
+ message(`history-${index}`, index % 2 ? 'assistant' : 'user', `History ${index}`)
+ )
+ probe.session = {
+ messages: transcript,
+ status: 'working',
+ sessionId: 'preview-perf',
+ agent: 'claude',
+ hasMore: false,
+ loadingEarlier: false,
+ olderHistoryGeneration: 0,
+ loadEarlier: vi.fn(),
+ readPhase: 'ready'
+ }
+ setPreview('Current reply')
+ render(
+
+
+
+ )
+ previewTicks(transcript)
+ act(() => fireEvent.click(screen.getByRole('button', { name: 'send' })))
+ const queued = probe.inputs.at(-1)!
+ expect(queued).toHaveLength(size + 1)
+ expect(queued.at(-1)?.pendingSend).toBe('queued')
+ previewTicks(queued)
+ expect(probe.shown.at(-2)?.id).toBe('streaming')
+ expect(probe.shown.at(-1)?.pendingSend).toBe('queued')
+ previewTicks(queued, { lastAssistantMessageIsToolOutput: true })
+
+ const caughtUp = [...transcript, message('reply', 'assistant', 'Current reply 19')]
+ act(() => {
+ probe.session = { ...probe.session!, messages: caughtUp }
+ setPreview('Current reply 19')
+ })
+ const updated = probe.inputs.at(-1)!
+ expect(updated).not.toBe(queued)
+ expect(updated.at(-2)?.id).toBe('reply')
+ expect(updated.at(-1)?.pendingSend).toBe('queued')
+ previewTicks(updated)
+
+ act(() => {
+ probe.session = { ...probe.session!, status: 'ready' }
+ setPreview('Idle', { state: 'done' })
+ })
+ const idle = probe.inputs.at(-1)!
+ expect(idle).not.toBe(updated)
+ previewTicks(idle, { state: 'done' })
+
+ const retired = [
+ ...caughtUp,
+ { ...message('follow-up', 'user', 'Follow up'), timestamp: 1_001 },
+ { ...message('next-reply', 'assistant', 'Done'), timestamp: 1_002 }
+ ]
+ act(() => {
+ probe.session = { ...probe.session!, messages: retired }
+ setPreview('Done', { state: 'done' })
+ })
+ previewTicks(retired, { state: 'done' })
+ })
+})
diff --git a/src/renderer/src/components/native-chat/NativeChatResolvedView.tsx b/src/renderer/src/components/native-chat/NativeChatResolvedView.tsx
index 0184136f664..53c0547c0ca 100644
--- a/src/renderer/src/components/native-chat/NativeChatResolvedView.tsx
+++ b/src/renderer/src/components/native-chat/NativeChatResolvedView.tsx
@@ -222,20 +222,21 @@ export function NativeChatResolvedView({
() => pendingSendsAsMessages(pending, sessionAfterCommandBoundaries.messages, sendWorkingEpoch),
[pending, sessionAfterCommandBoundaries.messages, sendWorkingEpoch]
)
+ const messagesWithPending = useMemo(
+ () =>
+ pendingMessages.length === 0
+ ? sessionAfterCommandBoundaries.messages
+ : [...sessionAfterCommandBoundaries.messages, ...pendingMessages],
+ [sessionAfterCommandBoundaries.messages, pendingMessages]
+ )
const streamingText = useMemo(() => {
return deriveNativeChatStreamingText({
- messages: [...sessionAfterCommandBoundaries.messages, ...pendingMessages],
+ messages: messagesWithPending,
previewText: hookPreview,
working: liveWorking,
previewIsToolOutput: hookPreviewIsToolOutput
})
- }, [
- sessionAfterCommandBoundaries.messages,
- pendingMessages,
- hookPreview,
- liveWorking,
- hookPreviewIsToolOutput
- ])
+ }, [messagesWithPending, hookPreview, liveWorking, hookPreviewIsToolOutput])
const sessionWithPending = useMemo(() => {
if (pending.length === 0 && commandMarkers.length === 0 && !streamingText) {
return sessionAfterCommandBoundaries