From 3099df1b7cb92383e06c3b5bbf8875b35779d14a Mon Sep 17 00:00:00 2001 From: Dev Randalpura Date: Wed, 18 Feb 2026 12:53:06 -0800 Subject: [PATCH] fix(ui): preventing empty history items from being added (#19014) --- .../cli/src/ui/hooks/useGeminiStream.test.tsx | 85 +++++++++++++++++++ packages/cli/src/ui/hooks/useGeminiStream.ts | 22 ++--- packages/cli/src/ui/utils/MarkdownDisplay.tsx | 2 +- 3 files changed, 98 insertions(+), 11 deletions(-) diff --git a/packages/cli/src/ui/hooks/useGeminiStream.test.tsx b/packages/cli/src/ui/hooks/useGeminiStream.test.tsx index 54c0c2231f..24c01ce06d 100644 --- a/packages/cli/src/ui/hooks/useGeminiStream.test.tsx +++ b/packages/cli/src/ui/hooks/useGeminiStream.test.tsx @@ -46,6 +46,7 @@ import type { SlashCommandProcessorResult } from '../types.js'; import { MessageType, StreamingState } from '../types.js'; import type { LoadedSettings } from '../../config/settings.js'; +import { findLastSafeSplitPoint } from '../utils/markdownUtilities.js'; // --- MOCKS --- const mockSendMessageStream = vi @@ -3320,4 +3321,88 @@ describe('useGeminiStream', () => { }); }); }); + + describe('Stream Splitting', () => { + it('should not add empty history item when splitting message results in empty or whitespace-only beforeText', async () => { + // Mock split point to always be 0, causing beforeText to be empty + vi.mocked(findLastSafeSplitPoint).mockReturnValue(0); + + mockSendMessageStream.mockReturnValue( + (async function* () { + yield { type: ServerGeminiEventType.Content, value: 'test content' }; + })(), + ); + + const { result } = renderTestHook(); + + await act(async () => { + await result.current.submitQuery('user query'); + }); + + await waitFor(() => { + // We expect the stream to be processed. + // Since beforeText is empty (0 split), addItem should NOT be called for it. + // addItem IS called for the user query "user query". + }); + + // Check addItem calls. + // It should be called for user query and for the content. + expect(mockAddItem).toHaveBeenCalledTimes(2); + expect(mockAddItem).toHaveBeenCalledWith( + expect.objectContaining({ type: 'user', text: 'user query' }), + expect.any(Number), + ); + expect(mockAddItem).toHaveBeenLastCalledWith( + expect.objectContaining({ + type: 'gemini_content', + text: 'test content', + }), + expect.any(Number), + ); + + // Verify that pendingHistoryItem is empty after (afterText). + expect(result.current.pendingHistoryItems.length).toEqual(0); + + // Reset mock + vi.mocked(findLastSafeSplitPoint).mockReset(); + vi.mocked(findLastSafeSplitPoint).mockImplementation( + (s: string) => s.length, + ); + }); + + it('should add whitespace-only history item when splitting message', async () => { + // Input: " content" + // Split at 3 -> before: " ", after: "content" + vi.mocked(findLastSafeSplitPoint).mockReturnValue(3); + + mockSendMessageStream.mockReturnValue( + (async function* () { + yield { type: ServerGeminiEventType.Content, value: ' content' }; + })(), + ); + + const { result } = renderTestHook(); + + await act(async () => { + await result.current.submitQuery('user query'); + }); + + await waitFor(() => {}); + + expect(mockAddItem).toHaveBeenCalledTimes(3); + expect(mockAddItem).toHaveBeenCalledWith( + expect.objectContaining({ type: 'user', text: 'user query' }), + expect.any(Number), + ); + expect(mockAddItem).toHaveBeenLastCalledWith( + expect.objectContaining({ + type: 'gemini_content', + text: 'content', + }), + expect.any(Number), + ); + + expect(result.current.pendingHistoryItems.length).toEqual(0); + }); + }); }); diff --git a/packages/cli/src/ui/hooks/useGeminiStream.ts b/packages/cli/src/ui/hooks/useGeminiStream.ts index 5fc7d628ac..e86f23a51e 100644 --- a/packages/cli/src/ui/hooks/useGeminiStream.ts +++ b/packages/cli/src/ui/hooks/useGeminiStream.ts @@ -819,16 +819,18 @@ export const useGeminiStream = ( // broken up so that there are more "statically" rendered. const beforeText = newGeminiMessageBuffer.substring(0, splitPoint); const afterText = newGeminiMessageBuffer.substring(splitPoint); - addItem( - { - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion - type: pendingHistoryItemRef.current?.type as - | 'gemini' - | 'gemini_content', - text: beforeText, - }, - userMessageTimestamp, - ); + if (beforeText.length > 0) { + addItem( + { + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + type: pendingHistoryItemRef.current?.type as + | 'gemini' + | 'gemini_content', + text: beforeText, + }, + userMessageTimestamp, + ); + } setPendingHistoryItem({ type: 'gemini_content', text: afterText }); newGeminiMessageBuffer = afterText; } diff --git a/packages/cli/src/ui/utils/MarkdownDisplay.tsx b/packages/cli/src/ui/utils/MarkdownDisplay.tsx index 60f15e9598..0200fbcb00 100644 --- a/packages/cli/src/ui/utils/MarkdownDisplay.tsx +++ b/packages/cli/src/ui/utils/MarkdownDisplay.tsx @@ -337,7 +337,7 @@ const RenderCodeBlockInternal: React.FC = ({ const RESERVED_LINES = 2; // Lines reserved for the message itself and potential padding // When not in alternate buffer mode we need to be careful that we don't - // trigger flicker when the pending code is to long to fit in the terminal + // trigger flicker when the pending code is too long to fit in the terminal if ( !isAlternateBuffer && isPending &&