Speculative changes to address react bug with overflowing state

This commit is contained in:
Jacob Richman
2026-04-12 18:34:32 -07:00
parent 0179726222
commit 9cedc6131e
6 changed files with 252 additions and 10 deletions
+79
View File
@@ -3290,6 +3290,85 @@ describe('AppContainer State Management', () => {
unmount();
});
it('does not reset the hint timer when overflowingIdsSize decreases', async () => {
const { unmount } = await act(async () => renderAppContainer());
await waitFor(() => expect(capturedOverflowActions).toBeTruthy());
act(() => {
capturedOverflowActions.addOverflowingId('test-id-1');
capturedOverflowActions.addOverflowingId('test-id-2');
});
await waitFor(() => {
expect(capturedUIState.showIsExpandableHint).toBe(true);
});
act(() => {
vi.advanceTimersByTime(EXPAND_HINT_DURATION_MS / 2);
});
expect(capturedUIState.showIsExpandableHint).toBe(true);
act(() => {
capturedOverflowActions.removeOverflowingId('test-id-2');
});
act(() => {
vi.advanceTimersByTime(1);
});
act(() => {
vi.advanceTimersByTime(EXPAND_HINT_DURATION_MS / 2);
});
await waitFor(() => {
expect(capturedUIState.showIsExpandableHint).toBe(false);
});
unmount();
});
it('does not auto-reset the hint timer for new overflow while expanded', async () => {
const { stdin, unmount } = await act(async () => renderAppContainer());
await waitFor(() => expect(capturedOverflowActions).toBeTruthy());
act(() => {
capturedOverflowActions.addOverflowingId('test-id-1');
});
await waitFor(() => {
expect(capturedUIState.showIsExpandableHint).toBe(true);
});
act(() => {
stdin.write('\x0f'); // Ctrl+O
});
expect(capturedUIState.constrainHeight).toBe(false);
act(() => {
vi.advanceTimersByTime(EXPAND_HINT_DURATION_MS - 1000);
});
expect(capturedUIState.showIsExpandableHint).toBe(true);
act(() => {
capturedOverflowActions.addOverflowingId('test-id-2');
});
act(() => {
vi.advanceTimersByTime(1);
});
act(() => {
vi.advanceTimersByTime(1000);
});
await waitFor(() => {
expect(capturedUIState.showIsExpandableHint).toBe(false);
});
unmount();
});
it('toggles expansion state and resets the hint timer when Ctrl+O is pressed in Standard Mode', async () => {
const { stdin, unmount } = await act(async () => renderAppContainer());
await waitFor(() => expect(capturedOverflowActions).toBeTruthy());
+20 -8
View File
@@ -187,6 +187,7 @@ import {
isToolAwaitingConfirmation,
getAllToolCalls,
} from './utils/historyUtils.js';
import { shouldAutoTriggerExpandHint } from './utils/expandHint.js';
interface AppContainerProps {
config: Config;
@@ -329,24 +330,35 @@ export const AppContainer = (props: AppContainerProps) => {
const showIsExpandableHint = Boolean(expandHintTrigger);
const overflowState = useOverflowState();
const overflowingIdsSize = overflowState?.overflowingIds.size ?? 0;
const hasOverflowState = overflowingIdsSize > 0 || !constrainHeight;
const previousOverflowingIdsSizeRef = useRef(0);
/**
* Manages the visibility and x-second timer for the expansion hint.
*
* This effect triggers the timer countdown whenever an overflow is detected
* or the user manually toggles the expansion state with Ctrl+O.
* By depending on overflowingIdsSize, the timer resets when *new* views
* overflow, but avoids infinitely resetting during single-view streaming.
* while the response is actually constrained. The Ctrl+O handler still
* refreshes the hint manually when the user toggles expansion.
*
* In alternate buffer mode, we don't trigger the hint automatically on overflow
* to avoid noise, but the user can still trigger it manually with Ctrl+O.
* We only auto-refresh when the number of overflowing regions grows. That
* keeps the "show more" hint responsive for newly truncated content without
* retriggering on layout churn, overflow shrinkage, or while the content is
* already expanded.
*/
useEffect(() => {
if (hasOverflowState) {
const previousOverflowingIdsSize = previousOverflowingIdsSizeRef.current;
if (
shouldAutoTriggerExpandHint({
constrainHeight,
overflowingIdsSize,
previousOverflowingIdsSize,
})
) {
triggerExpandHint(true);
}
}, [hasOverflowState, overflowingIdsSize, triggerExpandHint]);
previousOverflowingIdsSizeRef.current = overflowingIdsSize;
}, [constrainHeight, overflowingIdsSize, triggerExpandHint]);
const [defaultBannerText, setDefaultBannerText] = useState('');
const [warningBannerText, setWarningBannerText] = useState('');
@@ -0,0 +1,71 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest';
import { act } from 'react';
import { renderHook } from '../../test-utils/render.js';
import { useTimedMessage } from './useTimedMessage.js';
describe('useTimedMessage', () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
it('resets the timeout when the same message is retriggered', async () => {
const { result, unmount } = await renderHook(() => useTimedMessage(1000));
act(() => {
result.current[1]('hint');
});
expect(result.current[0]).toBe('hint');
act(() => {
vi.advanceTimersByTime(500);
});
act(() => {
result.current[1]('hint');
});
expect(result.current[0]).toBe('hint');
act(() => {
vi.advanceTimersByTime(500);
});
expect(result.current[0]).toBe('hint');
act(() => {
vi.advanceTimersByTime(500);
});
expect(result.current[0]).toBeNull();
unmount();
});
it('clears the message immediately when asked to hide it', async () => {
const { result, unmount } = await renderHook(() => useTimedMessage(1000));
act(() => {
result.current[1]('hint');
});
expect(result.current[0]).toBe('hint');
act(() => {
result.current[1](null);
});
expect(result.current[0]).toBeNull();
act(() => {
vi.advanceTimersByTime(1000);
});
expect(result.current[0]).toBeNull();
unmount();
});
});
+13 -2
View File
@@ -12,19 +12,29 @@ import { useState, useCallback, useRef, useEffect } from 'react';
*/
export function useTimedMessage<T>(durationMs: number) {
const [message, setMessage] = useState<T | null>(null);
const messageRef = useRef<T | null>(null);
const timeoutRef = useRef<NodeJS.Timeout | null>(null);
const showMessage = useCallback(
(msg: T | null) => {
setMessage(msg);
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
timeoutRef.current = null;
}
if (msg !== null) {
timeoutRef.current = setTimeout(() => {
setMessage(null);
timeoutRef.current = null;
messageRef.current = null;
setMessage((prev) => (prev === null ? prev : null));
}, durationMs);
}
if (Object.is(messageRef.current, msg)) {
return;
}
messageRef.current = msg;
setMessage(msg);
},
[durationMs],
);
@@ -33,6 +43,7 @@ export function useTimedMessage<T>(durationMs: number) {
() => () => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
timeoutRef.current = null;
}
},
[],
@@ -0,0 +1,50 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, expect, it } from 'vitest';
import { shouldAutoTriggerExpandHint } from './expandHint.js';
describe('shouldAutoTriggerExpandHint', () => {
it('returns true when constrained content gains a new overflowing region', () => {
expect(
shouldAutoTriggerExpandHint({
constrainHeight: true,
overflowingIdsSize: 2,
previousOverflowingIdsSize: 1,
}),
).toBe(true);
});
it('returns false when overflowingIdsSize decreases', () => {
expect(
shouldAutoTriggerExpandHint({
constrainHeight: true,
overflowingIdsSize: 1,
previousOverflowingIdsSize: 2,
}),
).toBe(false);
});
it('returns false when overflowingIdsSize is unchanged', () => {
expect(
shouldAutoTriggerExpandHint({
constrainHeight: true,
overflowingIdsSize: 1,
previousOverflowingIdsSize: 1,
}),
).toBe(false);
});
it('returns false while content is already expanded', () => {
expect(
shouldAutoTriggerExpandHint({
constrainHeight: false,
overflowingIdsSize: 2,
previousOverflowingIdsSize: 1,
}),
).toBe(false);
});
});
+19
View File
@@ -0,0 +1,19 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
interface ExpandHintAutoTriggerParams {
constrainHeight: boolean;
overflowingIdsSize: number;
previousOverflowingIdsSize: number;
}
export function shouldAutoTriggerExpandHint({
constrainHeight,
overflowingIdsSize,
previousOverflowingIdsSize,
}: ExpandHintAutoTriggerParams): boolean {
return constrainHeight && overflowingIdsSize > previousOverflowingIdsSize;
}