diff --git a/Sources/Cloud/CloudTerminalAttachmentStatus.swift b/Sources/Cloud/CloudTerminalAttachmentStatus.swift index 279cb986bdec..f5b210f7e8fb 100644 --- a/Sources/Cloud/CloudTerminalAttachmentStatus.swift +++ b/Sources/Cloud/CloudTerminalAttachmentStatus.swift @@ -11,6 +11,13 @@ import Observation final class CloudTerminalAttachmentStatus { let machineID: String private(set) var state: CloudTerminalAttachmentState = .attaching(attempt: 1) + /// The overlay snapshot owned by the same attachment as ``state``. + /// + /// A native Cloud panel must not fall back to workspace-wide controller + /// state when its catalog projection is temporarily unavailable. Keeping + /// the presentation beside the attachment state makes that ownership + /// explicit and survives catalog refreshes and pane moves. + private(set) var presentation: CloudTerminalReconnectOverlayPolicy.Presentation? /// One-shot style hook for owners that are not SwiftUI views (the workspace /// clearing an optimistic pane's tab spinner). Set by the pane owner only. @ObservationIgnored var onStateChange: (@MainActor (CloudTerminalAttachmentState) -> Void)? @@ -19,9 +26,14 @@ final class CloudTerminalAttachmentStatus { self.machineID = machineID } - func update(_ state: CloudTerminalAttachmentState) { - guard self.state != state else { return } + func update( + _ state: CloudTerminalAttachmentState, + presentation: CloudTerminalReconnectOverlayPolicy.Presentation? + ) { + let stateChanged = self.state != state + guard stateChanged || self.presentation != presentation else { return } self.state = state - onStateChange?(state) + self.presentation = presentation + if stateChanged { onStateChange?(state) } } } diff --git a/Sources/Cloud/CloudTuiManualMirrorSession.swift b/Sources/Cloud/CloudTuiManualMirrorSession.swift index 0147777a8785..ebeece3118cf 100644 --- a/Sources/Cloud/CloudTuiManualMirrorSession.swift +++ b/Sources/Cloud/CloudTuiManualMirrorSession.swift @@ -213,6 +213,7 @@ final class CloudTuiManualMirrorSession { } surface.flushPendingManualSizeReportIfAttached() runtimeReady() + publishAttachmentPresentation() } /// Re-samples on reveal even without a frame-size delta. A valid grid in /// the visible, real pane makes sizing eligible; initial focus is irrelevant. @@ -522,15 +523,15 @@ final class CloudTuiManualMirrorSession { } private func synchronizePresentation() { - surface?.hostedView.synchronizeCloudTerminalReconnectOverlay() + publishAttachmentPresentation() surface?.owningWorkspace()?.postRemoteConnectionPresentationDidChange() } private func finishDiagnostics(error: Error? = nil) { + defer { synchronizePresentation() } diagnosticDeadline?.cancel() diagnosticDeadline = nil if let error, !(error is CancellationError) { diagnosticFailure = .classify(error) } - surface?.owningWorkspace()?.postRemoteConnectionPresentationDidChange() let context = diagnosticContext ?? (error != nil && !(error is CancellationError) ? operations?.begin(.terminal, foreground: false) : nil) guard let context else { return } diagnosticReference = "operation=\(context.operationID.uuidString.lowercased()) trace=\(context.traceID)" @@ -693,7 +694,20 @@ final class CloudTuiManualMirrorSession { attachAttempts = 0 } log.phase(machineID: machineID, terminalID: terminalID, surfaceID: remoteSurfaceID, phase: next, reason: reason) - attachmentStatus.update(attachmentState) + publishAttachmentPresentation() + } + + /// Publishes the one presentation snapshot for this pane's attachment. + /// Workspace-wide remote controller state is intentionally excluded. + private func publishAttachmentPresentation() { + attachmentStatus.update( + attachmentState, + presentation: connectionPresentation + ) + // The status snapshot is the presentation source for both the portal + // card and the workspace bridge. Reconcile after publishing so a + // phase didSet callback cannot leave the card on the previous phase. + surface?.hostedView.synchronizeCloudTerminalReconnectOverlay() } private var attachmentState: CloudTerminalAttachmentState { diff --git a/Sources/CloudTerminalOverlayCoordinator.swift b/Sources/CloudTerminalOverlayCoordinator.swift index a51f01665a4a..94daaf31739b 100644 --- a/Sources/CloudTerminalOverlayCoordinator.swift +++ b/Sources/CloudTerminalOverlayCoordinator.swift @@ -73,7 +73,10 @@ final class CloudTerminalOverlayCoordinator { } let presentation: CloudTerminalReconnectOverlayPolicy.Presentation? if let session { - presentation = session.connectionPresentation + // The session publishes one immutable presentation snapshot for + // every phase/readiness change. The native card and workspace + // fallback both consume that same snapshot. + presentation = session.attachmentStatus.presentation } else { presentation = legacyPresentation } diff --git a/Sources/Workspace.swift b/Sources/Workspace.swift index 7244a3d17b65..09f51748c181 100644 --- a/Sources/Workspace.swift +++ b/Sources/Workspace.swift @@ -7543,20 +7543,43 @@ final class Workspace: Identifiable, ObservableObject, FilePreviewTabMetadataHos } func cloudTerminalReconnectOverlayPresentation(forSurfaceId surfaceId: UUID) -> CloudTerminalReconnectOverlayPolicy.Presentation? { + // A native Cloud pane owns its presentation through the attachment + // session. Do not let a stale workspace controller state cover a + // usable terminal when the catalog projection is being refreshed. + if let panel = panels[surfaceId] as? TerminalPanel, + let attachment = panel.cloudAttachment { + return attachment.presentation + } if let failure = cloudMaterializationFailures[surfaceId] { return Self.cloudMaterializationFailurePresentation( detail: failure.detail, reference: failure.reference ) } - // A reserved pane still waiting for its terminal shows nothing but its - // tab spinner; only a recorded failure (above) puts a card on it. + // A reserved pane only shows its tab spinner until creation fails. if cloudPendingCreations[surfaceId] != nil { return nil } - if let resource = cloudProjectedResource(forPanel: surfaceId), let machineID = resource.id.machine.cloudMachineID, let session = CmuxTuiSurfaceProviderRegistry.shared.provider(machineID: machineID)?.manualMirrorSessions[surfaceId] { return session.connectionPresentation } + let surfaceConnectionState: WorkspaceRemoteConnectionState + switch remoteTerminalSessionStatesBySurfaceId[surfaceId]?.phase { + case .some(.connected): + // A workspace controller can reconnect while this terminal's + // established PTY remains usable. Its per-surface liveness owns + // the card, so the controller state cannot cover this pane. + return nil + case .some(.launching): + // A workspace can be connected through another pane. Keep this + // panel's loading card until its own attach callback arrives. + surfaceConnectionState = remoteConnectionState == .connected + ? .connecting + : remoteConnectionState + case .some(.ended): + surfaceConnectionState = .disconnected + case .none: + surfaceConnectionState = remoteConnectionState + } return CloudTerminalReconnectOverlayPolicy.presentation( isManagedCloudWorkspace: isManagedCloudVMWorkspace, isRemoteTerminalSurface: isRemoteTerminalSurface(surfaceId) || remoteDisconnectPlaceholderPanelIds.contains(surfaceId), - connectionState: remoteConnectionState, + connectionState: surfaceConnectionState, detail: remoteConnectionDetail ) } diff --git a/cmuxTests/WorkspaceRemoteReconnectPolicyTests.swift b/cmuxTests/WorkspaceRemoteReconnectPolicyTests.swift index 332bf61a2895..b16100d993ef 100644 --- a/cmuxTests/WorkspaceRemoteReconnectPolicyTests.swift +++ b/cmuxTests/WorkspaceRemoteReconnectPolicyTests.swift @@ -106,6 +106,66 @@ struct WorkspaceRemoteReconnectPolicyTests { @Suite("Cloud terminal reconnect overlay policy") struct CloudTerminalReconnectOverlayPolicyTests { + @Test @MainActor + func connectedLegacyCloudSurfaceSuppressesWorkspaceReconnectCard() throws { + let workspace = Workspace() + let panel = try #require(workspace.focusedTerminalPanel) + let configuration = WorkspaceRemoteConfiguration( + destination: "cloud VM", + port: 22, + identityFile: nil, + sshOptions: [], + localProxyPort: nil, + relayPort: 64_015, + relayID: String(repeating: "a", count: 16), + relayToken: String(repeating: "b", count: 64), + localSocketPath: "/tmp/cmux-debug-test.sock", + managedCloudVMID: "machine", + terminalStartupCommand: "cmux vm ssh-attach --id machine" + ) + workspace.configureRemoteConnection(configuration, autoConnect: false) + #expect(workspace.markRemoteTerminalSessionConnected( + surfaceId: panel.id, + relayPort: configuration.relayPort + )) + workspace.remoteConnectionState = .reconnecting + + #expect(workspace.cloudTerminalReconnectOverlayPresentation(forSurfaceId: panel.id) == nil) + } + + @Test @MainActor + func nativeCloudAttachmentOwnsPresentationWhenCatalogProjectionIsMissing() throws { + let workspace = Workspace() + workspace.cloudVMBinding = WorkspaceCloudVMBinding( + vmID: "machine", + isBase: false + ) + let panelID = try #require(workspace.focusedPanelId) + let panel = try #require(workspace.panels[panelID] as? TerminalPanel) + let status = CloudTerminalAttachmentStatus(machineID: "machine") + panel.cloudAttachment = status + workspace.remoteConnectionState = .reconnecting + workspace.remoteConnectionDetail = nil + #expect(workspace.cloudTerminalReconnectOverlayPresentation(forSurfaceId: panelID) == nil) + + let nativePresentation = try #require( + CloudTerminalReconnectOverlayPolicy.presentation( + isManagedCloudWorkspace: true, + isRemoteTerminalSurface: true, + connectionState: .disconnected, + detail: "the terminal attachment ended" + ) + ) + status.update( + .reconnecting(attempt: 2, reason: .transportClosed), + presentation: nativePresentation + ) + + #expect( + workspace.cloudTerminalReconnectOverlayPresentation(forSurfaceId: panelID) == nativePresentation + ) + } + @Test("Cloud terminal surfaces show reconnect UI when disconnected") func cloudTerminalShowsReconnectWhenDisconnected() { let presentation = CloudTerminalReconnectOverlayPolicy.presentation(