From 69b2c8bd159b785042ecba74ca9588122fd2c957 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Mon, 1 Jun 2026 07:42:24 +0100 Subject: [PATCH] perf(ui): record pending send paint timing (#88960) --- ui/src/ui/app-chat.test.ts | 37 +++++++++++++++++++++++++++ ui/src/ui/app-chat.ts | 39 +++++++++++++++++++++++++++++ ui/src/ui/control-ui-performance.ts | 9 +++++++ 3 files changed, 85 insertions(+) diff --git a/ui/src/ui/app-chat.test.ts b/ui/src/ui/app-chat.test.ts index 257048cd4dfa..1dbbccf4f532 100644 --- a/ui/src/ui/app-chat.test.ts +++ b/ui/src/ui/app-chat.test.ts @@ -1057,6 +1057,7 @@ describe("handleSendChat", () => { }); afterEach(() => { + vi.restoreAllMocks(); vi.unstubAllGlobals(); }); @@ -1223,6 +1224,42 @@ describe("handleSendChat", () => { expect(ack?.requestDurationMs).toEqual(expect.any(Number)); }); + it("records pending send paint timing before a delayed chat.send ACK", async () => { + vi.spyOn(window, "requestAnimationFrame").mockImplementation((callback) => { + queueMicrotask(() => callback(0)); + return 1; + }); + const chatSend = createDeferred<{ status: "started" }>(); + const request = vi.fn((method: string) => { + if (method === "chat.send") { + return chatSend.promise; + } + throw new Error(`Unexpected request: ${method}`); + }); + const host = makeHost({ + client: { request } as unknown as ChatHost["client"], + chatMessage: "measure painted pending send", + eventLogBuffer: [], + tab: "debug", + }); + + const send = handleSendChat(host); + + await vi.waitFor(() => + expect(eventPayloads(host, "control-ui.chat.send").map((payload) => payload.phase)).toEqual( + expect.arrayContaining(["pending-visible", "request-start", "pending-painted"]), + ), + ); + + chatSend.resolve({ status: "started" }); + await send; + + const phasesAfterAck = eventPayloads(host, "control-ui.chat.send").map( + (payload) => payload.phase, + ); + expect(phasesAfterAck).toEqual(expect.arrayContaining(["ack"])); + }); + it("waits for an in-flight model picker update before sending chat", async () => { const switchUpdate = createDeferred(); const request = vi.fn(async (method: string) => { diff --git a/ui/src/ui/app-chat.ts b/ui/src/ui/app-chat.ts index 93748209cd24..7ed70a5858e4 100644 --- a/ui/src/ui/app-chat.ts +++ b/ui/src/ui/app-chat.ts @@ -31,6 +31,7 @@ import { controlUiNowMs, recordControlUiPerformanceEvent, roundedControlUiDurationMs, + scheduleControlUiAfterPaint, } from "./control-ui-performance.ts"; import { resolveControlUiAuthHeader } from "./control-ui-auth.ts"; import { @@ -417,6 +418,7 @@ function enqueuePendingSendMessage( }; host.chatQueue = [...host.chatQueue, pending]; recordChatSendTiming(host, pending, "pending-visible", submittedAtMs); + schedulePendingSendPaintTiming(host, pending, submittedAtMs); scheduleChatScroll(host as unknown as Parameters[0], true); return pending; } @@ -563,6 +565,7 @@ type QueuedChatSendResult = "sent" | "pending" | "failed"; type ChatSendTimingPhase = | "pending-visible" + | "pending-painted" | "request-start" | "ack" | "queued-busy" @@ -600,6 +603,42 @@ function recordChatSendTiming( ); } +function shouldRecordPendingSendPaint(item: ChatQueueItem): boolean { + return ( + typeof item.sendSubmittedAtMs === "number" && + (item.sendState === "waiting-model" || + item.sendState === "sending" || + item.sendState === "waiting-reconnect") + ); +} + +function schedulePendingSendPaintTiming( + host: ChatHost, + item: ChatQueueItem, + startedAtMs = item.sendSubmittedAtMs, +) { + const sessionKey = item.sessionKey ?? host.sessionKey; + const sendRunId = item.sendRunId; + if (!sendRunId || startedAtMs == null) { + return; + } + scheduleControlUiAfterPaint( + host as Parameters[0], + () => { + if (!visibleSessionMatches(host, sessionKey, item.agentId)) { + return; + } + const queued = readChatQueueForSession(host, sessionKey).find( + (entry) => entry.id === item.id && entry.sendRunId === sendRunId, + ); + if (!queued || !shouldRecordPendingSendPaint(queued)) { + return; + } + recordChatSendTiming(host, queued, "pending-painted", startedAtMs); + }, + ); +} + function ensureQueuedSendState( host: ChatHost, item: ChatQueueItem, diff --git a/ui/src/ui/control-ui-performance.ts b/ui/src/ui/control-ui-performance.ts index 83f6bf53f771..9205c87edac5 100644 --- a/ui/src/ui/control-ui-performance.ts +++ b/ui/src/ui/control-ui-performance.ts @@ -159,6 +159,15 @@ export function scheduleControlUiTabVisibleTiming( .then(() => runAfterPaint(record)); } +export function scheduleControlUiAfterPaint( + host: Pick, + callback: () => void, +) { + void Promise.resolve(host.updateComplete) + .catch(() => undefined) + .then(() => runAfterPaint(callback)); +} + export function beginControlUiRefresh( host: ControlUiPerformanceHost, tab: Tab,