From c2705e83328b8c01940c244a3ee1383c090ea9c0 Mon Sep 17 00:00:00 2001 From: Keith Guerin Date: Fri, 27 Mar 2026 17:06:07 -0700 Subject: [PATCH] fix(cli): resolve layout contention and flashing loop in StatusRow (#24065) --- .../cli/src/ui/components/StatusRow.test.tsx | 145 ++++++++++++++++++ packages/cli/src/ui/components/StatusRow.tsx | 35 +++-- 2 files changed, 169 insertions(+), 11 deletions(-) create mode 100644 packages/cli/src/ui/components/StatusRow.test.tsx diff --git a/packages/cli/src/ui/components/StatusRow.test.tsx b/packages/cli/src/ui/components/StatusRow.test.tsx new file mode 100644 index 0000000000..b80dbacabe --- /dev/null +++ b/packages/cli/src/ui/components/StatusRow.test.tsx @@ -0,0 +1,145 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { renderWithProviders } from '../../test-utils/render.js'; +import { StatusRow } from './StatusRow.js'; +import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest'; +import { useComposerStatus } from '../hooks/useComposerStatus.js'; +import { type UIState } from '../contexts/UIStateContext.js'; +import { type TextBuffer } from '../components/shared/text-buffer.js'; +import { type SessionStatsState } from '../contexts/SessionContext.js'; +import { type ThoughtSummary } from '../types.js'; +import { ApprovalMode } from '@google/gemini-cli-core'; + +vi.mock('../hooks/useComposerStatus.js', () => ({ + useComposerStatus: vi.fn(), +})); + +describe('', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + const defaultUiState: Partial = { + currentTip: undefined, + thought: null, + elapsedTime: 0, + currentWittyPhrase: undefined, + activeHooks: [], + buffer: { text: '' } as unknown as TextBuffer, + sessionStats: { lastPromptTokenCount: 0 } as unknown as SessionStatsState, + shortcutsHelpVisible: false, + contextFileNames: [], + showApprovalModeIndicator: ApprovalMode.DEFAULT, + allowPlanMode: false, + shellModeActive: false, + renderMarkdown: true, + currentModel: 'gemini-3', + }; + + it('renders status and tip correctly when they both fit', async () => { + (useComposerStatus as Mock).mockReturnValue({ + isInteractiveShellWaiting: false, + showLoadingIndicator: true, + showTips: true, + showWit: true, + modeContentObj: null, + showMinimalContext: false, + }); + + const uiState: Partial = { + ...defaultUiState, + currentTip: 'Test Tip', + thought: { subject: 'Thinking...' } as unknown as ThoughtSummary, + elapsedTime: 5, + currentWittyPhrase: 'I am witty', + }; + + const { lastFrame, waitUntilReady } = await renderWithProviders( + , + { + width: 100, + uiState, + }, + ); + + await waitUntilReady(); + const output = lastFrame(); + expect(output).toContain('Thinking...'); + expect(output).toContain('I am witty'); + expect(output).toContain('Tip: Test Tip'); + }); + + it('renders correctly when interactive shell is waiting', async () => { + (useComposerStatus as Mock).mockReturnValue({ + isInteractiveShellWaiting: true, + showLoadingIndicator: false, + showTips: false, + showWit: false, + modeContentObj: null, + showMinimalContext: false, + }); + + const { lastFrame, waitUntilReady } = await renderWithProviders( + , + { + width: 100, + uiState: defaultUiState, + }, + ); + + await waitUntilReady(); + expect(lastFrame()).toContain('! Shell awaiting input (Tab to focus)'); + }); + + it('renders tip with absolute positioning when it fits but might collide (verification of container logic)', async () => { + (useComposerStatus as Mock).mockReturnValue({ + isInteractiveShellWaiting: false, + showLoadingIndicator: true, + showTips: true, + showWit: true, + modeContentObj: null, + showMinimalContext: false, + }); + + const uiState: Partial = { + ...defaultUiState, + currentTip: 'Test Tip', + }; + + const { lastFrame, waitUntilReady } = await renderWithProviders( + , + { + width: 100, + uiState, + }, + ); + + await waitUntilReady(); + expect(lastFrame()).toContain('Tip: Test Tip'); + }); +}); diff --git a/packages/cli/src/ui/components/StatusRow.tsx b/packages/cli/src/ui/components/StatusRow.tsx index 4585438bee..adaa339a64 100644 --- a/packages/cli/src/ui/components/StatusRow.tsx +++ b/packages/cli/src/ui/components/StatusRow.tsx @@ -179,7 +179,13 @@ export const StatusRow: React.FC = ({ const observer = new ResizeObserver((entries) => { const entry = entries[0]; if (entry) { - setTipWidth(Math.round(entry.contentRect.width)); + const width = Math.round(entry.contentRect.width); + // Only update if width > 0 to prevent layout feedback loops + // when the tip is hidden. This ensures we always use the + // intrinsic width for collision detection. + if (width > 0) { + setTipWidth(width); + } } }); observer.observe(node); @@ -230,6 +236,10 @@ export const StatusRow: React.FC = ({ const showRow1 = showUiDetails || showRow1Minimal; const showRow2 = showUiDetails || showRow2Minimal; + const onStatusResize = useCallback((width: number) => { + if (width > 0) setStatusWidth(width); + }, []); + const statusNode = ( = ({ errorVerbosity={ settings.merged.ui.errorVerbosity as 'low' | 'full' | undefined } - onResize={setStatusWidth} + onResize={onStatusResize} /> ); @@ -322,20 +332,23 @@ export const StatusRow: React.FC = ({ {/* - We always render the tip node so it can be measured by ResizeObserver, - but we control its visibility based on the collision detection. + We always render the tip node so it can be measured by ResizeObserver. + When hidden, we use absolute positioning so it can still be measured + but doesn't affect the layout of Row 1. This prevents layout loops. */} - - {!isNarrow && tipContentStr && renderTipNode()} - + {!isNarrow && tipContentStr && renderTipNode()} )}