@@ -269,7 +269,26 @@ function escapeHtml(s: string): string {
269269 return s . replace ( / & / g, "&" ) . replace ( / < / g, "<" ) . replace ( / > / g, ">" ) ;
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+
272279function 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