Skip to content

Commit 2d10e5e

Browse files
committed
spawn: add microtask event batching for TUI renders
1 parent c63724a commit 2d10e5e

1 file changed

Lines changed: 45 additions & 1 deletion

File tree

spawn/renderer.ts

Lines changed: 45 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -190,13 +190,50 @@ class NestedAgentSessionComponent extends Container {
190190
private cachedLines?: string[];
191191
private cachedShowImages?: boolean;
192192

193+
/** Tracks whether a render microtask is already queued for this batch. */
194+
private renderScheduled = false;
195+
/** Monotonic token: incrementing invalidates stale queued microtask callbacks. */
196+
private renderScheduleToken = 0;
197+
193198
private clearRenderCache(): void {
194199
this.cachedWidth = undefined;
195200
this.cachedExpanded = undefined;
196201
this.cachedLines = undefined;
197202
this.cachedShowImages = undefined;
198203
}
199204

205+
/** Reset batching guard + invalidate any queued microtask via token bump. */
206+
private resetRenderBatching(): void {
207+
this.renderScheduled = false;
208+
this.renderScheduleToken++;
209+
}
210+
211+
/**
212+
* Queue one microtask per event batch. The token guard prevents stale
213+
* callbacks from running after dispose or error resets the batching state.
214+
*/
215+
private scheduleRender(): void {
216+
if (this.renderScheduled) {
217+
return;
218+
}
219+
this.renderScheduled = true;
220+
const token = ++this.renderScheduleToken;
221+
queueMicrotask(() => {
222+
if (!this.renderScheduled || this.renderScheduleToken !== token) {
223+
return;
224+
}
225+
this.renderScheduled = false;
226+
if (this.isStaleSession()) {
227+
// Drop optimistic same-turn event mutations when ownership changed
228+
// before the parent had a chance to render them.
229+
this.rebuildFromSession();
230+
this.clearRenderCache();
231+
return;
232+
}
233+
this.requestRender();
234+
});
235+
}
236+
200237
setRequestRender(requestRender: () => void): void {
201238
this.requestRender = requestRender;
202239
}
@@ -247,6 +284,7 @@ class NestedAgentSessionComponent extends Container {
247284
this.ownedToolCallId = toolCallId;
248285
this.state = state;
249286
this.attachedChildSessionEpoch = state.childSessionEpoch;
287+
this.resetRenderBatching();
250288
this.liveOutcome = this.details?.outcome ?? "running";
251289
this.toolNames.clear();
252290
this.toolComponentFailures.clear();
@@ -315,6 +353,7 @@ class NestedAgentSessionComponent extends Container {
315353
const session = this.session;
316354
const ownedToolCallId = this.ownedToolCallId;
317355
const liveChildSessions = this.state?.liveChildSessions;
356+
this.resetRenderBatching();
318357
this.clearRenderCache();
319358
this.details = undefined;
320359
this.nestTheme = undefined;
@@ -730,8 +769,13 @@ class NestedAgentSessionComponent extends Container {
730769
case "tool_execution_end": this.handleToolExecutionEnd(event); break;
731770
}
732771
this.clearRenderCache();
733-
this.requestRender();
772+
// Coalesce child bursts within the current turn. The first event queues
773+
// one microtask-backed parent invalidate; later same-turn events just
774+
// mutate state, so the next render observes the latest snapshot.
775+
this.scheduleRender();
734776
} catch (error) {
777+
this.clearRenderCache();
778+
this.resetRenderBatching();
735779
// Prevent a single bad event from killing the subscription.
736780
// The TUI degrades gracefully — stale content until next successful event.
737781
console.warn("[spawn] Event handler error:", event.type, this.ownedToolCallId, error);

0 commit comments

Comments
 (0)