Skip to content

sidebar perf: decouple workspace sidebar from notification-store churn - #5859

Merged
lawrencecchen merged 4 commits into
mainfrom
feat-scroll-perf-cleanup
Jun 11, 2026
Merged

lawrencecchen merged 4 commits into
mainfrom
feat-scroll-perf-cleanup

Conversation

@lawrencecchen

@lawrencecchen lawrencecchen commented Jun 11, 2026 •

Copy link
Copy Markdown
Contributor

Problem

Fast-scrolling a terminal felt laggy. Investigation (SwiftUI Instruments + Time Profiler on a live instance) showed it is main-thread starvation, not the scroll path: the workspace sidebar reconstructs every TabItemView whenever TerminalNotificationStore publishes, which fires on terminal/agent activity. With many workspaces this saturates the main thread, so terminal scroll (Ghostty Metal on its own thread) can't get serviced promptly. Same class as #2586.

Root cause: both ContentView and VerticalTabsSidebar held @EnvironmentObject notificationStore, so any notification publish re-rendered the whole content view and rebuilt every sidebar row, even when no workspace's displayed unread state changed.

Fix

Introduce SidebarUnreadModel: a coalesced, equality-guarded ObservableObject that mirrors the store's unread queries (badge count, latest text, per-surface unread, read indicator, manual-unread). TerminalNotificationStore drives it from its single refreshUnreadPresentation() hub, so notification churn that doesn't change a workspace's displayed unread no longer publishes.

ContentView and VerticalTabsSidebar now observe SidebarUnreadModel instead of the notification store. TerminalNotificationStore.shared stays reachable (singleton) for context-menu actions and pass-down; TabItemView already held an unobserved let. All ~14 body-reachable unread reads route through the model.

Net: the sidebar re-renders only when a workspace's actual unread state changes, not on every notification.

Verification

  • Builds clean (cloud builder).
  • Debug-CLI functional preflight: notify → unread, mark-read → read, clear → empty, no crash; store→model sync path (incl. new focusedReadIndicator didSet) exercised on all mutation paths.
  • Dogfood (tagged build scrollperf): fast-scroll smoother, unread badges appear/clear correctly, sidebar drag/drop works.

Scope / follow-ups

🤖 Generated with Claude Code


View with Codesmith Autofix with Codesmith
Need help on this PR? Tag /codesmith with what you need. Autofix is disabled.


Note

Medium Risk
Touches unread/badge presentation across the main window; APIs mirror the store but sync bugs on edge cases (read indicators, mark read/unread) could cause stale or missing sidebar badges.

Overview
Adds SidebarUnreadModel, a coalesced unread projection updated from TerminalNotificationStore.refreshUnreadPresentation() with equality checks so the sidebar only publishes when badge counts, latest message text, per-surface unread, read indicators, or manual-unread actually change.

ContentView and VerticalTabsSidebar now observe sidebarUnread instead of the full notification store; unread-related reads (overlays, command palette, custom sidebar data) go through the model. AppDelegate injects notificationStore.sidebarUnread into the SwiftUI environment while keeping the store for actions via TerminalNotificationStore.shared.

Also refreshes focusedReadIndicatorByTabId handling so read-indicator changes sync into the coalesced model, and bumps the Swift file-length budget for TerminalNotificationStore.swift.

Reviewed by Cursor Bugbot for commit be75130. Bugbot is set up for automated code reviews on this repo. Configure here.


Summary by cubic

Improved sidebar performance by decoupling it from notification-store churn. The sidebar now observes a coalesced unread model and only updates when displayed unread state changes, fixing terminal fast-scroll lag.

  • Refactors
    • Added SidebarUnreadModel (equality-guarded) with totalUnreadCount, per-workspace summaries (unread count + latest text), per-surface unread keys, focused read-indicator map, and manual-unread set.
    • TerminalNotificationStore.refreshUnreadPresentation() now builds per-workspace summaries and applies them to sidebarUnread; focusedReadIndicatorByTabId updates trigger a refresh.
    • Switched ContentView and VerticalTabsSidebar to observe SidebarUnreadModel; replaced unread queries (badge counts, latest text, per-surface unread, can-mark read/unread) with model APIs; kept TerminalNotificationStore.shared unobserved for actions.
    • Injected notificationStore.sidebarUnread into the environment in AppDelegate.

Written for commit be75130. Summary will update on new commits.

Review in cubic

Summary by CodeRabbit

  • Refactor
    • Sidebar unread tracking was reworked with a dedicated unread model to make unread counts, per-workspace totals, and latest message text more accurate and consistently available to the UI.
  • Bug Fixes
    • Unread indicators and per-panel read/unread behavior now stay synchronized and update reliably across windows and panels.

lawrencecchen and others added 2 commits June 10, 2026 16:08
Introduce SidebarUnreadModel, an equality-guarded per-workspace unread
projection driven from TerminalNotificationStore.refreshUnreadPresentation()
(the store's single coalescing hub). This is the observation boundary the
workspace sidebar will read from instead of the whole notification store, so
high-frequency notification churn that doesn't change a workspace's badge or
latest-text no longer re-renders the workspace list (issue #2586 class).

Additive only: the model is populated but not yet consumed; no behavior change.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…coalesced model

ContentView and VerticalTabsSidebar previously observed TerminalNotificationStore
via @EnvironmentObject, so every notification publish (which fires on terminal/
agent activity) re-rendered the whole content view and reconstructed every
workspace sidebar row, saturating the main thread and starving terminal scroll
(issue #2586 class; surfaced as fast-scroll lag).

Both now observe the coalesced, equality-guarded SidebarUnreadModel instead.
TerminalNotificationStore stays reachable as the .shared singleton for
context-menu actions and pass-down (TabItemView already held an unobserved ref).
All body-reachable unread reads (badge counts, latest text, per-surface unread,
read indicators, manual-unread, can-mark) route through the model, which the
store keeps in sync from its single refreshUnreadPresentation() hub. Result:
notification churn that does not change a workspace's displayed unread state no
longer re-renders the sidebar.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@vercel

vercel Bot commented Jun 11, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
cmux Ready Ready Preview, Comment Jun 11, 2026 7:46am
cmux-staging Building Building Preview, Comment Jun 11, 2026 7:46am

@coderabbitai

coderabbitai Bot commented Jun 11, 2026 •

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

This PR introduces a coalesced SidebarUnreadModel owned by TerminalNotificationStore, injects it into the main window environment, and changes ContentView and related UI bindings to observe and query sidebar unread state from that model instead of the full store.

Changes

Sidebar Unread State Observation

Layer / File(s) Summary
SidebarUnreadModel definition
Sources/TerminalNotificationStore.swift
New SidebarUnreadModel observable class and supporting value types (SidebarWorkspaceUnreadSummary, SidebarSurfaceUnreadKey) encapsulate sidebar-specific unread state with query helpers.
TerminalNotificationStore sidebar state management
Sources/TerminalNotificationStore.swift
sidebarUnread property introduced; refreshUnreadPresentation() computes totals, per-workspace summaries, per-surface keys, focused indicators, and applies them into sidebarUnread. focusedReadIndicatorByTabId didSet triggers guarded refresh.
AppDelegate environment injection
Sources/AppDelegate.swift
Main window ContentView environment injection adds notificationStore.sidebarUnread alongside notificationStore.
ContentView observation setup
Sources/ContentView.swift
ContentView switches to @EnvironmentObject sidebarUnread in two sections while keeping notificationStore as an unobserved .shared accessor for actions.
ContentView unread indicators and queries
Sources/ContentView.swift
Workspace unread indicators, panel unread checks, and command-palette "can mark read/unread" context keys updated to call sidebarUnread APIs instead of notificationStore.
ContentView sidebar UI data bindings
Sources/ContentView.swift
Sidebar view-model totals, per-workspace unread counts, latest notification text, and live sidebar message state now come from sidebarUnread query helpers; local trimming logic removed.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Possibly related PRs

  • manaflow-ai/cmux#4778: Both PRs modify Sources/TerminalNotificationStore.swift to update unread/read state: main PR refactors unread presentation into a coalesced sidebarUnread model, while PR #4778 adds markUnread(id:) that clears manual unread workspace state—directly impacting the same unread bookkeeping the new sidebar model consumes.
  • manaflow-ai/cmux#4371: Also touches TerminalNotificationStore unread-indicator bookkeeping and panel/unread indexing that the sidebar projection consumes.

Poem

🐰 I nibble at the sidebar's thread,

I gather unread bits into one bed,
The store whispers, I tidy and hum,
Views hop gently — renders drum.
Hooray for neat counts, snug and fed!


Important

Pre-merge checks failed

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

❌ Failed checks (3 errors, 1 warning)

Check name Status Explanation Resolution
Cmux Swift Concurrency ❌ Error SidebarUnreadModel introduces new Combine app state (@MainActor ObservableObject with @Published) instead of modern Observation framework—flagged legacy pattern per swift-concurrency-modernization.md. Replace ObservableObject/@published with @Observable from Observation framework to use modern Swift concurrency patterns.
Cmux Swift File And Package Boundaries ❌ Error SidebarUnreadModel should have been extracted to a new SwiftPM package (not added to TerminalNotificationStore) since it's independently testable, lifecycle-independent, and needs no app-specific d... Extract SidebarUnreadModel into a new package target (e.g., CmuxSidebarUnreadPresentation). This satisfies the extraction exception and avoids budget overruns on oversized files.
Cmux Swiftui State Layout ❌ Error SidebarUnreadModel uses ObservableObject with @Published properties; macOS 14+ target supports modern @Observable pattern instead. Replace SidebarUnreadModel with @Observable macro and use direct properties instead of @Published for per-property invalidation tracking.
Docstring Coverage ⚠️ Warning Docstring coverage is 13.64% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (17 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and specifically describes the main change: decoupling the workspace sidebar from notification-store churn to improve performance.
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 SidebarUnreadModel is properly marked @MainActor, TerminalNotificationStore is @MainActor, and all accesses happen on the main thread with no background context violations.
Cmux Swift Blocking Runtime ✅ Passed No blocking or timing-based synchronization patterns (semaphores, waits, sleeps, asyncAfter, manual locks, main-queue sync) were introduced in this PR. Pre-existing NSLocks unrelated to the PR's Si...
Cmux Expensive Synchronous Load ✅ Passed PR introduces SidebarUnreadModel with only lightweight O(1)/O(n) dictionary/set lookups; no RestorableAgentSessionIndex.load() or other expensive syscall/disk loaders added to main-actor or interac...
Cmux Cache Substitution Correctness ✅ Passed SidebarUnreadModel is a transient UI cache used only for rendering, not in persistence/history/undo/snapshot paths. It receives fresh reads from TerminalNotificationStore via equality-guarded apply...
Cmux No Hacky Sleeps ✅ Passed Check not applicable: PR modifies only Swift files. Custom check scope is TypeScript/JavaScript/shell/build-runtime scripts; Swift is excluded and covered separately.
Cmux Algorithmic Complexity ✅ Passed The PR's algorithmic complexity in TerminalNotificationStore.swift is acceptable. buildSidebarUnreadSummaries() has O(n) complexity (n = workspace IDs), with O(1) dictionary and set operations pe...
Cmux Swift @Concurrent ✅ Passed All new Swift code follows @concurrent annotation rules: SidebarUnreadModel is correctly @MainActor with synchronous-only methods, no missing @concurrent annotations, and no concurrent isolation vi...
Cmux Swift Logging ✅ Passed No logging violations found in diff: new SidebarUnreadModel and supporting types contain no print/debugPrint/dump/NSLog calls, and existing Logger uses proper nonisolated declaration.
Cmux User-Facing Error Privacy ✅ Passed PR violates no user-facing error privacy rules. Changes introduce SidebarUnreadModel with only internal APIs, safe data keys ("unreadTotal", "unread" for custom sidebar templates), and developer co...
Cmux Full Internationalization ✅ Passed PR introduces internal performance model (SidebarUnreadModel) with no new user-facing strings requiring localization; all existing strings already use String(localized:defaultValue:).
Cmux Architecture Rethink ✅ Passed PR introduces SidebarUnreadModel as equality-guarded projection of TerminalNotificationStore state with single coalescing update path (refreshUnreadPresentation), no timing/blocking repairs, clear...
Cmux Swift Auxiliary Window Close Shortcuts ✅ Passed PR introduces SidebarUnreadModel (an ObservableObject, not a window) and updates existing ContentView/TerminalNotificationStore to use it; no new NSWindow, NSPanel, NSWindowController, SwiftUI Wind...
Cmux Source Artifacts ✅ Passed PR modifies only 3 hand-written Swift source files and 1 required build-budget config file; no artifacts, logs, caches, or scratch directories are added.
Description check ✅ Passed PR description comprehensively covers problem, solution, verification, and scope with clear technical detail and actionable follow-ups.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat-scroll-perf-cleanup

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 and usage tips.

@greptile-apps

greptile-apps Bot commented Jun 11, 2026 •

Copy link
Copy Markdown
Contributor

Greptile Summary

Introduces SidebarUnreadModel, a coalesced ObservableObject that TerminalNotificationStore drives from its single refreshUnreadPresentation() hub with per-property equality guards. ContentView and VerticalTabsSidebar now observe this model instead of the full notification store, so high-frequency terminal/agent notification churn that doesn't change any workspace's badge count, latest text, surface-unread set, or read indicator no longer re-renders the sidebar.

  • SidebarUnreadModel.apply() individually compares each @Published field before assigning, preventing spurious objectWillChange events; TabItemView rows continue to receive plain value snapshots (unreadCount, latestNotificationText) rather than store references.
  • focusedReadIndicatorByTabId gains a didSet that calls refreshUnreadPresentation() when the value actually changes, ensuring read-indicator transitions propagate to the coalesced model even when no other unread property changed.
  • TerminalNotificationStore.shared is retained as an unobserved singleton in both views for context-menu mutations and the notificationStore: pass-down to AppKit bridge helpers.

Confidence Score: 5/5

Safe to merge; the coalesced model correctly mirrors all store query methods and the equality guards are sound.

All ~14 body-reachable unread reads in ContentView and VerticalTabsSidebar are migrated to the new model. The per-workspace summary builder, surface-key set, focused-read-indicator map, and manual-unread set all match the original store queries. The new focusedReadIndicatorByTabId didSet correctly propagates read-indicator-only changes. TabItemView receives value snapshots rather than store references. No new actor-isolation, blocking-primitive, or stale-state issues were introduced.

No files require special attention.

Important Files Changed

Filename Overview
Sources/TerminalNotificationStore.swift Adds SidebarUnreadModel, SidebarWorkspaceUnreadSummary, and SidebarSurfaceUnreadKey; wires refreshUnreadPresentation() to drive the new model with equality guards; adds focusedReadIndicatorByTabId didSet to keep the model in sync. Logic mirrors the original per-workspace queries correctly.
Sources/ContentView.swift Switches ContentView and VerticalTabsSidebar from @EnvironmentObject notificationStore to @EnvironmentObject sidebarUnread for all body-reachable unread reads; notificationStore becomes an unobserved computed singleton for mutations and pass-down only. TabItemView still receives value snapshots for unread counts.
Sources/AppDelegate.swift Injects notificationStore.sidebarUnread into the environment alongside the store; single-line addition, no issues.
.github/swift-file-length-budget.tsv Updates file-length budget entries for ContentView, AppDelegate, and TerminalNotificationStore to reflect the new sizes; routine housekeeping.

Sequence Diagram

sequenceDiagram
    participant N as TerminalNotificationStore
    participant S as SidebarUnreadModel
    participant CV as ContentView / VerticalTabsSidebar
    participant TI as TabItemView

    Note over N: Terminal/agent activity fires
    N->>N: notifications.didSet → refreshUnreadPresentation()
    N->>N: buildSidebarUnreadSummaries()
    N->>S: apply(totalUnreadCount, summaries, unreadSurfaceKeys, focusedReadIndicators, manualUnread)
    Note over S: Equality-guard each @Published property
    alt any property changed
        S-->>CV: objectWillChange fires → body re-render
        CV->>S: sidebarUnread.unreadCount(forWorkspaceId:)
        CV->>S: sidebarUnread.latestNotificationText(forWorkspaceId:)
        CV->>TI: Pass value snapshots (unreadCount, latestNotificationText)
    else no property changed
        Note over S: No publish → sidebar skips re-render
    end

    Note over N: User invokes context-menu action
    CV->>N: TerminalNotificationStore.shared.markRead(forTabId:)
    N->>N: notifications.didSet → refreshUnreadPresentation()
    N->>S: apply(...)
Loading

Reviews (3): Last reviewed commit: "Merge remote-tracking branch 'origin/mai..." | Re-trigger Greptile

Comment on lines +2459 to +2465
@MainActor
final class SidebarUnreadModel: ObservableObject {
@Published private(set) var totalUnreadCount: Int = 0
@Published private(set) var summaryByWorkspaceId: [UUID: SidebarWorkspaceUnreadSummary] = [:]
@Published private(set) var unreadSurfaceKeys: Set<SidebarSurfaceUnreadKey> = []
@Published private(set) var focusedReadIndicatorByWorkspaceId: [UUID: UUID] = [:]
@Published private(set) var manualUnreadWorkspaceIds: Set<UUID> = []

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P2 New ObservableObject/@Published where @Observable would give finer invalidation

Per the cmux-swiftui-state-layout rule, new observable state should use @Observable when correct. With the current design, any change to any of the five @Published properties fires objectWillChange and invalidates the entire VerticalTabsSidebar body — so a totalUnreadCount tick re-renders all TabItemView rows even if no individual workspace's summary changed. Switching to @Observable (Swift 5.9 macro) would let SwiftUI track access at property granularity, so a totalUnreadCount change only re-renders views that actually read totalUnreadCount, not every row. The trade-off is replacing @EnvironmentObject/.environmentObject() injection with @Environment/.environment(), which is a broader change — but worth scheduling given the per-row render cost this PR is optimizing.

Rule Used: Flag SwiftUI changes that can cause stale state, b... (source)

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

regenerated via scripts/swift_file_length_budget.py --write-budget
# Conflicts:
#	.github/swift-file-length-budget.tsv

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

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit be75130. Configure here.

NSApp?.dockTile.badgeLabel = label
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Stale pane read indicators

Medium Severity

The sidebar now reads pane read indicators from SidebarUnreadModel, but focusedReadIndicatorByTabId is often cleared via in-place dictionary updates that do not run refreshUnreadPresentation after an earlier refresh. remove(id:) updates notifications first, then clears the focused indicator, so the model can keep showing a read dot after the store cleared it.

Additional Locations (2)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit be75130. Configure here.

@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: 1

🤖 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 `@Sources/ContentView.swift`:
- Line 5: This PR mixes multiple unrelated features; split the changes into
focused PRs: keep the SidebarUnreadModel work (references: SidebarUnreadModel
and usage like sidebarUnread.unreadCount) in this PR and remove unrelated
additions (references: sidebarRenderWorkerClient binding/injection, functions
sidebarGitBranchesInDisplayOrder and customSidebarPullRequestValues, and the
remote-connection suspended state + reconnect UI code paths); create separate
PRs for the RenderWorkerClient threading (sidebarRenderWorkerClient), the Git
branch ordering refactor (sidebarGitBranchesInDisplayOrder,
customSidebarPullRequestValues), and the remote connection suspended/reconnect
feature (suspended state handling and reconnect UI), then update this branch to
only contain the minimal changes for SidebarUnreadModel before requesting
review.
🪄 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

Run ID: dda161a5-6e6a-4406-99f1-63b06cb72a3e

📥 Commits

Reviewing files that changed from the base of the PR and between 4cdbd4e and be75130.

⛔ Files ignored due to path filters (1)
  • .github/swift-file-length-budget.tsv is excluded by !**/*.tsv
📒 Files selected for processing (2)
  • Sources/AppDelegate.swift
  • Sources/ContentView.swift

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

Caution

Inline review comments failed to post. This is likely due to GitHub's internal server error or limits when posting large numbers of comments. If you are seeing this consistently it is likely a permissions issue. Please check "Moderation" -> "Code review limits" under your organization settings.

Actionable comments posted: 1

🤖 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 `@Sources/ContentView.swift`:
- Line 5: This PR mixes multiple unrelated features; split the changes into
focused PRs: keep the SidebarUnreadModel work (references: SidebarUnreadModel
and usage like sidebarUnread.unreadCount) in this PR and remove unrelated
additions (references: sidebarRenderWorkerClient binding/injection, functions
sidebarGitBranchesInDisplayOrder and customSidebarPullRequestValues, and the
remote-connection suspended state + reconnect UI code paths); create separate
PRs for the RenderWorkerClient threading (sidebarRenderWorkerClient), the Git
branch ordering refactor (sidebarGitBranchesInDisplayOrder,
customSidebarPullRequestValues), and the remote connection suspended/reconnect
feature (suspended state handling and reconnect UI), then update this branch to
only contain the minimal changes for SidebarUnreadModel before requesting
review.
🪄 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

Run ID: dda161a5-6e6a-4406-99f1-63b06cb72a3e

📥 Commits

Reviewing files that changed from the base of the PR and between 4cdbd4e and be75130.

⛔ Files ignored due to path filters (1)
  • .github/swift-file-length-budget.tsv is excluded by !**/*.tsv
📒 Files selected for processing (2)
  • Sources/AppDelegate.swift
  • Sources/ContentView.swift
🛑 Comments failed to post (1)
Sources/ContentView.swift (1)

5-5: ⚠️ Potential issue | 🟠 Major | ⚖️ Poor tradeoff

PR scope concern: multiple unrelated features bundled together.

The provided hunks include several features not mentioned in the PR objectives:

  1. RenderWorkerClient infrastructure (lines 5, 1093, 2039, 10631, 11426): Threading a sidebarRenderWorkerClient binding through the view hierarchy for sidebar extension rendering.

  2. Git branch ordering refactor (lines 10750, 10754-10757, 11883): Method name changes to sidebarGitBranchesInDisplayOrder() and customSidebarPullRequestValues().

  3. Remote connection suspended state + Reconnect UI (lines 15211, 15490-15600, 15509-15510, 15543-15544, 16653-16661, 16714): A complete feature for handling .suspended remote connection state with manual reconnect affordance.

The PR objectives state this PR is focused on introducing SidebarUnreadModel to decouple the workspace sidebar from TerminalNotificationStore churn for performance. Only line 11886 (sidebarUnread.unreadCount) clearly relates to that objective in the provided hunks.

Bundling unrelated features makes the PR harder to review, test, and potentially revert if issues arise. Consider splitting into focused PRs per the shared behavior policy.

Also applies to: 1093-1093, 2039-2039, 10631-10631, 10750-10757, 11425-11426, 11883-11883, 15211-15211, 15490-15600, 15509-15510, 15543-15544, 16653-16661, 16714-16714

🤖 Prompt for 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.

In `@Sources/ContentView.swift` at line 5, This PR mixes multiple unrelated
features; split the changes into focused PRs: keep the SidebarUnreadModel work
(references: SidebarUnreadModel and usage like sidebarUnread.unreadCount) in
this PR and remove unrelated additions (references: sidebarRenderWorkerClient
binding/injection, functions sidebarGitBranchesInDisplayOrder and
customSidebarPullRequestValues, and the remote-connection suspended state +
reconnect UI code paths); create separate PRs for the RenderWorkerClient
threading (sidebarRenderWorkerClient), the Git branch ordering refactor
(sidebarGitBranchesInDisplayOrder, customSidebarPullRequestValues), and the
remote connection suspended/reconnect feature (suspended state handling and
reconnect UI), then update this branch to only contain the minimal changes for
SidebarUnreadModel before requesting review.

@lawrencecchen
lawrencecchen merged commit 6443a7d into main Jun 11, 2026
25 checks passed
@lawrencecchen
lawrencecchen deleted the feat-scroll-perf-cleanup branch June 11, 2026 08:07
hhsw2015 pushed a commit to hhsw2015/cmux that referenced this pull request Jun 12, 2026
…browser CLI, pairing QR, iOS

PRs included:
- manaflow-ai#5816 ControlCommandCoordinator extraction (package coordinator skeleton; fork keeps legacy v2* dispatchers)
- manaflow-ai#5859 sidebar perf
- manaflow-ai#5857 RendererRealization (added as SurfaceHibernation adapter)
- manaflow-ai#5867 in-process custom sidebars
- manaflow-ai#5778 browser CLI / system-proxy bypass
- manaflow-ai#5872 minimal pairing QR
- iOS pairing/manual-entry stack
- 30+ hot fixes

Fork-side adjustments:
- Skip 21 TerminalController+Control* extension files (PR manaflow-ai#5816 architecture refactor not adopted)
- Add Sources/App/RendererRealizationSettingsAdapter.swift to bridge new RendererRealizationSettings to fork's existing SurfaceHibernationSettings
- Restore v2SurfaceDragToSplit shim removed by upstream
- Add SettingsNavigationTarget.customSidebars case
- Stub ghostty_surface_set_renderer_realized callsites pending GhosttyKit rebuild (zig 0.15.2 required, host has 0.16.0)
- Update ghostty submodule to 44b2baa81 (cherry-pick the 3 renderer commits onto fork's manaflow-ai#5128 link-fix pointer)
- Keep fork's CMUXSessionDaemon module pbxproj refs and SurfaceHibernation settings
azooz2003-bit added a commit that referenced this pull request Jun 13, 2026
…021 + 6026) (#6033)

* Sidebar: remove whole-content rows-height measurement (fixes layout livelock)

Replace the LazyVStack background GeometryReader ->
SidebarWorkspaceRowsHeightPreferenceKey -> @State workspaceRowsMeasurement ->
emptyAreaHeight round trip with SidebarRowsFillLayout, a custom Layout that
places the rows at their natural height and stretches the empty drop/tap area
to fill the remaining viewport from its own concrete bounds, in one geometry
pass with no state writes.

The preference write during layout fed a non-converging relayout transaction:
main thread pinned 100%+ in GraphHost.flushTransactions ->
LazySubviewPlacements.placeSubviews -> LazyStack.place ->
ForEachList.applyNodes. A fresh 2026-06-12 capture on stable 0.64.15 (which
already contains the mitigations from
#5708,
#5846,
#5855, and
#5859) shows the identical signature:
128% CPU, 400 threads, debug socket refusing connections, 3344/3715 main-thread
samples inside flushTransactions. The rows-height key is the last live
write-during-layout edge in the sidebar after
#5325 (frame anchors) and
#5708 (row IDs) removed their siblings.

Same approach as #5852, re-ported on
top of the #5846 pixel-alignment work
(contentMinHeight flooring is kept; only the empty-area math moves into the
Layout).

Fixes #5764.
Helps #2586,
#5570,
#5845.

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

* fix: create bundled helper directory before install

* Sidebar: replace render-item String id with an allocation-free Hashable enum

ForEach(renderItems, id: \.id) gathers every row's identifier on each list
diff, and the sidebar re-diffs all rows per update. The previous computed
String id ("workspace.\(uuid.uuidString)") allocated and formatted a fresh
36-char string per access; SidebarWorkspaceRenderItem.id.getter was the
hottest app-owned frame in the
#5764 livelock spindump.

SidebarWorkspaceRenderItemID is a two-case enum over UUID: identity compare
and hash with zero heap allocation, and group headers can never collide with
workspace rows on the same UUID (same guarantee the string prefixes gave).
Identity values are unchanged in meaning, so row lifetime and animations are
unaffected; nothing persisted the string form (the only consumers are the
ForEach key path and scrollTo, which targets the explicit inner .id(tab.id)
UUIDs, not the ForEach identity).

Pure per-pass cost cut for #5764,
#5845,
#2586; complements the structural
loop fix in #6019.

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

* Sidebar: make rows height-stable under agent churn (no height animation, eager markdown)

Two changes that stop agent activity from continuously varying sidebar row
heights, which kept re-feeding the sidebar-wide layout/measurement cycle at
animation frame rate (#5764,
#5845):

1. Remove the three implicit .animation(value:) modifiers on agent-mutable
   snapshot fields (latestLog, progress, metadataBlocks.count) and reduce the
   four height-moving .transition(.opacity.combined(.move(edge: .top)))
   modifiers in TabItemView's log/progress/metadata sections to
   .transition(.opacity). While a row-height animation runs, every frame
   produces a different LazyVStack content height; with dozens of agent
   sessions some row is always animating. Content changes now apply in one
   discrete layout pass.

2. SidebarMetadataMarkdownBlockRow parsed its markdown in onAppear into
   @State: a guaranteed nil -> attributed swap (and height change) on every
   first appearance of every block scrolling in. It now renders inline via a
   new SidebarMetadataMarkdownRenderer with a bounded (512-entry) memo cache,
   so the FIRST render is already attributed and appearance performs no state
   write and no height change. Matches the SidebarWorkspaceDescriptionText
   sibling, plus memoization to keep repeat body evals cheap and growth
   bounded.

WWDC backing: lazy rows must be height-stable after appearing; initialize row
state in the initializer, not onAppear (WWDC26 "Dive into lazy stacks", 321);
keep body cheap / precompute (WWDC23 10160, WWDC25 306).

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

* Cache failed parses with updateValue (subscript assignment drops nil values)

With [String: AttributedString?], `cache[markdown] = parsed` removes the key
when parsed is nil, so unparseable blocks re-parsed on every body eval and
appended phantom keys to insertionOrder, mis-evicting valid entries once at
capacity. Caught by Greptile on the PR.

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

* Byte-bound the metadata markdown cache (autoreview P1)

The 512-entry cap bounded entry count but not retained bytes. Metadata blocks
are agent/control-socket supplied and uncapped at this boundary, so a key
churning large unique markdown could keep hundreds of big payloads alive after
the workspace metadata was overwritten or cleared (worse than the old
row-local @State, which released on update).

Skip caching blocks over 4096 UTF-8 bytes: they parse inline each eval (rare,
still attributed from the first frame), and total retained cache bytes are now
bounded by capacity * maxCacheableBytes regardless of churn.

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

* Plain-text fallback for oversized metadata blocks (autoreview P1)

Parsing >4KB blocks inline (previous commit) removed the retention but moved
the cost to CPU: TabItemView.body re-runs on snapshot changes under agent
churn, so a large block reparsed each time. Return nil for oversized blocks
instead, so the row falls back to the existing Text(block.markdown) plain
path: no parse, no retention, and height-stable (the result never changes for
a given block, so no nil->attributed swap). Small blocks still cache and
render as markdown.

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

* Size the sidebar empty area from an explicit viewport, not the layout proposal (autoreview P2)

SidebarRowsFillLayout derived its container height from
proposal.replacingUnspecifiedDimensions(). A vertical ScrollView leaves the
scroll-axis height unspecified, so that fell back to a 10pt placeholder and the
empty area collapsed to 0 whenever the rows fit the viewport — dropping the
blank area below the last row out of the double-click/drop target.

Pass the viewport height (minHeight, the floored content height the call site
already computes from the scroll geometry) into the layout explicitly and size
the empty area from it. New emptyAreaFillHeight(viewportHeight:rowsHeight:)
overload encodes container = max(viewport, rows).

Verified at runtime via temporary instrumentation (since removed): rows fit ->
viewport=628 rows=421 empty=207 and rows=370 empty=258; rows overflow ->
viewport=628 rows=676 empty=0. Added unit coverage for both the fit and
overflow viewport paths.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
ShubhamPatilsd pushed a commit to emergent-inc/mosaic that referenced this pull request Jul 9, 2026
…021 + 6026) (#6033)

* Sidebar: remove whole-content rows-height measurement (fixes layout livelock)

Replace the LazyVStack background GeometryReader ->
SidebarWorkspaceRowsHeightPreferenceKey -> @State workspaceRowsMeasurement ->
emptyAreaHeight round trip with SidebarRowsFillLayout, a custom Layout that
places the rows at their natural height and stretches the empty drop/tap area
to fill the remaining viewport from its own concrete bounds, in one geometry
pass with no state writes.

The preference write during layout fed a non-converging relayout transaction:
main thread pinned 100%+ in GraphHost.flushTransactions ->
LazySubviewPlacements.placeSubviews -> LazyStack.place ->
ForEachList.applyNodes. A fresh 2026-06-12 capture on stable 0.64.15 (which
already contains the mitigations from
manaflow-ai/cmux#5708,
manaflow-ai/cmux#5846,
manaflow-ai/cmux#5855, and
manaflow-ai/cmux#5859) shows the identical signature:
128% CPU, 400 threads, debug socket refusing connections, 3344/3715 main-thread
samples inside flushTransactions. The rows-height key is the last live
write-during-layout edge in the sidebar after
manaflow-ai/cmux#5325 (frame anchors) and
manaflow-ai/cmux#5708 (row IDs) removed their siblings.

Same approach as manaflow-ai/cmux#5852, re-ported on
top of the manaflow-ai/cmux#5846 pixel-alignment work
(contentMinHeight flooring is kept; only the empty-area math moves into the
Layout).

Fixes manaflow-ai/cmux#5764.
Helps manaflow-ai/cmux#2586,
manaflow-ai/cmux#5570,
manaflow-ai/cmux#5845.

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

* fix: create bundled helper directory before install

* Sidebar: replace render-item String id with an allocation-free Hashable enum

ForEach(renderItems, id: \.id) gathers every row's identifier on each list
diff, and the sidebar re-diffs all rows per update. The previous computed
String id ("workspace.\(uuid.uuidString)") allocated and formatted a fresh
36-char string per access; SidebarWorkspaceRenderItem.id.getter was the
hottest app-owned frame in the
manaflow-ai/cmux#5764 livelock spindump.

SidebarWorkspaceRenderItemID is a two-case enum over UUID: identity compare
and hash with zero heap allocation, and group headers can never collide with
workspace rows on the same UUID (same guarantee the string prefixes gave).
Identity values are unchanged in meaning, so row lifetime and animations are
unaffected; nothing persisted the string form (the only consumers are the
ForEach key path and scrollTo, which targets the explicit inner .id(tab.id)
UUIDs, not the ForEach identity).

Pure per-pass cost cut for manaflow-ai/cmux#5764,
manaflow-ai/cmux#5845,
manaflow-ai/cmux#2586; complements the structural
loop fix in manaflow-ai/cmux#6019.

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

* Sidebar: make rows height-stable under agent churn (no height animation, eager markdown)

Two changes that stop agent activity from continuously varying sidebar row
heights, which kept re-feeding the sidebar-wide layout/measurement cycle at
animation frame rate (manaflow-ai/cmux#5764,
manaflow-ai/cmux#5845):

1. Remove the three implicit .animation(value:) modifiers on agent-mutable
   snapshot fields (latestLog, progress, metadataBlocks.count) and reduce the
   four height-moving .transition(.opacity.combined(.move(edge: .top)))
   modifiers in TabItemView's log/progress/metadata sections to
   .transition(.opacity). While a row-height animation runs, every frame
   produces a different LazyVStack content height; with dozens of agent
   sessions some row is always animating. Content changes now apply in one
   discrete layout pass.

2. SidebarMetadataMarkdownBlockRow parsed its markdown in onAppear into
   @State: a guaranteed nil -> attributed swap (and height change) on every
   first appearance of every block scrolling in. It now renders inline via a
   new SidebarMetadataMarkdownRenderer with a bounded (512-entry) memo cache,
   so the FIRST render is already attributed and appearance performs no state
   write and no height change. Matches the SidebarWorkspaceDescriptionText
   sibling, plus memoization to keep repeat body evals cheap and growth
   bounded.

WWDC backing: lazy rows must be height-stable after appearing; initialize row
state in the initializer, not onAppear (WWDC26 "Dive into lazy stacks", 321);
keep body cheap / precompute (WWDC23 10160, WWDC25 306).

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

* Cache failed parses with updateValue (subscript assignment drops nil values)

With [String: AttributedString?], `cache[markdown] = parsed` removes the key
when parsed is nil, so unparseable blocks re-parsed on every body eval and
appended phantom keys to insertionOrder, mis-evicting valid entries once at
capacity. Caught by Greptile on the PR.

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

* Byte-bound the metadata markdown cache (autoreview P1)

The 512-entry cap bounded entry count but not retained bytes. Metadata blocks
are agent/control-socket supplied and uncapped at this boundary, so a key
churning large unique markdown could keep hundreds of big payloads alive after
the workspace metadata was overwritten or cleared (worse than the old
row-local @State, which released on update).

Skip caching blocks over 4096 UTF-8 bytes: they parse inline each eval (rare,
still attributed from the first frame), and total retained cache bytes are now
bounded by capacity * maxCacheableBytes regardless of churn.

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

* Plain-text fallback for oversized metadata blocks (autoreview P1)

Parsing >4KB blocks inline (previous commit) removed the retention but moved
the cost to CPU: TabItemView.body re-runs on snapshot changes under agent
churn, so a large block reparsed each time. Return nil for oversized blocks
instead, so the row falls back to the existing Text(block.markdown) plain
path: no parse, no retention, and height-stable (the result never changes for
a given block, so no nil->attributed swap). Small blocks still cache and
render as markdown.

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

* Size the sidebar empty area from an explicit viewport, not the layout proposal (autoreview P2)

SidebarRowsFillLayout derived its container height from
proposal.replacingUnspecifiedDimensions(). A vertical ScrollView leaves the
scroll-axis height unspecified, so that fell back to a 10pt placeholder and the
empty area collapsed to 0 whenever the rows fit the viewport — dropping the
blank area below the last row out of the double-click/drop target.

Pass the viewport height (minHeight, the floored content height the call site
already computes from the scroll geometry) into the layout explicitly and size
the empty area from it. New emptyAreaFillHeight(viewportHeight:rowsHeight:)
overload encodes container = max(viewport, rows).

Verified at runtime via temporary instrumentation (since removed): rows fit ->
viewport=628 rows=421 empty=207 and rows=370 empty=258; rows overflow ->
viewport=628 rows=676 empty=0. Added unit coverage for both the fit and
overflow viewport paths.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
@lawrencecchen
lawrencecchen restored the feat-scroll-perf-cleanup branch July 18, 2026 10:24

This branch was successfully deployed

1 active deployment
Preview – cmux — be75130e Deployed Jun 11, 2026 by vercel[bot]
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