Repository navigation
fix(app): stop dropping frames while a queued prompt grows in - #60216
Merged
Merged
Conversation
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.
opencode-agent
Bot
requested review from
Brendonovich and
Hona
as code owners
October 29, 2026 12:51
There was a problem hiding this comment.
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
@propertyvariables are now only read on the viewport, which is the elementbindListRootbinds 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
keepLeavingrewrite 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
enabled auto-merge (squash)
October 29, 2026 15:02
There was a problem hiding this comment.
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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
--session-timeline-inset, set on the timeline root (and--session-timeline-fade-startderived from it, also inherited). Each change made Chrome restyle every rendered row, inside the dock ResizeObserver, on every frame.grow()measured margins and heights while starting the queue row's grow.resume()read the scroll range to scroll to the end.renderComposerEditorreset the caret after clearing the editor. In Chrome, any selection call on a focused contenteditable forces layout, even a read.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 rowShowis no longer keyed, so rows update in place. Also cleaned up the file's existing lint findings (spacing, andkeepLeavingcopying its reduce accumulator), whichlint:changedrequires 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.
Testing
bun run typecheck,bun run typecheck:tscandbun run lint:changedinpackages/appall pass.src/composer,src/session,test-browser): 501 tests pass.PLAYWRIGHT_BUILD=1): every spec ine2e/regressionpassed, includingsession-queue,timeline-geometry(inset, fade and end-above-region geometry),composer,session-request-docks,mobile-timeline-scrollandtimeline-stream. It ran in three batches because the shared host's thread limit crashed one long run partway through.Requested by: kamil via Slack