Skip to content

Commit 3afd882

Browse files
committed
perf(ui): cut forced reflows in chat render path
Profiled the Control UI with Chrome DevTools tracing against a real-data gateway: session-switch INP was 367ms with ForcedReflow insights on every load and interaction trace. - chat-thread: per-row stable Lit ref callbacks (keyed by row key) so the virtualizer stops cache-sweeping and re-measuring every visible row on every render; prune callbacks when rows leave the list - chat-composer: stable textarea ref on per-pane state instead of an inline arrow, so the textarea is re-measured only on attach or when the draft changes programmatically, not on every chat render - app-sidebar: coalesce scrollHeight/scrollTop reads from updated() into one rAF per frame instead of a forced layout flush per render After: INP 133-143ms on the same interaction sequence, no ForcedReflow insight in load or interaction traces.
1 parent 00eb33f commit 3afd882

3 files changed

Lines changed: 80 additions & 27 deletions

File tree

ui/src/components/app-sidebar-session-data.ts

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,7 @@ export abstract class AppSidebarSessionDataElement extends AppSidebarBase {
7777
private sessionMutationEpoch = 0;
7878
private sessionsScrollElement: HTMLElement | null = null;
7979
private sessionsScrollResizeObserver: ResizeObserver | null = null;
80+
private sessionsScrollStateFrame: number | null = null;
8081
private readonly sessionCatalogLive = new SessionCatalogLiveState();
8182
private sessionCatalogAgentId: string | null = null;
8283
private sessionCatalogGeneration = 0;
@@ -161,6 +162,10 @@ export abstract class AppSidebarSessionDataElement extends AppSidebarBase {
161162
this.sessionsScrollResizeObserver?.disconnect();
162163
this.sessionsScrollResizeObserver = null;
163164
this.sessionsScrollElement = null;
165+
if (this.sessionsScrollStateFrame !== null) {
166+
cancelAnimationFrame(this.sessionsScrollStateFrame);
167+
this.sessionsScrollStateFrame = null;
168+
}
164169
if (this.activeSessionLineageRetryTimer) {
165170
globalThis.clearTimeout(this.activeSessionLineageRetryTimer);
166171
this.activeSessionLineageRetryTimer = null;
@@ -384,10 +389,26 @@ export abstract class AppSidebarSessionDataElement extends AppSidebarBase {
384389
}
385390
}
386391
if (element) {
387-
this.updateSessionsScrollState(element);
392+
this.scheduleSessionsScrollStateSync();
388393
}
389394
}
390395

396+
// Reading scrollHeight/scrollTop synchronously inside updated() forces a
397+
// layout flush on every render. Coalescing the read into one rAF per frame
398+
// lets the browser batch it with the layout it computes for paint anyway.
399+
private scheduleSessionsScrollStateSync() {
400+
if (this.sessionsScrollStateFrame !== null) {
401+
return;
402+
}
403+
this.sessionsScrollStateFrame = requestAnimationFrame(() => {
404+
this.sessionsScrollStateFrame = null;
405+
const element = this.sessionsScrollElement;
406+
if (element?.isConnected) {
407+
this.updateSessionsScrollState(element);
408+
}
409+
});
410+
}
411+
391412
protected updateSessionsScrollState(element: HTMLElement) {
392413
const maxScrollTop = Math.max(0, element.scrollHeight - element.clientHeight);
393414
let nextState: SidebarSessionsScrollState = "none";

ui/src/pages/chat/components/chat-composer.ts

Lines changed: 29 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -161,6 +161,11 @@ type ChatComposerState = {
161161
composerInputIntentKey: string | null;
162162
pendingClearedSubmittedDraft: PendingClearedSubmittedDraft | null;
163163
goalExpandedId: string | null;
164+
composerTextarea: HTMLTextAreaElement | null;
165+
// Stable Lit ref: an inline arrow would change identity every render, making
166+
// Lit re-invoke it (undefined + element) and forcing a layout re-measure of
167+
// the textarea on every chat render instead of only on attach/detach.
168+
textareaRef: ((element?: Element) => void) | null;
164169
};
165170

166171
function createChatComposerState(): ChatComposerState {
@@ -178,6 +183,8 @@ function createChatComposerState(): ChatComposerState {
178183
composerInputIntentKey: null,
179184
pendingClearedSubmittedDraft: null,
180185
goalExpandedId: null,
186+
composerTextarea: null,
187+
textareaRef: null,
181188
};
182189
}
183190

@@ -1886,7 +1893,23 @@ export function renderChatComposer(props: ChatComposerProps) {
18861893
const draftKey = composerDraftKey(props);
18871894
const actionDraft =
18881895
state.composingDraft?.key === draftKey ? state.composingDraft.value : visibleDraft;
1889-
let composerTextarea: HTMLTextAreaElement | null = null;
1896+
state.textareaRef ??= (element?: Element) => {
1897+
const nextTextarea = element instanceof HTMLTextAreaElement ? element : null;
1898+
const prevTextarea = state.composerTextarea;
1899+
if (prevTextarea && prevTextarea !== nextTextarea) {
1900+
disconnectTextareaOverflowObserver(prevTextarea);
1901+
}
1902+
state.composerTextarea = nextTextarea;
1903+
if (nextTextarea) {
1904+
observeTextareaOverflow(nextTextarea);
1905+
scheduleTextareaHeightAdjustment(nextTextarea);
1906+
}
1907+
};
1908+
// The stable ref only measures on attach, so programmatic draft swaps (send
1909+
// clear, session switch, history restore) must re-measure explicitly.
1910+
if (state.composerTextarea?.isConnected && state.composerTextarea.value !== visibleDraft) {
1911+
scheduleTextareaHeightAdjustment(state.composerTextarea);
1912+
}
18901913
const hasVisualAttachments = (props.attachments ?? []).some(
18911914
(attachment) => !isLargePastedTextAttachment(attachment),
18921915
);
@@ -2142,20 +2165,20 @@ export function renderChatComposer(props: ChatComposerProps) {
21422165
commitComposerDraft(props, target.value);
21432166
};
21442167
const handleSend = () => {
2145-
const draft = composerTextarea?.value ?? props.draft;
2168+
const draft = state.composerTextarea?.value ?? props.draft;
21462169
if (!canSubmitDraft(draft)) {
21472170
return;
21482171
}
21492172
commitComposerDraft(props, draft);
21502173
props.onSend();
2151-
syncComposerDraftAfterSend(composerTextarea);
2174+
syncComposerDraftAfterSend(state.composerTextarea);
21522175
};
21532176
const handleVoicePrimaryAction = () => {
21542177
if (props.realtimeTalkActive) {
21552178
props.onToggleRealtimeTalk?.();
21562179
return;
21572180
}
2158-
const liveDraft = composerTextarea?.value ?? visibleDraft;
2181+
const liveDraft = state.composerTextarea?.value ?? visibleDraft;
21592182
if (liveDraft.trim() || props.attachments?.length) {
21602183
handleSend();
21612184
return;
@@ -2256,7 +2279,7 @@ export function renderChatComposer(props: ChatComposerProps) {
22562279
onGoalEdit: (goal) => {
22572280
commitComposerDraft(props, `/goal edit ${goal.objective}`);
22582281
requestUpdate();
2259-
queueMicrotask(() => composerTextarea?.focus({ preventScroll: true }));
2282+
queueMicrotask(() => state.composerTextarea?.focus({ preventScroll: true }));
22602283
},
22612284
requestUpdate,
22622285
})}
@@ -2290,17 +2313,7 @@ export function renderChatComposer(props: ChatComposerProps) {
22902313
${renderChatAttachmentMenu({ ...props, disabled: !canCompose })}
22912314
<div class="agent-chat__composer-combobox">
22922315
<textarea
2293-
${ref((element) => {
2294-
const nextTextarea = element instanceof HTMLTextAreaElement ? element : null;
2295-
if (composerTextarea && composerTextarea !== nextTextarea) {
2296-
disconnectTextareaOverflowObserver(composerTextarea);
2297-
}
2298-
composerTextarea = nextTextarea;
2299-
if (composerTextarea) {
2300-
observeTextareaOverflow(composerTextarea);
2301-
scheduleTextareaHeightAdjustment(composerTextarea);
2302-
}
2303-
})}
2316+
${ref(state.textareaRef)}
23042317
.value=${visibleDraft}
23052318
dir=${detectTextDirection(visibleDraft)}
23062319
?disabled=${!canCompose}

ui/src/pages/chat/components/chat-thread.ts

Lines changed: 29 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -199,6 +199,28 @@ class ChatSessionVirtualizerHost implements ReactiveControllerHost {
199199
private readonly controllers = new Set<ReactiveController>();
200200
private readonly virtualizerController: VirtualizerController<HTMLDivElement, HTMLElement>;
201201
private scrollElement: HTMLDivElement | null = null;
202+
// Stable Lit refs: inline arrows change identity every render, so Lit would
203+
// re-invoke them (undefined + element) for every visible row on every render,
204+
// sweeping the virtualizer's element cache and re-measuring rows each time.
205+
// Lit tracks the last element per (host, callback), so each row needs its own
206+
// stable callback; sharing one across rows would still churn every render.
207+
private readonly scrollElementRef = (element?: Element) => {
208+
this.scrollElement =
209+
element?.parentElement instanceof HTMLDivElement ? element.parentElement : null;
210+
};
211+
private readonly measureRowRefs = new Map<string, (element?: Element) => void>();
212+
private measureRowRefFor(key: string): (element?: Element) => void {
213+
let callback = this.measureRowRefs.get(key);
214+
if (!callback) {
215+
callback = (element?: Element) => {
216+
this.virtualizerController
217+
.getVirtualizer()
218+
.measureElement(element instanceof HTMLElement ? element : null);
219+
};
220+
this.measureRowRefs.set(key, callback);
221+
}
222+
return callback;
223+
}
202224
private rowKeys: readonly string[] = [];
203225
private rowIndexesByKey = new Map<string, number>();
204226
private focusedRowKey: string | null = null;
@@ -292,13 +314,7 @@ class ChatSessionVirtualizerHost implements ReactiveControllerHost {
292314
const virtualizer = this.virtualizerController.getVirtualizer();
293315
const virtualRows = virtualizer.getVirtualItems();
294316
return html`
295-
<div
296-
class="chat-thread-inner chat-thread-inner--virtual"
297-
${ref((element) => {
298-
this.scrollElement =
299-
element?.parentElement instanceof HTMLDivElement ? element.parentElement : null;
300-
})}
301-
>
317+
<div class="chat-thread-inner chat-thread-inner--virtual" ${ref(this.scrollElementRef)}>
302318
<div
303319
class="chat-virtual-sizer"
304320
style=${styleMap({ height: `${virtualizer.getTotalSize()}px` })}
@@ -324,9 +340,7 @@ class ChatSessionVirtualizerHost implements ReactiveControllerHost {
324340
})}
325341
data-index=${String(virtualRow.index)}
326342
data-virtual-row-key=${row.key}
327-
${ref((element) =>
328-
virtualizer.measureElement(element instanceof HTMLElement ? element : null),
329-
)}
343+
${ref(this.measureRowRefFor(row.key))}
330344
>
331345
${renderRow(row)}
332346
</div>
@@ -388,6 +402,11 @@ class ChatSessionVirtualizerHost implements ReactiveControllerHost {
388402
}
389403
this.rowKeys = Object.freeze(nextKeys);
390404
this.rowIndexesByKey = new Map(this.rowKeys.map((key, index) => [key, index]));
405+
for (const key of this.measureRowRefs.keys()) {
406+
if (!this.rowIndexesByKey.has(key)) {
407+
this.measureRowRefs.delete(key);
408+
}
409+
}
391410
const keys = this.rowKeys;
392411
const virtualizer = this.virtualizerController.getVirtualizer();
393412
virtualizer.setOptions({

0 commit comments

Comments
 (0)