Skip to content

Commit e05c5ef

Browse files
marthakellyclaude
andcommitted
perf(inspector): keep tab DOM mounted, cache panel templates, defer off-screen events
Three layered fixes for the tab-switch jank on threads with many AG-UI events. Symptoms: clicking back to a previously-opened tab took roughly a second per switch on a thread with several hundred recorded events, even though the underlying data was already cached. 1. Keep activated tab panels mounted. The render conditional swapped between renderConversation/renderState/renderEvents based on `_tab`, so Lit tore down the previous panel's DOM and rebuilt the next one from scratch on every switch. Now once a tab is activated, its panel stays mounted and inactive panels are hidden via `display:none`. Activated set resets on threadId change. 2. Memoize per-panel TemplateResults by data reference. Even with the panel mounted, render() still re-evaluated the template on every parent update, allocating fresh nested TemplateResults for every event row. Each render now returns the cached TemplateResult when `_conversation` / `_fetchedState` / events array references haven't changed; Lit then short-circuits the entire diff. 3. Defer layout for off-screen events with `content-visibility: auto` plus a `contain-intrinsic-size` hint. The cached-data switch back to the events panel still triggered a full layout pass over every recorded event, which on a 600-event thread shows up as a seconds- long freeze when the panel becomes visible. The browser now skips layout/paint for off-screen rows entirely. Also adds a WeakMap memo around `highlightedJson` so identical event payloads don't re-run JSON.stringify + the syntax-highlight regex pass on every render. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent ce00492 commit e05c5ef

1 file changed

Lines changed: 156 additions & 19 deletions

File tree

packages/web-inspector/src/index.ts

Lines changed: 156 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -269,7 +269,26 @@ function escapeHtml(s: string): string {
269269
return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
270270
}
271271

272+
// Memoize highlight output by payload reference. Tab switches cause Lit to
273+
// re-render the active panel from scratch, and `highlightedJson` is by far
274+
// the most expensive thing in the events / state panels (JSON.stringify +
275+
// regex pass over potentially MB of agent state). Caching by object reference
276+
// turns subsequent renders of an unchanged event list into near-zero JS work.
277+
const highlightedJsonCache = new WeakMap<object, string>();
278+
272279
function highlightedJson(obj: unknown): string {
280+
if (typeof obj === "object" && obj !== null) {
281+
const cached = highlightedJsonCache.get(obj);
282+
if (cached !== undefined) return cached;
283+
}
284+
const result = highlightedJsonImpl(obj);
285+
if (typeof obj === "object" && obj !== null) {
286+
highlightedJsonCache.set(obj, result);
287+
}
288+
return result;
289+
}
290+
291+
function highlightedJsonImpl(obj: unknown): string {
273292
const colors = {
274293
key: "#5558B2",
275294
str: "#189370",
@@ -664,6 +683,7 @@ class CpkThreadDetails extends LitElement {
664683
_eventsNotAvailable: { state: true },
665684
_stateNotAvailable: { state: true },
666685
_panelInitializing: { state: true },
686+
_activatedTabs: { state: true },
667687
};
668688

669689
threadId: string | null = null;
@@ -705,6 +725,37 @@ class CpkThreadDetails extends LitElement {
705725
* sees the click as unresponsive for seconds.
706726
*/
707727
private _panelInitializing = false;
728+
/**
729+
* Tabs that have been opened at least once for the current thread. Once a
730+
* tab is activated, its rendered DOM stays mounted (we hide inactive tabs
731+
* via display:none) so flipping back to it is just a CSS swap rather than
732+
* tearing down and rebuilding the entire panel from scratch. Without this,
733+
* switching back to AG-UI Events on a thread with hundreds of events
734+
* triggers a multi-second DOM-creation pass each time.
735+
*
736+
* Reset to {"conversation"} when the selected thread changes.
737+
*/
738+
private _activatedTabs: Set<ThreadDetailsTab> = new Set(["conversation"]);
739+
/**
740+
* Memoized per-panel templates keyed by the inputs they render from.
741+
* When the underlying data hasn't changed (same `_conversation` /
742+
* `_fetchedState` / events array reference), we return the previously
743+
* built TemplateResult. Lit then sees "same template, same values" and
744+
* skips the diff entirely, so re-rendering on tab switch is near-zero
745+
* work even when the panel content is large.
746+
*/
747+
private _conversationTplCache: {
748+
items: ConversationItem[];
749+
tpl: ReturnType<typeof html>;
750+
} | null = null;
751+
private _stateTplCache: {
752+
state: Record<string, unknown> | null;
753+
tpl: ReturnType<typeof html>;
754+
} | null = null;
755+
private _eventsTplCache: {
756+
events: ApiAgentEvent[];
757+
tpl: ReturnType<typeof html>;
758+
} | null = null;
708759
/**
709760
* Tracks whether we've fetched events for the current thread yet. Events
710761
* fetch lazily on first sub-tab click so a large response's JSON.parse
@@ -1122,6 +1173,18 @@ class CpkThreadDetails extends LitElement {
11221173
border: 1px solid #e9e9ef;
11231174
border-radius: 6px;
11241175
overflow: hidden;
1176+
/*
1177+
* content-visibility: auto lets the browser skip layout + paint for
1178+
* off-screen events while keeping them in the DOM (so scroll size
1179+
* stays correct). Without this, switching back to AG-UI Events on a
1180+
* thread with hundreds of events triggers a full layout pass over
1181+
* every event row, which on Martha's intelligence-backed example
1182+
* shows up as a multi-second freeze each time the panel becomes
1183+
* visible. The intrinsic-size hint avoids the visible jump as the
1184+
* browser swaps in real heights when items scroll into view.
1185+
*/
1186+
content-visibility: auto;
1187+
contain-intrinsic-size: 0 80px;
11251188
}
11261189
11271190
.cpk-td__event-header {
@@ -1267,6 +1330,10 @@ class CpkThreadDetails extends LitElement {
12671330
this._lastFetchedThreadId = this.threadId;
12681331
this._lastSeenLiveMessageVersion = this.liveMessageVersion;
12691332
this._tab = "conversation";
1333+
this._activatedTabs = new Set(["conversation"]);
1334+
this._conversationTplCache = null;
1335+
this._stateTplCache = null;
1336+
this._eventsTplCache = null;
12701337
this._expandedTools = new Set();
12711338
this._expandedMessages = new Set();
12721339
this._messagesAbort?.abort();
@@ -1695,15 +1762,29 @@ class CpkThreadDetails extends LitElement {
16951762
}"
16961763
@click=${() => {
16971764
if (this._tab === tab.id) return;
1765+
const isFirstActivation = !this._activatedTabs.has(
1766+
tab.id,
1767+
);
16981768
this._tab = tab.id;
1699-
// Show a generic spinner for one paint frame before the
1700-
// heavy per-tab render runs, so the active-tab highlight
1701-
// is visible immediately even when the panel content
1702-
// (e.g. a large events list) takes seconds to render.
1703-
this._panelInitializing = true;
1704-
requestAnimationFrame(() => {
1705-
this._panelInitializing = false;
1706-
});
1769+
if (isFirstActivation) {
1770+
// First time opening this tab: paint a "Loading…"
1771+
// overlay for one frame so the tab highlight appears
1772+
// before the heavy per-tab render runs (events list,
1773+
// state JSON). The next rAF mounts the panel and the
1774+
// one after clears the spinner. Subsequent activations
1775+
// are pure CSS toggles via display:none on the
1776+
// already-mounted panel — no re-render required.
1777+
this._panelInitializing = true;
1778+
requestAnimationFrame(() => {
1779+
this._activatedTabs = new Set([
1780+
...this._activatedTabs,
1781+
tab.id,
1782+
]);
1783+
requestAnimationFrame(() => {
1784+
this._panelInitializing = false;
1785+
});
1786+
});
1787+
}
17071788
// Lazy-trigger the events / state fetches so their
17081789
// (potentially huge) JSON.parse only blocks the main
17091790
// thread after the user has shown intent to view that
@@ -1743,11 +1824,46 @@ class CpkThreadDetails extends LitElement {
17431824
? html`
17441825
<div class="cpk-td__status">Loading…</div>
17451826
`
1746-
: this._tab === "conversation"
1747-
? this.renderConversation()
1748-
: this._tab === "agent-state"
1749-
? this.renderState()
1750-
: this.renderEvents()
1827+
: nothing
1828+
}
1829+
${
1830+
this._activatedTabs.has("conversation")
1831+
? html`<div
1832+
style=${
1833+
this._tab === "conversation" && !this._panelInitializing
1834+
? ""
1835+
: "display:none"
1836+
}
1837+
>
1838+
${this.renderConversation()}
1839+
</div>`
1840+
: nothing
1841+
}
1842+
${
1843+
this._activatedTabs.has("agent-state")
1844+
? html`<div
1845+
style=${
1846+
this._tab === "agent-state" && !this._panelInitializing
1847+
? ""
1848+
: "display:none"
1849+
}
1850+
>
1851+
${this.renderState()}
1852+
</div>`
1853+
: nothing
1854+
}
1855+
${
1856+
this._activatedTabs.has("ag-ui-events")
1857+
? html`<div
1858+
style=${
1859+
this._tab === "ag-ui-events" && !this._panelInitializing
1860+
? ""
1861+
: "display:none"
1862+
}
1863+
>
1864+
${this.renderEvents()}
1865+
</div>`
1866+
: nothing
17511867
}
17521868
</div>
17531869
</div>
@@ -1794,8 +1910,7 @@ class CpkThreadDetails extends LitElement {
17941910
${this._messagesError}
17951911
</div>`;
17961912
}
1797-
const items = this.renderItems;
1798-
if (items.length === 0) {
1913+
if (this._conversation.length === 0) {
17991914
return html`
18001915
<div class="cpk-td__empty-state">
18011916
<svg
@@ -1814,7 +1929,18 @@ class CpkThreadDetails extends LitElement {
18141929
</div>
18151930
`;
18161931
}
1817-
return html`${items.map((item) => this.renderRenderItem(item))}`;
1932+
// Reuse the cached TemplateResult when the underlying message list
1933+
// hasn't changed. This is the hot path for tab switches: re-running
1934+
// the iteration over `_conversation` and creating fresh TemplateResults
1935+
// for every item is many ms of pure JS work, even though the data is
1936+
// identical to the previous render.
1937+
if (this._conversationTplCache?.items === this._conversation) {
1938+
return this._conversationTplCache.tpl;
1939+
}
1940+
const items = this.renderItems;
1941+
const tpl = html`${items.map((item) => this.renderRenderItem(item))}`;
1942+
this._conversationTplCache = { items: this._conversation, tpl };
1943+
return tpl;
18181944
}
18191945

18201946
private renderRenderItem(item: RenderItem) {
@@ -2027,9 +2153,15 @@ ${unsafeHTML(highlightedJson(item.result))}</pre
20272153
</div>
20282154
`;
20292155
}
2030-
return html`<pre class="cpk-td__json-block">
2031-
${unsafeHTML(highlightedJson(this.activeState))}</pre
2156+
const stateValue = this.activeState;
2157+
if (this._stateTplCache?.state === stateValue) {
2158+
return this._stateTplCache.tpl;
2159+
}
2160+
const tpl = html`<pre class="cpk-td__json-block">
2161+
${unsafeHTML(highlightedJson(stateValue))}</pre
20322162
>`;
2163+
this._stateTplCache = { state: stateValue, tpl };
2164+
return tpl;
20332165
}
20342166

20352167
private renderEvents() {
@@ -2065,7 +2197,10 @@ ${unsafeHTML(highlightedJson(this.activeState))}</pre
20652197
</div>
20662198
`;
20672199
}
2068-
return html`${events.map((event) => {
2200+
if (this._eventsTplCache?.events === events) {
2201+
return this._eventsTplCache.tpl;
2202+
}
2203+
const tpl = html`${events.map((event) => {
20692204
const { bg, fg } = eventColors(event.type);
20702205
return html`
20712206
<div class="cpk-td__event">
@@ -2083,6 +2218,8 @@ ${unsafeHTML(highlightedJson(event.payload))}</pre
20832218
</div>
20842219
`;
20852220
})}`;
2221+
this._eventsTplCache = { events, tpl };
2222+
return tpl;
20862223
}
20872224

20882225
private renderPanelToggle() {

0 commit comments

Comments
 (0)