Repository navigation
Hold workspace handoff until incoming terminals are presentable (#1291) #10868
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
8883f19
ddbac06
e8939d9
ec41522
dd7b78d
2ebbaa3
6d354c5
b92d01f
c0995c3
3d362b8
c7524eb
60a03b8
5a586ac
f3df6ca
a48929f
99c9024
9f4a648
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -934,6 +934,7 @@ struct ContentView: View { | |
| @State private var previousSelectedWorkspaceId: UUID? | ||
| @State private var retiringWorkspaceId: UUID? | ||
| @State private var workspaceHandoffFallbackScheduler = MainActorDeferredActionScheduler() | ||
| @State private var workspaceHandoffFrameWatcher = WorkspaceHandoffFrameWatcher() | ||
| @State private var didApplyUITestSidebarSelection = false | ||
| @State private var titlebarThemeGeneration: UInt64 = 0 | ||
| @State private var sidebarDraggedTabId: UUID? | ||
|
|
@@ -3026,6 +3027,7 @@ struct ContentView: View { | |
| if let retiringWorkspaceId, !existingIds.contains(retiringWorkspaceId) { | ||
| self.retiringWorkspaceId = nil | ||
| workspaceHandoffFallbackScheduler.cancel() | ||
| workspaceHandoffFrameWatcher.cancel() | ||
| } | ||
| if let previousSelectedWorkspaceId, !existingIds.contains(previousSelectedWorkspaceId) { | ||
| self.previousSelectedWorkspaceId = tabManager.selectedTabId | ||
|
|
@@ -3571,11 +3573,13 @@ struct ContentView: View { | |
| tabManager.completePendingWorkspaceUnfocus(reason: "no_handoff") | ||
| retiringWorkspaceId = nil | ||
| workspaceHandoffFallbackScheduler.cancel() | ||
| workspaceHandoffFrameWatcher.cancel() | ||
| return | ||
| } | ||
|
|
||
| retiringWorkspaceId = oldSelectedId | ||
| workspaceHandoffFallbackScheduler.cancel() | ||
| workspaceHandoffFrameWatcher.cancel() | ||
|
|
||
| #if DEBUG | ||
| if let snapshot = tabManager.debugCurrentWorkspaceSwitchSnapshot() { | ||
|
|
@@ -3606,14 +3610,34 @@ struct ContentView: View { | |
| return | ||
| } | ||
|
|
||
| workspaceHandoffFallbackScheduler.schedule(after: .milliseconds(150)) { | ||
| // Complete as soon as every incoming visible terminal renders a frame, | ||
| // so the retiring workspace's content covers the whole gap without a | ||
| // blank transition frame (#1291). The timeout below stays the ceiling. | ||
| if let workspace = tabManager.tabs.first(where: { $0.id == newSelectedId }) { | ||
| workspaceHandoffFrameWatcher.begin( | ||
| workspaceId: newSelectedId, | ||
| targets: workspace.handoffWatchTargets() | ||
| ) { | ||
| completeWorkspaceHandoff(reason: "first_frame") | ||
| } | ||
| } | ||
|
|
||
| // The timeout is a liveness escape (dead PTY, wedged renderer), not | ||
| // the normal completion: frame-driven completion typically lands well | ||
| // under 100ms. Holding the retiring content a bit longer beats | ||
| // painting a blank frame. | ||
| workspaceHandoffFallbackScheduler.schedule(after: .milliseconds(500)) { | ||
| completeWorkspaceHandoff(reason: "timeout") | ||
| } | ||
| } | ||
|
|
||
| private func completeWorkspaceHandoffIfNeeded(focusedTabId: UUID, reason: String) { | ||
| guard focusedTabId == tabManager.selectedTabId else { return } | ||
| guard retiringWorkspaceId != nil else { return } | ||
| // Focus can land on an incoming terminal before it renders its first | ||
| // frame; completing then hides the retiring content over an empty | ||
| // layer (#1291). Let the frame watcher (or the timeout) finish. | ||
| guard !workspaceHandoffFrameWatcher.isPending else { return } | ||
| completeWorkspaceHandoff(reason: reason) | ||
| } | ||
|
|
||
|
|
@@ -3623,11 +3647,18 @@ struct ContentView: View { | |
| workspace.browserPanel(for: focusedPanelId) != nil { | ||
| return true | ||
|
Comment on lines
3647
to
3648
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When the incoming workspace has a focused browser plus terminals in other visible split panes, this returns true solely because the browser is focused, so the frame watcher is never started for those terminals. A remounted terminal pane can consequently still be blank when the retiring workspace is hidden; the browser shortcut should only bypass terminal readiness when there are no co-visible terminal targets. Useful? React with 👍 / 👎. |
||
| } | ||
| return workspace.hasLoadedTerminalSurface() | ||
| guard workspace.hasLoadedTerminalSurface() else { return false } | ||
| // Surface existence is not presentation: a freshly mounted workspace | ||
| // has hidden, unrevealed portals, and hiding the old content at that | ||
| // point paints a frame with neither workspace's terminals (#1291). | ||
| // Complete immediately only when the incoming terminals are already | ||
| // presented on screen (e.g. the cycle-hot mounted pair). | ||
| return workspace.visibleTerminalsReadyForImmediateHandoff() | ||
| } | ||
|
|
||
| private func completeWorkspaceHandoff(reason: String) { | ||
| workspaceHandoffFallbackScheduler.cancel() | ||
| workspaceHandoffFrameWatcher.cancel() | ||
| let retiring = retiringWorkspaceId | ||
|
|
||
| // Disable before clearing retiringWorkspaceId: unmount teardown does not | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -5150,6 +5150,46 @@ final class Workspace: Identifiable, ObservableObject, FilePreviewTabMetadataHos | |
| return terminalPanels.contains { $0.surface.surface != nil } | ||
| } | ||
|
|
||
| /// The terminals the current layout model would show, as handoff watch | ||
| /// targets. The workspace handoff waits for each one's first rendered | ||
| /// frame and reveal before the retiring workspace's content is hidden | ||
| /// (#1291). | ||
| func handoffWatchTargets() -> [WorkspaceHandoffFrameWatcher.Target] { | ||
| let visiblePanelIds = expectedVisiblePanelIdsForLayout() | ||
| var targets: [WorkspaceHandoffFrameWatcher.Target] = [] | ||
| for panel in panels.values { | ||
| guard let terminalPanel = panel as? TerminalPanel else { continue } | ||
| // Mirror-rendered window-tab panels are drawn by their split view, | ||
| // not this panel's surface (see the portal visibility reconcile). | ||
| if remoteTmuxWindowMirrors[terminalPanel.id] != nil { continue } | ||
|
Comment on lines
+5162
to
+5164
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When switching into a remote-tmux workspace, this skips the stable container but never expands it into the mirror-owned Useful? React with 👍 / 👎. |
||
| guard visiblePanelIds.contains(terminalPanel.id) else { continue } | ||
| targets.append(WorkspaceHandoffFrameWatcher.Target( | ||
| surface: terminalPanel.surface, | ||
| hostedView: terminalPanel.hostedView | ||
| )) | ||
| } | ||
| return targets | ||
| } | ||
|
|
||
| /// Whether every rendered-visible terminal is already presented in the | ||
| /// window, so hiding the retiring workspace's content cannot expose a | ||
| /// frame with neither workspace's terminals (#1291). False for a freshly | ||
| /// mounted workspace whose portals have not revealed yet. | ||
| func visibleTerminalsReadyForImmediateHandoff() -> Bool { | ||
| let visiblePanelIds = expectedVisiblePanelIdsForLayout() | ||
| for panel in panels.values { | ||
| guard let terminalPanel = panel as? TerminalPanel else { continue } | ||
| if remoteTmuxWindowMirrors[terminalPanel.id] != nil { continue } | ||
| guard visiblePanelIds.contains(terminalPanel.id) else { continue } | ||
| let hostedView = terminalPanel.hostedView | ||
| guard !hostedView.isHidden, | ||
| hostedView.superview != nil, | ||
| terminalPanel.surface.isViewInWindow, | ||
| terminalPanel.surface.isRendererPresented else { return false } | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win Gate every handoff completion on actual layer pixels. 📍 Affects 2 files
🤖 Prompt for AI AgentsSource: Coding guidelines |
||
| } | ||
| return true | ||
| } | ||
|
|
||
| func panelTitle(panelId: UUID) -> String? { | ||
| if let remotePane = remoteTmuxControlPane(surfaceID: panelId) { | ||
| return remotePane.pane.title | ||
|
|
@@ -11642,6 +11682,14 @@ final class Workspace: Identifiable, ObservableObject, FilePreviewTabMetadataHos | |
|
|
||
| private func renderedVisiblePanelIdsForCurrentLayout() -> Set<UUID> { | ||
| guard portalRenderingEnabled else { return [] } | ||
| return expectedVisiblePanelIdsForLayout() | ||
| } | ||
|
|
||
| /// The panel ids the current layout model would show, independent of | ||
| /// whether this workspace's portals are rendering yet. The workspace | ||
| /// handoff consults this for a workspace that is about to mount (#1291), | ||
| /// when `portalRenderingEnabled` is still false. | ||
| private func expectedVisiblePanelIdsForLayout() -> Set<UUID> { | ||
| // Canvas mode renders one panel per canvas pane — its selected tab. | ||
| // Background tabs are unmounted, so reporting them as rendered makes | ||
| // the terminal window portal float them at stale frames (chromeless | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,150 @@ | ||
| import AppKit | ||
| import CmuxFoundation | ||
| import CmuxTerminal | ||
|
|
||
| /// Completes a workspace handoff when every incoming visible terminal is | ||
| /// presentable, so hiding the retiring workspace's content can never expose a | ||
| /// blank frame (#1291). | ||
| /// | ||
| /// Presentable means the hosted view is revealed (unhidden, in a window) and | ||
| /// the terminal layer holds pixels (`layer.contents != nil`). A warm surface | ||
| /// keeps its last IOSurface across hides, so it is presentable the moment the | ||
| /// portal reveals it; a surface whose renderer was reclaimed becomes | ||
| /// presentable when the rebuilt renderer publishes its first IOSurface. Both | ||
| /// transitions are observed (portal visibility notification, `isHidden` and | ||
| /// `contents` KVO); the caller's timeout stays the liveness backstop. | ||
| @MainActor | ||
| final class WorkspaceHandoffFrameWatcher { | ||
| struct Target { | ||
| let surface: TerminalSurface | ||
| let hostedView: GhosttySurfaceScrollView | ||
| } | ||
|
|
||
| private var targets: [Target] = [] | ||
| private var observers: [NSObjectProtocol] = [] | ||
| private var kvoObservations: [NSKeyValueObservation] = [] | ||
| private var onReady: (() -> Void)? | ||
| private var workspaceId: UUID? | ||
| private let recheckScheduler = MainActorDeferredActionScheduler() | ||
|
|
||
| /// CALayer contents changes bypass KVO for the core-owned terminal layer, | ||
| /// so cold reveals (reclaimed renderer republishing its IOSurface) are | ||
| /// re-checked on a short bounded cadence. The handoff's timeout ends the | ||
| /// loop; completion or cancel stops it earlier. | ||
| private static let recheckInterval: Duration = .milliseconds(32) | ||
|
|
||
| /// True while incoming terminals are not yet presentable. Focus-driven | ||
| /// handoff completions defer to this so focus arriving ahead of pixels | ||
| /// cannot re-expose the blank transition (#1291). | ||
| var isPending: Bool { onReady != nil } | ||
|
|
||
| /// Starts watching. With no targets this never fires `onReady`; callers | ||
| /// complete such handoffs through the immediate path instead. | ||
| func begin( | ||
| workspaceId: UUID, | ||
| targets: [Target], | ||
| onReady: @escaping () -> Void | ||
| ) { | ||
| cancel() | ||
| guard !targets.isEmpty else { return } | ||
| #if DEBUG | ||
| cmuxDebugLog( | ||
| "ws.handoff.frameWatch.begin ws=\(workspaceId.uuidString.prefix(5)) expected=\(targets.count)" | ||
| ) | ||
| #endif | ||
| self.workspaceId = workspaceId | ||
| self.targets = targets | ||
| self.onReady = onReady | ||
|
|
||
| observers.append(NotificationCenter.default.addObserver( | ||
| forName: .terminalPortalVisibilityDidChange, | ||
| object: nil, | ||
| queue: .main | ||
| ) { [weak self] _ in | ||
| self?.completeIfReady() | ||
| }) | ||
| for target in targets { | ||
| // The portal reveal path can unhide a hosted view without posting | ||
| // a visibility notification; observe the hidden bit directly. | ||
| kvoObservations.append(target.hostedView.observe( | ||
| \.isHidden, options: [.new] | ||
| ) { [weak self] _, _ in | ||
| Task { @MainActor [weak self] in | ||
| self?.completeIfReady() | ||
| } | ||
| }) | ||
| // A reclaimed renderer publishes its first IOSurface by setting | ||
| // the terminal layer's contents; observe that for cold reveals. | ||
| if let layer = target.hostedView.surfaceView.layer { | ||
| kvoObservations.append(layer.observe( | ||
| \.contents, options: [.new] | ||
| ) { [weak self] _, _ in | ||
| Task { @MainActor [weak self] in | ||
| self?.completeIfReady() | ||
| } | ||
| }) | ||
| } | ||
| } | ||
| // The reveal may already be complete (cycle-hot warm pair). | ||
| completeIfReady() | ||
| if isPending { scheduleRecheck() } | ||
| } | ||
|
|
||
| private func scheduleRecheck() { | ||
| recheckScheduler.schedule(after: Self.recheckInterval) { [weak self] in | ||
| guard let self, self.isPending else { return } | ||
| self.completeIfReady() | ||
| if self.isPending { self.scheduleRecheck() } | ||
| } | ||
| } | ||
|
|
||
| func cancel() { | ||
| #if DEBUG | ||
| if onReady != nil { | ||
| for target in targets { | ||
| let view = target.hostedView | ||
| let layer = view.surfaceView.layer | ||
| cmuxDebugLog( | ||
| "ws.handoff.frameWatch.state surface=\(target.surface.id.uuidString.prefix(5)) " + | ||
| "hidden=\(view.isHidden ? 1 : 0) inWindow=\(view.window != nil ? 1 : 0) " + | ||
| "layer=\(layer.map { String(describing: type(of: $0)) } ?? "nil") " + | ||
| "contents=\((layer?.presentation() ?? layer)?.contents != nil ? 1 : 0)" | ||
| ) | ||
| } | ||
| } | ||
| #endif | ||
| recheckScheduler.cancel() | ||
| observers.forEach { NotificationCenter.default.removeObserver($0) } | ||
| observers = [] | ||
| kvoObservations.forEach { $0.invalidate() } | ||
| kvoObservations = [] | ||
| targets = [] | ||
| onReady = nil | ||
| workspaceId = nil | ||
| } | ||
|
Comment on lines
+101
to
+124
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win Release the observers in
Add a 🧹 Proposed cleanup path func cancel() {Add this member to the class: deinit {
recheckScheduler.cancel()
observers.forEach { NotificationCenter.default.removeObserver($0) }
kvoObservations.forEach { $0.invalidate() }
}🤖 Prompt for AI AgentsSource: Linters/SAST tools |
||
|
|
||
| private func isPresentable(_ target: Target) -> Bool { | ||
| let view = target.hostedView | ||
| guard !view.isHidden, view.window != nil else { return false } | ||
| // The core publishes IOSurface contents off the main thread; the model | ||
| // layer can read nil while the presentation copy already has pixels | ||
| // (same idiom as the debug present-stats reader). | ||
| guard let layer = view.surfaceView.layer else { return false } | ||
| return (layer.presentation() ?? layer).contents != nil | ||
| } | ||
|
|
||
| private func completeIfReady() { | ||
| guard onReady != nil else { return } | ||
| guard targets.allSatisfy(isPresentable) else { return } | ||
| #if DEBUG | ||
| cmuxDebugLog( | ||
| "ws.handoff.frameWatch.presentable ws=\(workspaceId?.uuidString.prefix(5) ?? "nil") targets=\(targets.count)" | ||
| ) | ||
| #endif | ||
| let ready = onReady | ||
| cancel() | ||
| // One main-queue turn so any contents commit queued behind this event | ||
| // lands before the retiring content is hidden. | ||
| DispatchQueue.main.async { ready?() } | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
During rapid workspace switching, readiness for A→B can reach this line and enqueue its callback, then B→C can begin and cancel/start the watcher before the queued callback executes. Because the closure has already been copied out and carries no workspace or generation check, the stale A→B callback completes the current B→C handoff, cancels its watcher, and hides B before C has pixels. Guard the deferred callback with the watched workspace/request generation or make its cancellation ownership persist through this queue turn. Useful? React with 👍 / 👎. |
||
| } | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win
Update the downstream default contract.
Line [103] changes
rendererRealizationMaxWarmRenderers.defaultValueto4, butPackages/macOS/CmuxSettings/Tests/CmuxSettingsTests/RendererRealizationDefaultsTests.swiftLines [7-13] still expect1and describe the old behavior. That test will fail when this package test runs. Update the assertion and description to4, or keep this default at1if the behavior change is not intended.🤖 Prompt for AI Agents