Repository navigation
sidebar perf: decouple workspace sidebar from notification-store churn - #5859
Conversation
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>
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
📝 WalkthroughWalkthroughThis 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. ChangesSidebar Unread State Observation
Estimated code review effort🎯 3 (Moderate) | ⏱️ ~20 minutes Possibly related PRs
Poem
Important Pre-merge checks failedPlease resolve all errors before merging. Addressing warnings is optional. ❌ Failed checks (3 errors, 1 warning)
✅ Passed checks (17 passed)
✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
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. Comment |
Greptile SummaryIntroduces
Confidence Score: 5/5Safe 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
Sequence DiagramsequenceDiagram
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(...)
Reviews (3): Last reviewed commit: "Merge remote-tracking branch 'origin/mai..." | Re-trigger Greptile |
| @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> = [] |
There was a problem hiding this comment.
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
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 1 potential issue.
❌ 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 | ||
| } | ||
| } | ||
|
|
There was a problem hiding this comment.
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)
Reviewed by Cursor Bugbot for commit be75130. Configure here.
There was a problem hiding this comment.
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
⛔ Files ignored due to path filters (1)
.github/swift-file-length-budget.tsvis excluded by!**/*.tsv
📒 Files selected for processing (2)
Sources/AppDelegate.swiftSources/ContentView.swift
There was a problem hiding this comment.
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
⛔ Files ignored due to path filters (1)
.github/swift-file-length-budget.tsvis excluded by!**/*.tsv
📒 Files selected for processing (2)
Sources/AppDelegate.swiftSources/ContentView.swift
🛑 Comments failed to post (1)
Sources/ContentView.swift (1)
5-5:
⚠️ Potential issue | 🟠 Major | ⚖️ Poor tradeoffPR scope concern: multiple unrelated features bundled together.
The provided hunks include several features not mentioned in the PR objectives:
RenderWorkerClient infrastructure (lines 5, 1093, 2039, 10631, 11426): Threading a
sidebarRenderWorkerClientbinding through the view hierarchy for sidebar extension rendering.Git branch ordering refactor (lines 10750, 10754-10757, 11883): Method name changes to
sidebarGitBranchesInDisplayOrder()andcustomSidebarPullRequestValues().Remote connection suspended state + Reconnect UI (lines 15211, 15490-15600, 15509-15510, 15543-15544, 16653-16661, 16714): A complete feature for handling
.suspendedremote connection state with manual reconnect affordance.The PR objectives state this PR is focused on introducing
SidebarUnreadModelto decouple the workspace sidebar fromTerminalNotificationStorechurn 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.
…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
…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>
…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>
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
TabItemViewwheneverTerminalNotificationStorepublishes, 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
ContentViewandVerticalTabsSidebarheld@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-guardedObservableObjectthat mirrors the store's unread queries (badge count, latest text, per-surface unread, read indicator, manual-unread).TerminalNotificationStoredrives it from its singlerefreshUnreadPresentation()hub, so notification churn that doesn't change a workspace's displayed unread no longer publishes.ContentViewandVerticalTabsSidebarnow observeSidebarUnreadModelinstead of the notification store.TerminalNotificationStore.sharedstays reachable (singleton) for context-menu actions and pass-down;TabItemViewalready held an unobservedlet. 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
focusedReadIndicatordidSet) exercised on all mutation paths.scrollperf): fast-scroll smoother, unread badges appear/clear correctly, sidebar drag/drop works.Scope / follow-ups
ContentViewstill observestabManager(unchanged). If a future profile shows tabManager churn dominating, that's a separate pass.NotificationsPage.swift,UpdateTitlebarAccessory.swift) andCmuxTopProcessSnapshotpoll-cost trims.🤖 Generated with Claude Code
Need help on this PR? Tag
/codesmithwith 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 fromTerminalNotificationStore.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.ContentViewandVerticalTabsSidebarnow observesidebarUnreadinstead of the full notification store; unread-related reads (overlays, command palette, custom sidebar data) go through the model.AppDelegateinjectsnotificationStore.sidebarUnreadinto the SwiftUI environment while keeping the store for actions viaTerminalNotificationStore.shared.Also refreshes
focusedReadIndicatorByTabIdhandling so read-indicator changes sync into the coalesced model, and bumps the Swift file-length budget forTerminalNotificationStore.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.
SidebarUnreadModel(equality-guarded) withtotalUnreadCount, 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 tosidebarUnread;focusedReadIndicatorByTabIdupdates trigger a refresh.ContentViewandVerticalTabsSidebarto observeSidebarUnreadModel; replaced unread queries (badge counts, latest text, per-surface unread, can-mark read/unread) with model APIs; keptTerminalNotificationStore.sharedunobserved for actions.notificationStore.sidebarUnreadinto the environment inAppDelegate.Written for commit be75130. Summary will update on new commits.
Summary by CodeRabbit