Repository navigation
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
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 | ||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -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)', () => { | ||||||||||||||||||||||||||||||
| it('batches thought and response text separately per chunk', () => { | ||||||||||||||||||||||||||||||
| 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; | ||
| 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.
[Critical]
setTimeout(0)defersremountStaticHistory()(which callssetHistoryRemountKey) to a macrotask, breaking the same-commit batching invariant documented at lines 1155–1175 of this file. TheonModelChangehandler (line 1183) callsrefreshStatic()thensetCurrentModel(model)synchronously — previously both state updates landed in the same React commit. NowsetCurrentModelcommits first with the OLDhistoryRemountKey, producing a full-history Static render that bypasses progressive replay — the exact #3899 freeze regression that PR #4119 fixed.Additionally,
refreshStaticRefhas nouseEffectcleanup on unmount — a pending timer can firesetHistoryRemountKeyon an unmounted component.— Failure scenario: user changes model (Ctrl+M or /model) →
refreshStatic()schedules macrotask →setCurrentModelcommits immediately →<Static>renders with new model but old remount key → full-history flash / freeze.Keep
refreshStaticsynchronous. If debounce is needed for the resize path, extract a separaterefreshStaticDebouncedwrapper used only by those callers.— qwen3.7-max via Qwen Code /review