diff --git a/Sources/Cloud/CloudWorkspaceSidebarPresentation.swift b/Sources/Cloud/CloudWorkspaceSidebarPresentation.swift index 5b86f8720c2e..836f5e369bde 100644 --- a/Sources/Cloud/CloudWorkspaceSidebarPresentation.swift +++ b/Sources/Cloud/CloudWorkspaceSidebarPresentation.swift @@ -47,6 +47,12 @@ struct CloudWorkspaceSidebarPresentation { init?(workspace: Workspace, orderedPanelIDs: [UUID], usesLastSegmentPath: Bool) { let state = workspace.cloudBindingState + func machineMetadata(for id: String) -> String? { + if let name = state.machineNames[id] { return name } + if let name = state.machineNames[SurfaceMachineID.cloud(id).rawValue] { return name } + return SurfaceCatalog.shared.machineInfo(for: .cloud(id))?.name + } + var cloudMachineIDs = Set(state.projectedResources.values.compactMap { $0.machine.cloudMachineID }) if let id = workspace.cloudVMID { cloudMachineIDs.insert(id) } let deviceMachines = Self.deviceMachines(for: workspace) @@ -57,9 +63,21 @@ struct CloudWorkspaceSidebarPresentation { guard !machineIDs.isEmpty else { return nil } deviceLabel = Self.deviceLabel(workspace: workspace, machines: deviceMachines) let names = Dictionary(uniqueKeysWithValues: machineIDs.map { id in - let name = state.machineNames[id]?.trimmingCharacters(in: .whitespacesAndNewlines) ?? id + let name = machineMetadata(for: id)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? id return (id, name.isEmpty ? id : name) }) + // A restored Cloud terminal can publish its projection before the + // catalog has finished loading machine metadata. Do not turn that + // transient state into user-visible identity or directory copy: the + // raw VM id and "Directory unavailable" are loading placeholders, not + // the values the sidebar is meant to present. + let projectedCloudMachineIDs = Set(state.projectedResources.values.compactMap { resource in + resource.machine.cloudMachineID + }) + guard projectedCloudMachineIDs.allSatisfy({ id in + guard machineMetadata(for: id) != nil else { return false } + return true + }) else { return nil } // Keep stable IDs in badge help/accessibility; width-dependent rows use // them only when friendly names collide across machines. let identities = machineIDs.sorted().map { id -> String in diff --git a/Sources/SidebarWorkspaceSnapshotFactory.swift b/Sources/SidebarWorkspaceSnapshotFactory.swift index 4e0a97aec7c7..0bbd2477048b 100644 --- a/Sources/SidebarWorkspaceSnapshotFactory.swift +++ b/Sources/SidebarWorkspaceSnapshotFactory.swift @@ -27,6 +27,8 @@ struct SidebarWorkspaceSnapshotFactory { let showsPullRequestRows = detailVisibility.showsPullRequests && !settings.compactsAgentStatus let orderedPanelIds = workspace.sidebarOrderedPanelIds() let cloud = CloudWorkspaceSidebarPresentation(workspace: workspace, orderedPanelIDs: orderedPanelIds, usesLastSegmentPath: settings.usesLastSegmentPath) + let hasCloudProjection = workspace.cloudVMID != nil + || workspace.cloudBindingState.projectedResources.values.contains { $0.machine.cloudMachineID != nil } let taskStatusInput = SidebarWorkspaceTaskStatusSnapshot.capture(workspace: workspace, orderedPanelIds: orderedPanelIds) let compactGitBranchSummaryText: String? = { guard showsBranchDirectoryRows, @@ -41,7 +43,7 @@ struct SidebarWorkspaceSnapshotFactory { settings.branchDirectory.branchLayout == .inline else { return [] } - return cloud?.directoryCandidates ?? compactDirectoryCandidatesList(orderedPanelIds: orderedPanelIds) + return cloud?.directoryCandidates ?? (hasCloudProjection ? [] : compactDirectoryCandidatesList(orderedPanelIds: orderedPanelIds)) }() let compactBranchDirectoryCandidates = compactBranchDirectoryCandidatesList( gitSummary: compactGitBranchSummaryText, @@ -53,6 +55,7 @@ struct SidebarWorkspaceSnapshotFactory { return [] } if let cloud { return [.init(branch: nil, directoryCandidates: cloud.directoryCandidates)] } + if hasCloudProjection { return [] } return verticalBranchDirectoryLines(orderedPanelIds: orderedPanelIds) }() let pullRequestRows: [SidebarWorkspaceSnapshotBuilder.PullRequestDisplay] = { @@ -95,7 +98,7 @@ struct SidebarWorkspaceSnapshotFactory { // The directory toggle itself, like the branch and PR ones, so // the tooltip keeps it under Hide All Details. directory: settings.details.showBranchDirectory - ? (cloud?.directoryCandidates ?? compactDirectoryCandidatesList(orderedPanelIds: orderedPanelIds)).first + ? (cloud?.directoryCandidates ?? (hasCloudProjection ? [] : compactDirectoryCandidatesList(orderedPanelIds: orderedPanelIds))).first : nil, orderedPanelIds: orderedPanelIds )) diff --git a/cmuxTests/CLICoderouterCommandTests.swift b/cmuxTests/CLICoderouterCommandTests.swift index 429d4a4c5b02..71172b9cfeb2 100644 --- a/cmuxTests/CLICoderouterCommandTests.swift +++ b/cmuxTests/CLICoderouterCommandTests.swift @@ -9,10 +9,6 @@ import Testing @testable import cmux #endif -// The CLI executable's CMUXCLI type is not part of the app test target. The -// provider-first alias is a pure routing helper shared with the app instead. -typealias CMUXCLI = CmuxTuiRemoteRouting - @Suite struct AgentAliasArgumentTests { final class BundleProbe {} @@ -670,16 +666,16 @@ extension CLINotifyProcessIntegrationRegressionTests { func testProviderFirstAgentAliasAddsTheCanonicalSeparator() { XCTAssertEqual( - CMUXCLI.vmAgentAliasArgs(["claude", "--machine", "vm-agent-test", "reply exactly pong"]), + CmuxTuiRemoteRouting.vmAgentAliasArgs(["claude", "--machine", "vm-agent-test", "reply exactly pong"]), ["--agent", "claude", "--machine", "vm-agent-test", "--", "reply exactly pong"] ) XCTAssertEqual( - CMUXCLI.vmAgentAliasArgs(["codex", "--", "exec", "summarize"]), + CmuxTuiRemoteRouting.vmAgentAliasArgs(["codex", "--", "exec", "summarize"]), ["--agent", "codex", "--", "exec", "summarize"] ) // Focus flags belong to `vm agent`, not to the agent's prompt. XCTAssertEqual( - CMUXCLI.vmAgentAliasArgs(["claude", "--no-focus", "reply exactly pong"]), + CmuxTuiRemoteRouting.vmAgentAliasArgs(["claude", "--no-focus", "reply exactly pong"]), ["--agent", "claude", "--no-focus", "--", "reply exactly pong"] ) } diff --git a/cmuxTests/CodexForkMonitorArgumentTests.swift b/cmuxTests/CodexForkMonitorArgumentTests.swift index 460ca14aef18..ae883d23a24c 100644 --- a/cmuxTests/CodexForkMonitorArgumentTests.swift +++ b/cmuxTests/CodexForkMonitorArgumentTests.swift @@ -10,7 +10,7 @@ import Testing struct CodexForkMonitorArgumentTests { @Test func forwardsForkParentClaimToDetachedMonitor() { - let arguments = CMUXCLI.codexForkMonitorArguments(environment: [ + let arguments = CmuxTuiRemoteRouting.codexForkMonitorArguments(environment: [ "CMUX_AGENT_FORK_PARENT_SESSION_ID": "parent-session", "CMUX_AGENT_FORK_LAUNCH_ID": "launch-id", "CMUX_CODEX_PID": "1234", @@ -25,6 +25,6 @@ struct CodexForkMonitorArgumentTests { @Test func omitsForkArgumentsForNormalCodexMonitor() { - #expect(CMUXCLI.codexForkMonitorArguments(environment: [:]).isEmpty) + #expect(CmuxTuiRemoteRouting.codexForkMonitorArguments(environment: [:]).isEmpty) } } diff --git a/cmuxTests/SidebarCloudWorkspaceBadgeTests.swift b/cmuxTests/SidebarCloudWorkspaceBadgeTests.swift index 9ec5743cf0a5..d9fe79a6cc1a 100644 --- a/cmuxTests/SidebarCloudWorkspaceBadgeTests.swift +++ b/cmuxTests/SidebarCloudWorkspaceBadgeTests.swift @@ -9,6 +9,36 @@ import Testing @Suite(.serialized) @MainActor struct SidebarCloudWorkspaceBadgeTests { + @Test("Cloud sidebar waits for machine metadata before showing identity or directory") + func cloudProjectionLoadingDoesNotShowPlaceholders() throws { + let workspace = Workspace(title: "Project", initialSurface: .cloudVMLoading) + defer { workspace.teardownAllPanels() } + let panelID = try #require(workspace.focusedPanelId) + let machine = SurfaceMachineID.cloud("vm-loading") + workspace.cloudVMBinding = WorkspaceCloudVMBinding(vmID: machine.rawValue, isBase: true) + workspace.cloudBindingState.updateCatalogMetadata( + resources: [panelID: SurfaceResourceID(machine: machine, kind: .terminal, key: "terminal")], + machineNames: [:] + ) + workspace.updateCloudPanelDirectory(panelId: panelID, directory: nil) + + let settings = SidebarTabItemSettingsSnapshot(defaults: Self.makeDefaults()) + let factory = SidebarWorkspaceSnapshotFactory(workspace: workspace, settings: settings, showsAgentActivity: false) + let loading = factory.makeSnapshot() + #expect(loading.cloudWorkspaceLabel == nil) + #expect(loading.compactDirectoryCandidates.isEmpty) + #expect(loading.remoteWorkspaceBadgeLabel?.contains("Directory unavailable") != true) + #expect(loading.remoteWorkspaceBadgeLabel?.contains(machine.rawValue) != true) + + workspace.cloudBindingState.updateCatalogMetadata( + resources: [panelID: SurfaceResourceID(machine: machine, kind: .terminal, key: "terminal")], + machineNames: [machine.rawValue: "Friendly machine"] + ) + workspace.updateRemotePanelDirectory(panelId: panelID, directory: "/home/cmux/project") + let loaded = factory.makeSnapshot() + #expect(loaded.cloudWorkspaceLabel?.contains("Friendly machine") == true) + } + @Test(arguments: [false, true]) func deviceNameIsVisibleBesideItsDirectory(vertical: Bool) throws { let defaults = Self.makeDefaults() diff --git a/cmuxTests/SurfaceMachineIDDeviceEncodingTests.swift b/cmuxTests/SurfaceMachineIDDeviceEncodingTests.swift index e94b49abcaa0..39a06e01f7a8 100644 --- a/cmuxTests/SurfaceMachineIDDeviceEncodingTests.swift +++ b/cmuxTests/SurfaceMachineIDDeviceEncodingTests.swift @@ -128,7 +128,7 @@ struct SurfaceMachineIDDeviceEncodingTests { #expect(payload["link_error"] as? String == "cloud_api_unavailable") #expect(payload["link_error_message"] as? String == "cmux cannot reach the Cloud service for this machine right now.") - let lines = CMUXCLI.vmTreeLines( + let lines = CmuxTuiRemoteRouting.vmTreeLines( machine: [ "id": "brave-otter", "status": "running", @@ -141,7 +141,7 @@ struct SurfaceMachineIDDeviceEncodingTests { #expect(lines.contains { $0.contains("cmux cannot reach the Cloud service") }) #expect(!lines.contains { $0.contains("cloud_api_unavailable") }) - let fallbackLines = CMUXCLI.vmTreeLines( + let fallbackLines = CmuxTuiRemoteRouting.vmTreeLines( machine: [ "id": "brave-otter", "status": "running",