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