-
Notifications
You must be signed in to change notification settings - Fork 3k
fix(ui): reduce UI flicker — throttle + startTransition + batch STREAM_TEXT + debounce refresh + resize settle #5396
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
82935c1
51bf0d4
80b01cc
fdf7839
cdc9399
1a6916e
0bdc5d9
7a09466
6e6a57e
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -1303,11 +1303,18 @@ export const AppContainer = (props: AppContainerProps) => { | |||||||||||||||||||||||||||
| ), | ||||||||||||||||||||||||||||
| ); | ||||||||||||||||||||||||||||
| const showScrollbar = settings.merged.ui?.showScrollbar ?? true; | ||||||||||||||||||||||||||||
| const refreshStaticRef = useRef<ReturnType<typeof setTimeout> | null>(null); | ||||||||||||||||||||||||||||
| const refreshStatic = useCallback(() => { | ||||||||||||||||||||||||||||
| if (!useTerminalBuffer) { | ||||||||||||||||||||||||||||
| stdout.write(ansiEscapes.clearTerminal); | ||||||||||||||||||||||||||||
| if (refreshStaticRef.current) { | ||||||||||||||||||||||||||||
| clearTimeout(refreshStaticRef.current); | ||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||
| remountStaticHistory(); | ||||||||||||||||||||||||||||
| refreshStaticRef.current = setTimeout(() => { | ||||||||||||||||||||||||||||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [Critical] The Additionally, the This also breaks 4 existing tests in
Suggested change
This keeps — qwen3.7-max via Qwen Code /review |
||||||||||||||||||||||||||||
| refreshStaticRef.current = null; | ||||||||||||||||||||||||||||
| if (!useTerminalBuffer) { | ||||||||||||||||||||||||||||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [Suggestion] The new ref-based debounce ( Consider adding a test with — qwen3.7-max via Qwen Code /review
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [Critical] This affects 3 call sites:
Suggested change
Keep — qwen3.7-max via Qwen Code /review |
||||||||||||||||||||||||||||
| stdout.write(ansiEscapes.clearTerminal); | ||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||
| remountStaticHistory(); | ||||||||||||||||||||||||||||
| }, 0); | ||||||||||||||||||||||||||||
|
Comment on lines
+1311
to
+1317
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [Critical] Additionally, — Failure scenario: user changes model (Ctrl+M or /model) →
Suggested change
Keep — qwen3.7-max via Qwen Code /review |
||||||||||||||||||||||||||||
| }, [useTerminalBuffer, remountStaticHistory, stdout]); | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| // Keep the static header in sync with model changes without polling. | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -6074,7 +6074,7 @@ describe('useGeminiStream', () => { | |
| expect(result.current.pendingHistoryItems).toEqual([]); | ||
|
|
||
| await act(async () => { | ||
| vi.advanceTimersByTime(60); | ||
| vi.advanceTimersByTime(100); | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [Critical] Two Also, the comment at line 7308 still says — qwen3.7-max via Qwen Code /review |
||
| }); | ||
|
|
||
| expect(result.current.pendingHistoryItems).toEqual([ | ||
|
|
@@ -6134,7 +6134,7 @@ describe('useGeminiStream', () => { | |
| }); | ||
|
|
||
| await act(async () => { | ||
| vi.advanceTimersByTime(60); | ||
| vi.advanceTimersByTime(100); | ||
| }); | ||
|
|
||
| expect(result.current.pendingHistoryItems).toEqual([]); | ||
|
|
@@ -6146,7 +6146,7 @@ describe('useGeminiStream', () => { | |
| }); | ||
|
|
||
| await act(async () => { | ||
| vi.advanceTimersByTime(60); | ||
| vi.advanceTimersByTime(100); | ||
| }); | ||
|
|
||
| expect(result.current.pendingHistoryItems).toEqual([ | ||
|
|
@@ -6203,7 +6203,7 @@ describe('useGeminiStream', () => { | |
| expect(result.current.pendingHistoryItems).toEqual([]); | ||
|
|
||
| await act(async () => { | ||
| vi.advanceTimersByTime(60); | ||
| vi.advanceTimersByTime(100); | ||
| }); | ||
|
|
||
| expect(result.current.pendingHistoryItems).toEqual([ | ||
|
|
@@ -6262,7 +6262,7 @@ describe('useGeminiStream', () => { | |
| }); | ||
|
|
||
| await act(async () => { | ||
| vi.advanceTimersByTime(60); | ||
| vi.advanceTimersByTime(100); | ||
| }); | ||
|
|
||
| const thoughtItems = mockAddItem.mock.calls | ||
|
|
@@ -6794,7 +6794,7 @@ describe('useGeminiStream', () => { | |
| }); | ||
|
|
||
| await act(async () => { | ||
| vi.advanceTimersByTime(60); | ||
| vi.advanceTimersByTime(100); | ||
| }); | ||
|
|
||
| expect(result.current.pendingHistoryItems).toEqual([]); | ||
|
|
@@ -6807,7 +6807,7 @@ describe('useGeminiStream', () => { | |
| }); | ||
|
|
||
| await act(async () => { | ||
| vi.advanceTimersByTime(60); | ||
| vi.advanceTimersByTime(100); | ||
| }); | ||
|
|
||
| expect(result.current.pendingHistoryItems).toEqual([ | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -352,7 +352,7 @@ const EDIT_TOOL_NAMES = new Set([ | |
| ToolNames.WRITE_FILE, | ||
| ToolNames.NOTEBOOK_EDIT, | ||
| ]); | ||
| const STREAM_UPDATE_THROTTLE_MS = 60; | ||
| const STREAM_UPDATE_THROTTLE_MS = 100; | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [Critical] Bumping — qwen3.8-max-preview via Qwen Code /review |
||
| const STREAM_PENDING_ITEM_MAX_CHARS = 16_384; | ||
| // Rows kept in reserve below the commit budget so the incremental commit fires | ||
| // BEFORE MarkdownDisplay's safety-net clip (which reserves 2). Keeping the | ||
|
|
||
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -44,6 +44,7 @@ import type { | |||||||||||||||||||||||||||||
| ContentGenerator, | ||||||||||||||||||||||||||||||
| ContentGeneratorConfig, | ||||||||||||||||||||||||||||||
| } from '../../core/contentGenerator.js'; | ||||||||||||||||||||||||||||||
| import { AgentEventEmitter, AgentEventType } from './agent-events.js'; | ||||||||||||||||||||||||||||||
| import { | ||||||||||||||||||||||||||||||
| getInvocationContext, | ||||||||||||||||||||||||||||||
| runWithInvocationContext, | ||||||||||||||||||||||||||||||
|
|
@@ -898,6 +899,33 @@ describe('AgentCore.prepareTools', () => { | |||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| describe('AgentCore STREAM_TEXT batching (#2928)', () => { | ||||||||||||||||||||||||||||||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [Suggestion] This test creates an A proper test would feed a multi-part chunk through the actual streaming code path (or extract the batch-and-emit logic into a testable function) and assert that fewer — qwen3.7-max via Qwen Code /review |
||||||||||||||||||||||||||||||
| it('batches thought and response text separately per chunk', () => { | ||||||||||||||||||||||||||||||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [Critical] This test creates an A proper test should feed a chunk with multiple text parts through — qwen3.7-max via Qwen Code /review |
||||||||||||||||||||||||||||||
| const emitter = new AgentEventEmitter(); | ||||||||||||||||||||||||||||||
| const events: Array<{ text: string; thought: boolean }> = []; | ||||||||||||||||||||||||||||||
| emitter.on(AgentEventType.STREAM_TEXT, (e) => { | ||||||||||||||||||||||||||||||
| events.push({ text: e.text, thought: e.thought ?? false }); | ||||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||||
|
Comment on lines
+903
to
+908
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [Suggestion] This test creates a bare — Concrete cost: if a future refactor breaks the batching (e.g. reverts to per-part emission, or concatenates thought + response text into one emit), this test still passes. It provides zero regression protection for the change it claims to verify.
Suggested change
— qwen3.7-max via Qwen Code /review |
||||||||||||||||||||||||||||||
| emitter.emit(AgentEventType.STREAM_TEXT, { | ||||||||||||||||||||||||||||||
| subagentId: 'test', | ||||||||||||||||||||||||||||||
| round: 1, | ||||||||||||||||||||||||||||||
| text: 'reasoning', | ||||||||||||||||||||||||||||||
| thought: true, | ||||||||||||||||||||||||||||||
| timestamp: Date.now(), | ||||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||||
| emitter.emit(AgentEventType.STREAM_TEXT, { | ||||||||||||||||||||||||||||||
| subagentId: 'test', | ||||||||||||||||||||||||||||||
| round: 1, | ||||||||||||||||||||||||||||||
| text: 'output', | ||||||||||||||||||||||||||||||
| thought: false, | ||||||||||||||||||||||||||||||
| timestamp: Date.now(), | ||||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||||
| expect(events).toHaveLength(2); | ||||||||||||||||||||||||||||||
| expect(events[0].thought).toBe(true); | ||||||||||||||||||||||||||||||
| expect(events[1].thought).toBe(false); | ||||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| describe('extractParentToolNames', () => { | ||||||||||||||||||||||||||||||
| const configWithTools = ( | ||||||||||||||||||||||||||||||
| tools: Array<{ functionDeclarations?: FunctionDeclaration[] }>, | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -1001,21 +1001,41 @@ export class AgentCore { | |
| } | ||
| const content = resp.candidates?.[0]?.content; | ||
| const parts = content?.parts || []; | ||
| // #2928: Batch STREAM_TEXT emits per chunk to reduce UI flicker | ||
| // during parallel sub-agent execution. Accumulate text parts | ||
| // then emit once instead of per-part. | ||
| let chunkThoughtText = ''; | ||
| let chunkStreamText = ''; | ||
| for (const p of parts) { | ||
| const txt = p.text; | ||
| const isThought = p.thought ?? false; | ||
| if (txt && isThought) roundThoughtText += txt; | ||
| if (txt && !isThought) roundText += txt; | ||
| if (txt) | ||
| this.eventEmitter?.emit(AgentEventType.STREAM_TEXT, { | ||
| subagentId: this.subagentId, | ||
| runId, | ||
| round: turnCounter, | ||
| text: txt, | ||
| thought: isThought, | ||
| timestamp: Date.now(), | ||
| }); | ||
| if (txt && isThought) { | ||
| roundThoughtText += txt; | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [Suggestion] The batching change has no test coverage —
A regression (e.g., accidentally emitting per-part again, or mixing thought/non-thought text) would go undetected and could reintroduce the UI flicker this PR aims to fix. Consider adding a test that feeds a mock stream with mixed thought/text parts and asserts on event count, content, and — qwen3.7-max via Qwen Code /review |
||
| chunkThoughtText += txt; | ||
| } | ||
| if (txt && !isThought) { | ||
| roundText += txt; | ||
| chunkStreamText += txt; | ||
| } | ||
| } | ||
| if (chunkThoughtText) | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [Suggestion] The per-chunk batching emits all thought text before all regular text, which reorders vs the original per-part order. For standard Suggest coalescing only adjacent same-kind runs instead of bucketing by type — flush the running buffer whenever — claude-opus-4-8 via Claude Code /qreview |
||
| this.eventEmitter?.emit(AgentEventType.STREAM_TEXT, { | ||
| subagentId: this.subagentId, | ||
| runId, | ||
| round: turnCounter, | ||
| text: chunkThoughtText, | ||
| thought: true, | ||
| timestamp: Date.now(), | ||
| }); | ||
| if (chunkStreamText) | ||
| this.eventEmitter?.emit(AgentEventType.STREAM_TEXT, { | ||
| subagentId: this.subagentId, | ||
| runId, | ||
| round: turnCounter, | ||
| text: chunkStreamText, | ||
| thought: false, | ||
| timestamp: Date.now(), | ||
| }); | ||
| if (resp.usageMetadata) lastUsage = resp.usageMetadata; | ||
|
|
||
| const thoughtSummary = getThoughtSummary(resp); | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -5,6 +5,7 @@ import { | |
| useEffect, | ||
| useLayoutEffect, | ||
| useMemo, | ||
| useTransition, | ||
| useRef, | ||
| useState, | ||
| type CSSProperties, | ||
|
|
@@ -5346,6 +5347,7 @@ export function App({ | |
| return options; | ||
| }, [connection.models]); | ||
| const [compactMode, setCompactMode] = useState(false); | ||
| const [, startTransition] = useTransition(); | ||
| const compactModeRef = useRef(compactMode); | ||
| compactModeRef.current = compactMode; | ||
|
|
||
|
|
@@ -5428,7 +5430,7 @@ export function App({ | |
| const handleToggleCompact = useCallback(() => { | ||
| const previous = compactModeRef.current; | ||
| const next = !compactModeRef.current; | ||
| setCompactMode(next); | ||
| startTransition(() => setCompactMode(next)); | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [Suggestion] Wrapping this toggle in If the transition is meant to defer the compact re-layout, read the value without the ref — e.g. add — claude-opus-4-8 via Claude Code /qreview
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [Suggestion] Suggest a functional updater so it never depends on the deferred ref: startTransition(() => setCompactMode((prev) => !prev));(compute/persist — claude-opus-4-8 via Claude Code /qreview |
||
| setWorkspaceSetting('workspace', COMPACT_MODE_SETTING_KEY, next).catch( | ||
| (error: unknown) => { | ||
| setCompactMode(previous); | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
[Suggestion]
refreshStaticRefis never cleaned up on component unmount. Every other timer-ref pattern in this codebase includes auseEffectcleanup (e.g.,useDoublePress,useFrameCoalescedFlush,useAnimatedScrollbar). IfAppContainerunmounts while asetTimeout(0)is pending, the callback fires against torn-down state — callingstdout.write()andremountStaticHistory()after the Ink instance has been cleaned up.— qwen3.7-max via Qwen Code /review