Skip to content

Resync terminal portals after sidebar changes - #1253

Merged
lawrencecchen merged 4 commits into
mainfrom
task-sidebar-portal-geometry-resync
Mar 12, 2026
Merged

lawrencecchen merged 4 commits into
mainfrom
task-sidebar-portal-geometry-resync

Conversation

@lawrencecchen

@lawrencecchen lawrencecchen commented Mar 12, 2026 •

Copy link
Copy Markdown
Contributor

Summary

  • resync terminal portals after sidebar width and visibility changes
  • add a regression test for ancestor-only layout shifts that leave the anchor's local frame unchanged

Testing

  • xcodebuild -project GhosttyTabs.xcodeproj -scheme cmux-unit -destination 'platform=macOS' -derivedDataPath /tmp/cmux-unit-task-sidebar-portal-geometry-resync build
  • ./scripts/reload.sh --tag task-sidebar-portal-geometry-resync

Issues

  • Task: blank terminal overlay can persist over the sidebar after sidebar resize

Summary by cubic

Resyncs terminal portal geometry after sidebar width/visibility changes to prevent stale overlays and bad hit-testing. Fixes the task where a blank terminal overlay could persist over the sidebar after resize.

  • Bug Fixes

    • Added a coalesced, post-layout external geometry sync across all windows to handle ancestor-only SwiftUI layout shifts; restored safeHelp view helper to skip empty help text.
    • Trigger the sync when sidebar width or visibility changes to keep portals aligned with their anchors.
  • Tests

    • Regression test covers ancestor-only layout shifts and waits for the scheduled external sync on the main runloop.

Written for commit 2f847cc. Summary will update on new commits.

Summary by CodeRabbit

  • Bug Fixes

    • Terminal windows now synchronize their positions after sidebar resize or visibility changes.
    • Help tooltips are only applied when text is present, avoiding empty tooltip behavior.
  • Tests

    • Added coverage verifying terminal window synchronization after ancestor layout changes.

@vercel

vercel Bot commented Mar 12, 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 Mar 12, 2026 9:40am

@coderabbitai

coderabbitai Bot commented Mar 12, 2026 •

Copy link
Copy Markdown
📝 Walkthrough

Walkthrough

Adds a debounced registry-level external-geometry synchronization and schedules it from ContentView after sidebar width or visibility layout changes; includes a test and a safeHelp View helper.

Changes

Cohort / File(s) Summary
Layout Integration
Sources/ContentView.swift
Schedules TerminalWindowPortalRegistry.scheduleExternalGeometrySynchronizeForAllWindows() after sidebar width sanitization and when sidebar isVisible changes.
Portal & Registry
Sources/TerminalWindowPortal.swift
Made synchronizeAllEntriesFromExternalGeometryChange() fileprivate; added hasPendingExternalGeometrySyncForAllWindows and scheduleExternalGeometrySynchronizeForAllWindows() to debounce/batch external-geometry syncs and dispatch them on the main queue.
Tests
cmuxTests/CmuxWebViewKeyEquivalentTests.swift
Adds testScheduledExternalGeometrySyncRefreshesAncestorLayoutShift to verify scheduled external geometry sync updates portal host resolved window positions after an ancestor layout shift.
View Backport
Sources/Backport.swift
Adds View.safeHelp(_:) extension that conditionally applies .help(text) only when text is non-empty.

Sequence Diagram(s)

sequenceDiagram
    participant User as User
    participant CV as ContentView
    participant Registry as TerminalWindowPortalRegistry
    participant Portal as WindowTerminalPortal
    participant Entries as PortalEntries

    User->>CV: change sidebar width / visibility
    CV->>CV: apply layout changes
    CV->>Registry: scheduleExternalGeometrySynchronizeForAllWindows()
    Registry->>Registry: set pending (if not already) and debounce
    Registry->>Registry: dispatch on main queue
    Registry->>Portal: synchronizeAllEntriesFromExternalGeometryChange()
    Portal->>Entries: update resolved window positions
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~22 minutes

Possibly related PRs

Poem

🐰
A sidebar nudges, layouts sway,
I hop to queue the sync today.
Debounced and batched, we call in tune,
Portal windows find their room —
Hooray, geometry: aligned by moon!

🚥 Pre-merge checks | ✅ 2 | ❌ 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 (2 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly summarizes the main change: resyncing terminal portals after sidebar changes, which directly addresses the core problem described in the PR.
Description check ✅ Passed PR description covers summary and testing, but lacks demo video and incomplete checklist items.

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

✨ Finishing Touches
  • 📝 Generate docstrings (stacked PR)
  • 📝 Generate docstrings (commit on current branch)
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch task-sidebar-portal-geometry-resync

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

@greptile-apps

greptile-apps Bot commented Mar 12, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR fixes a visual regression where terminal portal overlays could persist over the sidebar after a sidebar resize or visibility toggle. Because sidebar layout changes are pure SwiftUI layout updates, the NSView-level portal geometry is never automatically invalidated — so the fix introduces TerminalWindowPortalRegistry.scheduleExternalGeometrySynchronizeForAllWindows(), a coalesced DispatchQueue.main.async call that re-runs the full geometry synchronisation for every registered portal after the run loop settles.

Key changes:

  • ContentView.swift now calls scheduleExternalGeometrySynchronizeForAllWindows() from the sidebarWidth and sidebarState.isVisible onChange handlers.
  • TerminalWindowPortal.swift adds a hasPendingExternalGeometrySyncForAllWindows flag to coalesce duplicate dispatches (e.g., when both width and visibility change in the same frame), and widens synchronizeAllEntriesFromExternalGeometryChange to fileprivate so the registry can call it.
  • A regression test in CmuxWebViewKeyEquivalentTests.swift verifies that an ancestor-only layout shift leaves the portal stale until the scheduled sync runs, then moves the portal to the correct position.

Confidence Score: 4/5

  • PR is safe to merge after addressing the missing MainActor.assumeIsolated in the new DispatchQueue.main.async closure.
  • The overall approach is sound: the coalescing flag prevents redundant dispatches, the regression test covers the exact failure scenario, and the ContentView hook placements are correct. One issue stands out — the DispatchQueue.main.async closure in scheduleExternalGeometrySynchronizeForAllWindows accesses @MainActor-isolated state without MainActor.assumeIsolated, inconsistent with the established pattern in the same file and a build error under Swift 6 strict concurrency. This is a straightforward one-line fix.
  • Sources/TerminalWindowPortal.swift — the new DispatchQueue.main.async block needs MainActor.assumeIsolated.

Important Files Changed

Filename Overview
Sources/ContentView.swift Adds scheduleExternalGeometrySynchronizeForAllWindows() calls in two sidebar onChange handlers (width and visibility); logic is correct and coalescing is handled in TerminalWindowPortalRegistry.
Sources/TerminalWindowPortal.swift Introduces scheduleExternalGeometrySynchronizeForAllWindows with a coalescing flag; the DispatchQueue.main.async closure accesses @MainActor-isolated state without MainActor.assumeIsolated, inconsistent with the existing installWindowCloseObserverIfNeeded pattern and a potential Swift 6 strict concurrency issue.
cmuxTests/CmuxWebViewKeyEquivalentTests.swift New regression test correctly validates the pre-sync stale state and the post-sync updated geometry for ancestor-only layout shifts; uses the class's own realizeWindowLayout helper and a 50 ms RunLoop spin consistent with other tests in the file.

Sequence Diagram

sequenceDiagram
    participant SW as SwiftUI (ContentView)
    participant REG as TerminalWindowPortalRegistry
    participant DQ as DispatchQueue.main
    participant PORTAL as WindowTerminalPortal

    SW->>SW: onChange(sidebarWidth) fires
    SW->>REG: scheduleExternalGeometrySynchronizeForAllWindows()
    REG->>REG: hasPendingExternalGeometrySyncForAllWindows = true
    REG->>DQ: DispatchQueue.main.async { ... }

    note over SW,REG: Subsequent sidebar isVisible / width changes<br/>are no-ops (flag is set)

    DQ-->>REG: (run loop tick)
    REG->>REG: hasPendingExternalGeometrySyncForAllWindows = false
    loop each portal in portalsByWindowId
        REG->>PORTAL: synchronizeAllEntriesFromExternalGeometryChange()
        PORTAL->>PORTAL: ensureInstalled()
        PORTAL->>PORTAL: synchronizeLayoutHierarchy()
        PORTAL->>PORTAL: synchronizeAllHostedViews(excluding: nil)
        PORTAL->>PORTAL: reconcileGeometryNow() + refreshSurfaceNow()
    end
Loading

Last reviewed commit: 1ddda0e

Comment on lines +1787 to +1792
DispatchQueue.main.async {
Self.hasPendingExternalGeometrySyncForAllWindows = false
for portal in Self.portalsByWindowId.values {
portal.synchronizeAllEntriesFromExternalGeometryChange()
}
}

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.

Missing MainActor.assumeIsolated in DispatchQueue.main.async closure

The DispatchQueue.main.async closure accesses @MainActor-isolated static properties (Self.hasPendingExternalGeometrySyncForAllWindows and Self.portalsByWindowId) without asserting main-actor isolation. The existing installWindowCloseObserverIfNeeded function in the same file already establishes the correct pattern: even when dispatching to .main, the compiler cannot statically prove the closure inherits @MainActor isolation, so MainActor.assumeIsolated { } is required.

Without it, in Swift 6 strict concurrency mode these accesses are unisolated captures of @MainActor-guarded state, which can generate errors. The fix mirrors the existing pattern used at line 1672:

Suggested change
DispatchQueue.main.async {
Self.hasPendingExternalGeometrySyncForAllWindows = false
for portal in Self.portalsByWindowId.values {
portal.synchronizeAllEntriesFromExternalGeometryChange()
}
}
DispatchQueue.main.async {
MainActor.assumeIsolated {
Self.hasPendingExternalGeometrySyncForAllWindows = false
for portal in Self.portalsByWindowId.values {
portal.synchronizeAllEntriesFromExternalGeometryChange()
}
}
}

@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/TerminalWindowPortal.swift (1)

1638-1793: Reuse the per-portal debounce instead of bypassing it.

This registry path calls synchronizeAllEntriesFromExternalGeometryChange() directly, so any window that already has a pending scheduleExternalGeometrySynchronize() from its own resize/frame observers will still run a second full sync afterward. On live sidebar drags that means duplicate geometry reconciliation and duplicate surface refreshes for the same portal.

♻️ Suggested refactor
-    private func scheduleExternalGeometrySynchronize() {
+    fileprivate func scheduleExternalGeometrySynchronize() {
         guard !hasExternalGeometrySyncScheduled else { return }
         hasExternalGeometrySyncScheduled = true
         DispatchQueue.main.async { [weak self] in
             guard let self else { return }
             self.hasExternalGeometrySyncScheduled = false
             self.synchronizeAllEntriesFromExternalGeometryChange()
         }
     }
     static func scheduleExternalGeometrySynchronizeForAllWindows() {
         guard !Self.hasPendingExternalGeometrySyncForAllWindows else { return }
         Self.hasPendingExternalGeometrySyncForAllWindows = true
         DispatchQueue.main.async {
             Self.hasPendingExternalGeometrySyncForAllWindows = false
             for portal in Self.portalsByWindowId.values {
-                portal.synchronizeAllEntriesFromExternalGeometryChange()
+                portal.scheduleExternalGeometrySynchronize()
             }
         }
     }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Sources/TerminalWindowPortal.swift` around lines 1638 - 1793, The
scheduleExternalGeometrySynchronizeForAllWindows currently iterates
Self.portalsByWindowId and calls
portal.synchronizeAllEntriesFromExternalGeometryChange() directly causing
duplicate work; change it to call each portal's debounce-aware scheduler (e.g.
portal.scheduleExternalGeometrySynchronize()) instead so existing per-portal
debouncing is honored; keep the global
hasPendingExternalGeometrySyncForAllWindows guard and DispatchQueue.main.async
semantics but replace the direct synchronizeAllEntriesFromExternalGeometryChange
invocation with the per-portal scheduleExternalGeometrySynchronize call while
iterating Self.portalsByWindowId.values.
🤖 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/TerminalWindowPortal.swift`:
- Around line 1638-1793: The scheduleExternalGeometrySynchronizeForAllWindows
currently iterates Self.portalsByWindowId and calls
portal.synchronizeAllEntriesFromExternalGeometryChange() directly causing
duplicate work; change it to call each portal's debounce-aware scheduler (e.g.
portal.scheduleExternalGeometrySynchronize()) instead so existing per-portal
debouncing is honored; keep the global
hasPendingExternalGeometrySyncForAllWindows guard and DispatchQueue.main.async
semantics but replace the direct synchronizeAllEntriesFromExternalGeometryChange
invocation with the per-portal scheduleExternalGeometrySynchronize call while
iterating Self.portalsByWindowId.values.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 727f35c5-b393-4d90-9be9-4f112cd98792

📥 Commits

Reviewing files that changed from the base of the PR and between 74b0d89 and 1ddda0e.

📒 Files selected for processing (3)
  • Sources/ContentView.swift
  • Sources/TerminalWindowPortal.swift
  • cmuxTests/CmuxWebViewKeyEquivalentTests.swift

@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

@lawrencecchen
lawrencecchen merged commit 8d5a1f6 into main Mar 12, 2026
12 checks passed

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

1 issue found across 1 file (changes from recent commits).

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="cmuxTests/CmuxWebViewKeyEquivalentTests.swift">

<violation number="1" location="cmuxTests/CmuxWebViewKeyEquivalentTests.swift:11906">
P3: This regression test now only checks for a non-nil terminal view, so it can pass without proving hit-testing resolves the bound surface. Assert the returned terminal’s surface ID matches `surface.id` to keep the test specific.</violation>
</file>

Reply with feedback, questions, or to request a fix. Tag @cubic-dev-ai to re-run a review.

Comment on lines +11906 to +11909
XCTAssertNotNil(
TerminalWindowPortalRegistry.terminalViewAtWindowPoint(originalWindowPoint, in: window),
"Initial hit-testing should resolve the portal-hosted terminal at its original window position"
)

@cubic-dev-ai cubic-dev-ai Bot Mar 12, 2026 •

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P3: This regression test now only checks for a non-nil terminal view, so it can pass without proving hit-testing resolves the bound surface. Assert the returned terminal’s surface ID matches surface.id to keep the test specific.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At cmuxTests/CmuxWebViewKeyEquivalentTests.swift, line 11906:

<comment>This regression test now only checks for a non-nil terminal view, so it can pass without proving hit-testing resolves the bound surface. Assert the returned terminal’s surface ID matches `surface.id` to keep the test specific.</comment>

<file context>
@@ -11880,20 +11880,31 @@ final class TerminalWindowPortalLifecycleTests: XCTestCase {
         let originalWindowPoint = anchor.convert(anchorCenter, to: nil)
-        XCTAssertTrue(
-            TerminalWindowPortalRegistry.terminalViewAtWindowPoint(originalWindowPoint, in: window) === terminal,
+        XCTAssertNotNil(
+            TerminalWindowPortalRegistry.terminalViewAtWindowPoint(originalWindowPoint, in: window),
             "Initial hit-testing should resolve the portal-hosted terminal at its original window position"
</file context>
Suggested change
XCTAssertNotNil(
TerminalWindowPortalRegistry.terminalViewAtWindowPoint(originalWindowPoint, in: window),
"Initial hit-testing should resolve the portal-hosted terminal at its original window position"
)
XCTAssertEqual(
TerminalWindowPortalRegistry.terminalViewAtWindowPoint(originalWindowPoint, in: window)?.terminalSurface?.id,
surface.id,
"Initial hit-testing should resolve the portal-hosted terminal at its original window position"
)
Fix with Cubic

This branch was successfully deployed

1 active deployment
Preview — 2f847cc8 Deployed Mar 12, 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