From fae9a11720c237442ce9e567be0a8ac70f628fa3 Mon Sep 17 00:00:00 2001 From: Leo Li Date: Fri, 30 Oct 2026 09:54:16 +0000 Subject: [PATCH 1/3] cmux NEXT Cloud: a new machine gets a readable default name, not its raw id The cmux-next backend stores no name and no slug for an unnamed machine, so the first Cloud machine was titled vm_... in the sidebar and menus. A create with no typed name on the app server path now sends "Cloud Machine" (then "Cloud Machine 2", ...), unique among the account's machines and creations in flight, pinned on the creation so a Retry sends the same params. Co-Authored-By: Claude Opus 5.5 --- .../Cloud/CloudMachineCreation.swift | 4 + .../CmuxNextApp/Cloud/CloudService.swift | 12 +- .../API/CloudMachine+DefaultName.swift | 15 + .../CmuxNextDaemonService/CloudStrings.swift | 7 + .../Resources/Cloud.xcstrings | 264 ++++++++++++++++++ .../CloudMachineDefaultNameTests.swift | 31 ++ 6 files changed, 332 insertions(+), 1 deletion(-) create mode 100644 Packages/macOS/CmuxNext/Sources/CmuxNextCloud/API/CloudMachine+DefaultName.swift create mode 100644 Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/CloudMachineDefaultNameTests.swift diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Cloud/CloudMachineCreation.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Cloud/CloudMachineCreation.swift index ee8c4286946c..11e726c6b794 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Cloud/CloudMachineCreation.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Cloud/CloudMachineCreation.swift @@ -52,6 +52,10 @@ final class CloudMachineCreation: Identifiable { /// What `cloud new-machine` asked for (size, idle pause); a Retry asks for the same, with /// the same idempotency key. private(set) var request: CloudMachineRequest + /// The readable default name the first attempt picked (``CloudService/createMachine``) when the + /// person typed none; every Retry sends the same one. Not part of ``request``: a named request is + /// a request option the app server's create refuses. + var defaultMachineName: String? /// The create went to the server once, to `team`: a Retry may find the machine it made, and /// goes to that same team. diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Cloud/CloudService.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Cloud/CloudService.swift index 744805e355c1..199d3551d2a3 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Cloud/CloudService.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Cloud/CloudService.swift @@ -544,7 +544,7 @@ final class CloudService { request: CloudMachineRequest = CloudMachineRequest(), proceed: () throws -> Void = {}) async throws -> CloudMachineSession { if let reason = unavailableReason { throw ActionFailure(message: reason) } guard auth.isSignedIn else { throw ActionFailure(message: CloudStrings.signInFirst) } - let name = name ?? request.name + var name = name ?? request.name // The app server's create takes none of them yet, and its machines have no `/api/vm` file // or exec route for a bootstrap: refuse rather than make a machine without what was asked. if configuration.linkSource == .appServer, !request.isDefault { @@ -554,6 +554,16 @@ final class CloudService { if configuration.linkSource != .appServer, request.environment != nil { throw ActionFailure(message: CloudEnvironmentStrings().unavailable) } + // The cmux-next backend stores no name and no slug for an unnamed machine, so it would be titled by + // its raw id: give it a readable one, unique among this account's machines and creations in flight. + // The classic `/api/vm` create names it itself (its slug). + if name?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ?? true, configuration.linkSource == .appServer { + let picked = creation?.defaultMachineName ?? CloudMachine.defaultName( + taken: machines.cloud.map(\.machine.title) + creations.all.compactMap(\.defaultMachineName), + base: CloudStrings.defaultMachineName, numbered: CloudStrings.defaultMachineName(numbered:)) + creation?.defaultMachineName = picked + name = picked + } creating += 1 defer { creating -= 1 } let onSent: @MainActor @Sendable () async -> Void = { [weak creation] in creation?.note(.creating) } diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextCloud/API/CloudMachine+DefaultName.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextCloud/API/CloudMachine+DefaultName.swift new file mode 100644 index 000000000000..d11d4c7fd814 --- /dev/null +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextCloud/API/CloudMachine+DefaultName.swift @@ -0,0 +1,15 @@ +import Foundation + +extension CloudMachine { + /// The name a new machine gets when the person typed none: `base` ("Cloud Machine") when no + /// machine in `taken` has that title, else the first free `numbered(n)` from 2 ("Cloud Machine 2"). + /// Titles compare trimmed and case-insensitively, so a renamed "cloud machine" still counts. + public static func defaultName(taken: some Sequence, base: String, numbered: (Int) -> String) -> String { + let used = Set(taken.map { $0.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() }) + let free: (String) -> Bool = { !used.contains($0.lowercased()) } + if free(base) { return base } + var number = 2 + while !free(numbered(number)) { number += 1 } + return numbered(number) + } +} diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextDaemonService/CloudStrings.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextDaemonService/CloudStrings.swift index e7c3db6a33fa..2830263e436e 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextDaemonService/CloudStrings.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextDaemonService/CloudStrings.swift @@ -7,6 +7,13 @@ public enum CloudStrings { String(localized: "cloud.unavailable.localBackend", defaultValue: "This build has no Cloud backend. Rebuild it with ./scripts/reload.sh --tag --direct-backend.", table: "Cloud", bundle: .module) } public static var noClient: String { String(localized: "cloud.unavailable.noClient", defaultValue: "The bundled cmux-tui client is missing, so Cloud machines cannot connect. Reinstall cmux from cmux.com/download, or rebuild a local build with cmux-tui bundled.", table: "Cloud", bundle: .module) } + /// The name a new Cloud machine gets when the person typed none (``CloudMachine/defaultName(taken:base:numbered:)``), + /// so the sidebar and menus never title it by its raw id. + nonisolated public static var defaultMachineName: String { String(localized: "cloud.machine.defaultName", defaultValue: "Cloud Machine", table: "Cloud", bundle: .module) } + /// The default name when ``defaultMachineName`` is taken: "Cloud Machine 2". + nonisolated public static func defaultMachineName(numbered number: Int) -> String { + String(format: String(localized: "cloud.machine.defaultNameNumbered", defaultValue: "Cloud Machine %lld", table: "Cloud", bundle: .module), number) + } nonisolated public static var signInFirst: String { String(localized: "cloud.failed.signInFirst", defaultValue: "Sign in to use Cloud machines: run Sign In from the command palette, or `cmux cloud sign-in`.", table: "Cloud", bundle: .module) } public static var noMachine: String { String(localized: "cloud.failed.noMachine", defaultValue: "No Cloud machine is selected. Right-click a machine or pass --target machine:; `cmux cloud list-machines` lists ids.", table: "Cloud", bundle: .module) } /// Why a Cloud machine needs an update, for its sidebar header and the diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextDaemonService/Resources/Cloud.xcstrings b/Packages/macOS/CmuxNext/Sources/CmuxNextDaemonService/Resources/Cloud.xcstrings index b4e681d11f6b..0198bd95f6ed 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextDaemonService/Resources/Cloud.xcstrings +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextDaemonService/Resources/Cloud.xcstrings @@ -9704,6 +9704,270 @@ } } } + }, + "cloud.machine.defaultName": { + "comment": "The name a new Cloud machine gets when the person typed none (sidebar, menus, Cloud page).", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Cloud Machine" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "クラウドマシン" + } + }, + "ar": { + "stringUnit": { + "state": "needs_review", + "value": "جهاز سحابي" + } + }, + "bs": { + "stringUnit": { + "state": "needs_review", + "value": "Mašina u oblaku" + } + }, + "da": { + "stringUnit": { + "state": "needs_review", + "value": "Cloudmaskine" + } + }, + "de": { + "stringUnit": { + "state": "needs_review", + "value": "Cloud-Maschine" + } + }, + "es": { + "stringUnit": { + "state": "needs_review", + "value": "Máquina en la nube" + } + }, + "fr": { + "stringUnit": { + "state": "needs_review", + "value": "Machine cloud" + } + }, + "it": { + "stringUnit": { + "state": "needs_review", + "value": "Macchina cloud" + } + }, + "km": { + "stringUnit": { + "state": "needs_review", + "value": "ម៉ាស៊ីនពពក" + } + }, + "ko": { + "stringUnit": { + "state": "needs_review", + "value": "클라우드 머신" + } + }, + "nb": { + "stringUnit": { + "state": "needs_review", + "value": "Skymaskin" + } + }, + "pl": { + "stringUnit": { + "state": "needs_review", + "value": "Maszyna w chmurze" + } + }, + "pt-BR": { + "stringUnit": { + "state": "needs_review", + "value": "Máquina na nuvem" + } + }, + "ru": { + "stringUnit": { + "state": "needs_review", + "value": "Облачная машина" + } + }, + "th": { + "stringUnit": { + "state": "needs_review", + "value": "เครื่องบนคลาวด์" + } + }, + "tr": { + "stringUnit": { + "state": "needs_review", + "value": "Bulut makinesi" + } + }, + "uk": { + "stringUnit": { + "state": "needs_review", + "value": "Хмарна машина" + } + }, + "vi": { + "stringUnit": { + "state": "needs_review", + "value": "Máy đám mây" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "needs_review", + "value": "云端机器" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "needs_review", + "value": "雲端機器" + } + } + } + }, + "cloud.machine.defaultNameNumbered": { + "comment": "The default Cloud machine name when the plain one is taken; %lld is 2, 3, …", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Cloud Machine %lld" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "クラウドマシン %lld" + } + }, + "ar": { + "stringUnit": { + "state": "needs_review", + "value": "جهاز سحابي %lld" + } + }, + "bs": { + "stringUnit": { + "state": "needs_review", + "value": "Mašina u oblaku %lld" + } + }, + "da": { + "stringUnit": { + "state": "needs_review", + "value": "Cloudmaskine %lld" + } + }, + "de": { + "stringUnit": { + "state": "needs_review", + "value": "Cloud-Maschine %lld" + } + }, + "es": { + "stringUnit": { + "state": "needs_review", + "value": "Máquina en la nube %lld" + } + }, + "fr": { + "stringUnit": { + "state": "needs_review", + "value": "Machine cloud %lld" + } + }, + "it": { + "stringUnit": { + "state": "needs_review", + "value": "Macchina cloud %lld" + } + }, + "km": { + "stringUnit": { + "state": "needs_review", + "value": "ម៉ាស៊ីនពពក %lld" + } + }, + "ko": { + "stringUnit": { + "state": "needs_review", + "value": "클라우드 머신 %lld" + } + }, + "nb": { + "stringUnit": { + "state": "needs_review", + "value": "Skymaskin %lld" + } + }, + "pl": { + "stringUnit": { + "state": "needs_review", + "value": "Maszyna w chmurze %lld" + } + }, + "pt-BR": { + "stringUnit": { + "state": "needs_review", + "value": "Máquina na nuvem %lld" + } + }, + "ru": { + "stringUnit": { + "state": "needs_review", + "value": "Облачная машина %lld" + } + }, + "th": { + "stringUnit": { + "state": "needs_review", + "value": "เครื่องบนคลาวด์ %lld" + } + }, + "tr": { + "stringUnit": { + "state": "needs_review", + "value": "Bulut makinesi %lld" + } + }, + "uk": { + "stringUnit": { + "state": "needs_review", + "value": "Хмарна машина %lld" + } + }, + "vi": { + "stringUnit": { + "state": "needs_review", + "value": "Máy đám mây %lld" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "needs_review", + "value": "云端机器 %lld" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "needs_review", + "value": "雲端機器 %lld" + } + } + } } }, "version": "1.0" diff --git a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/CloudMachineDefaultNameTests.swift b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/CloudMachineDefaultNameTests.swift new file mode 100644 index 000000000000..154e7bde9c11 --- /dev/null +++ b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/CloudMachineDefaultNameTests.swift @@ -0,0 +1,31 @@ +import CmuxNextCloud +import Testing + +/// A new cmux-next Cloud machine with no typed name gets a readable one, never its raw id. +@Suite struct CloudMachineDefaultNameTests { + private func pick(_ taken: [String]) -> String { + CloudMachine.defaultName(taken: taken, base: "Cloud Machine", numbered: { "Cloud Machine \($0)" }) + } + + @Test func theFirstMachineIsCloudMachine() { + #expect(pick([]) == "Cloud Machine") + #expect(pick(["vm_8f2c1a", "build-box"]) == "Cloud Machine") + } + + @Test func aTakenNameCountsOnFromTwo() { + #expect(pick(["Cloud Machine"]) == "Cloud Machine 2") + #expect(pick(["Cloud Machine", "Cloud Machine 2"]) == "Cloud Machine 3") + #expect(pick(["Cloud Machine", "Cloud Machine 3"]) == "Cloud Machine 2", "the first free number") + } + + @Test func titlesCompareTrimmedAndCaseInsensitively() { + #expect(pick([" cloud machine "]) == "Cloud Machine 2") + } + + @Test func aMachineWithNoNameIsTitledByItsDefaultOnlyOnceNamed() { + let unnamed = CloudMachine(id: "vm_8f2c1a", provider: "cmux-next") + #expect(unnamed.title == "vm_8f2c1a", "an old record keeps its id; only new creates get a name") + let named = CloudMachine(id: "vm_8f2c1a", provider: "cmux-next", displayName: pick([unnamed.title])) + #expect(named.title == "Cloud Machine") + } +} From 5bde26a9773e0e649d17733dfda266e9fcbcfe28 Mon Sep 17 00:00:00 2001 From: Leo Li Date: Fri, 30 Oct 2026 11:38:35 +0000 Subject: [PATCH 2/3] cmux NEXT Cloud: a default machine name skips names other creates in flight asked for Review of fae9a117: an unnamed create that started while a create typed "Cloud Machine" was still in flight also got "Cloud Machine". Every app server create now holds its name (typed or default) until it ends. Co-Authored-By: Claude Opus 5.5 --- .../CmuxNextApp/Cloud/CloudService.swift | 21 +++++++++++++------ 1 file changed, 15 insertions(+), 6 deletions(-) diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Cloud/CloudService.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Cloud/CloudService.swift index 199d3551d2a3..979bc7ca7658 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Cloud/CloudService.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Cloud/CloudService.swift @@ -89,6 +89,8 @@ final class CloudService { @ObservationIgnored var confirmCreate: (@MainActor (CloudMachineCreateFlow.Prompt) async -> Bool)? /// Machine creations in flight (sidebar can show a placeholder). private(set) var creating = 0 + /// The names app server creates in flight asked for, typed or default: a default picked meanwhile skips them. + @ObservationIgnored private var machineNamesInFlight: [String] = [] /// New Cloud Workspace runs from the click to the open terminal: each /// window's progress view and sidebar row (cx-lu8f). let creations = CloudCreations() @@ -557,13 +559,20 @@ final class CloudService { // The cmux-next backend stores no name and no slug for an unnamed machine, so it would be titled by // its raw id: give it a readable one, unique among this account's machines and creations in flight. // The classic `/api/vm` create names it itself (its slug). - if name?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ?? true, configuration.linkSource == .appServer { - let picked = creation?.defaultMachineName ?? CloudMachine.defaultName( - taken: machines.cloud.map(\.machine.title) + creations.all.compactMap(\.defaultMachineName), - base: CloudStrings.defaultMachineName, numbered: CloudStrings.defaultMachineName(numbered:)) - creation?.defaultMachineName = picked - name = picked + var reserved: String? + if configuration.linkSource == .appServer { + if name?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ?? true { + let picked = creation?.defaultMachineName ?? CloudMachine.defaultName( + taken: machines.cloud.map(\.machine.title) + creations.all.compactMap(\.defaultMachineName) + machineNamesInFlight, + base: CloudStrings.defaultMachineName, numbered: CloudStrings.defaultMachineName(numbered:)) + creation?.defaultMachineName = picked + name = picked + } + // Held until this create ends, typed names included, so a default picked meanwhile is another one. + reserved = name + if let name { machineNamesInFlight.append(name) } } + defer { if let reserved, let index = machineNamesInFlight.firstIndex(of: reserved) { machineNamesInFlight.remove(at: index) } } creating += 1 defer { creating -= 1 } let onSent: @MainActor @Sendable () async -> Void = { [weak creation] in creation?.note(.creating) } From 39840f523e46b63de8c33b5c4c2c06f4201d4042 Mon Sep 17 00:00:00 2001 From: Leo Li Date: Fri, 30 Oct 2026 12:02:38 +0000 Subject: [PATCH 3/3] cmux NEXT Cloud: a creation pins the name its first attempt sent, typed or default Review of 5bde26a9: a Retry must send the exact params the held approval has, so the creation keeps whichever name its first app server attempt sent, not only a picked default. Co-Authored-By: Claude Opus 5.5 --- .../CmuxNextApp/Cloud/CloudMachineCreation.swift | 8 ++++---- .../Sources/CmuxNextApp/Cloud/CloudService.swift | 11 ++++++----- 2 files changed, 10 insertions(+), 9 deletions(-) diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Cloud/CloudMachineCreation.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Cloud/CloudMachineCreation.swift index 11e726c6b794..804b6c9edcab 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Cloud/CloudMachineCreation.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Cloud/CloudMachineCreation.swift @@ -52,10 +52,10 @@ final class CloudMachineCreation: Identifiable { /// What `cloud new-machine` asked for (size, idle pause); a Retry asks for the same, with /// the same idempotency key. private(set) var request: CloudMachineRequest - /// The readable default name the first attempt picked (``CloudService/createMachine``) when the - /// person typed none; every Retry sends the same one. Not part of ``request``: a named request is - /// a request option the app server's create refuses. - var defaultMachineName: String? + /// The name the first app server attempt sent (``CloudService/createMachine``): the one asked for, else + /// a readable default. Every Retry sends the same one, since an approval holds the exact params. Not + /// part of ``request``: a named request is a request option the app server's create refuses. + var pinnedMachineName: String? /// The create went to the server once, to `team`: a Retry may find the machine it made, and /// goes to that same team. diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Cloud/CloudService.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Cloud/CloudService.swift index 979bc7ca7658..b6a5f01ee677 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Cloud/CloudService.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Cloud/CloudService.swift @@ -561,13 +561,14 @@ final class CloudService { // The classic `/api/vm` create names it itself (its slug). var reserved: String? if configuration.linkSource == .appServer { - if name?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ?? true { - let picked = creation?.defaultMachineName ?? CloudMachine.defaultName( - taken: machines.cloud.map(\.machine.title) + creations.all.compactMap(\.defaultMachineName) + machineNamesInFlight, + if let pinned = creation?.pinnedMachineName { + name = pinned + } else if name?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ?? true { + name = CloudMachine.defaultName( + taken: machines.cloud.map(\.machine.title) + creations.all.compactMap(\.pinnedMachineName) + machineNamesInFlight, base: CloudStrings.defaultMachineName, numbered: CloudStrings.defaultMachineName(numbered:)) - creation?.defaultMachineName = picked - name = picked } + creation?.pinnedMachineName = name // Held until this create ends, typed names included, so a default picked meanwhile is another one. reserved = name if let name { machineNamesInFlight.append(name) }