Skip to content

Session resume scroll performance degrades on long sessions — deferred layout causes visible bottom-chase #345

@akuehner

Description

@akuehner

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:

  1. Syntax highlightinghljs.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.
  2. 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.
  3. Mermaid diagram renders — async, fire whenever the library resolves.
  4. Image loads — async, each changes the scroller's scrollHeight.
  5. Tool widget finalizationmarkAllToolsDone() 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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions