Skip to content

cmux-next iPhone Feed: UIKit rows laid out off the main thread (hitches 50 → 1–3 ms/s) - #24683

Merged
azooz2003-bit merged 13 commits into
feat-cmux-nextfrom
feat-cmux-next-ios-feed-uikit
Oct 24, 2026
Merged

azooz2003-bit merged 13 commits into
feat-cmux-nextfrom
feat-cmux-next-ios-feed-uikit

Conversation

@azooz2003-bit

@azooz2003-bit azooz2003-bit commented Oct 24, 2026 •

Copy link
Copy Markdown
Collaborator

Summary

The Feed's rows are now UIKit: a UICollectionView with a diffable data source keyed by event id, and cells made of plain UIKit views (no SwiftUI hosting in rows). Each row's layout (every frame and attributed string) is computed off the main thread and kept until its event or row state changes. Markdown bodies are native attributed text, code cards and tables using the web renderer's metrics; only math still uses the pooled web view, once scrolling settles. Replaces #24467, whose SwiftUI trimming showed no gain.

Why: each SwiftUI row that scrolled in cost 14–20 ms of main-thread work (SwiftUI building and measuring the whole row tree), about one per row at fling speed. A store update also re-walked all ~300 rows.

What stays the same: the reply box (including terminal-agent replies typed into the terminal), question cards with drafts, Allow/Deny with Face ID, Answer/Open in Terminal, Decline, the … and long-press menus, the rounded swipe actions with the drawer handoff, quote bubbles, Show more, Copy/Share, links (web opens Safari, a file path opens the event), pull to refresh, the "N new" pill with the reading position kept, routing to the source Mac, and the demo driver.

Details, numbers and evidence: cmux-next-ios/verify/feed-perf-20261023.md.

Results (iPhone 16 Plus simulator, 320 events, 2400 pt/s, base vs fix in the same hour)

Run Base hitch ms/s · p99 · main CPU ms/s Fix hitch ms/s · p99 · main CPU ms/s
no live traffic (4 runs) 38–56 · 35–37 ms · ~183 0.7–1.7 · 16.67 ms · ~58
4 live changes/s (4 runs) 49–62 · 35–38 ms · ~190 1.1–3.3 · 16.67 ms · ~80

The < 5 ms/s target is met in every run. Without live traffic the latest runs have no frame over 33.3 ms. With live traffic a few single frames reach 34–51 ms per run, from a new cell's first commit or an insert landing in that frame.

Real use: two rig Macs, real Codex and Claude sessions on one, permission asks on the other, the phone linked to both. See the verify doc.

Changelog

  • Changed: the Feed scrolls smoothly with many events: rows are native views laid out ahead of time.

Testing

  • swift test --filter "Feed|TerminalFeed|MultiMac|FrameInterval|Markdown" on mini-cmux7s: 83 tests in 21 suites passed.
  • New FeedListPlanTests committed red first (8ef597f55f73): one layout per event, state and width; a change reconfigures only its row; snapshot edits; only never-shown events count as new.
  • Demo simulator builds on the fleet; screenshots of base vs new for every item kind, light and dark, under verify/evidence/feedui/shots/ (not committed; numbers in the doc).

Observed / Not run

Review

Two correctness subagent reviews (at 26bc5b4 and 5144b51); findings and fixes are in a PR comment.

No capture: the Feed is iOS; light and dark simulator stills of every item kind are listed above and described in the verify doc.

🤖 Generated with Claude Code


View with [code]smith Autofix with [code]smith
Need help on this PR? Tag @codesmith-bot with what you need.


Summary by cubic

Replaces the Feed's SwiftUI rows with UIKit UICollectionView cells laid out off the main thread, cutting main-thread hitches from 38–62 ms/s to 0.7–3.3 ms/s on a 320-event iPhone simulator feed (p99 frame time 16.67 ms, within the 33.3 ms budget). The prior SwiftUI trimming attempt showed no gain.

Refactors

  • Each row's frames and attributed strings are computed off the main thread and cached until its event, row state, or text size changes; a store update re-keys only the rows whose event changed, and a reconfigure re-sums only the frames below the first changed row.
  • Markdown bodies, code cards, and tables render as native attributed text using the web renderer's metrics; only math uses the pooled web view, after scrolling settles.
  • Fixes a crash when a row height changed right after an insert, before the layout pass ran: the quick re-sum path now only runs on current frames.
  • All Feed interactions are preserved: reply box (including terminal-agent replies), question cards with drafts, Allow/Deny with Face ID, Open in Terminal, swipe actions, quote bubbles, Show more, copy/share, links (web opens Safari, a file path opens the event), and the "N new" pill with the reading position kept.

Written for commit 659e639. Summary will update on new commits.

View guided diff Turn on auto-fix

azooz2003-bit and others added 8 commits October 22, 2026 22:09
…out once per event and a change reconfigures that row only

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The Feed's list is a UICollectionView of UIKit cells (FeedRowCell) with a
diffable data source keyed by event id. Each row's layout (FeedRowLayout:
every frame and attributed string) is computed off the main thread and kept
until its event or row state changes; a fixed-height column layout
(FeedListLayout) places rows by prefix sums. Markdown bodies are native
attributed text, code cards and tables (NativeMarkdown, the web renderer's
metrics); only math still renders in a pooled web view, once scrolling settles.
Live inserts and removals are snapshot edits, the row under the reader stays
in place, and new rows above count into the N new pill. Labels draw
asynchronously. Interactions kept: reply box, question cards, Allow/Deny with
Face ID, the ... and long-press menus, rounded swipe actions, quote bubbles,
Show more, copy and share, routing to the source Mac.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Review (correctness subagent at 26bc5b4):
- the unread dot and swipe actions redraw on a read change (the plan was empty);
- a width or text size change keeps the list's order and row state, lays every
  row out again and reconfigures them all, and counts nothing as new;
- the question card's Back, Next and Answer sit at their card coordinates;
- a saved question draft shows on the card (seeded before the layout), the card
  state and draft stay until the question stops pending (a refused answer keeps
  the picks), and Other takes the keyboard only when the person opens it;
- stale accessibility identifiers and hints are reset on reuse; the header is
  measured again on a text size change; only never-shown events count as new;
  one reply observation per row, ended when the row leaves; duplicate ids are
  dropped; width changes apply after the layout pass; the minute timer stops.

Performance: a new cell made mid-scroll cost ~20 ms (its first commit of every
part); parts a row may not show are made when first needed. Batch results are
checked against current keys in one pass (it was quadratic). The rig flushes
the frame log before exiting (the last window was lost).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A store update re-keyed, copied and deep-compared all ~300 rows and refiltered
four dictionaries (~10 ms of main thread per change, 2-5 a second with agents
working). Rows whose event and state did not change keep their shown key;
heights are kept per row; pruning runs only when the order changed. The real
Feed rig links each rig Mac it is told to (Connect).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- a submitted question redraws its card at once (mutateState);
- VoiceOver stops reading Unread after a read on the Mac;
- a prefetched cell catches up on a read flag or the minute when it scrolls in;
- a text size change re-measures the header off the layout pass, with the
  collection view's size category;
- a web body's view is made only once the row settles;
- reuse resigns the question card's Other field; set lookups in commit.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… measuring it

Under real agent load every new ask changed the Needs you count, and the list
measured its SwiftUI header again each time (a hosting controller sizing pass).
The count now only redraws the header cell; its height is measured when
something that can change it does.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The SwiftUI FeedCell is gone on this branch; its new base behavior is carried
into the UIKit rows: a terminal agent's reply (typed into its terminal, the
item's sending line, "Sent to the terminal", one reply at a time,
FeedItem.takesReply), "Open in Terminal" under a terminal agent's question,
and links in bodies (a web link opens in Safari, a file path opens the event,
as InlineMarkdownCache and the web renderer do).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…annot reach each other

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@github-actions

github-actions Bot commented Oct 24, 2026 •

Copy link
Copy Markdown
Contributor

cmux status (one comment, edited in place; each line is updated by the job that owns it)

Review 659e639: HQ feed card · gallery before/after runs · checks

App of this head (app.zip, kept 1 day)

gh workflow run reload-build.yml -R manaflow-ai/cmux --ref feat-cmux-next -f tag=pr24683 -f ref=feat-cmux-next-ios-feed-uikit -f platform=macos -f backend_mode=local

@azooz2003-bit

Copy link
Copy Markdown
Collaborator Author

Correctness reviews (subagent) and resolutions

Review 1, at 26bc5b4: 4 blocking findings, all fixed in 1314ffb

Finding Fix
The unread dot and swipe actions were not redrawn on a read-only change (empty plan returned early) redraw computed before the early return
A width or Dynamic Type change dropped rows, lost row state and counted a false "N new" relayoutAll keeps order and state, waits for all layouts, then reconfigures every row; nothing counts as new
Question card Back, Next and Answer were offset by the card's x Frames used as given (card coordinates)
A saved question draft was not shown, but was acted on Drafts are seeded before layout; state kept until the question stops pending; Other takes the keyboard only on .beginOtherEditing
Non-blocking: stale accessibility ids, header not re-measured on text size change, false new counts, Other refocusing, reply observation chains piling up, submit dropping picks All fixed
Risks: duplicate ids, snapshots applied mid-layout, minute timer not invalidated Ids deduplicated; width change deferred; timer invalidated in deinit

Review 2, at 5144b51: nothing blocking; fixed in 6da6b1c

Finding Fix
A submitted question did not redraw at once mutateState
VoiceOver kept reading "Unread" after a read on the Mac accessibilityElementsHidden updated in redraw
A prefetched cell missed a read flag or minute change Catch-up in willDisplay
Header measured during a trait-change layout pass Deferred, using the collection view's size category
A web body's view was created mid-scroll Created only once the row settles
Nits: Set lookups, reply height kept, Other resigned on reuse Done

Not changed: the tab bar badge rebuild cost (SwiftUI TabView, in RootView, also in the base) is left for a follow-up.

@blacksmith-sh

blacksmith-sh Bot commented Oct 24, 2026 •

Copy link
Copy Markdown

Found 2 test failures on Blacksmith runners:

Failures

Test View Logs
test_base_passes (main.NoticesPairTests.test_base_passes)/
test_base_passes (main.NoticesPairTests.test_base_passes)
View Logs
test_the_repository_highlights_validate (main.DigestTests.test_the_repository_highl
ights_validate)/
test_the_repository_highlights_validate (main.DigestTests.test_the_repository_highl
ights_validate)
View Logs

Fix with [code]smith
Need help on this PR? Tag @codesmith-bot with what you need.

@teamleaderleo

Copy link
Copy Markdown
Collaborator

Bead cx-frdwq created for this PR by the cmux coordinator.

azooz2003-bit and others added 5 commits October 23, 2026 18:03
… the frames below it

Review (merge carry-over): links in short bodies open too; a tap on blank
space near a link opens the event; the hit test lays out with unbounded
height; Open in Terminal is an explicit action, not an option id that an agent
could also send; file paths in code are not underlined (the web Feed never
linked them).

Under real use a commit still cost ~10 ms: it rebuilt the plan's dictionary,
scanned every row for warm-up shapes and set all 300 heights. The plan reads
the kept keys, warm-up looks at changed rows only, and the layout re-sums
from the first changed row (FeedListLayoutTests, iOS simulator: 11 passed).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…t pass after an insert traps

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…n insert re-sums safely

Review (at 3687fdf): setHeights' quick path trusted frames that a full reset
had not rebuilt yet (prepare() still pending), and resum wrote past their end.
It now runs only on current frames. A link tap below a capped body's visible
part opens the event. FeedListLayoutTests (iOS simulator): red at 9e2ed53
(crash in FeedListLayout.resum), green here, 12 passed with FeedListPlanTests.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@azooz2003-bit

Copy link
Copy Markdown
Collaborator Author

Review 3, the merge carry-over (at ee9e53c): nothing blocking; fixed in 3687fdf

  • Links in short bodies opened the event instead of Safari. The hit test is now shared and also checks the inline label.
  • A tap on blank space near a link opened Safari. The hit is now bounded by the glyph's rect.
  • The hit test's text container now has unbounded height.
  • "Open in Terminal" is an explicit action. Before, a "terminal" option id could have been sent by an agent and taken as that action.
  • File paths in code spans are no longer underlined, since the web Feed never linked them.

Review 4 (at 3687fdf): one likely crash, fixed in 659e639

  • FeedListLayout.setHeights quick path: after a full reset, a height change that arrived before prepare() re-summed frames of the old length, writing past the end of the array.
    • Red: FeedListLayoutTests, iOS simulator at 9e2ed53, "Crash: xctest at FeedListLayout.resum(from:)".
    • Green: at 659e639, 12 passed with FeedListPlanTests. The quick path now runs only on current frames.
  • A link tap in the hidden overflow under Show more now opens the event.
  • The test moved into CmuxMobileLiveTests (a direct Feed dependency).

Measured at 3687fdf and 659e639

  • Synthetic, no live traffic: 0.2 and 0.9 ms/s.
  • Synthetic, 4 live changes a second: 1.7 and 4.0 ms/s.
  • Real use (two rig Macs, real Codex and Claude, a loaded real Feed): 0.0–0.1 ms/s against base's 23–35 ms/s. Details are in verify/feed-perf-20261023.md.

CI

@azooz2003-bit
azooz2003-bit merged commit 5c3687a into feat-cmux-next Oct 24, 2026
94 of 99 checks passed
@azooz2003-bit
azooz2003-bit deleted the feat-cmux-next-ios-feed-uikit branch October 24, 2026 02:31
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

risk:normal Risk tier (docs/ci/risk-tiers.md)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants