Skip to content

fix(app): stop dropping frames while a queued prompt grows in - #60216

Merged
usrnk1 merged 3 commits into
devfrom
fix/timeline-inset-restyle
Oct 29, 2026
Merged

usrnk1 merged 3 commits into
devfrom
fix/timeline-inset-restyle

Conversation

@opencode-agent

Copy link
Copy Markdown
Contributor

What

Adding a prompt to the queue dropped frames during the queue panel's grow animation, even on fast hardware (reported on an M5 Pro). This PR removes the per-frame timeline restyle the grow caused and the forced layouts in the submit key handler.

Why it dropped frames

  1. Per-frame timeline restyle. The composer region's height changes on every frame of a dock's grow, so the timeline inset changes too. The inset was the inherited custom property --session-timeline-inset, set on the timeline root (and --session-timeline-fade-start derived from it, also inherited). Each change made Chrome restyle every rendered row, inside the dock ResizeObserver, on every frame.
  2. Forced layouts in the submit key handler. Right after the submit changed the DOM, three reads forced synchronous layouts:
    • grow() measured margins and heights while starting the queue row's grow.
    • resume() read the scroll range to scroll to the end.
    • renderComposerEditor reset the caret after clearing the editor. In Chrome, any selection call on a focused contenteditable forces layout, even a read.
  3. Queue rows remounted. queue.rows() rebuilds every row object, and the row's <Show keyed> remounted every existing row (Kobalte tooltips included) on each add, and again when the server echo confirmed the prompt.

Changes

  • index.css, virtualizer.tsx: register both inset variables with @property { inherits: false }, set the inset variable only on the viewport (its mask and scroll padding read it), and give the jump button, inset spacer and visible-area overlay inline pixel values.
  • spring-height.tsx: grow() only writes when it starts, then measures in the frame loop's read phase on the first frame, before that frame paints.
  • interaction.ts: resume() pins immediately and scrolls to the end on the next frame, before paint.
  • editor.tsx: skip the caret reset when the rendered prompt is empty. Emptying the editor already collapses the caret to its start, which is also its end.
  • queue-panel.tsx: the row Show is no longer keyed, so rows update in place. Also cleaned up the file's existing lint findings (spacing, and keepLeaving copying its reduce accumulator), which lint:changed requires for any touched file.

Measurements

These come from a production build of the real session page against the e2e mock server, in headless Chromium at 60Hz. Each run queues 6 prompts with Enter into a running session with 30 message pairs. Dropped frames are frame gaps over 20ms in the 700ms after Enter.

  • No CPU throttling, 3 runs per build:
    • Dropped frames per 6 adds: before 7, 7 and 11; after 2, 1 and 2.
    • Keydown handler processing time: before 15–37ms (median about 24ms); after 9–21ms (median about 12ms). The first add is the slowest because it mounts the panel.
    • Dock ResizeObserver callback per frame: before about 9.9ms on average (max 28ms); after about 1ms.
  • 4× CPU throttle:
    • Frames rendered in the 700ms after Enter: before 9–30 per add (median about 14); after 29–40 (median about 34). With the inset updates stubbed out entirely, the same harness reaches 33–37.
    • Keydown processing time: before 55–101ms; after 29–83ms.

Testing

  • bun run typecheck, bun run typecheck:tsc and bun run lint:changed in packages/app all pass.
  • App unit and browser tests (src/composer, src/session, test-browser): 501 tests pass.
  • Built e2e regression suite (PLAYWRIGHT_BUILD=1): every spec in e2e/regression passed, including session-queue, timeline-geometry (inset, fade and end-above-region geometry), composer, session-request-docks, mobile-timeline-scroll and timeline-stream. It ran in three batches because the shared host's thread limit crashed one long run partway through.
  • Not run: the Storybook component tests. Their web server can't start threads under the same host limit.

Requested by: kamil via Slack

The timeline inset changes on every frame of a composer dock's grow. It was an inherited CSS variable on the timeline root, so each frame restyled every rendered row. The variable now lives only on the viewport and does not inherit.

Submitting a prompt also forced layouts inside the key handler: the queue row's grow measured itself, the timeline read its scroll range to pin to the end, and the editor reset a caret it had already collapsed. Those reads now happen on the next frame or are skipped. Queue rows no longer remount on every queue change.

@opencodejr opencodejr Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The change looks sound. I couldn't measure the frame drops, because the sandbox has no Chromium, so I reviewed the code and ran the app's checks: tsc -b passes, and the composer/session unit tests (250) and browser tests (251) pass.

  • The non-inheriting @property variables are now only read on the viewport, which is the element bindListRoot binds and the one the mask and scroll-padding CSS target. The jump button, inset spacer and visible-area overlay get inline pixel values, so nothing that read the inherited variable is left without it.
  • Moving grow()'s measurements into the frame loop's read phase is correct: frameLoop.add(job, read) runs reads before writes, and the margins and height are written before the first paint.
  • Attachments render no DOM, so skipping the caret reset for an attachment-only prompt is safe.
  • The unkeyed queue row and the keepLeaving rewrite keep the same behaviour.
  • With resume() scrolling on the next frame, that frame's scroll-state read sees the old position, but the scroll that follows corrects it.

Public API: unchanged (only internal app CSS and components).

@usrnk1 usrnk1 self-assigned this Oct 29, 2026

@opencodejr opencodejr Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new commit only merges dev into the branch and doesn't touch any of the six files this pull request changes, so my previous review still applies. tsc -b in packages/app still passes. Nothing new to raise. Public API: unchanged.

@usrnk1
usrnk1 enabled auto-merge (squash) October 29, 2026 15:02

@opencodejr opencodejr Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new commit only merges dev, so this pull request's own changes are as I last reviewed them. The queue row changes from #60235 still fit the unkeyed queue row, since it reads each row's fields through entry(). tsc -b and the src/session/composer tests pass. Nothing new to raise. Public API: unchanged.

@usrnk1
usrnk1 merged commit 304e835 into dev Oct 29, 2026
20 checks passed
@usrnk1
usrnk1 deleted the fix/timeline-inset-restyle branch October 29, 2026 15:09
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant