mirror of
https://github.com/block/buzz.git
synced 2026-10-27 04:30:52 +00:00
perf(desktop): thread panel rebuilt on every render — fix memo + add typing-latency benchmark (#1652)
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -80,6 +80,23 @@ export function useChannelPaneHandlers({
|
||||
const toggleMutateRef = React.useRef(toggleReactionMutation.mutateAsync);
|
||||
toggleMutateRef.current = toggleReactionMutation.mutateAsync;
|
||||
|
||||
// These three recompute whenever timelineMessages changes (every ingest).
|
||||
// Read them through refs so handleExpandThreadReplies keeps a stable
|
||||
// identity — it feeds MessageThreadPanel's per-row onCollapseDepthGuide,
|
||||
// and an identity change there re-renders every thread row. With agents
|
||||
// streaming into an open long thread that meant all rows re-rendered
|
||||
// several times per second (see typing-latency.perf.ts "thread68+").
|
||||
const getFirstReplyIdRef = React.useRef(getFirstReplyIdForMessage);
|
||||
getFirstReplyIdRef.current = getFirstReplyIdForMessage;
|
||||
|
||||
const getReplyDescendantIdsRef = React.useRef(
|
||||
getReplyDescendantIdsForMessage,
|
||||
);
|
||||
getReplyDescendantIdsRef.current = getReplyDescendantIdsForMessage;
|
||||
|
||||
const markRevealedRepliesReadRef = React.useRef(markRevealedRepliesRead);
|
||||
markRevealedRepliesReadRef.current = markRevealedRepliesRead;
|
||||
|
||||
const deferPanelState = React.useCallback((update: () => void) => {
|
||||
window.setTimeout(() => {
|
||||
React.startTransition(update);
|
||||
@@ -188,7 +205,7 @@ export function useChannelPaneHandlers({
|
||||
const handleExpandThreadReplies = React.useCallback(
|
||||
(message: { id: string }) => {
|
||||
if (expandedThreadReplyIdsRef.current.has(message.id)) {
|
||||
const descendantIds = getReplyDescendantIdsForMessage(message.id);
|
||||
const descendantIds = getReplyDescendantIdsRef.current(message.id);
|
||||
setExpandedThreadReplyIds((current) => {
|
||||
const next = new Set(current);
|
||||
next.delete(message.id);
|
||||
@@ -200,7 +217,7 @@ export function useChannelPaneHandlers({
|
||||
return;
|
||||
}
|
||||
|
||||
const firstReplyId = getFirstReplyIdForMessage(message.id);
|
||||
const firstReplyId = getFirstReplyIdRef.current(message.id);
|
||||
setExpandedThreadReplyIds((current) => {
|
||||
const next = new Set(current);
|
||||
next.add(message.id);
|
||||
@@ -212,19 +229,13 @@ export function useChannelPaneHandlers({
|
||||
// reply still nested in a collapsed grandchild branch keeps its badge
|
||||
// until it too is revealed — the deliberate reversal of #1118's
|
||||
// whole-subtree-on-open collapse.
|
||||
markRevealedRepliesRead(message.id);
|
||||
markRevealedRepliesReadRef.current(message.id);
|
||||
|
||||
if (firstReplyId) {
|
||||
setThreadScrollTargetId(firstReplyId);
|
||||
}
|
||||
},
|
||||
[
|
||||
getFirstReplyIdForMessage,
|
||||
getReplyDescendantIdsForMessage,
|
||||
markRevealedRepliesRead,
|
||||
setExpandedThreadReplyIds,
|
||||
setThreadScrollTargetId,
|
||||
],
|
||||
[setExpandedThreadReplyIds, setThreadScrollTargetId],
|
||||
);
|
||||
|
||||
const handleSendMessage = React.useCallback(
|
||||
|
||||
114
desktop/src/features/messages/lib/messageRowEquality.test.mjs
Normal file
114
desktop/src/features/messages/lib/messageRowEquality.test.mjs
Normal file
@@ -0,0 +1,114 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
depthGuideActionsEqual,
|
||||
numberArrayEqual,
|
||||
reactionsEqual,
|
||||
tagsEqual,
|
||||
} from "./messageRowEquality.ts";
|
||||
|
||||
// These helpers exist so MessageRow's memo holds when arrays are rebuilt
|
||||
// with fresh identities but unchanged values (every ingest/refetch does
|
||||
// this). Equal-by-value must return true; any real change must return false.
|
||||
|
||||
test("tagsEqual: fresh identity, same values → equal", () => {
|
||||
assert.equal(
|
||||
tagsEqual(
|
||||
[
|
||||
["e", "abc"],
|
||||
["h", "chan"],
|
||||
],
|
||||
[
|
||||
["e", "abc"],
|
||||
["h", "chan"],
|
||||
],
|
||||
),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test("tagsEqual: changed tag value → not equal", () => {
|
||||
assert.equal(tagsEqual([["e", "abc"]], [["e", "abd"]]), false);
|
||||
assert.equal(
|
||||
tagsEqual(
|
||||
[["e", "abc"]],
|
||||
[
|
||||
["e", "abc"],
|
||||
["p", "x"],
|
||||
],
|
||||
),
|
||||
false,
|
||||
);
|
||||
assert.equal(tagsEqual(undefined, [["e", "abc"]]), false);
|
||||
assert.equal(tagsEqual(undefined, undefined), true);
|
||||
});
|
||||
|
||||
test("reactionsEqual: fresh identity, same values → equal", () => {
|
||||
const make = () => [
|
||||
{
|
||||
emoji: "🔥",
|
||||
count: 2,
|
||||
reactedByCurrentUser: true,
|
||||
users: [
|
||||
{ pubkey: "aa", displayName: "A", avatarUrl: null },
|
||||
{ pubkey: "bb", displayName: "B", avatarUrl: "u" },
|
||||
],
|
||||
},
|
||||
];
|
||||
assert.equal(reactionsEqual(make(), make()), true);
|
||||
});
|
||||
|
||||
test("reactionsEqual: any changed field → not equal", () => {
|
||||
const base = {
|
||||
emoji: "🔥",
|
||||
count: 2,
|
||||
reactedByCurrentUser: false,
|
||||
users: [{ pubkey: "aa", displayName: "A", avatarUrl: null }],
|
||||
};
|
||||
assert.equal(reactionsEqual([base], [{ ...base, count: 3 }]), false);
|
||||
assert.equal(
|
||||
reactionsEqual([base], [{ ...base, reactedByCurrentUser: true }]),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
reactionsEqual(
|
||||
[base],
|
||||
[
|
||||
{
|
||||
...base,
|
||||
users: [{ pubkey: "cc", displayName: "A", avatarUrl: null }],
|
||||
},
|
||||
],
|
||||
),
|
||||
false,
|
||||
);
|
||||
assert.equal(reactionsEqual(undefined, undefined), true);
|
||||
assert.equal(reactionsEqual([base], undefined), false);
|
||||
});
|
||||
|
||||
test("numberArrayEqual", () => {
|
||||
assert.equal(numberArrayEqual([1, 2], [1, 2]), true);
|
||||
assert.equal(numberArrayEqual([1, 2], [2, 1]), false);
|
||||
assert.equal(numberArrayEqual(undefined, undefined), true);
|
||||
assert.equal(numberArrayEqual([1], undefined), false);
|
||||
});
|
||||
|
||||
test("depthGuideActionsEqual: same values (message by id) → equal", () => {
|
||||
const message = { id: "m1" };
|
||||
const other = { id: "m1" };
|
||||
assert.equal(
|
||||
depthGuideActionsEqual(
|
||||
[{ active: false, depth: 1, label: "Collapse replies", message }],
|
||||
[{ active: false, depth: 1, label: "Collapse replies", message: other }],
|
||||
),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
depthGuideActionsEqual(
|
||||
[{ active: false, depth: 1, label: "Collapse replies", message }],
|
||||
[{ active: true, depth: 1, label: "Collapse replies", message }],
|
||||
),
|
||||
false,
|
||||
);
|
||||
});
|
||||
119
desktop/src/features/messages/lib/messageRowEquality.ts
Normal file
119
desktop/src/features/messages/lib/messageRowEquality.ts
Normal file
@@ -0,0 +1,119 @@
|
||||
import type {
|
||||
TimelineMessage,
|
||||
TimelineReaction,
|
||||
} from "@/features/messages/types";
|
||||
|
||||
/**
|
||||
* Value-equality helpers for `MessageRow`'s memo comparator.
|
||||
*
|
||||
* Several row props are rebuilt with fresh identities on every ingest even
|
||||
* when their values are unchanged: `message.tags` (every relay/IPC refetch
|
||||
* deserializes new event objects), `message.reactions` (rebuilt per
|
||||
* `formatTimelineMessages` run), and the thread panel's per-row layout
|
||||
* arrays. Comparing them by identity made every row re-render on every
|
||||
* streamed event — in a long open thread with agents streaming, that
|
||||
* re-reconciled every row's action bar/tooltip/provider subtree several
|
||||
* times per second and saturated the main thread (see
|
||||
* typing-latency.perf.ts, scenario "thread68+"). These value comparisons
|
||||
* are O(a few tiny arrays) per row — far cheaper than one spurious row
|
||||
* render.
|
||||
*/
|
||||
|
||||
export function tagsEqual(
|
||||
a: TimelineMessage["tags"],
|
||||
b: TimelineMessage["tags"],
|
||||
): boolean {
|
||||
if (a === b) return true;
|
||||
if (!a || !b || a.length !== b.length) return false;
|
||||
for (let i = 0; i < a.length; i += 1) {
|
||||
const tagA = a[i];
|
||||
const tagB = b[i];
|
||||
if (tagA.length !== tagB.length) return false;
|
||||
for (let j = 0; j < tagA.length; j += 1) {
|
||||
if (tagA[j] !== tagB[j]) return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
export function reactionsEqual(
|
||||
a: TimelineReaction[] | undefined,
|
||||
b: TimelineReaction[] | undefined,
|
||||
): boolean {
|
||||
if (a === b) return true;
|
||||
if (!a || !b || a.length !== b.length) return false;
|
||||
for (let i = 0; i < a.length; i += 1) {
|
||||
const left = a[i];
|
||||
const right = b[i];
|
||||
if (
|
||||
left.emoji !== right.emoji ||
|
||||
left.emojiUrl !== right.emojiUrl ||
|
||||
left.count !== right.count ||
|
||||
left.reactedByCurrentUser !== right.reactedByCurrentUser ||
|
||||
left.users.length !== right.users.length
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
for (let j = 0; j < left.users.length; j += 1) {
|
||||
const userA = left.users[j];
|
||||
const userB = right.users[j];
|
||||
if (
|
||||
userA.pubkey !== userB.pubkey ||
|
||||
userA.displayName !== userB.displayName ||
|
||||
userA.avatarUrl !== userB.avatarUrl
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
export function numberArrayEqual(
|
||||
a: readonly number[] | undefined,
|
||||
b: readonly number[] | undefined,
|
||||
): boolean {
|
||||
if (a === b) return true;
|
||||
if (!a || !b || a.length !== b.length) return false;
|
||||
for (let i = 0; i < a.length; i += 1) {
|
||||
if (a[i] !== b[i]) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Thread depth-guide actions: value-equal when every action matches on its
|
||||
* scalar fields and refers to the same ancestor message (by id). Structural
|
||||
* type mirrors `ThreadDepthGuideAction` (MessageRow.tsx) — importing it here
|
||||
* would create an import cycle with MessageRow. */
|
||||
export function depthGuideActionsEqual(
|
||||
a:
|
||||
| ReadonlyArray<{
|
||||
active?: boolean;
|
||||
depth: number;
|
||||
label: string;
|
||||
message: TimelineMessage;
|
||||
}>
|
||||
| undefined,
|
||||
b:
|
||||
| ReadonlyArray<{
|
||||
active?: boolean;
|
||||
depth: number;
|
||||
label: string;
|
||||
message: TimelineMessage;
|
||||
}>
|
||||
| undefined,
|
||||
): boolean {
|
||||
if (a === b) return true;
|
||||
if (!a || !b || a.length !== b.length) return false;
|
||||
for (let i = 0; i < a.length; i += 1) {
|
||||
if (
|
||||
a[i].active !== b[i].active ||
|
||||
a[i].depth !== b[i].depth ||
|
||||
a[i].label !== b[i].label ||
|
||||
a[i].message.id !== b[i].message.id
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
@@ -1,5 +1,11 @@
|
||||
import * as React from "react";
|
||||
|
||||
import {
|
||||
depthGuideActionsEqual,
|
||||
numberArrayEqual,
|
||||
reactionsEqual,
|
||||
tagsEqual,
|
||||
} from "@/features/messages/lib/messageRowEquality";
|
||||
import type { TimelineMessage } from "@/features/messages/types";
|
||||
import { HuddleAttachment } from "@/features/huddle/components/HuddleAttachment";
|
||||
import { MessageReactions } from "@/features/messages/ui/MessageReactions";
|
||||
@@ -781,19 +787,29 @@ export const MessageRow = React.memo(
|
||||
prev.message.kind === next.message.kind &&
|
||||
prev.message.pending === next.message.pending &&
|
||||
prev.message.edited === next.message.edited &&
|
||||
prev.message.reactions === next.message.reactions &&
|
||||
prev.message.tags === next.message.tags &&
|
||||
// Value comparisons, not identity: these arrays are rebuilt with fresh
|
||||
// identities on every ingest/refetch even when unchanged — identity
|
||||
// checks made every row re-render on every streamed event in an open
|
||||
// thread (see messageRowEquality.ts).
|
||||
reactionsEqual(prev.message.reactions, next.message.reactions) &&
|
||||
tagsEqual(prev.message.tags, next.message.tags) &&
|
||||
prev.message.role === next.message.role &&
|
||||
prev.message.personaDisplayName === next.message.personaDisplayName &&
|
||||
prev.agentPubkeys === next.agentPubkeys &&
|
||||
prev.collapseDepthGuideActions === next.collapseDepthGuideActions &&
|
||||
depthGuideActionsEqual(
|
||||
prev.collapseDepthGuideActions,
|
||||
next.collapseDepthGuideActions,
|
||||
) &&
|
||||
prev.collapseDescendantsLabel === next.collapseDescendantsLabel &&
|
||||
prev.connectDescendants === next.connectDescendants &&
|
||||
prev.depthGuideDepths === next.depthGuideDepths &&
|
||||
numberArrayEqual(prev.depthGuideDepths, next.depthGuideDepths) &&
|
||||
prev.highlightDescendantRail === next.highlightDescendantRail &&
|
||||
prev.highlighted === next.highlighted &&
|
||||
prev.highlightReplyConnector === next.highlightReplyConnector &&
|
||||
prev.highlightThreadLineDepths === next.highlightThreadLineDepths &&
|
||||
numberArrayEqual(
|
||||
prev.highlightThreadLineDepths,
|
||||
next.highlightThreadLineDepths,
|
||||
) &&
|
||||
prev.hoverBackground === next.hoverBackground &&
|
||||
prev.huddleMemberPubkeys === next.huddleMemberPubkeys &&
|
||||
prev.huddleMemberPubkeysPending === next.huddleMemberPubkeysPending &&
|
||||
|
||||
@@ -23,6 +23,14 @@ export function useIndependentThreadPanel(args: {
|
||||
personaLookup: Map<string, string>;
|
||||
respondToLookup: Map<string, RespondToMode>;
|
||||
}) {
|
||||
// Depend on the individual fields, NOT the `args` object — callers pass a
|
||||
// fresh object literal every render, so `[args]` never memoizes and the
|
||||
// full O(replies) formatTimelineMessages + buildThreadPanelData rebuild
|
||||
// runs on every ChannelScreen render. In a long thread with agents
|
||||
// streaming (an event or typing tick per ~150ms, each re-rendering the
|
||||
// screen) that saturates the main thread and starves keystrokes — see
|
||||
// typing-latency.perf.ts scenario "thread68+". Mirrors the main timeline's
|
||||
// memoization of the same formatter (ChannelScreen `timelineMessages`).
|
||||
return React.useMemo(
|
||||
() =>
|
||||
buildIndependentThreadPanel(
|
||||
@@ -39,6 +47,20 @@ export function useIndependentThreadPanel(args: {
|
||||
args.personaLookup,
|
||||
args.respondToLookup,
|
||||
),
|
||||
[args],
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: fields listed explicitly — see comment above
|
||||
[
|
||||
args.channelEvents,
|
||||
args.threadReplyEvents,
|
||||
args.rootId,
|
||||
args.replyTargetId,
|
||||
args.expandedReplyIds,
|
||||
args.activeChannel,
|
||||
args.currentPubkey,
|
||||
args.currentAvatarUrl,
|
||||
args.profiles,
|
||||
args.members,
|
||||
args.personaLookup,
|
||||
args.respondToLookup,
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
595
desktop/tests/e2e/typing-latency.perf.ts
Normal file
595
desktop/tests/e2e/typing-latency.perf.ts
Normal file
@@ -0,0 +1,595 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
import { installMockBridge } from "../helpers/bridge";
|
||||
|
||||
/**
|
||||
* Composer typing-latency benchmark.
|
||||
*
|
||||
* Measures per-keystroke input-to-paint latency in the message composer,
|
||||
* quiet vs. under simulated agent activity (typing indicators from N agent
|
||||
* pubkeys + periodic live messages), to quantify the "typing feels slow in
|
||||
* agent-busy channels" report.
|
||||
*
|
||||
* METRIC: the browser's Event Timing API (`PerformanceObserver` type
|
||||
* "event") — each entry's `duration` is input timestamp → next paint, the
|
||||
* engine-level definition of keystroke responsiveness (8ms granularity).
|
||||
* We record entries for `input` events with durationThreshold 16ms and
|
||||
* report median/p95/max plus the count of >50ms (frame-budget-blowing)
|
||||
* keystrokes. Longtask totals are captured per scenario as a second axis.
|
||||
*
|
||||
* SCENARIOS (same 80-char typing burst, 4x CPU throttle):
|
||||
* quiet — no agent traffic.
|
||||
* busy — 8 agent pubkeys emit typing indicators round-robin every 250ms
|
||||
* (≈ the arrival rate of 8 agents refreshing every 2-3s), plus a
|
||||
* live markdown message lands every 2s.
|
||||
*
|
||||
* Absolute ms are machine-specific; the quiet-vs-busy DELTA on one machine
|
||||
* is the signal. Run it (from desktop/):
|
||||
* pnpm build
|
||||
* npx playwright test --config=playwright.perf.config.ts typing-latency.perf.ts
|
||||
*/
|
||||
|
||||
const THROTTLE_RATE = 4;
|
||||
const TYPED_TEXT =
|
||||
"The quick brown fox jumps over the lazy dog while agents keep working away";
|
||||
const KEY_DELAY_MS = 60;
|
||||
const AGENT_COUNT = 8;
|
||||
const TYPING_EMIT_INTERVAL_MS = 250;
|
||||
const LIVE_MESSAGE_INTERVAL_MS = 2000;
|
||||
|
||||
type LatencyReport = {
|
||||
count: number;
|
||||
median: number;
|
||||
p95: number;
|
||||
max: number;
|
||||
over50: number;
|
||||
longtaskTotal: number;
|
||||
};
|
||||
|
||||
function quantile(sorted: number[], q: number): number {
|
||||
if (sorted.length === 0) return 0;
|
||||
const index = Math.min(
|
||||
sorted.length - 1,
|
||||
Math.floor(q * (sorted.length - 1)),
|
||||
);
|
||||
return sorted[index];
|
||||
}
|
||||
|
||||
async function resetWindowMetrics(page: import("@playwright/test").Page) {
|
||||
await page.evaluate(() => {
|
||||
const store = window as unknown as {
|
||||
__INPUT_EVENTS__: number[];
|
||||
__LONGTASKS__: number[];
|
||||
};
|
||||
store.__INPUT_EVENTS__ = [];
|
||||
store.__LONGTASKS__ = [];
|
||||
});
|
||||
}
|
||||
|
||||
async function readWindowMetrics(
|
||||
page: import("@playwright/test").Page,
|
||||
): Promise<LatencyReport> {
|
||||
return page.evaluate(() => {
|
||||
const store = window as unknown as {
|
||||
__INPUT_EVENTS__: number[];
|
||||
__LONGTASKS__: number[];
|
||||
};
|
||||
const durations = [...(store.__INPUT_EVENTS__ ?? [])].sort((a, b) => a - b);
|
||||
const at = (q: number) =>
|
||||
durations.length === 0
|
||||
? 0
|
||||
: durations[
|
||||
Math.min(
|
||||
durations.length - 1,
|
||||
Math.floor(q * (durations.length - 1)),
|
||||
)
|
||||
];
|
||||
return {
|
||||
count: durations.length,
|
||||
median: at(0.5),
|
||||
p95: at(0.95),
|
||||
max: durations.length ? durations[durations.length - 1] : 0,
|
||||
over50: durations.filter((d) => d > 50).length,
|
||||
longtaskTotal: (store.__LONGTASKS__ ?? []).reduce((s, d) => s + d, 0),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async function typeBurst(
|
||||
page: import("@playwright/test").Page,
|
||||
scope?: import("@playwright/test").Locator,
|
||||
) {
|
||||
const input = (scope ?? page).getByTestId("message-input").last();
|
||||
await input.click();
|
||||
await input.pressSequentially(TYPED_TEXT, { delay: KEY_DELAY_MS });
|
||||
// Let trailing event-timing entries (reported after paint) flush.
|
||||
await page.waitForTimeout(500);
|
||||
await input.press("Meta+A");
|
||||
await input.press("Backspace");
|
||||
await page.waitForTimeout(300);
|
||||
}
|
||||
|
||||
function log(label: string, report: LatencyReport) {
|
||||
/* eslint-disable no-console */
|
||||
console.log(`\n=== TYPING LATENCY: ${label} ===`);
|
||||
console.log(`CPU throttle: ${THROTTLE_RATE}x`);
|
||||
console.log(`input events >=16ms: ${report.count}`);
|
||||
console.log(`median duration: ${report.median.toFixed(0)}ms`);
|
||||
console.log(`p95 duration: ${report.p95.toFixed(0)}ms`);
|
||||
console.log(`max duration: ${report.max.toFixed(0)}ms`);
|
||||
console.log(`keystrokes >50ms: ${report.over50}`);
|
||||
console.log(`longtask total: ${report.longtaskTotal.toFixed(0)}ms`);
|
||||
/* eslint-enable no-console */
|
||||
}
|
||||
|
||||
test("MEASURE: composer keystroke latency, quiet vs agent-busy channel", async ({
|
||||
page,
|
||||
}) => {
|
||||
test.setTimeout(240_000);
|
||||
await installMockBridge(page);
|
||||
await page.goto("/");
|
||||
await page.waitForFunction(
|
||||
() => typeof window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__ === "function",
|
||||
);
|
||||
|
||||
await page.addInitScript(() => {
|
||||
const store = window as unknown as {
|
||||
__INPUT_EVENTS__?: number[];
|
||||
__LONGTASKS__?: number[];
|
||||
};
|
||||
store.__INPUT_EVENTS__ = [];
|
||||
store.__LONGTASKS__ = [];
|
||||
new PerformanceObserver((list) => {
|
||||
for (const entry of list.getEntries()) {
|
||||
if (entry.name === "input" || entry.name === "keydown") {
|
||||
store.__INPUT_EVENTS__?.push(entry.duration);
|
||||
}
|
||||
}
|
||||
}).observe({ type: "event", buffered: true, durationThreshold: 16 });
|
||||
new PerformanceObserver((list) => {
|
||||
for (const entry of list.getEntries()) {
|
||||
store.__LONGTASKS__?.push(entry.duration);
|
||||
}
|
||||
}).observe({ type: "longtask", buffered: true });
|
||||
});
|
||||
await page.reload();
|
||||
await page.waitForFunction(
|
||||
() =>
|
||||
typeof window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__ === "function" &&
|
||||
Array.isArray(
|
||||
(window as unknown as { __INPUT_EVENTS__?: number[] }).__INPUT_EVENTS__,
|
||||
),
|
||||
);
|
||||
|
||||
// The `agents` channel: agent members, realistic surface for the report.
|
||||
await page.getByTestId("channel-agents").click();
|
||||
await expect(page.getByTestId("chat-title")).toHaveText("agents");
|
||||
await expect(
|
||||
page.getByTestId("message-timeline").locator("[data-message-id]").first(),
|
||||
).toBeVisible();
|
||||
|
||||
const client = await page.context().newCDPSession(page);
|
||||
await client.send("Emulation.setCPUThrottlingRate", { rate: THROTTLE_RATE });
|
||||
|
||||
// Warmup burst (JIT, first-render costs), unmeasured.
|
||||
await typeBurst(page);
|
||||
|
||||
// ---- Scenario A: quiet ----
|
||||
await resetWindowMetrics(page);
|
||||
await typeBurst(page);
|
||||
const quiet = await readWindowMetrics(page);
|
||||
log("quiet channel", quiet);
|
||||
|
||||
// ---- Scenario B: agent-busy ----
|
||||
// Round-robin typing indicators from N synthetic agent pubkeys, plus a
|
||||
// live markdown message every 2s — in-page timers so the traffic keeps
|
||||
// flowing while pressSequentially runs.
|
||||
await page.evaluate(
|
||||
({ agentCount, typingIntervalMs, messageIntervalMs }) => {
|
||||
const w = window as unknown as {
|
||||
__BUZZ_E2E_EMIT_MOCK_TYPING__?: (input: {
|
||||
channelName: string;
|
||||
pubkey?: string;
|
||||
}) => unknown;
|
||||
__BUZZ_E2E_EMIT_MOCK_MESSAGE__?: (input: {
|
||||
channelName: string;
|
||||
content: string;
|
||||
}) => unknown;
|
||||
__BUSY_TIMERS__?: number[];
|
||||
};
|
||||
const pubkeys = Array.from({ length: agentCount }, (_, index) =>
|
||||
`a${index}`.repeat(32),
|
||||
);
|
||||
let tick = 0;
|
||||
const typingTimer = window.setInterval(() => {
|
||||
tick += 1;
|
||||
w.__BUZZ_E2E_EMIT_MOCK_TYPING__?.({
|
||||
channelName: "agents",
|
||||
pubkey: pubkeys[tick % pubkeys.length],
|
||||
});
|
||||
}, typingIntervalMs);
|
||||
let messageIndex = 0;
|
||||
const messageTimer = window.setInterval(() => {
|
||||
messageIndex += 1;
|
||||
w.__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({
|
||||
channelName: "agents",
|
||||
content: `**Progress ${messageIndex}**\n\n- step done\n- \`cargo check\` ok`,
|
||||
});
|
||||
}, messageIntervalMs);
|
||||
w.__BUSY_TIMERS__ = [typingTimer, messageTimer];
|
||||
},
|
||||
{
|
||||
agentCount: AGENT_COUNT,
|
||||
typingIntervalMs: TYPING_EMIT_INTERVAL_MS,
|
||||
messageIntervalMs: LIVE_MESSAGE_INTERVAL_MS,
|
||||
},
|
||||
);
|
||||
// Let the busy traffic reach steady state (typing TTLs, working badges).
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
await resetWindowMetrics(page);
|
||||
await typeBurst(page);
|
||||
const busy = await readWindowMetrics(page);
|
||||
log(`agent-busy (${AGENT_COUNT} agents typing + live messages)`, busy);
|
||||
|
||||
await page.evaluate(() => {
|
||||
const w = window as unknown as { __BUSY_TIMERS__?: number[] };
|
||||
for (const timer of w.__BUSY_TIMERS__ ?? []) {
|
||||
window.clearInterval(timer);
|
||||
}
|
||||
});
|
||||
|
||||
// ---- Scenario C: observer-frame storm ----
|
||||
// Pre-fill 6 agents' observer buffers (1200 events each), then append one
|
||||
// frame every 100ms round-robin. Each append runs the production ingestion
|
||||
// path: O(E log E) buffer re-sort + the syncAll bridge's O(agents x
|
||||
// events) rescan (see observerRelayStore.appendAgentEvent /
|
||||
// activeAgentTurnsStore syncAll) — the suspected typing-lag source.
|
||||
await page.evaluate(
|
||||
({ agentCount, bufferSize, channelId }) => {
|
||||
const w = window as unknown as {
|
||||
__BUZZ_E2E_SEED_OBSERVER_EVENTS__?: (input: {
|
||||
agentPubkey: string;
|
||||
events: unknown[];
|
||||
}) => void;
|
||||
__OBS_AGENTS__?: Array<{ pubkey: string; seq: number }>;
|
||||
};
|
||||
const base = Date.parse("2025-06-15T12:00:00Z");
|
||||
const makeEvent = (pubkey: string, seq: number) => ({
|
||||
seq,
|
||||
timestamp: new Date(base + seq * 1000).toISOString(),
|
||||
kind: "acp_write",
|
||||
agentIndex: 0,
|
||||
channelId,
|
||||
sessionId: `sess-${pubkey.slice(0, 4)}`,
|
||||
turnId: `turn-${pubkey.slice(0, 4)}`,
|
||||
payload: {
|
||||
jsonrpc: "2.0",
|
||||
method: "session/update",
|
||||
params: { note: `event ${seq}` },
|
||||
},
|
||||
});
|
||||
w.__OBS_AGENTS__ = Array.from({ length: agentCount }, (_, index) => ({
|
||||
pubkey: `b${index}`.repeat(32),
|
||||
seq: bufferSize,
|
||||
}));
|
||||
for (const agent of w.__OBS_AGENTS__) {
|
||||
w.__BUZZ_E2E_SEED_OBSERVER_EVENTS__?.({
|
||||
agentPubkey: agent.pubkey,
|
||||
events: Array.from({ length: bufferSize }, (_, seq) =>
|
||||
makeEvent(agent.pubkey, seq),
|
||||
),
|
||||
});
|
||||
}
|
||||
let stormTick = 0;
|
||||
const stormTimer = window.setInterval(() => {
|
||||
const agents = w.__OBS_AGENTS__ ?? [];
|
||||
if (agents.length === 0) return;
|
||||
stormTick += 1;
|
||||
const agent = agents[stormTick % agents.length];
|
||||
agent.seq += 1;
|
||||
w.__BUZZ_E2E_SEED_OBSERVER_EVENTS__?.({
|
||||
agentPubkey: agent.pubkey,
|
||||
events: [makeEvent(agent.pubkey, agent.seq)],
|
||||
});
|
||||
}, 100);
|
||||
(w as unknown as { __STORM_TIMER__?: number }).__STORM_TIMER__ =
|
||||
stormTimer;
|
||||
},
|
||||
{
|
||||
agentCount: 6,
|
||||
bufferSize: 1200,
|
||||
channelId: "94a444a4-c0a3-5966-ab05-530c6ddc2301", // #agents
|
||||
},
|
||||
);
|
||||
await page.waitForTimeout(1500);
|
||||
|
||||
await resetWindowMetrics(page);
|
||||
await typeBurst(page);
|
||||
const storm = await readWindowMetrics(page);
|
||||
log("observer storm (6 agents, 1200-event buffers, 10 frames/s)", storm);
|
||||
|
||||
// ---- Scenario D: everything at once (storm + typing + messages) ----
|
||||
await page.evaluate(
|
||||
({ agentCount, typingIntervalMs, messageIntervalMs }) => {
|
||||
const w = window as unknown as {
|
||||
__BUZZ_E2E_EMIT_MOCK_TYPING__?: (input: {
|
||||
channelName: string;
|
||||
pubkey?: string;
|
||||
}) => unknown;
|
||||
__BUZZ_E2E_EMIT_MOCK_MESSAGE__?: (input: {
|
||||
channelName: string;
|
||||
content: string;
|
||||
}) => unknown;
|
||||
__BUSY_TIMERS__?: number[];
|
||||
};
|
||||
const pubkeys = Array.from({ length: agentCount }, (_, index) =>
|
||||
`a${index}`.repeat(32),
|
||||
);
|
||||
let tick = 0;
|
||||
const typingTimer = window.setInterval(() => {
|
||||
tick += 1;
|
||||
w.__BUZZ_E2E_EMIT_MOCK_TYPING__?.({
|
||||
channelName: "agents",
|
||||
pubkey: pubkeys[tick % pubkeys.length],
|
||||
});
|
||||
}, typingIntervalMs);
|
||||
let messageIndex = 0;
|
||||
const messageTimer = window.setInterval(() => {
|
||||
messageIndex += 1;
|
||||
w.__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({
|
||||
channelName: "agents",
|
||||
content: `**Progress ${messageIndex}**\n\n- step done\n- \`cargo check\` ok`,
|
||||
});
|
||||
}, messageIntervalMs);
|
||||
w.__BUSY_TIMERS__ = [typingTimer, messageTimer];
|
||||
},
|
||||
{
|
||||
agentCount: AGENT_COUNT,
|
||||
typingIntervalMs: TYPING_EMIT_INTERVAL_MS,
|
||||
messageIntervalMs: LIVE_MESSAGE_INTERVAL_MS,
|
||||
},
|
||||
);
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
await resetWindowMetrics(page);
|
||||
await typeBurst(page);
|
||||
const everything = await readWindowMetrics(page);
|
||||
log("storm + typing + messages", everything);
|
||||
|
||||
await page.evaluate(() => {
|
||||
const w = window as unknown as {
|
||||
__BUSY_TIMERS__?: number[];
|
||||
__STORM_TIMER__?: number;
|
||||
};
|
||||
for (const timer of w.__BUSY_TIMERS__ ?? []) {
|
||||
window.clearInterval(timer);
|
||||
}
|
||||
if (w.__STORM_TIMER__) window.clearInterval(w.__STORM_TIMER__);
|
||||
});
|
||||
|
||||
// ---- Scenario E: streaming agent into a full markdown timeline ----
|
||||
// The one O(rows) path is message-content change: each kind-40003 edit
|
||||
// re-runs formatTimelineMessages over the whole window and re-parses the
|
||||
// edited row's (growing) markdown. Agents streaming replies emit exactly
|
||||
// this shape at high frequency. Seed a realistic timeline first.
|
||||
await page.evaluate(
|
||||
({ rows }) => {
|
||||
const w = window as unknown as {
|
||||
__BUZZ_E2E_EMIT_MOCK_MESSAGE__?: (input: {
|
||||
channelName: string;
|
||||
content: string;
|
||||
createdAt?: number;
|
||||
}) => { id: string };
|
||||
};
|
||||
const base = Math.floor(Date.now() / 1000) - rows - 30;
|
||||
for (let index = 0; index < rows; index += 1) {
|
||||
w.__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({
|
||||
channelName: "agents",
|
||||
content: [
|
||||
`**Task ${index}** update from the build agent`,
|
||||
"```rust",
|
||||
`fn step_${index}() -> Result<(), Error> { run(${index}) }`,
|
||||
"```",
|
||||
`- [x] compile ${index}`,
|
||||
`- see [logs](https://example.com/${index})`,
|
||||
].join("\n"),
|
||||
createdAt: base + index,
|
||||
});
|
||||
}
|
||||
},
|
||||
{ rows: 50 },
|
||||
);
|
||||
await page.waitForTimeout(1500);
|
||||
|
||||
await page.evaluate(
|
||||
({ streamCount, prefillLines }) => {
|
||||
const w = window as unknown as {
|
||||
__BUZZ_E2E_EMIT_MOCK_MESSAGE__?: (input: {
|
||||
channelName: string;
|
||||
content: string;
|
||||
kind?: number;
|
||||
extraTags?: string[][];
|
||||
}) => { id: string };
|
||||
__STREAM_TIMERS__?: number[];
|
||||
};
|
||||
w.__STREAM_TIMERS__ = [];
|
||||
for (let stream = 0; stream < streamCount; stream += 1) {
|
||||
const target = w.__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({
|
||||
channelName: "agents",
|
||||
content: "Working on it…",
|
||||
});
|
||||
if (!target) continue;
|
||||
// Pre-grown reply: mid-stream in a long code fence, the realistic
|
||||
// worst case — every edit re-parses the whole accumulated content.
|
||||
const chunks: string[] = ["**Streaming reply**", "", "```ts"];
|
||||
for (let line = 0; line < prefillLines; line += 1) {
|
||||
chunks.push(`const step${line} = await runStep(${line});`);
|
||||
}
|
||||
let line = prefillLines;
|
||||
const streamTimer = window.setInterval(() => {
|
||||
line += 1;
|
||||
chunks.push(`const step${line} = await runStep(${line}); // live`);
|
||||
w.__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({
|
||||
channelName: "agents",
|
||||
content: `${chunks.join("\n")}\n\`\`\``,
|
||||
kind: 40003,
|
||||
extraTags: [["e", target.id]],
|
||||
});
|
||||
}, 120);
|
||||
w.__STREAM_TIMERS__?.push(streamTimer);
|
||||
}
|
||||
},
|
||||
{ streamCount: 3, prefillLines: 150 },
|
||||
);
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
await resetWindowMetrics(page);
|
||||
await typeBurst(page);
|
||||
const streaming = await readWindowMetrics(page);
|
||||
log(
|
||||
"3 streaming agents (150-line replies, edit every 120ms each)",
|
||||
streaming,
|
||||
);
|
||||
|
||||
await page.evaluate(() => {
|
||||
const w = window as unknown as { __STREAM_TIMERS__?: number[] };
|
||||
for (const timer of w.__STREAM_TIMERS__ ?? []) {
|
||||
window.clearInterval(timer);
|
||||
}
|
||||
});
|
||||
|
||||
// ---- Scenario F: typing in a long thread (the real-world repro) ----
|
||||
// Field report: a 68-reply thread with active agents is near-unusable to
|
||||
// type in (median 408ms, p95 8s+ per keystroke in WKWebView), while a
|
||||
// fresh thread in the same channel is instant — cost scales with the open
|
||||
// thread's reply count. Seed a 68-reply thread, open it, and type; then
|
||||
// repeat with agents streaming replies into the thread.
|
||||
const rootId = await page.evaluate(() => {
|
||||
const w = window as unknown as {
|
||||
__BUZZ_E2E_EMIT_MOCK_MESSAGE__?: (input: {
|
||||
channelName: string;
|
||||
content: string;
|
||||
parentEventId?: string;
|
||||
createdAt?: number;
|
||||
}) => { id: string };
|
||||
};
|
||||
const base = Math.floor(Date.now() / 1000) - 300;
|
||||
const root = w.__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({
|
||||
channelName: "agents",
|
||||
content: "**Deploy thread** — agents report here",
|
||||
createdAt: base,
|
||||
});
|
||||
if (!root) return null;
|
||||
for (let index = 0; index < 68; index += 1) {
|
||||
w.__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({
|
||||
channelName: "agents",
|
||||
content: [
|
||||
`**Reply ${index}** from agent`,
|
||||
"```rust",
|
||||
`fn check_${index}() -> bool { ${index} % 2 == 0 }`,
|
||||
"```",
|
||||
`- [x] validated step ${index}`,
|
||||
].join("\n"),
|
||||
parentEventId: root.id,
|
||||
createdAt: base + index + 1,
|
||||
});
|
||||
}
|
||||
return root.id;
|
||||
});
|
||||
expect(rootId).not.toBeNull();
|
||||
await page.waitForTimeout(1500);
|
||||
|
||||
const threadSummary = page.getByTestId("message-thread-summary").last();
|
||||
await threadSummary.scrollIntoViewIfNeeded();
|
||||
await threadSummary.click();
|
||||
const threadPanel = page.getByTestId("message-thread-panel");
|
||||
await expect(threadPanel).toBeVisible();
|
||||
await expect(threadPanel.getByTestId("message-row").first()).toBeVisible();
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
// F1: long thread, no traffic.
|
||||
await typeBurst(page, threadPanel); // warmup
|
||||
await resetWindowMetrics(page);
|
||||
await typeBurst(page, threadPanel);
|
||||
const threadQuiet = await readWindowMetrics(page);
|
||||
log("68-reply thread, quiet", threadQuiet);
|
||||
|
||||
// F2: agents streaming into the open thread while typing.
|
||||
await page.evaluate(
|
||||
({ rootEventId }) => {
|
||||
const w = window as unknown as {
|
||||
__BUZZ_E2E_EMIT_MOCK_MESSAGE__?: (input: {
|
||||
channelName: string;
|
||||
content: string;
|
||||
parentEventId?: string;
|
||||
kind?: number;
|
||||
extraTags?: string[][];
|
||||
}) => { id: string };
|
||||
__THREAD_TIMERS__?: number[];
|
||||
};
|
||||
// A new agent reply lands every 600ms…
|
||||
let replyIndex = 0;
|
||||
let lastReplyId: string | null = null;
|
||||
const replyTimer = window.setInterval(() => {
|
||||
replyIndex += 1;
|
||||
const reply = w.__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({
|
||||
channelName: "agents",
|
||||
content: `**Live reply ${replyIndex}**\n\n- working…`,
|
||||
parentEventId: rootEventId ?? undefined,
|
||||
});
|
||||
lastReplyId = reply?.id ?? lastReplyId;
|
||||
}, 600);
|
||||
// …and the latest reply streams edits every 150ms.
|
||||
const chunks: string[] = ["**Live reply**", "", "```ts"];
|
||||
let line = 0;
|
||||
const editTimer = window.setInterval(() => {
|
||||
if (!lastReplyId) return;
|
||||
line += 1;
|
||||
chunks.push(`const s${line} = await step(${line});`);
|
||||
w.__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({
|
||||
channelName: "agents",
|
||||
content: `${chunks.join("\n")}\n\`\`\``,
|
||||
kind: 40003,
|
||||
extraTags: [["e", lastReplyId]],
|
||||
});
|
||||
}, 150);
|
||||
w.__THREAD_TIMERS__ = [replyTimer, editTimer];
|
||||
},
|
||||
{ rootEventId: rootId },
|
||||
);
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
await resetWindowMetrics(page);
|
||||
await typeBurst(page, threadPanel);
|
||||
const threadBusy = await readWindowMetrics(page);
|
||||
log("68-reply thread + agents streaming into it", threadBusy);
|
||||
|
||||
await page.evaluate(() => {
|
||||
const w = window as unknown as { __THREAD_TIMERS__?: number[] };
|
||||
for (const timer of w.__THREAD_TIMERS__ ?? []) {
|
||||
window.clearInterval(timer);
|
||||
}
|
||||
});
|
||||
await client.send("Emulation.setCPUThrottlingRate", { rate: 1 });
|
||||
|
||||
/* eslint-disable no-console */
|
||||
console.log("\n=== SUMMARY (median / p95 / >50ms count / longtask) ===");
|
||||
for (const [label, report] of [
|
||||
["quiet ", quiet],
|
||||
["busy ", busy],
|
||||
["storm ", storm],
|
||||
["everything", everything],
|
||||
["streaming ", streaming],
|
||||
["thread68 ", threadQuiet],
|
||||
["thread68+ ", threadBusy],
|
||||
] as const) {
|
||||
console.log(
|
||||
`${label}: ${report.median.toFixed(0)} / ${report.p95.toFixed(0)} / ${report.over50} / ${report.longtaskTotal.toFixed(0)}ms`,
|
||||
);
|
||||
}
|
||||
/* eslint-enable no-console */
|
||||
|
||||
// Instrument, not a gate: confirm the harness measured real keystrokes.
|
||||
expect(quiet.count + busy.count + storm.count).toBeGreaterThan(0);
|
||||
});
|
||||
Reference in New Issue
Block a user