diff --git a/Sources/GhosttyTerminalView.swift b/Sources/GhosttyTerminalView.swift index 4e2139e45..b8a342eed 100644 --- a/Sources/GhosttyTerminalView.swift +++ b/Sources/GhosttyTerminalView.swift @@ -3058,6 +3058,9 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { func applyWindowBackgroundIfActive() { guard let window else { return } + // Always maintain the surface-level background to prevent blank panes. + // This is safe even when window-level background is skipped (idempotent). + applySurfaceBackground() let appDelegate = AppDelegate.shared let owningManager = tabId.flatMap { appDelegate?.tabManagerFor(tabId: $0) } let owningSelectedTabId = owningManager?.selectedTabId @@ -3070,7 +3073,6 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { ) else { return } - applySurfaceBackground() let color = effectiveBackgroundColor() if cmuxShouldUseClearWindowBackground(for: color.alphaComponent) { window.backgroundColor = cmuxTransparentWindowBaseColor() diff --git a/Sources/TerminalNotificationStore.swift b/Sources/TerminalNotificationStore.swift index 5bb768cb3..194be1bb8 100644 --- a/Sources/TerminalNotificationStore.swift +++ b/Sources/TerminalNotificationStore.swift @@ -843,7 +843,12 @@ final class TerminalNotificationStore: ObservableObject { } if WorkspaceAutoReorderSettings.isEnabled() { - AppDelegate.shared?.tabManager?.moveTabToTop(tabId) + // Defer tab reordering to the next run loop iteration to avoid + // cascading SwiftUI re-renders (tabs + notifications @Published + // changes in the same frame) that can blank the active pane (#914). + DispatchQueue.main.async { + AppDelegate.shared?.tabManager?.moveTabToTop(tabId) + } } let notification = TerminalNotification( diff --git a/cmuxTests/GhosttyConfigTests.swift b/cmuxTests/GhosttyConfigTests.swift index 53f988aa2..ea1414f57 100644 --- a/cmuxTests/GhosttyConfigTests.swift +++ b/cmuxTests/GhosttyConfigTests.swift @@ -749,6 +749,83 @@ final class WindowBackgroundSelectionGateTests: XCTestCase { } } +final class WindowBackgroundSurfaceBackgroundGuaranteeTests: XCTestCase { + func testShouldApplyRejectsWhenOwningSelectionDiffersButSurfaceMustStayVisible() { + let surfaceTabId = UUID() + let differentSelectedTab = UUID() + + let rejected = !GhosttyNSView.shouldApplyWindowBackground( + surfaceTabId: surfaceTabId, + owningManagerExists: true, + owningSelectedTabId: differentSelectedTab, + activeSelectedTabId: surfaceTabId + ) + XCTAssertTrue(rejected, "Guard should reject when owning selection differs — this is the #914 trigger condition") + } + + func testShouldApplyRejectsWhenActiveSelectionDiffersWithNoOwningManager() { + let surfaceTabId = UUID() + let differentActiveTab = UUID() + + let rejected = !GhosttyNSView.shouldApplyWindowBackground( + surfaceTabId: surfaceTabId, + owningManagerExists: false, + owningSelectedTabId: nil, + activeSelectedTabId: differentActiveTab + ) + XCTAssertTrue(rejected, "Guard should reject when active selection differs — surface background must still be maintained") + } +} + +final class NotificationTabReorderDeferralTests: XCTestCase { + func testMoveTabToTopIsDeferredInsideAddNotification() throws { + let projectRoot = findProjectRoot() + let storeURL = projectRoot.appendingPathComponent("Sources/TerminalNotificationStore.swift") + let source = try String(contentsOf: storeURL, encoding: .utf8) + + guard let methodStart = source.range(of: "func addNotification(tabId:") else { + XCTFail("Failed to locate addNotification(tabId:) in TerminalNotificationStore.swift") + return + } + + let searchRange = methodStart.upperBound.. URL { + var dir = URL(fileURLWithPath: #file).deletingLastPathComponent().deletingLastPathComponent() + for _ in 0..<10 { + let marker = dir.appendingPathComponent("GhosttyTabs.xcodeproj") + if FileManager.default.fileExists(atPath: marker.path) { + return dir + } + dir = dir.deletingLastPathComponent() + } + return URL(fileURLWithPath: FileManager.default.currentDirectoryPath) + } +} + final class NotificationBurstCoalescerTests: XCTestCase { func testSignalsInSameBurstFlushOnce() { let coalescer = NotificationBurstCoalescer(delay: 0.01)