Skip to content

Fix idle CPU usage from animation timeline and git polling - #2561

Closed
austinywang wants to merge 6 commits into
mainfrom
issue-2540-idle-cpu-usage
Closed

austinywang wants to merge 6 commits into
mainfrom
issue-2540-idle-cpu-usage

Conversation

@austinywang

@austinywang austinywang commented Apr 3, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Closes #2540

  • Stop continuous animation rendering when idle: TmuxWorkspacePaneOverlayView previously used TimelineView(.animation) unconditionally, causing the GPU/CPU to render every frame even with nothing visible. Now it conditionally switches between TimelineView(.animation) (only during flash), static Canvas (unread indicators only), or Color.clear (nothing visible).
  • Auto-reset flash state: Flash animation now schedules a DispatchWorkItem to clear flashStartedAt after FocusFlashPattern.duration, so the view naturally falls back to the cheaper render path.
  • Reduce git metadata polling frequency: Background workspace polling increased from 30s to 5min, selected workspace from 5s to 2min. Workspace selection changes now trigger an immediate refresh, so switching tabs still feels responsive without constant polling.
  • Use reactive visibility: Overlay container visibility is now driven by a Combine publisher on the model, replacing manual view reconstruction on every update.

Test plan

  • Verify idle CPU usage drops significantly when no flash/unread indicators are active (Activity Monitor)
  • Verify pane focus flash still animates correctly when switching panes
  • Verify unread indicators still render on workspace tabs
  • Verify git/PR metadata still refreshes on workspace selection change
  • Verify background PR metadata eventually refreshes for open PRs

🤖 Generated with Claude Code


Summary by cubic

Stops continuous overlay animation and slows git/PR polling to cut idle CPU/GPU use. Closes #2540 and fixes overlay re-entrancy and prevents flash replay after completion.

  • Bug Fixes
    • Overlay: switch to a callback-driven model; animate only during flash, use static Canvas for unread, hide when empty via hasVisibleContent; latch completed flash tokens to avoid replay.
    • Flash: auto-clear after FocusFlashPattern.duration; preserve across transient nil rects and resume when rect returns; cancel/reset on workspace change or when superseded.
    • Git/PR polling: background set to 5 min, selected workspace to 2 min; immediate refresh on tab selection that preserves in-flight bootstrap retries; periodic polls only for open PR panels; gh probe timeout set to 5s.

Written for commit 792e1c2. Summary will update on new commits.

Summary by CodeRabbit

  • Performance

    • Reduced git/PR polling frequency and added an immediate metadata refresh on workspace selection to balance resource use and freshness.
  • New Features

    • Reworked overlay rendering to persist and reuse the view, with visibility auto-updating from model state.
    • Improved animated flash and unread-indicator behavior for more consistent visuals across transient updates.
  • Tests

    • Added unit tests covering flash lifecycle, transient flash behavior, and pull-request probe/retry scenarios.

@vercel

vercel Bot commented Apr 3, 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 Apr 6, 2026 7:16am

@coderabbitai

coderabbitai Bot commented Apr 3, 2026 •

Copy link
Copy Markdown

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 1ced3f0f-0ff9-401c-a6f3-3a17fdcb2d04

📥 Commits

Reviewing files that changed from the base of the PR and between f91d201 and 792e1c2.

📒 Files selected for processing (2)
  • Sources/WorkspaceContentView.swift
  • cmuxTests/WindowAndDragTests.swift
🚧 Files skipped from review as they are similar to previous changes (1)
  • Sources/WorkspaceContentView.swift

📝 Walkthrough

Walkthrough

Controller now installs a persistent NSHostingView root and drives overlay visibility from a model callback; overlay model flash lifecycle was rewritten to use token-guarded state and a cancellable delayed-reset work item; TabManager polling cadence and immediate selected-workspace refresh scheduling were changed and exposed for testing.

Changes

Cohort / File(s) Summary
Overlay controller & hosting
Sources/ContentView.swift
Controller creates and retains a single NSHostingView root, moves root assignment into renderModelState(), and subscribes to model.onStateChange to update containerView visibility. update(state:) only applies model state then calls renderModelState().
Overlay model & view rendering
Sources/WorkspaceContentView.swift
TmuxWorkspacePaneOverlayModel removed ObservableObject/@Published in favor of @MainActor with private(set) state, onStateChange callback, derived showsAnimatedFlash/hasVisibleContent, token-tracked flash lifecycle, and a cancellable DispatchWorkItem reset. TmuxWorkspacePaneOverlayView consolidates drawing into renderOverlay(in:currentDate:) and conditionally uses TimelineView(.animation) only when an animated flash is active.
Polling & refresh scheduling
Sources/TabManager.swift
Adjusted polling intervals (background and selected-workspace changed). Added scheduleSelectedWorkspaceGitMetadataRefresh(reason:) to trigger immediate refresh on selection changes with guards against duplicate in-flight probes. Added test helper workspaceGitProbeAttemptCountForTesting(workspaceId:panelId:).
Tests — overlay & TabManager
cmuxTests/WindowAndDragTests.swift, cmuxTests/TabManagerUnitTests.swift
Added unit tests covering overlay flash lifecycle behaviors and transient-nil rect handling. Added TabManager tests that create temp git repos and a gh shim to validate probe retry and slow-probe behavior; introduced PATH mutation helper and synchronization lock for tests.

Sequence Diagram(s)

sequenceDiagram
    participant Controller as "WindowTmuxWorkspacePaneOverlayController"
    participant Model as "TmuxWorkspacePaneOverlayModel"
    participant Hosting as "NSHostingView / SwiftUI View"
    participant Scheduler as "DispatchQueue (reset workItem)"

    Controller->>Model: update(state:)
    Model-->>Controller: onStateChange()
    Controller->>Hosting: renderModelState() -> set hostingView.rootView
    alt Model.hasVisibleContent
        Controller->>Controller: update containerView.alpha/isHidden
        Hosting->>Hosting: renderOverlay(in: currentDate)
    else
        Controller->>Controller: hide containerView
    end
    Note over Model,Scheduler: when flash starts -> schedule delayed reset
    Model->>Scheduler: dispatch workItem (captures token/workspace)
    Scheduler-->>Model: workItem executes if token/workspace match
    Model-->>Controller: onStateChange() (flash cleared)
    Controller->>Hosting: renderModelState() updates view
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

Poem

🐰
I kept a root so views stay true,
Flashes wait, then bloom anew,
Timers nap while probes align,
I nibble bugs and sip carrot brine,
Hooray — the overlay hops fine!

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately and concisely summarizes the primary changes: fixing idle CPU usage by addressing animation timeline and git polling.
Description check ✅ Passed The description covers the summary with clear bullet points explaining what changed and why, and includes a test plan checklist addressing key behavioral aspects.
Linked Issues check ✅ Passed The PR directly addresses issue #2540 by reducing animation rendering when idle and lowering git polling frequencies, matching the core requirement to fix idle CPU usage.
Out of Scope Changes check ✅ Passed All changes are directly related to the stated objectives: animation optimization, flash auto-reset, polling frequency reduction, reactive visibility, and supporting tests.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ 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 issue-2540-idle-cpu-usage

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.

@cubic-dev-ai cubic-dev-ai 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.

No issues found across 3 files

@greptile-apps

greptile-apps Bot commented Apr 3, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR fixes two sources of idle CPU/GPU usage: TmuxWorkspacePaneOverlayView now conditionally uses TimelineView(.animation) only during an active flash (auto-reset via a DispatchWorkItem after FocusFlashPattern.duration), and background git polling intervals are increased from 30 s → 5 min / 5 s → 2 min with an immediate refresh on workspace selection change. Container visibility is now driven reactively via a CombineLatest3 Combine publisher instead of manual view reconstruction.

Confidence Score: 5/5

Safe to merge — no logic, data integrity, or runtime correctness issues found; remaining findings are style and concurrency-annotation suggestions.

All findings are P2: a missing MainActor.assumeIsolated wrapper (runtime-safe, compiler hygiene only) and multiple intermediate Combine emissions on sequential @published mutations (sub-frame, visually harmless). Flash-reset timing logic, workspace-change guards, and git polling deduplication all look correct.

Sources/WorkspaceContentView.swift — scheduleFlashReset DispatchWorkItem should use MainActor.assumeIsolated for strict concurrency hygiene.

Important Files Changed

Filename Overview
Sources/WorkspaceContentView.swift Core animation-idle fix: TmuxWorkspacePaneOverlayModel and TmuxWorkspacePaneOverlayView rewritten to conditionally use TimelineView only during flash; DispatchWorkItem schedules flashStartedAt reset after FocusFlashPattern.duration (0.9s) with correct workspace/token guards.
Sources/TabManager.swift Background git poll increased 30s→5min, selected-workspace poll 5s→2min; workspace selection change now triggers an immediate refresh via scheduleSelectedWorkspaceGitMetadataRefresh; timer deduplication via workspaceGitProbeGenerationByKey handles rapid successive calls correctly.
Sources/ContentView.swift WindowTmuxWorkspacePaneOverlayController now uses CombineLatest3 on three @published properties to drive container visibility reactively; multiple sequential property mutations in apply() each trigger a separate CombineLatest3 emission before the sink coalesces them.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    A[apply state called] --> B{didChangeWorkspace?}
    B -- yes --> C[Set unreadRects / flashRect / flashReason\nCancelFlashReset\nflashStartedAt = nil\nReturn early]
    B -- no --> D{flashRect == nil?}
    D -- yes --> E[CancelFlashReset\nflashStartedAt = nil]
    D -- no --> F{flashToken changed?}
    E --> G[Update lastFlashToken]
    F -- yes --> H[flashStartedAt = now\nscheduleFlashReset after 0.9s]
    F -- no --> G
    H --> G
    G --> I[CombineLatest3 emits]
    C --> I
    I --> J{hasVisibleContent?}
    J -- yes --> K[containerView shown]
    J -- no --> L[containerView hidden]
    K --> M{showsAnimatedFlash?}
    M -- yes --> N[TimelineView animation]
    M -- no --> O{unreadRects non-empty?}
    O -- yes --> P[Static Canvas]
    O -- no --> Q[Color.clear]
    H --> R[DispatchWorkItem after 0.9s]
    R --> S{workspaceId and flashToken match?}
    S -- yes --> T[flashStartedAt = nil]
    S -- no --> U[No-op]
Loading

Reviews (1): Last reviewed commit: "wip" | Re-trigger Greptile

Comment on lines +168 to +181
let workItem = DispatchWorkItem { [weak self] in
guard let self else { return }
guard self.lastWorkspaceId == workspaceId,
self.lastFlashToken == flashToken else {
return
}

self.flashStartedAt = nil
}
flashResetWorkItem = workItem
DispatchQueue.main.asyncAfter(
deadline: .now() + FocusFlashPattern.duration,
execute: workItem
)

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 @MainActor-isolated property access in @Sendable closure

DispatchWorkItem takes a @Sendable closure, so Swift Concurrency's type checker sees access to self.lastWorkspaceId, self.lastFlashToken, and self.flashStartedAt (all @MainActor-isolated) as potential data races. At runtime this is safe because the work item is dispatched to DispatchQueue.main, but the compiler cannot verify the equivalence between DispatchQueue.main and @MainActor in a @Sendable closure without an explicit annotation.

Wrapping the body with MainActor.assumeIsolated documents the intent and avoids warnings under -strict-concurrency=complete:

Suggested change
let workItem = DispatchWorkItem { [weak self] in
guard let self else { return }
guard self.lastWorkspaceId == workspaceId,
self.lastFlashToken == flashToken else {
return
}
self.flashStartedAt = nil
}
flashResetWorkItem = workItem
DispatchQueue.main.asyncAfter(
deadline: .now() + FocusFlashPattern.duration,
execute: workItem
)
let workItem = DispatchWorkItem { [weak self] in
MainActor.assumeIsolated {
guard let self else { return }
guard self.lastWorkspaceId == workspaceId,
self.lastFlashToken == flashToken else {
return
}
self.flashStartedAt = nil
}
}

Comment thread Sources/ContentView.swift Outdated
Comment on lines +1337 to +1348
modelVisibilityCancellable = Publishers.CombineLatest3(
model.$unreadRects,
model.$flashRect,
model.$flashStartedAt
)
.receive(on: RunLoop.main)
.sink { [weak self] unreadRects, flashRect, flashStartedAt in
guard let self else { return }
let hasVisibleContent = !unreadRects.isEmpty || (flashRect != nil && flashStartedAt != nil)
self.containerView.alphaValue = hasVisibleContent ? 1 : 0
self.containerView.isHidden = !hasVisibleContent
}

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 Multiple intermediate CombineLatest3 emissions from sequential @Published mutations

apply() mutates unreadRects, flashRect, flashReason, and flashStartedAt in sequence. Each assignment fires a separate @Published event, so CombineLatest3 emits up to four times before settling on the final state. With receive(on: RunLoop.main), each event is queued as a separate RunLoop callback and the sink runs once per event — meaning containerView.isHidden and containerView.alphaValue can be toggled multiple times within the same logical update.

This is visually harmless today (AppKit defers compositing), but it produces unnecessary AppKit property churn on every overlay update. Coalescing the sink with .removeDuplicates() would eliminate the intermediate states without changing any externally observable behaviour.

@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

🧹 Nitpick comments (3)
Sources/TabManager.swift (1)

1030-1051: Consider a low-frequency fallback probe for non-open cached PR states.

With this filter, background polling won’t re-check panels currently marked merged/closed, so remote reopen transitions can stay stale until explicit selection/manual triggers. A very low-rate fallback probe (e.g., sampled focused panel) would preserve the CPU win while improving eventual consistency.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Sources/TabManager.swift` around lines 1030 - 1051, The current
trackedWorkspaceGitMetadataPeriodicPollCandidatePanelIds function excludes
panels whose pullRequest.status != .open so closed/merged PRs never get
background rechecked; change the selection logic to include a very-low-frequency
fallback (e.g., always include workspace.focusedPanelId even if its
pullRequest.status != .open, or probabilistically sample a small subset of
non-open panels from workspace.panelPullRequests) before building the filtered
Set; ensure you still respect activeProbeKeys by constructing
WorkspaceGitProbeKey(workspaceId: workspace.id, panelId: panelId) and skipping
probes already in activeProbeKeys, but allow the fallback panel(s) through so
reopen transitions are eventually detected.
Sources/WorkspaceContentView.swift (1)

127-129: Skip no-op @Published writes in apply(_:).

Now that the overlay host is driven directly from this model, assigning the same unreadRects / flashRect / flashReason still emits and forces another visibility/body pass. Cheap equality guards here would trim a bit more idle churn.

♻️ Proposed change
-        unreadRects = state.unreadRects
-        flashRect = state.flashRect
-        flashReason = state.flashReason
+        if unreadRects != state.unreadRects {
+            unreadRects = state.unreadRects
+        }
+        if flashRect != state.flashRect {
+            flashRect = state.flashRect
+        }
+        if flashReason != state.flashReason {
+            flashReason = state.flashReason
+        }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Sources/WorkspaceContentView.swift` around lines 127 - 129, The apply(_:)
path currently assigns unreadRects, flashRect and flashReason unconditionally
which triggers redundant `@Published` emissions; change apply(_:) to compare
incoming state values to the current properties (unreadRects, flashRect,
flashReason) and only assign when the new value is != the existing value so you
avoid no-op publishes and extra SwiftUI updates. Locate the apply(_:)
implementation (and the properties unreadRects, flashRect, flashReason) and add
cheap equality guards per property (or a combined equality check) to skip
assignments and early-return when nothing changed.
Sources/ContentView.swift (1)

1337-1348: Keep overlay visibility as a single model-owned rule.

This re-derives visibility from unreadRects / flashRect / flashStartedAt inside the controller. Now that the view is driven by TmuxWorkspacePaneOverlayModel, it would be safer to expose one visibility contract from the model and consume that here; otherwise the container hiding logic can drift from the view’s render logic on a future change.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Sources/ContentView.swift` around lines 1337 - 1348, The controller currently
recomputes visibility from model.$unreadRects, model.$flashRect and
model.$flashStartedAt inside modelVisibilityCancellable; instead, add a single
visibility contract on TmuxWorkspacePaneOverlayModel (for example a published
property like isVisible or a visibilityPublisher) that encapsulates the logic,
and update this sink to subscribe only to that model property (e.g.,
model.$isVisible or model.visibilityPublisher) and set
containerView.alphaValue/isHidden based on that single boolean; update
TmuxWorkspacePaneOverlayModel to compute and publish visibility from
unreadRects/flashRect/flashStartedAt so the view and controller share one source
of truth.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@Sources/WorkspaceContentView.swift`:
- Around line 140-143: The code currently cancels the flash timer and clears
flashStartedAt whenever state.flashRect is nil; instead only cancel/reset when
the flash is truly finished or the token changed. Update the condition around
cancelFlashReset()/flashStartedAt = nil to check the flash token: only cancel if
state.flashRect == nil AND (state.flashToken == nil OR state.flashToken !=
flashToken) so transient nil rects that keep the same state.flashToken don't
stop the in-flight flash. Ensure you reference state.flashRect,
state.flashToken, flashToken, flashStartedAt, and cancelFlashReset() when making
the change.

---

Nitpick comments:
In `@Sources/ContentView.swift`:
- Around line 1337-1348: The controller currently recomputes visibility from
model.$unreadRects, model.$flashRect and model.$flashStartedAt inside
modelVisibilityCancellable; instead, add a single visibility contract on
TmuxWorkspacePaneOverlayModel (for example a published property like isVisible
or a visibilityPublisher) that encapsulates the logic, and update this sink to
subscribe only to that model property (e.g., model.$isVisible or
model.visibilityPublisher) and set containerView.alphaValue/isHidden based on
that single boolean; update TmuxWorkspacePaneOverlayModel to compute and publish
visibility from unreadRects/flashRect/flashStartedAt so the view and controller
share one source of truth.

In `@Sources/TabManager.swift`:
- Around line 1030-1051: The current
trackedWorkspaceGitMetadataPeriodicPollCandidatePanelIds function excludes
panels whose pullRequest.status != .open so closed/merged PRs never get
background rechecked; change the selection logic to include a very-low-frequency
fallback (e.g., always include workspace.focusedPanelId even if its
pullRequest.status != .open, or probabilistically sample a small subset of
non-open panels from workspace.panelPullRequests) before building the filtered
Set; ensure you still respect activeProbeKeys by constructing
WorkspaceGitProbeKey(workspaceId: workspace.id, panelId: panelId) and skipping
probes already in activeProbeKeys, but allow the fallback panel(s) through so
reopen transitions are eventually detected.

In `@Sources/WorkspaceContentView.swift`:
- Around line 127-129: The apply(_:) path currently assigns unreadRects,
flashRect and flashReason unconditionally which triggers redundant `@Published`
emissions; change apply(_:) to compare incoming state values to the current
properties (unreadRects, flashRect, flashReason) and only assign when the new
value is != the existing value so you avoid no-op publishes and extra SwiftUI
updates. Locate the apply(_:) implementation (and the properties unreadRects,
flashRect, flashReason) and add cheap equality guards per property (or a
combined equality check) to skip assignments and early-return when nothing
changed.
🪄 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: defaults

Review profile: CHILL

Plan: Pro

Run ID: 3a2895a4-1de6-4c39-bc37-d9048b9c1305

📥 Commits

Reviewing files that changed from the base of the PR and between 096ed62 and 358bd7c.

📒 Files selected for processing (3)
  • Sources/ContentView.swift
  • Sources/TabManager.swift
  • Sources/WorkspaceContentView.swift

Comment thread Sources/WorkspaceContentView.swift Outdated

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

🧹 Nitpick comments (1)
Sources/ContentView.swift (1)

1377-1389: Avoid unconditional overlay rerenders here.

Line 1377 still calls renderModelState() on every update(state:), and Line 1382 replaces hostingView.rootView even when the effective overlay state is unchanged. Since this controller is refreshed from WindowAccessor, that keeps overlay work on the parent render path and chips away at the idle-CPU win this PR is targeting. Let onStateChange own rerenders, or gate renderModelState() behind a cheap state/signature check.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Sources/ContentView.swift` around lines 1377 - 1389, renderModelState() is
being called unconditionally from update(state:) and always replaces
hostingView.rootView causing unnecessary overlay rerenders; change this so
either (a) move the render responsibility to onStateChange and stop calling
renderModelState() from update(state:), or (b) add a cheap signature/state check
in renderModelState() (compare a small snapshot/key derived from
model.unreadRects, model.flashRect, model.flashStartedAt, model.flashReason, and
model.hasVisibleContent) and return early when unchanged before setting
hostingView.rootView or updating containerView; reference the
renderModelState(), update(state:), onStateChange, hostingView.rootView and
model.* fields to locate and gate the update.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@Sources/ContentView.swift`:
- Around line 1377-1389: renderModelState() is being called unconditionally from
update(state:) and always replaces hostingView.rootView causing unnecessary
overlay rerenders; change this so either (a) move the render responsibility to
onStateChange and stop calling renderModelState() from update(state:), or (b)
add a cheap signature/state check in renderModelState() (compare a small
snapshot/key derived from model.unreadRects, model.flashRect,
model.flashStartedAt, model.flashReason, and model.hasVisibleContent) and return
early when unchanged before setting hostingView.rootView or updating
containerView; reference the renderModelState(), update(state:), onStateChange,
hostingView.rootView and model.* fields to locate and gate the update.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 6a37d53c-0038-42dc-abd4-4e67e133bf3b

📥 Commits

Reviewing files that changed from the base of the PR and between 358bd7c and 2c5d8f5.

📒 Files selected for processing (3)
  • Sources/ContentView.swift
  • Sources/WorkspaceContentView.swift
  • cmuxTests/WindowAndDragTests.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.

🧹 Nitpick comments (2)
cmuxTests/TabManagerUnitTests.swift (1)

575-582: Tighten the assertion so this test validates retry preservation directly.

At Line 576, the test only checks eventual PR success. Since the stubbed gh succeeds on the third global call, this can pass without proving retry state behavior. Consider asserting probe-attempt progression/count for this workspace/panel via the testing probe counter API, then asserting PR resolution.

Suggested assertion upgrade
         XCTAssertTrue(
             waitForCondition(timeout: 3.0) {
                 workspace.panelPullRequests[panelId]?.number == 42
             }
         )
+        XCTAssertGreaterThanOrEqual(
+            manager.workspaceGitProbeAttemptCountForTesting(workspaceId: workspace.id, panelId: panelId),
+            3,
+            "Expected retries to be preserved until the probe succeeds"
+        )
         XCTAssertEqual(workspace.panelGitBranches[panelId]?.branch, "feature/bootstrap-refresh")
         XCTAssertEqual(workspace.pullRequest?.number, 42)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@cmuxTests/TabManagerUnitTests.swift` around lines 575 - 582, The test
currently only verifies eventual PR success via waitForCondition and doesn't
assert retry/probe progression; update the test to first read the testing probe
counter API for this workspace/panel (use the probe counter for workspace and
panelId) and assert that the probe attempt count increments as retries occur
(e.g., becomes 1 then 2 before the final success), then keep the existing
waitForCondition/assertions on workspace.panelPullRequests[panelId]?.number and
workspace.pullRequest?.number == 42 to verify final resolution. Ensure you
reference the same panelId and the probe counter API used by other tests so the
assertions validate retry preservation directly.
Sources/TabManager.swift (1)

1018-1021: Consider narrowing test-only API exposure.

workspaceGitProbeAttemptCountForTesting(...) is useful, but you may want to hide it from non-test builds to keep production surface area lean.

Optional patch
+#if DEBUG
     func workspaceGitProbeAttemptCountForTesting(workspaceId: UUID, panelId: UUID) -> Int {
         let probeKey = WorkspaceGitProbeKey(workspaceId: workspaceId, panelId: panelId)
         return workspaceGitProbeTimersByKey[probeKey]?.count ?? 0
     }
+#endif
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Sources/TabManager.swift` around lines 1018 - 1021, The method
workspaceGitProbeAttemptCountForTesting(workspaceId:panelId:) exposes a
test-only API in production; wrap its declaration in a conditional compilation
block (e.g. `#if` canImport(XCTest) || DEBUG ... `#endif`) so it is only compiled
for test/debug builds, leaving workspaceGitProbeTimersByKey and
WorkspaceGitProbeKey unchanged and ensuring the function remains available to
tests but not in release builds.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@cmuxTests/TabManagerUnitTests.swift`:
- Around line 575-582: The test currently only verifies eventual PR success via
waitForCondition and doesn't assert retry/probe progression; update the test to
first read the testing probe counter API for this workspace/panel (use the probe
counter for workspace and panelId) and assert that the probe attempt count
increments as retries occur (e.g., becomes 1 then 2 before the final success),
then keep the existing waitForCondition/assertions on
workspace.panelPullRequests[panelId]?.number and workspace.pullRequest?.number
== 42 to verify final resolution. Ensure you reference the same panelId and the
probe counter API used by other tests so the assertions validate retry
preservation directly.

In `@Sources/TabManager.swift`:
- Around line 1018-1021: The method
workspaceGitProbeAttemptCountForTesting(workspaceId:panelId:) exposes a
test-only API in production; wrap its declaration in a conditional compilation
block (e.g. `#if` canImport(XCTest) || DEBUG ... `#endif`) so it is only compiled
for test/debug builds, leaving workspaceGitProbeTimersByKey and
WorkspaceGitProbeKey unchanged and ensuring the function remains available to
tests but not in release builds.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 7b29ae92-236c-48c9-add0-f4aa0a4e8ab0

📥 Commits

Reviewing files that changed from the base of the PR and between 2c5d8f5 and 0a876f9.

📒 Files selected for processing (2)
  • Sources/TabManager.swift
  • cmuxTests/TabManagerUnitTests.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.

Actionable comments posted: 1

🧹 Nitpick comments (1)
cmuxTests/TabManagerUnitTests.swift (1)

591-664: Strengthen retry intent with an explicit attempt-count assertion.

At Lines 657-663, the test currently proves eventual success, but not explicitly that retries occurred as intended. Since you already track calls in .gh-call-count, assert >= 3 to lock the retry contract and avoid future false positives from cached/alternate paths.

Suggested assertion addition
         XCTAssertTrue(
             waitForCondition(timeout: 3.0) {
                 workspace.panelPullRequests[panelId]?.number == 42
             }
         )
+        let attempts = Int(
+            (try? String(contentsOf: ghCounterURL, encoding: .utf8))
+                ?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
+        ) ?? 0
+        XCTAssertGreaterThanOrEqual(
+            attempts,
+            3,
+            "Expected at least 3 `gh pr list` attempts to verify retry behavior"
+        )
         XCTAssertEqual(workspace.panelGitBranches[panelId]?.branch, "feature/bootstrap-refresh")
         XCTAssertEqual(workspace.pullRequest?.number, 42)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@cmuxTests/TabManagerUnitTests.swift` around lines 591 - 664, The test
testSelectingNewWorkspacePreservesInitialGitMetadataRetries should assert that
the gh retry script was invoked at least 3 times; after the existing assertions
(or right before the end of the withPrependedPath block) read the counter file
at ghCounterURL.path (the .gh-call-count tracked by the gh script) and assert
its integer value is >= 3 to guarantee retries occurred, failing the test if the
file is missing or the parsed count is less than 3.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@Sources/WorkspaceContentView.swift`:
- Around line 165-171: The flash can restart after auto-clear because the
cleared runningFlashToken allows startFlash to run again with the same
state.flashToken; add a small latch: introduce a lastLatchedFlashToken (or reuse
a similarly-scoped var) and when you clear runningFlashToken in apply(...) also
set lastLatchedFlashToken = state.flashToken, then change the start condition
(the block that calls startFlash with workspaceId/state.flashToken/startedAt) to
require that state.flashToken != lastLatchedFlashToken (in addition to
state.flashRect != nil and runningFlashToken == nil) so the same token cannot
immediately re-enter the start path.

---

Nitpick comments:
In `@cmuxTests/TabManagerUnitTests.swift`:
- Around line 591-664: The test
testSelectingNewWorkspacePreservesInitialGitMetadataRetries should assert that
the gh retry script was invoked at least 3 times; after the existing assertions
(or right before the end of the withPrependedPath block) read the counter file
at ghCounterURL.path (the .gh-call-count tracked by the gh script) and assert
its integer value is >= 3 to guarantee retries occurred, failing the test if the
file is missing or the parsed count is less than 3.
🪄 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: defaults

Review profile: CHILL

Plan: Pro

Run ID: c3a98ed4-970e-4c7d-9d8e-f68b40facc4f

📥 Commits

Reviewing files that changed from the base of the PR and between 0a876f9 and f91d201.

📒 Files selected for processing (5)
  • Sources/ContentView.swift
  • Sources/TabManager.swift
  • Sources/WorkspaceContentView.swift
  • cmuxTests/TabManagerUnitTests.swift
  • cmuxTests/WindowAndDragTests.swift
🚧 Files skipped from review as they are similar to previous changes (3)
  • cmuxTests/WindowAndDragTests.swift
  • Sources/TabManager.swift
  • Sources/ContentView.swift

Comment on lines +165 to +171
if runningFlashToken == nil,
state.flashRect != nil {
flashStartedAt = now()
startFlash(
workspaceId: state.workspaceId,
flashToken: state.flashToken,
startedAt: now()
)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Latch the token after auto-clear.

Because Lines 202-203 clear runningFlashToken, the same flashToken falls back into the Lines 165-171 start path on the next apply(...) with a non-nil rect. Any later layout/unread update can replay the flash without a new attention event, which risks repeated animation work after the flash was supposed to be finished.

🛠️ Minimal fix
         let workItem = DispatchWorkItem { [weak self] in
             guard let self else { return }
             guard self.lastWorkspaceId == workspaceId,
                   self.runningFlashToken == flashToken else {
                 return
             }

             self.flashStartedAt = nil
-            self.runningFlashToken = nil
             self.onStateChange?()
         }

Also applies to: 202-203

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Sources/WorkspaceContentView.swift` around lines 165 - 171, The flash can
restart after auto-clear because the cleared runningFlashToken allows startFlash
to run again with the same state.flashToken; add a small latch: introduce a
lastLatchedFlashToken (or reuse a similarly-scoped var) and when you clear
runningFlashToken in apply(...) also set lastLatchedFlashToken =
state.flashToken, then change the start condition (the block that calls
startFlash with workspaceId/state.flashToken/startedAt) to require that
state.flashToken != lastLatchedFlashToken (in addition to state.flashRect != nil
and runningFlashToken == nil) so the same token cannot immediately re-enter the
start path.

@lawrencecchen lawrencecchen added the stale-revisit Closed after 30+ days without activity; preserved for possible revisit or reopening. label Sep 23, 2026
@github-project-automation github-project-automation Bot moved this from Todo to Done in cmux backlog Sep 23, 2026

This branch was successfully deployed

1 active deployment
Preview — 792e1c2c Deployed Apr 6, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

stale-revisit Closed after 30+ days without activity; preserved for possible revisit or reopening.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

CPU usage is consistently ~15% when not doing anything

3 participants