Skip to content

Fix iOS workspace-list scroll stutter from live updates - #9139

Merged
azooz2003-bit merged 4 commits into
mainfrom
feat-ios-wslist-scroll-perf
Jul 29, 2026
Merged

azooz2003-bit merged 4 commits into
mainfrom
feat-ios-wslist-scroll-perf

Conversation

@azooz2003-bit

@azooz2003-bit azooz2003-bit commented Jul 29, 2026 •

Copy link
Copy Markdown
Collaborator

Scrolling the iOS workspace list stuttered slightly while agents were active. Static scrolling profiled clean in an isolated simulator; the hitches came from list updates landing mid-scroll. Two main-thread costs ran on every workspace-list emission.

First, the Mac restamps preview_at/last_activity_at from the latest notification on every list emission, while the row renders that time at minute granularity, so sub-minute restamps re-rendered rows that look identical. Reconfigure now decides by render equivalence: full struct equality (fail-closed for any field added later) with same-minute timestamps normalized out (WorkspaceListTableCoordinator.workspaceRenderEquivalent).

Second, payload-only updates rode NSDiffableDataSourceSnapshot.apply with nothing to diff, which still runs the diffable apply queue plus UITableView's whole batch-update pass per tick (~1.3ms on an M-series simulator, more on an iPhone Debug build). When no changed row's height key moved, the visible changed cells are now re-configured in place and offscreen rows pick up the new payload on dequeue; height-changing payloads (description arrival, chip digit growth, banner text) keep the snapshot path so UITableView re-queries heights.

Measured on an isolated sim (400-row fixture, an update every 80ms, 30s window): timestamp-only churn drops 3.27s to 2.59s CPU and the __UIDiffableDataSource apply subtree disappears from the sample profile; visible churn drops 3.33s to 3.00s. Sweep invariants from #8186 hold: 0 contentSize corrections, 1 distinct draw height, and static scrolling is unchanged at 0 hitches. The remaining per-emission cost is SwiftUI re-deriving WorkspaceListView.body (about 1ms at realistic row counts on the sim); not addressed here.

Also adds measurement infra: the DEBUG scroll probe records display-link frame pacing during its sweep, and the layout fixture gains a timestamps live-update mode reproducing the Mac's restamp-only emissions.

WorkspaceListScrollUpdateTests covers route selection (no work for sub-minute restamps, in-place reconfigure for text/unread changes, snapshot apply for height and structure changes) and the equivalence edge cases (minute crossing, nil transitions, non-timestamp fields).

🤖 Generated with Claude Code


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


Summary by cubic

Fixes iOS workspace-list scroll stutter during live updates by ignoring sub-minute timestamp churn and reconfiguring visible cells in place when layout and heights are unchanged. Adds per-frame hitch metrics and a timestamps-only fixture that restamps within the current minute to reproduce and measure the issue.

  • Bug Fixes

    • Ignore sub-minute changes to previewAt/lastActivityAt via a render-equivalence check so identical rows don’t re-render.
    • For payload-only updates with stable row heights, reconfigure visible cells in place instead of calling NSDiffableDataSourceSnapshot.apply; keep snapshot applies when structure or heights change.
  • New Features

    • DEBUG: scroll probe records per-frame pacing (hitch frames, hitch ms/s, max frame ms) using each frame’s own expected interval.
    • Layout preview adds a timestamps live-update mode that restamps within the current minute (guaranteed render-equivalent); 1 keeps visible churn, off disables updates. The DEBUG apply-route probe is isolated and now uses weak-key storage to avoid leaks and stale routes.

Written for commit 5875bf6. Summary will update on new commits.

Review in cubic

Summary by CodeRabbit

  • Performance
    • Improved workspace list updating by routing payload-only changes to in-place reconfiguration for visible cells when row heights are stable.
    • Avoided unnecessary updates for sub-minute restamps; minute-boundary restamps now trigger in-place reconfiguration.
  • Diagnostics
    • Enhanced workspace list scroll metrics with frame pacing and hitch statistics in generated scroll reports.
  • Testing
    • Added coverage for in-place vs snapshot apply routing, sub-minute vs minute-boundary timestamp behavior, and render-equivalence rules.
  • Preview
    • Added configurable workspace list preview live-update modes, including timestamps-only behavior.

azooz2003-bit and others added 2 commits July 29, 2026 01:10
…live-update fixture mode

The DEBUG scroll-metrics probe now records display-link frame pacing
during its sweep (hitch frames, hitch ms/s, max frame ms) so workspace
list scroll work is quantifiable before and after changes. The layout
preview fixture gains CMUX_UITEST_WORKSPACE_LIST_PREVIEW_LIVE_UPDATES=timestamps,
which restamps previewAt/lastActivityAt sub-minute without visible
changes - the exact update shape the Mac emits while agents stream.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…nd re-rendering unchanged rows

Two measured main-thread costs ran on every workspace-list emission
while agents stream (the iOS workspace-list scroll stutter):

1. Any workspace field delta reconfigured the row, but the Mac restamps
   preview_at/last_activity_at from the latest notification on every
   emission while the row renders that time at minute granularity.
   Reconfigure now decides by render equivalence: full struct equality
   (fail-closed for future fields) with same-minute timestamps
   normalized out.

2. Payload-only updates rode NSDiffableDataSourceSnapshot.apply, which
   runs the diffable apply queue plus UITableView's whole batch-update
   pass per tick (~1.3ms on an M-series simulator, more on device) with
   nothing to diff. When no changed row's height key moved, the visible
   changed cells are now re-configured in place and offscreen rows pick
   up the payload on dequeue; height-changing payloads keep the
   snapshot path so UITableView re-queries heights.

30s fixture window, 400 rows, updates every 80ms, M-series simulator:
timestamp-only churn 3.27s -> 2.59s CPU, visible churn 3.33s -> 3.00s;
the diffable-apply subtree disappears from the sample profile. Sweep
invariants hold: 0 contentSize corrections, 1 distinct draw height.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Jul 29, 2026 •

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

The DEBUG workspace preview gains configurable live-update modes, the scroll probe reports display-link frame pacing, and the table coordinator routes stable payload changes through in-place cell reconfiguration while preserving snapshot application for structural changes.

Changes

Workspace preview live updates

Layer / File(s) Summary
Configurable preview update modes
Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceListLayoutPreviewView.swift
Live updates now support disabled, visible, and timestamp-only modes, with environment-variable wiring and conditional unread/timestamp updates.

Scroll frame pacing metrics

Layer / File(s) Summary
Frame pacing sampling and reporting
Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceListScrollMetricsProbe.swift
Display-link intervals are sampled during sweeps, hitch statistics are derived, and optional pacing data is logged and encoded in scroll-metrics.json.

Workspace table payload routing

Layer / File(s) Summary
Render equivalence and height-key inputs
Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceListTableCoordinator.swift
Workspace timestamps are compared at minute granularity, and height-cache keys receive configuration context.
In-place versus snapshot application
Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceListTableCoordinator.swift, Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceListTableCoordinator+ApplyRouteProbe.swift
Updates are classified as no-change, visible-cell reconfiguration, or diffable snapshot application based on payload and row-height changes.
Payload routing coverage
Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/WorkspaceListScrollUpdateTests.swift
Tests cover restamps, preview changes, description-driven height changes, equivalence, and configuration helpers.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant CADisplayLink
  participant WorkspaceListScrollMetricsProbe
  participant ScrollMetricsReport
  CADisplayLink->>WorkspaceListScrollMetricsProbe: provide frame timestamps
  WorkspaceListScrollMetricsProbe->>WorkspaceListScrollMetricsProbe: calculate intervals and hitch statistics
  WorkspaceListScrollMetricsProbe->>ScrollMetricsReport: write framePacing to scroll-metrics.json
Loading

Suggested reviewers: lawrencecchen


Important

Pre-merge checks failed

Please resolve all errors before merging. Addressing warnings is optional.

❌ Failed checks (2 errors, 2 warnings)

Check name Status Explanation Resolution
Cmux No Test Or Debug Seam In Production Source ❌ Error A new DEBUG-only probe in Sources exposes lastPayloadApplyRoute/recordPayloadApplyRoute solely for tests, which matches the banned test/debug seam pattern. Move the observation into the test target via @testable import or a dedicated debug-only module/folder; avoid adding test-only accessors to production Sources.
Cmux No Ambient Global State ❌ Error WorkspaceListTableCoordinator+ApplyRouteProbe.swift:36 adds a caseless enum with a static NSMapTable registry, i.e. ambient shared state. Move route tracking onto WorkspaceListTableCoordinator (or a dedicated debug probe instance) and inject it at the table seam instead of a file-scope static registry.
Docstring Coverage ⚠️ Warning Docstring coverage is 18.18% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
Description check ⚠️ Warning It covers summary and testing, but it omits the required Demo Video, Review Trigger, and Checklist sections from the template. Add the missing template sections or mark them not applicable, include a demo link for UI changes, and paste the review-trigger block and checklist.
✅ Passed checks (21 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Cmux Swift Actor Isolation ✅ Passed Changes stay within @MainActor UI types; the new NSMapTable route probe is main-actor isolated, with no new Sendable/shared-state or background UI access.
Cmux Swift Blocking Runtime ✅ Passed No new blocking primitives were introduced; the only Task.sleep/CADisplayLink timing already predated the PR and lives in DEBUG-only scaffolding.
Cmux Browser Automation Off-Main ✅ Passed PASS: commit only changes iOS workspace-list preview/probe files; no TerminalController or control-socket browser automation routing is touched.
Cmux Expensive Synchronous Load ✅ Passed No agent-history/session/transcript load paths were added or moved; changes are render-equivalence, diffable routing, and DEBUG probe plumbing.
Cmux Cache Substitution Correctness ✅ Passed No persistence/history/undo path swaps to cached data; the only cache-like reuse is event-driven, cold-fallback UI state with height-stability checks and DEBUG-only probes.
Cmux No Hacky Sleeps ✅ Passed Only Swift files changed; the no-hacky-sleeps rule applies to non-Swift runtime scripts, so this PR is out of scope.
Cmux Algorithmic Complexity ✅ Passed No new nested scalable scans in production hot paths; the added full-collection work is linear, and the heavier sorts/scans are DEBUG/test/preview-only.
Cmux Swift Concurrency ✅ Passed Full PR diff adds only SwiftUI/OS callback-boundary async work; no new DispatchQueue, Combine, completion-handler, or fire-and-forget Task patterns appear.
Cmux Swift @Concurrent ✅ Passed No changed Swift function adds or misuses @concurrent; the new async work is UI-bound on @MainActor, with explicit MainActor hops where needed.
Cmux Swift Package Boundaries ✅ Passed All production edits stay inside the CmuxMobileShellUI SwiftPM target; the new logic is UI coordinator/preview/DEBUG instrumentation, with tests in the test target.
Cmux Swiftpm Lockfiles ✅ Passed Diff only touches two Swift source files; no Package.resolved, .gitignore, Xcode project, or workflow/dependency files changed, so the lockfile rule isn’t implicated.
Cmux Swift Logging ✅ Passed Added logging is confined to a DEBUG-only scroll probe via Logger; no forbidden print/NSLog/stdout logging was introduced in production Swift code.
Cmux User-Facing Error Privacy ✅ Passed No new user-facing error/alert/copy strings were added; changes are DEBUG-only or generic UI labels, with one internal log line only.
Cmux Full Internationalization ✅ Passed Diff only changes internal/debug code and comments; no user-facing strings, catalogs, plist, or web i18n files were added or edited.
Cmux Swiftui State Layout ✅ Passed No rule violations found: the preview uses @Observable state, and the diff adds no GeometryReader, @Published/ObservableObject, or render-time state mutation.
Cmux Architecture Rethink ✅ Passed Changes stay owner-local; live updates remain in a preview-owned task, and DEBUG probes/test seams don’t alter production lifecycle or add repair-by-timing logic.
Cmux Swift Auxiliary Window Close Shortcuts ✅ Passed PR only changes list preview, scroll probe, coordinator, and tests; no NSWindow/NSPanel/WindowGroup code or cmuxAuxiliaryWindowIdentifiers changes were introduced.
Cmux Source Artifacts ✅ Passed Changed paths are hand-written Swift source and tests; no logs, screenshots, caches, DerivedData, or scratch artifact directories were added.
Title check ✅ Passed The title is concise and accurately summarizes the main change: fixing iOS workspace-list scroll stutter from live updates.
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat-ios-wslist-scroll-perf

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai 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.

Actionable comments posted: 3

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In
`@Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceListLayoutPreviewView.swift`:
- Around line 50-51: The .timestampsOnly update in seeded() must preserve each
row’s existing normalized minute bucket. Compute one sub-minute restamp relative
to the row’s current timestamp and assign that same value to both previewAt and
lastActivityAt, instead of replacing both with the current Date().

In
`@Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceListScrollMetricsProbe.swift`:
- Around line 244-269: Update the framePacing calculation to iterate over each
frame duration together with its corresponding sweepExpectedDurations value,
classifying hitches using that frame’s expected duration and accumulating
duration minus that value. Retain the median expected duration only for
FramePacing.expectedFrameMs, leaving the other aggregate metrics unchanged.

In
`@Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceListTableCoordinator.swift`:
- Around line 49-69: Move PayloadApplyRoute, lastPayloadApplyRoute, and
recordPayloadApplyRoute out of WorkspaceListTableCoordinator into a dedicated
DEBUG-only extension/file following the WorkspaceListScrollMetricsProbe pattern.
Guard every recordPayloadApplyRoute call site with `#if` DEBUG, including the
changed ID mapping, so Release builds perform no tracking or array allocation.
Preserve the existing test-observable routing behavior.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 0b3e3c01-b567-46e8-a2c8-590d6390ca22

📥 Commits

Reviewing files that changed from the base of the PR and between 9a16a84 and b2a6180.

📒 Files selected for processing (4)
  • Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceListLayoutPreviewView.swift
  • Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceListScrollMetricsProbe.swift
  • Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceListTableCoordinator.swift
  • Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/WorkspaceListScrollUpdateTests.swift

@azooz2003-bit

Copy link
Copy Markdown
Collaborator Author

CI evidence: the five new WorkspaceListScrollUpdateTests pass on the iPhone simulator in https://github.com/manaflow-ai/cmux/actions/runs/30437841665 (test_filter=CmuxMobileShellUITests, 277 tests). The run's only failure, "uses the Japanese localized count without English inflection markup" in TerminalArtifactChildCountFormatterTests, fails identically on main in https://github.com/manaflow-ai/cmux/actions/runs/30439529771 (272 tests, same 3 issues), so it predates this branch; the 5-test delta between the runs is exactly the new coverage. The unfiltered dispatch https://github.com/manaflow-ai/cmux/actions/runs/30434879901 hit the 35-minute job timeout on a slow runner plus main's pre-existing mobile-core-package and package-conventions-lint failures; neither is related to this change.

…s, in-bucket fixture restamps

The apply-route test hook moves out of the production coordinator into a
dedicated DEBUG file (extension + registry) with #if DEBUG call sites, so
Release builds also stop allocating the changed-id array. The scroll
probe judges each sweep frame against the expected interval captured at
the callback that started it instead of the median, so a mid-sweep
refresh-rate change is not misclassified as a hitch. The timestamps
fixture mode restamps relative to each row's own clock so the first tick
no longer jumps seeded hours-old timestamps across a rendered minute.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

@coderabbitai coderabbitai 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.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In
`@Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceListLayoutPreviewView.swift`:
- Around line 52-62: The timestamp restamp in the preview update logic must
remain within the current minute bucket. Replace the unconditional one-second
addition in the workspaces update block with logic that alternates between two
second values in the existing minute, preserving the timestamp’s date, hour, and
minute while avoiding any minute rollover.

In
`@Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceListTableCoordinator`+ApplyRouteProbe.swift:
- Around line 32-50: Update WorkspaceListApplyRouteProbe so route entries are
tied to coordinator lifetime rather than retained in the process-wide
ObjectIdentifier dictionary. Use coordinator-owned/associated storage or
weak-key storage with cleanup, ensuring deallocated coordinators and their
routes are released and address reuse cannot return stale routes.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 71ad6d09-e968-4c13-b663-d1a7af8ac444

📥 Commits

Reviewing files that changed from the base of the PR and between b2a6180 and 5363835.

📒 Files selected for processing (4)
  • Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceListLayoutPreviewView.swift
  • Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceListScrollMetricsProbe.swift
  • Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceListTableCoordinator+ApplyRouteProbe.swift
  • Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceListTableCoordinator.swift

…obe storage

The timestamps fixture bump now wraps to the start of the row's current
minute instead of crossing into the next one, so every tick honors the
mode's zero-work contract. The DEBUG route registry keys weakly through
NSMapTable so entries die with their coordinator and a reused address
cannot return a predecessor's route.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@azooz2003-bit

Copy link
Copy Markdown
Collaborator Author

Final-head verification (https://github.com/manaflow-ai/cmux/actions/runs/30497899613): all five WorkspaceListScrollUpdateTests pass. Two unrelated failures: the Japanese localized-count formatter test (pre-existing, fails identically on main in https://github.com/manaflow-ai/cmux/actions/runs/30439529771) and a one-off deadline race in TerminalFolderTapPolicyTests ("disabled still accepts a fast classification before the deadline"), which shares no code with this PR and passed in both earlier runs of this suite on this branch (https://github.com/manaflow-ai/cmux/actions/runs/30437841665, https://github.com/manaflow-ai/cmux/actions/runs/30497207325). All review threads resolved.

@azooz2003-bit
azooz2003-bit merged commit d2c80b4 into main Jul 29, 2026
14 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant