Generated: 2026-05-18
Source: local OpenCode session archive (opencode db) plus GitHub PR metadata via gh.
Before: measured stable session timeline loads were roughly 2-4 seconds, with real-world bad sessions reported around 5-15 seconds.
After: the main virtualized path reached sub-500ms first useful timeline render. Later cache/anchor work made the bottom-aligned visual load appear in about 113ms on the measured cold path.
| Metric | Before | After |
|---|---|---|
Stable baseline Timeline Ready |
~1928ms |
~385ms |
| Worse clean baseline run | ~4098ms |
1219-1494ms from quick wins alone |
| Cold bottom-aligned visual settle | 807ms |
113ms |
| Revisit/cache bottom rows visible | 2417ms |
612ms |
| Icon route/session switch INP median | 857ms |
618ms |
Best headline sentence:
OpenCodeDesktop session timeline load went from roughly 2-4 seconds to sub-500ms first useful render, with later anchoring work making the bottom-aligned view appear in about 113ms on the measured cold path.
Core sessions found in the local OpenCode database:
| Session | Title | Brisbane time |
|---|---|---|
ses_1fa5b6ef9ffeVr5goC3tBBil8Y |
Electron tools: recording and analyzing Chrome performance trace | May 8 13:32 -> May 9 09:38 |
ses_1f6097408ffejXNZZgCpgOWu8r |
perf: Virtualised infinite scroll | May 9 09:40 -> May 12 11:47 |
ses_1ebf7fee7ffeWa8fn2oAzhe298 |
Opencode Timeline Perf: analysis & next steps planning | May 11 08:35 -> 10:02 |
ses_1eaabe311ffe5Lu6XrDYa6ubF2 |
perf: Quick wins | May 11 14:38 -> May 12 09:09 |
ses_1e62fb38dffegWEV7SJWQKxJXZ |
fix: Flicker on load | May 12 11:32 -> 11:41 |
ses_1e62e1625ffe3NVdzAbse0nNZC |
fix: ResizeObserver crash | May 12 11:34 -> May 15 12:11 |
ses_1dfcbc5d8ffe5CBmBKr292x6Qp |
refactor: icons thru a sprite | May 13 17:19 -> 17:56 |
Cross-checked PRs:
| PR | Status | Role |
|---|---|---|
anomalyco/opencode#26282 |
merged | ToolStatusTitle width measurement / FLIP attempt reverted |
anomalyco/opencode#26619 |
open draft | implementation-aware -> virtualization-aware smoke test |
anomalyco/opencode#26781 |
closed draft | first TanStack turn-level virtualization attempt |
anomalyco/opencode#26813 |
closed | deferred default-open heavy tool bodies / baseline hot path wins |
anomalyco/opencode#26949 |
open | main virtua row-level timeline virtualization PR |
anomalyco/opencode#26950 |
merged | icon sprite PR |
Hona/opencode#3 |
merged into virtua branch | restore virtual timeline cache |
Hona/opencode#4 |
merged into virtua branch | bottom anchor / cold load hint |
For about a week, I worked on one problem: opening large sessions in OpenCodeDesktop felt terrible.
Sometimes switching into an old session took one or two seconds. Sometimes it was worse: five, ten, even fifteen seconds before the app felt settled. The failure mode was not subtle. The UI would visibly replay itself, flicker from top to bottom, jank while scrolling, or hit one-frame flashes when streaming content grew.
The fun part: I did not hand-write the fixes myself.
I fixed OpenCodeDesktop using OpenCodeDesktop.
Every meaningful step came through the app I was trying to optimize: agents, local database queries, Chrome DevTools MCP, Electron MCP, Playwright traces, draft PRs, smoke tests, perf traces, GitHub review, and a lot of “stop guessing, measure it”.
This is the timeline.
The first question was basic:
do the electron_ tools also include a method to record a chrome performance trace, then view and analyse the full data?
At first, the answer was no. The Electron MCP tools could inspect windows, DOM, logs, and state, but they did not expose Chrome tracing.
Then I added Chrome DevTools MCP. That changed the whole investigation.
Within minutes, OpenCode could attach DevTools to the Electron app over the remote debugging port. That meant I could record real Chrome performance traces of the actual desktop app while switching sessions.
The first useful trace gave a clear shape:
| Metric | Value |
|---|---|
| Large session switch INP | 838ms |
| JS processing | 804ms |
| Input delay | 7ms |
| Presentation delay | 27ms |
| Forced reflow total | 361ms |
| DOM size | 8,895 elements |
The important part: the delay was not network. It was not input delay. It was the renderer doing synchronous work.
The first dumb hotspot was ToolStatusTitle.
It looked visually like static text: labels like “Explored”. But the component measured its rendered text width using Range.getBoundingClientRect(). During historical session mount, that forced layout repeatedly.
Measured cost:
| Hotspot | Cost |
|---|---|
ToolStatusTitle.contentWidth() |
187ms |
| Share of JS processing | 23.3% |
| Share of full INP | 22.3% |
| Share of forced reflow | 51.8% |
That was the first rule of the week:
Performance work is allowed to be boring. If 187ms disappears by not measuring old labels on mount, take the win.
A small PR deferred the measurement. A larger FLIP rewrite was attempted, then rejected as too much. The final change stayed small.
After that, ToolStatusTitle/contentWidth disappeared from the trace: 0ms.
But the session was still slow.
After the title fix, a proper DevTools trace on the stable dribble.tf testbed showed:
| Metric | Value |
|---|---|
| INP | 1662ms |
| Processing | 1627ms |
| Click task | 1618.4ms |
ToolStatusTitle/contentWidth |
0ms |
| Forced reflow total | 1434ms |
solid-presence getAnimationName |
1175ms |
| DOM elements | 26,193 |
This was the second rule:
Removing a hotspot does not mean the app is fast. It means the next bottleneck is finally visible.
The next bottleneck was Kobalte / solid-presence doing animation-presence checks during historical mount. It was reading computed style and animation state across a huge DOM. That work made sense for live animated UI. It did not make sense for rendering a giant historical chat transcript.
At this point, the perf work became disciplined:
- pick a stable session, not the live session I kept adding messages to
- use the same source session and target session
- reset zoom
- normalize viewport
- mark “Timeline Ready”
- mark “DOM Settled”
- save traces every time
The chosen testbed became a large dribble.tf session:
GitHub CLI: sticky jump movement & physics analysis for Momentum Mod project
The benchmark repo tracked two north-star metrics:
| Metric | Meaning |
|---|---|
Timeline Ready |
click -> first useful target timeline row exists |
DOM Settled |
click -> DOM quiet, excluding quiet-window padding |
Secondary metrics included TBT, Max Long Task, Main Thread Work, and Worker Main Handoff.
Early windowing experiments produced seductive numbers.
One run hit:
| Metric | Value |
|---|---|
| Timeline Ready | 71.6ms |
| DOM Settled | 137.6ms |
| TBT | 14.1ms |
Another fake part-windowing experiment got first useful render down to 76.8ms.
But the UI was wrong.
There was flicker. There were weird scrollbars. The viewport could show unloaded gaps. There were proposed “Load details” / “Show earlier steps” controls. Those were rejected.
The core constraint became explicit:
No benchmark-only hacks.
No fake controls.
No crude spacer pretending full history height is known.
The UI must look the same.
That constraint saved the work.
The goal was not to win a benchmark. The goal was production-grade session timeline performance.
The first serious virtualizer attempt used @tanstack/solid-virtual.
That made sense. OpenCode already used TanStack, Solid support existed, and TanStack Virtual is a respected low-level engine.
A draft PR was created:
#26781—perf(app): virtualize session timeline turns
PR body summary:
- turn-level session timeline virtualizer
- hash reveal through virtual window
- reduced timeline/dev overhead
- smoke path passing
- guardrail still failing on long tasks
Headline numbers looked promising:
| Metric | Baseline | Perf branch |
|---|---|---|
| Timeline Ready | 1928ms |
321ms |
| DOM Settled | 2057ms |
1349ms |
| TBT | 1603ms |
354ms |
But the branch felt bad.
The headed smoke test and traces showed 1-2 FPS states, scroll bounce, hash reveal trouble, huge long tasks, and layout thrashing.
The likely causes included:
- stale
data-index - virtualizer measurement contention
- nested diff virtualizer contention
- scroll anchoring disabled or fighting the app
- row object identity churn
- turn-level virtualization being the wrong abstraction
The key realization:
A “turn” is not the real render unit.
One user message can produce one assistant response, or it can produce 80 tool calls, edits, shell outputs, diffs, and streaming parts. Virtualizing by turn means a single virtual row can still contain hours of content.
OpenCode’s real data model is messages and parts. The UI concept of a “turn” is derived.
That meant the timeline had to move lower.
Before continuing with virtualization, a smoke test was built.
Draft PR:
#26619—test(app): add session timeline smoke coverage
The test generated a representative session fixture covering:
- text
- reasoning
- grouped context tools
- edits
- shell
- web
- question
- task parts
It then scrolled through the timeline and asserted that mounted / visible parts appeared in order.
This mattered because virtualization bugs often pass normal tests. The DOM is allowed to unmount offscreen content, so the test had to become virtualization-aware without becoming implementation-aware.
The final direction was:
- do not require all offscreen tools to be mounted immediately
- scroll through the whole history
- accumulate what becomes visible/mounted
- assert order
- assert product behavior, not implementation internals
That smoke test became the safety net for the actual virtualization branch.
After TanStack, the work shifted to virtua.
The reasoning was practical. virtua had Solid support and primitives that better matched chat/timeline needs:
- dynamic heights
- reverse/prepend behavior
shiftscrollRefkeepMounted- automatic measurement through ResizeObserver
The first virtua pass was still turn-level. It mounted only three rows, but first content was still around 1.5-1.6s.
Then the quick-win deferred tool-body PR was layered in.
PR:
#26813—perf(ui): defer default-open tool bodies
PR numbers:
| Metric | Clean baseline | With PR |
|---|---|---|
| Timeline Ready | ~4098ms |
1219-1494ms |
| Max Long Task | ~3267ms |
1157-1414ms |
This changed default-open heavy tool bodies so they no longer mounted during initial session render. Importantly, the initial implementation mounted deferred bodies FIFO, but the bottom of the timeline matters most. The queue was changed so the newest/bottom content mounted first.
That was a good example of product-aware performance work: not “render less forever”, but “render what the user is actually looking at first”.
Then virtua moved to row/part-level.
Main PR:
#26949—perf(app): virtualize session timeline rows
PR summary:
- upgrade
virtua - virtualize at timeline-row granularity, not full turns
- flatten loaded user/assistant messages into row keys
- preserve message anchors and hash reveal
- keep prepend loading, context groups, diffs, retry/thinking/error rows
- keep existing message-page API for now
- add TODOs for future cursor-paged timeline parts
This was the real architectural turn.
The app was still loading message pages from the existing API, but the UI stopped treating whole turns as the virtual row. It built a flattened row model over messages and parts.
The smoke test passed on both branches:
| Branch | Smoke result |
|---|---|
latest upstream/dev |
2.3m |
| virtua branch | 1.4m |
While profiling route/session switch INP, another hotspot appeared: icons.
The app rendered inline SVGs by injecting innerHTML per icon. In traces, icon rendering showed up as a measurable synchronous CPU bucket.
PR:
#26950—perf(ui): render icons through an svg sprite
Benchmark from the PR:
| Metric | upstream/dev | PR | Delta |
|---|---|---|---|
| INP median | 857ms |
618ms |
-28% |
| Icon CPU median | 44ms |
18ms |
-59% |
Follow-up validation found the per-icon getElementById concern was real but tiny: roughly 0.2-0.3ms across ~200 lookups. A follow-up cached repeated lookup work.
Smoke validation later showed:
| Check | Value |
|---|---|
| Sprite symbols | 95 |
| Icon uses | 129 |
| Unresolved uses | 0 |
| Lookup after transition | 0 |
This was not the whole performance story. But it was a clean measured win.
Then came the stability blocker.
Creating a new session / sending a prompt could crash with:
TypeError: Cannot read properties of null (reading 'ResizeObserver')Root cause: virtua@0.49.1 called through to something equivalent to:
getCurrentWindow(getCurrentDocument(element)).ResizeObserverBut in the crash case, ownerDocument.defaultView was null.
So the virtualizer was observing a root tied to a document/window that no longer existed.
The fix was not to patch around random symptoms. The branch was refactored so the virtualizer only mounted when it had a valid connected scroll root. This landed as:
dcbe29c7c6—fix(app): stabilize session timeline virtualization12c6c0925f—fix(app): guard virtualizer scroll root
This made the virtualization usable instead of just fast.
The next class of bugs was more subtle: flicker.
On session load, the timeline could briefly start at the top or at a bad estimated height, then correct downward as measurements arrived. To a human, it looked like the app was replaying the session from top to bottom.
The first fix restored measured virtual timeline cache.
Fork PR:
Hona/opencode#3—fix(app): restore virtual timeline cache
Measured result:
| Metric | Baseline | Cache PR |
|---|---|---|
| Time to bottom rows after route | ~2417ms |
~612ms |
| Height correction | 4160 -> 10306 -> 12598 -> 13622 |
11477 stable |
Then the branch anchored initial mount to the bottom when in normal follow-bottom mode.
Fork PR:
Hona/opencode#4—fix(app): anchor virtual timeline to bottom
Cold-load result:
| Metric | Baseline | PR |
|---|---|---|
| First bottom-aligned sample | 807ms |
113ms |
rAF samples at scrollTop=0 |
1 |
0 |
| Height correction | 2600 -> 9969 -> 11488 -> 12126 |
3900 -> 8444 |
This removed the obvious load-time top-position flash.
But streaming introduced another version of the same problem.
When AI text streamed and wrapped to a new line, the row height changed. When shell/edit/tool rows expanded from a compact pending state into full content, the row height changed more dramatically.
Headless repros measured it:
| Scenario | Before |
|---|---|
| Text streaming | one-frame 34-35px bottom gaps |
| Shell expansion | maxBottomGap: 132, framesOver8: 1 |
| Edit insertion | maxBottomGap: 2081, framesOver8: 1 |
The right product behavior was simple:
If the user is bottom-anchored and content grows at the bottom, keep them at the bottom.
For text streaming, using the public virtua API to scroll the last row into align: "end" on active content changes got:
| Metric | After |
|---|---|
maxBottomGap |
0 |
framesOver8 |
0 |
For shell/edit expansion, a small commented workaround for virtua issue #301 was accepted: a short rAF bottom-lock loop while measured content resized.
After validation:
| Scenario | After |
|---|---|
| Shell expansion | maxBottomGap: 0, framesOver8: 0 |
| Edit expansion | maxBottomGap: 0, framesOver8: 0 |
This is the one place I would explicitly call out as a workaround, but it was not hidden: it was commented, tied to an upstream virtua issue, measured before/after, and narrowly scoped.
Another blocker was collapse state.
If a tool or edit was visible and I manually collapsed it, a new streamed part could cause that row to remount and reset to the default open state.
Root cause: keyed rendering was tied to changing row object identity / metadata instead of stable row identity.
A row could change from something like:
lastAssistantPart: trueto:
lastAssistantPart: falseand Solid would treat it as a new keyed child.
The fix was stable row identity.
Commits included:
4a72af3ed7—fix(app): preserve virtual timeline row state9b59d5fb31—fix(app): preserve timeline row state
The stated product goal was narrow and correct:
I do not care about collapse state after unmounting by scrolling away.
I care that if it is on screen and currently rendered, a new chat message should not reset it.
That is exactly the kind of constraint that keeps virtualization sane.
The most useful parts of this week were not just the wins. They were the rejected paths.
The active OpenCode session kept changing because I kept prompting in it. That polluted comparisons. The benchmark moved to a stable dribble.tf session.
A larger animation rewrite for ToolStatusTitle was created, then reverted. It was too much for the measured problem.
Any visible control that changed the product behavior was rejected.
They hit amazing numbers — even 76.8ms first useful render — but made the scrollbar and viewport wrong.
The TanStack branch produced good headline numbers, but failed the actual product feel: 1-2 FPS, bounce, long tasks, hash reveal issues.
A min-height guess could reduce some correction, but it also caused visible buffers and became maintenance debt. The better model was bottom-locking when the user is already at the bottom.
When upstream SDK/event changes broke typecheck, the tempting local workaround was rejected. The branch merged latest dev after the upstream fix instead.
All times Brisbane / AEST.
| Time | Milestone |
|---|---|
| May 8 13:32 | Asked whether Electron tools could record Chrome traces |
| May 8 13:35 | Chrome DevTools MCP configured |
| May 8 afternoon | First trace: 838ms INP, 187ms in ToolStatusTitle |
| May 8 | #26282 merged: defer tool status width measurement |
| May 8 evening | Next trace: 1662ms INP, 1175ms solid-presence |
| May 9 09:40 | Main virtualization session starts |
| May 9 | Benchmark repo and metrics: Timeline Ready, DOM Settled |
| May 9 | Early fast hacks rejected for flicker / bad scrollbar |
| May 9-11 | TanStack attempt built, measured, draft PR #26781, later abandoned |
| May 10-11 | Smoke test PR #26619 created |
| May 11 | Quick-win PR #26813: deferred default-open tool bodies |
| May 12 | Virtua branch PR #26949: row-level timeline virtualization |
| May 12 | Icon sprite PR #26950: INP median 857ms -> 618ms |
| May 12 | ResizeObserver crash root-caused |
| May 13 | Cache and bottom-anchor fixes merged into virtua branch |
| May 14-15 | Row state, streaming anchor, resize bottom-lock fixes |
| May 15 | Latest upstream merged; SDK workaround reverted; branch healthy again |
The actual technical fix was not “use virtualization”.
The fix was building a truth pipeline:
- Attach DevTools to the real Electron renderer.
- Pick a stable session.
- Measure the same interaction repeatedly.
- Name the product metrics.
- Preserve screenshots and traces.
- Reject benchmark-only improvements.
- Add smoke coverage that encodes product behavior.
- Iterate on the smallest measured bottleneck.
- Keep using the app being fixed to fix itself.
The final architecture was not clever. It was less clever than the bad attempts.
- Flat row model.
- Stable keys.
- Real measured DOM.
- Bottom-follow as a first-class behavior.
- No fake controls.
- No fake fixed spacers.
- Heavy content deferred only where product behavior allows it.
- Cache measured row heights.
- Let the virtualizer do virtualizer things.
That is the part I like most: the best version was the most boring one.
OpenCodeDesktop got faster because the process got stricter. Every time I guessed, I was wrong or half-right. Every time I measured, the next step was obvious.