Summary
On sessions with many turns, tool outputs, code blocks, and/or a todo widget, resuming a session causes a visible and sometimes slow animated scroll from mid-conversation to the bottom. On very long sessions this can take several seconds. The root cause is structural: Clay builds the full message DOM sequentially on resume, and multiple async layout phases (syntax highlighting, tool widget reflows, image loads, IntersectionObserver-driven todo sticky behavior) all complete after the initial scroll-to-bottom attempt.
PR #342 (already submitted) mitigates the worst failure mode — landing and staying mid-page permanently — via a ResizeObserver-based sticky-bottom mechanism that chases layout as it settles. But it makes the scroll movement visible where before it was silent. The underlying settle time on long sessions is still significant.
What's happening
When history_done fires, the following work is still pending:
- Syntax highlighting —
hljs.highlightElement() runs per code block, triggered by a 150ms-debounced timer in the streaming path. A long session with many code blocks triggers many sequential reflows.
- Todo widget reflow — the
IntersectionObserver watching the todo widget triggers updateTodoSticky reflows after first paint. If the session ended with in_progress or pending items, the widget renders tall (full item list + spinner), adding substantial height mid-conversation.
- Mermaid diagram renders — async, fire whenever the library resolves.
- Image loads — async, each changes the scroller's scrollHeight.
- Tool widget finalization —
markAllToolsDone() iterates and closes tool groups, changing heights.
Each of these fires a ResizeObserver callback (with #342's fix), re-pins scrollTop = scrollHeight, and resets the 750ms quiet window. On a session with 50+ tool calls and a dozen code blocks, the observer can keep firing for 4–8 seconds (bounded only by the hard 8s ceiling).
Concrete compound: dead-session todo widget
When a session ends without a clean agent exit (e.g. interrupted, timed out), its last TodoWrite payload typically has in_progress or pending items. Every resume re-renders the full widget in that state — tall, with spinners for items that will never complete. This both:
- Anchors the initial visual position mid-page (the todo widget is a large, prominent element)
- Keeps extending the sticky-bottom window because the widget's IntersectionObserver fires late
The agent is never coming back to close those items. Rendering them as in_progress on every resume is incorrect for a dead session.
Proposed improvements
These are independent and can be done incrementally:
1. Skip per-turn syntax highlighting during replay; batch after history_done
The 150ms debounce timer in the streaming path runs per assistant block during replay. Instead, skip highlightCodeBlocks during replay (replayingHistory === true) and run one batched pass with requestIdleCallback after history_done. Reduces N sequential reflows to 1.
2. Compact todo widget on dead-session resume
After history_done, if the session has no live SDK process and the todo widget still has in_progress/pending items, render it collapsed (just N/M steps count, expandable on click) rather than the full item list. Eliminates the largest single source of late mid-page height. Does not rewrite on-disk state — display only.
3. content-visibility: auto on message bubbles
CSS-only. The browser skips layout/paint for off-screen elements, making scrollHeight settle faster because off-screen blocks do not block layout. Risk: scrollHeight becomes estimate-based, so the initial pin may land slightly off. Mitigated by pairing with a bottom-sentinel scrollIntoView.
4. Lower HISTORY_PAGE_SIZE for initial paint
Currently 200 items. A session with 200 JSONL entries and many code blocks builds a large DOM before history_done even fires. Lowering to 75–100 for initial paint (progressive history already loads older items on scroll-up) reduces initial layout work proportionally.
5. Longer-term: defer highlight to requestIdleCallback per block
Rather than synchronous hljs.highlightElement() in the main thread, queue each block to requestIdleCallback. The browser highlights when idle, never blocking scroll or paint. Would require marking un-highlighted blocks visually so they do not flash.
Relationship to PR #342
PR #342 fixes the correctness bug (wrong landing position, repeated clicks to reach bottom). This issue documents the remaining latency and proposes fixes that address the root causes. Items 1 and 2 above are the most impactful and least invasive — both are self-contained client-side changes with no server impact.
Environment
Reproduces on desktop Chromium and Firefox. Worst on sessions with 50+ turns, multiple code blocks, and/or a todo widget with unfinished items.
Summary
On sessions with many turns, tool outputs, code blocks, and/or a todo widget, resuming a session causes a visible and sometimes slow animated scroll from mid-conversation to the bottom. On very long sessions this can take several seconds. The root cause is structural: Clay builds the full message DOM sequentially on resume, and multiple async layout phases (syntax highlighting, tool widget reflows, image loads, IntersectionObserver-driven todo sticky behavior) all complete after the initial scroll-to-bottom attempt.
PR #342 (already submitted) mitigates the worst failure mode — landing and staying mid-page permanently — via a
ResizeObserver-based sticky-bottom mechanism that chases layout as it settles. But it makes the scroll movement visible where before it was silent. The underlying settle time on long sessions is still significant.What's happening
When
history_donefires, the following work is still pending:hljs.highlightElement()runs per code block, triggered by a 150ms-debounced timer in the streaming path. A long session with many code blocks triggers many sequential reflows.IntersectionObserverwatching the todo widget triggersupdateTodoStickyreflows after first paint. If the session ended within_progressorpendingitems, the widget renders tall (full item list + spinner), adding substantial height mid-conversation.markAllToolsDone()iterates and closes tool groups, changing heights.Each of these fires a
ResizeObservercallback (with #342's fix), re-pinsscrollTop = scrollHeight, and resets the 750ms quiet window. On a session with 50+ tool calls and a dozen code blocks, the observer can keep firing for 4–8 seconds (bounded only by the hard 8s ceiling).Concrete compound: dead-session todo widget
When a session ends without a clean agent exit (e.g. interrupted, timed out), its last
TodoWritepayload typically hasin_progressorpendingitems. Every resume re-renders the full widget in that state — tall, with spinners for items that will never complete. This both:The agent is never coming back to close those items. Rendering them as
in_progresson every resume is incorrect for a dead session.Proposed improvements
These are independent and can be done incrementally:
1. Skip per-turn syntax highlighting during replay; batch after
history_doneThe 150ms debounce timer in the streaming path runs per assistant block during replay. Instead, skip
highlightCodeBlocksduring replay (replayingHistory === true) and run one batched pass withrequestIdleCallbackafterhistory_done. Reduces N sequential reflows to 1.2. Compact todo widget on dead-session resume
After
history_done, if the session has no live SDK process and the todo widget still hasin_progress/pendingitems, render it collapsed (justN/M stepscount, expandable on click) rather than the full item list. Eliminates the largest single source of late mid-page height. Does not rewrite on-disk state — display only.3.
content-visibility: autoon message bubblesCSS-only. The browser skips layout/paint for off-screen elements, making
scrollHeightsettle faster because off-screen blocks do not block layout. Risk:scrollHeightbecomes estimate-based, so the initial pin may land slightly off. Mitigated by pairing with a bottom-sentinelscrollIntoView.4. Lower
HISTORY_PAGE_SIZEfor initial paintCurrently 200 items. A session with 200 JSONL entries and many code blocks builds a large DOM before
history_doneeven fires. Lowering to 75–100 for initial paint (progressive history already loads older items on scroll-up) reduces initial layout work proportionally.5. Longer-term: defer highlight to
requestIdleCallbackper blockRather than synchronous
hljs.highlightElement()in the main thread, queue each block torequestIdleCallback. The browser highlights when idle, never blocking scroll or paint. Would require marking un-highlighted blocks visually so they do not flash.Relationship to PR #342
PR #342 fixes the correctness bug (wrong landing position, repeated clicks to reach bottom). This issue documents the remaining latency and proposes fixes that address the root causes. Items 1 and 2 above are the most impactful and least invasive — both are self-contained client-side changes with no server impact.
Environment
Reproduces on desktop Chromium and Firefox. Worst on sessions with 50+ turns, multiple code blocks, and/or a todo widget with unfinished items.