diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextActions/ActionSurfaceCatalog+Exemptions.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextActions/ActionSurfaceCatalog+Exemptions.swift index 437d6b93db4a..abaa6703de40 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextActions/ActionSurfaceCatalog+Exemptions.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextActions/ActionSurfaceCatalog+Exemptions.swift @@ -50,7 +50,7 @@ nonisolated extension ActionSurfaceCatalog { "space.moveRight", "space.moveToStart", "space.moveToEnd", "space.move", "space.switch", "workspace.moveToSpace", "workspace.duplicateToSpace", "workspaceGroup.moveToSpace", "space.setTheme", "space.clearTheme", // Every space action is a verb (Lawrence 14223: no exceptions): the space keys and the color family too. - "space.next", "space.previous", "space.selectByNumber", "space.openInNewWindow", "space.bringHere", "space.newIncognito", "space.color.grey", "space.color.blue", + "space.next", "space.previous", "space.selectByNumber", "space.openInNewWindow", "space.bringHere", "space.newIncognito", "space.newCloud", "space.color.grey", "space.color.blue", "space.color.red", "space.color.yellow", "space.color.green", "space.color.pink", "space.color.purple", "space.color.cyan", "space.color.orange", "space.newWindow", "space.newWorkspace", "space.rename", "space.setColor", diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextActions/ActionSurfaceCatalog+Menus.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextActions/ActionSurfaceCatalog+Menus.swift index 37d7d88eacbc..53e98a218a95 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextActions/ActionSurfaceCatalog+Menus.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextActions/ActionSurfaceCatalog+Menus.swift @@ -138,6 +138,8 @@ nonisolated extension ActionSurfaceCatalog { // the space actions stay in the palette and the CLI (`.minimalMenu`). "space.new": [p(.sidebarBackground, .create, 6, folder: .new)], "space.newIncognito": [p(.sidebarBackground, .create, 6, folder: .new)], + // A Cloud machine opens as its own space from its menu too (RoomHandlers.bindCloudSpace). + "space.newCloud": [p(.sidebarBackground, .create, 6, folder: .new), p(.cloudMachine, .create, 4)], "space.newWorkspace": [p(.profile, .create, 0, label: SpaceMenuLabels.newWorkspace)], "space.openInNewWindow": [p(.profile, .create, 1, label: SpaceMenuLabels.openInNewWindow)], "space.bringHere": [p(.profile, .create, 2, label: SpaceMenuLabels.bringHere)], diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextActions/Catalog/ProfileActionCatalog.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextActions/Catalog/ProfileActionCatalog.swift index 0bcee402f997..c290732012df 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextActions/Catalog/ProfileActionCatalog.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextActions/Catalog/ProfileActionCatalog.swift @@ -29,6 +29,16 @@ nonisolated enum ProfileActionCatalog: ActionCatalogGroup { surfaces: [.palette, .keyboard, .menu, .contextMenu], arguments: [CatalogArgument.nameString.optional], cliName: "space new-incognito", mainMenu: .window ), + // Cloud space: a space that is one Cloud machine (its target, else the one New Cloud Workspace uses, else a + // new one). It follows the machine's session and creates there, so switching to it shows the machine's + // own workspaces, terminals and browser tabs (RoomHandlers.bindCloudSpace). + ActionDescriptor( + id: "space.newCloud", + title: String(localized: "action.space.newCloud", defaultValue: "New Cloud Space", table: "ProfileActions", bundle: .module), + keywords: ["room", "profile", "space", "cloud", "vm", "remote", "machine"], category: .workspace, symbol: "cloud", + surfaces: [.palette, .keyboard, .menu, .contextMenu], arguments: [CatalogArgument.nameString.optional], + targets: [.machine], cliName: "space new-cloud", mainMenu: .window + ), // Open Space in New Window (spaces-overhaul-DESIGN.md rule 4): a new window shows the space and the // window it came from keeps it. The space's right-click menu, a dot dragged out of the switcher, // the palette, the CLI and MCP run it. diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextActions/ProfileActions.xcstrings b/Packages/macOS/CmuxNext/Sources/CmuxNextActions/ProfileActions.xcstrings index babe0bfecea8..5a0e57fa2e0f 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextActions/ProfileActions.xcstrings +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextActions/ProfileActions.xcstrings @@ -7731,6 +7731,137 @@ } } }, + "action.space.newCloud" : { + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "مساحة سحابية جديدة" + } + }, + "bs" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Novi prostor u oblaku" + } + }, + "da" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Nyt cloud-space" + } + }, + "de" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Neuer Cloud-Space" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "New Cloud Space" + } + }, + "es" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Nuevo espacio en la nube" + } + }, + "fr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Nouvel espace cloud" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Nuovo spazio cloud" + } + }, + "ja" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "新規クラウドスペース" + } + }, + "km" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Space ពពកថ្មី" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "새 클라우드 스페이스" + } + }, + "nb" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Nytt skyområde" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Nowa przestrzeń w chmurze" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Novo Espaço na Nuvem" + } + }, + "ru" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Новое облачное пространство" + } + }, + "th" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "สเปซคลาวด์ใหม่" + } + }, + "tr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Yeni Bulut Alanı" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Новий хмарний простір" + } + }, + "vi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Không gian đám mây mới" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "新建云空间" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "新增雲端空間" + } + } + } + }, "action.space.bringHere" : { "extractionState" : "manual", "localizations" : { diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/AgentTabs+Header.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/AgentTabs+Header.swift index a4da6f5f24c2..df6e4f4f2312 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/AgentTabs+Header.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/AgentTabs+Header.swift @@ -80,7 +80,7 @@ extension AgentTabStore { private func openNewTabColumn(beside provisional: String, cwd: String? = nil, split: Bool = true) -> Bool { let chat = resolve(provisional) // The page opens on the chat's machine: that machine's folders, never this Mac's (cx-k21v). - guard let newTab = firstPageNewTab?(cwd ?? lookup(chat)?.store.tab(id: chat)?.cwd, chat) else { return false } + guard let newTab = firstPageNewTab?(cwd ?? lookup(chat)?.store.tab(id: chat)?.cwd, chat, nil) else { return false } return openBeside(provisional, split: split) { pane, daemon in try self.open(in: pane, of: daemon, newTab: newTab) } } diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/AgentTabs+Wiring.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/AgentTabs+Wiring.swift index a1b0857f49ac..3dcfc74170c0 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/AgentTabs+Wiring.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/AgentTabs+Wiring.swift @@ -25,11 +25,11 @@ extension AgentTabStore { func openFirstPage(workspace: WorkspaceHandle, cwd: String?, on daemon: DaemonService, chat: AgentPaneSeed? = nil) async throws -> SurfaceID? { guard let connection = daemon.connection, let localHost, canHost(on: daemon) else { throw DaemonError.notConnected } - // A workspace of another machine: its chat runs there (cx-k21v), never in this Mac's acpmux, - // and it gets no New Tab page (that page runs here). Callers open a terminal there instead - // of a first page (``opensFirstPage(on:chat:)``). + // A workspace of another machine: its chat runs there (cx-k21v), never in this Mac's acpmux. + // It gets the New Tab page only where that machine starts chats itself (a Cloud space's + // machine, ``opensRemotePage``); callers open a terminal there otherwise (``opensFirstPage(on:chat:)``). let remote = !daemon.isLocal - guard !remote || chat != nil else { throw DaemonError.notConnected } + guard !remote || chat != nil || opensRemotePage(daemon) else { throw DaemonError.notConnected } // A chat of this Mac (an outside chat it resumes, a past chat of its list) never opens in a // workspace of another machine (cx-k21v). if remote, chat?.adopt != nil || chat?.pastChat != nil { throw AgentTabRefusal(message: RefusalStrings.agentTabMachineMismatch) } @@ -56,7 +56,7 @@ extension AgentTabStore { return response.surface } seeds[key] = AgentPaneSeedSource(AgentPaneSeed(cwd: cwd)) - let made = firstPage(cwd: cwd, token: firstPageTokens[workspace]) + let made = firstPage(cwd: cwd, token: firstPageTokens[workspace], machine: remote ? daemon : nil) // The tree listed the tab first and its view already shows the page (with this // workspace's token): it keeps that page, since a new one remounts the screen and drops // what was typed (cx-kql5h); only the actions take this folder. @@ -77,17 +77,21 @@ extension AgentTabStore { } /// Whether a new or empty workspace of `daemon` opens with an agent tab: this Mac's opens the - /// New Tab page; another machine's opens only a chat it asked for (its terminal otherwise). - func opensFirstPage(on daemon: DaemonService, chat: Bool = false) -> Bool { canHost(on: daemon) && (daemon.isLocal || chat) } + /// New Tab page, and so does a Cloud machine's whose daemon starts chats there + /// (``opensRemotePage``); another machine's opens only a chat it asked for (its terminal otherwise). + func opensFirstPage(on daemon: DaemonService, chat: Bool = false) -> Bool { + canHost(on: daemon) && (daemon.isLocal || chat || opensRemotePage(daemon)) + } /// The chat seed of new `workspace`, held before its tab is known: the /// tree can list the tab before new-conversation-tab replies, and a view /// built then takes it (``firstChatSeed(of:in:)``). func seedFirstChat(_ seed: AgentPaneSeed, in workspace: WorkspaceHandle) { firstChats[workspace] = AgentPaneSeedSource(seed) } - /// A new workspace's first New Tab page, carrying that workspace's key-hold token. - func firstPage(cwd: String?, token: String?) -> (page: AgentPaneNewTab, handler: NewTabPageHandler)? { - guard var made = firstPageNewTab?(cwd, nil) else { return nil } + /// A new workspace's first New Tab page, carrying that workspace's key-hold token. `machine`: the + /// other machine the workspace is on (its folders, no Mac home); nil for this Mac. + func firstPage(cwd: String?, token: String?, machine: DaemonService? = nil) -> (page: AgentPaneNewTab, handler: NewTabPageHandler)? { + guard var made = firstPageNewTab?(cwd, nil, machine) else { return nil } if let token { made.page.inputToken = token onFirstPageToken?(token) @@ -198,15 +202,16 @@ extension AgentTabStore { NewTabPage.replace(key, with: request, cwd: request.cwd ?? cwd, in: controller, keep: tabs?.sessions[key] != nil) } } - tabs.firstPageNewTab = { [weak services] cwd, beside in + tabs.firstPageNewTab = { [weak services] cwd, beside, machine in guard let services else { return nil } - // A page beside a chat of another machine shows that machine's folders (cx-k21v). - // (A chat of this Mac keeps the plain page, as before.) + // A page beside a chat of another machine shows that machine's folders (cx-k21v), and so + // does the first page of a new workspace there (`machine`). (A chat of this Mac keeps the + // plain page, as before.) let tab = beside.flatMap { services.locateTabOnAnyMachine($0)?.0 }.flatMap { services.machines.daemon(forTab: $0).isLocal ? nil : $0 } // Without the Commands palette's actions (30-50 ms on the main thread, most of Cmd-N's // key time: instant-new-surfaces `newTab.paletteActions`): they fill in on the frame // after the page shows, as Cmd-T's do (``NewTabPage/fillPaletteActions(_:)``). - var page = NewTabPage.page(services, selected: tab, paletteActions: false) + var page = NewTabPage.page(services, selected: tab, machine: machine, paletteActions: false) page.cwd = cwd ?? page.cwd NewTabPage.fillPaletteActions(services) // Keys typed since Cmd-N wait for this page's field (NewTabInputCoordinator, cx-kql5h); @@ -335,6 +340,13 @@ extension AgentTabStore { guard let daemon = try? services.machines.agentDaemon(forTab: tab) else { return nil } return services.agentGit.link(for: daemon) } + // A Cloud machine whose daemon starts and attaches chats there opens the New Tab page first, + // as this Mac does: a Cloud space's new workspace is the agent page, its chats on the machine. + tabs.opensRemotePage = { [weak services] daemon in + guard let services, services.machines.cloudSession(of: daemon) != nil else { return false } + return daemon.supports(DaemonCapability.agentSessionAttach.rawValue) && daemon.supports(DaemonCapability.agentSessionStart.rawValue) + && AgentTabStore.registryHost(of: daemon) != nil + } tabs.remoteMachineName = { [weak services] daemon in services?.machines.machineName(daemon.machineID).flatMap(AgentTabStore.displayName) } diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/AgentTabs.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/AgentTabs.swift index 3980315c57af..2a3bc44da2de 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/AgentTabs.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/AgentTabs.swift @@ -189,8 +189,12 @@ final class AgentTabStore { /// Opens a redeemed Run attempt's terminal, owned by an enrolled worker daemon, in the tab's pane. var runAttach: (@MainActor (_ key: String, _ request: AgentPaneRunAttach) async throws -> Void)? /// The New Tab page a new workspace's first tab shows, starting in the given folder. - /// `(cwd, beside)`: `beside` is the tab whose machine the page opens on (nil: this Mac). - var firstPageNewTab: ((String?, String?) -> (page: AgentPaneNewTab, handler: NewTabPageHandler)?)? + /// `(cwd, beside, machine)`: `beside` is the tab whose machine the page opens on, else `machine` + /// (a new workspace of another machine, before its tab is mirrored); nil for both: this Mac. + var firstPageNewTab: ((String?, String?, DaemonService?) -> (page: AgentPaneNewTab, handler: NewTabPageHandler)?)? + /// Whether a new or empty workspace of another machine opens the New Tab page, its chats run + /// there (a Cloud machine whose daemon starts them); otherwise it opens a terminal. + var opensRemotePage: @MainActor (DaemonService) -> Bool = { _ in false } /// Tabs opened as the chooser page, and the actions for their selected kind. var newTabPages: [String: (page: AgentPaneNewTab, handler: NewTabPageHandler)] = [:] { diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/AppActions+Workspaces.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/AppActions+Workspaces.swift index 7e1274572df6..e27a7e05dd02 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/AppActions+Workspaces.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/AppActions+Workspaces.swift @@ -152,17 +152,26 @@ extension AppActions { let target: String? = show || !hasOpenWindow ? windows.targetWindow(preferring: sourceID ?? sourceWindow?.state.id) : nil // `machine` (App machine id; the CLI's `--session`): born on that // session's daemon (plans/cmux-next/data-model.md 1.3). - let daemon = (invocation["machine"]?.targetValue?.id ?? invocation["machine"]?.stringValue).flatMap(services.machines.daemon(machine:)) - if invocation["machine"] != nil, daemon?.connection == nil { + let asked = (invocation["machine"]?.targetValue?.id ?? invocation["machine"]?.stringValue).flatMap(services.machines.daemon(machine:)) + // With none asked for, a person's create (or one naming a space) goes where the space creates; a script's + // stays on this Mac, so a `cmux new-workspace` never wakes a paused Cloud machine of the space shown. + let daemon = asked ?? (invocation.origin == .user || spawn.profile != nil ? nil : services.daemon) + // A named machine that does not resolve (unknown, or Cloud off) is refused, never made on this Mac instead. + if invocation["machine"] != nil, asked?.connection == nil { services.registry.track(Task { ActionWorkFailure(WorkspaceVerbStrings.machineNotConnected) }) return } + // With no machine asked for, the create goes where the space creates (a Cloud space's machine), so the page + // decision and the key hold below are that daemon's. + let spaceDaemon = windows.spaceCreation(spawn, into: target).daemon ?? services.daemon // A person's Cmd-N onto the New Tab page in the window they type in: every key from this // moment waits for the new page's field, in order (cx-kql5h), not for the workspace to show. let input = services.keyRouter.newTabInputCoordinator let opensPage = show && hasOpenWindow && spawn.opensNewTabPage && spawn.command == nil && spawn.firstChat == nil - && spawn.firstSession == nil && services.agentTabs.opensFirstPage(on: daemon ?? services.daemon) - let keys = opensPage && target == sourceWindow?.state.id ? input.beginIncoming(in: sourceWindow?.window) : nil + && spawn.firstSession == nil && services.agentTabs.opensFirstPage(on: daemon ?? spaceDaemon) + // A Cloud space's machine still waking would hold the keys for its whole resume: only a connected one holds them. + let keys = opensPage && target == sourceWindow?.state.id && (daemon ?? spaceDaemon).connection != nil + ? input.beginIncoming(in: sourceWindow?.window) : nil spawn.inputToken = keys // In this turn: the row, and for Cmd-N's page the workspace itself, show before any daemon reply. let create = BenchSpans.measure("cmdN.start") { windows.startWorkspace(spawn, on: daemon, into: target) } diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/AppActions.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/AppActions.swift index 89fd3816a23d..ef0bea950fee 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/AppActions.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/AppActions.swift @@ -51,6 +51,7 @@ enum AppActions { SidebarActionSet(host: services, autoArchive: services.autoArchive, settings: { [weak services] in services?.settings }).bind(into: registry) NotificationSoundActions(host: services, gate: services.notifications.soundGate).bind(into: registry) RoomHandlers.bind(into: registry, context: context) + RoomHandlers.bindCloudSpace(into: registry, context: context) RoomMenuHandlers.bind(into: registry, context: context) ThemeHandlers.bind(into: registry, context: context) RemoteLocalhostHandlers.bind(into: registry, context: context) diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Cloud/CloudMachineSession.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Cloud/CloudMachineSession.swift index 7eea1bbfb189..474e22ded1ac 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Cloud/CloudMachineSession.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Cloud/CloudMachineSession.swift @@ -1,3 +1,4 @@ +import CmuxNextActions import CmuxNextCloud import CmuxNextCloudCredentials import CmuxNextDaemon @@ -539,3 +540,23 @@ final class CloudMachineSession { } } } + +extension CloudMachineSession { + /// Wakes this machine for a Cloud space (a paused machine resumes, an ended link connects) and returns once its + /// daemon is connected. A failed resume ends the wait at once with its reason; the bound is `CloudMove`'s. + func connectedForSpace() async throws { + // A machine the person disconnected stays so until they connect it (its menu's Connect): no wait for it. + if disconnected { throw ActionFailure(message: CloudStrings.notConnected) } + // A failure left from an earlier attempt must not end this wait before this attempt's own resume runs. + resumeFailure = nil + if daemon.connection == nil { + await refreshIfMaybePaused() + if machine.status == .paused { attach(origin: .user) } else { connect(origin: .user) } + } + let daemon = daemon + guard await CloudMove.holds(within: .seconds(180), until: { [weak self] in + self?.resumeFailure != nil || daemon.connection != nil + }) else { throw DaemonError.notConnected } + if let failure = resumeFailure { throw ActionFailure(message: failure) } + } +} diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Cloud/MachineRegistry.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Cloud/MachineRegistry.swift index dae64a5cd71f..7d6a98999e51 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Cloud/MachineRegistry.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Cloud/MachineRegistry.swift @@ -55,6 +55,20 @@ final class MachineRegistry { self.updates = updates } + /// Where a create in a space goes (`creation(in:)`). + enum SpaceCreation { + /// The space has no default session: this Mac. + case mac + /// The space's default session's daemon. + case machine(DaemonService) + /// The space names a session no listed machine serves (the machine list has not come yet, the account + /// signed out, or the machine was deleted): the create is refused, never made on the Mac instead. + case unresolved + + /// The machine's daemon, nil for the other cases. + var daemon: DaemonService? { if case let .machine(daemon) = self { daemon } else { nil } } + } + /// Local first, then Cloud machines, then SSH machines, then servers. var daemons: [DaemonService] { [local] + remoteDaemons } @@ -243,3 +257,28 @@ nonisolated struct MachineRoutingRefusal: Error, Equatable { /// The machine whose tree lists the tab. let machineID: String } + +// Where a space's new workspaces go (plans/cmux-next/data-model.md 4 rule 3): a space with a default session +// creates there. A Cloud space follows one Cloud machine's session and creates on it, so the window shows that +// machine's own workspaces, terminals and browser tabs. The session is the stable key; the machine is found +// through the daemon that serves it now, else through the home registry's record of how to reach it, so a +// paused machine (no daemon identity yet this launch) still resolves to its session and wakes. +extension MachineRegistry { + func creation(in profile: ProfileID) -> SpaceCreation { + guard let session = local.store.profile(profile)?.defaultSessionID else { return .mac } + guard let daemon = daemon(forSession: session) else { return .unresolved } + return daemon.isLocal ? .mac : .machine(daemon) + } + + /// The daemon serving `sessionID` now, else the machine the home registry recorded for it. + func daemon(forSession sessionID: String) -> DaemonService? { + if let live = daemon(session: sessionID) { return live } + let record = local.store.personal.session(sessionID.lowercased()) ?? local.store.personal.session(sessionID) + return Self.machineID(transport: record?.transport).flatMap(anyDaemon(machine:)) + } + + /// The Cloud machine behind `daemon`, if it is one. + func cloudSession(of daemon: DaemonService) -> CloudMachineSession? { + daemon.isLocal ? nil : session(daemon.machineID) + } +} diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Handlers/RoomHandlers.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Handlers/RoomHandlers.swift index d7a698b95bcd..85009bce2b25 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Handlers/RoomHandlers.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Handlers/RoomHandlers.swift @@ -1,5 +1,6 @@ import AppKit import CmuxNextActions +import CmuxNextCloud import CmuxNextDaemon import CmuxNextDesign import CmuxNextSidebar @@ -317,3 +318,78 @@ final class RoomDefaultsUpdates { } } } + +/// New Cloud Space: a space that is one Cloud machine. It follows the machine's session, so every workspace on the +/// machine (its terminals and browser tabs) shows in it and nowhere else, and its default session is the machine, +/// so New Workspace in it creates there (`MachineRegistry.creationDaemon`). Switching to it shows the machine's +/// whole tree in the window; switching back shows the Mac's. One path for the palette, the File menu, a Cloud +/// machine's right-click menu, `cmux space new-cloud` and MCP. +extension RoomHandlers { + static func bindCloudSpace(into registry: ActionRegistry, context: AppActionContext) { + let cloud = context.services.cloud + let bound = registry.bindEffect("space.newCloud", requires: DaemonCapability.profiles.rawValue, daemon: context.services.machines.local, + unavailable: { cloud.unavailableReason ?? (cloud.isSignedIn ? nil : CloudStrings.signInFirst) }, + effect: { invocation in try newCloudSpace(invocation, context) }) + assert(bound, "space.newCloud is not in the action catalog") + } + + /// The machine (the target, else the one New Cloud Workspace would use, else a new one) wakes and connects, + /// then the space is made, or the existing space of that machine is reused, and shown when the run may change + /// the view. + private static func newCloudSpace(_ invocation: ActionInvocation, _ context: AppActionContext) throws -> ActionEffect { + try context.requireRooms() + let target = try CloudHandlers.workspaceMachine(invocation, context) + let services = context.services + let name = invocation["name"]?.stringValue.flatMap { $0.isEmpty ? nil : $0 } + let window = invocation.allowsViewChange ? context.activeWindow?.state.id : nil + // action-wait-allow: the space follows a session id that exists only once the Cloud machine is up and connected. + return .waiting(.network) { + do { + let machine: CloudMachineSession + if let target { + machine = target + } else { + machine = try await services.cloud.createMachine(name: nil, startedByPerson: invocation.origin == .user, + askedBy: invocation.callerAgentSession) + } + try await machine.connectedForSpace() + let daemon = machine.daemon + // The session id as membership qualifies workspaces (`WindowProfiles`): the tree's `registry_id`. + guard await CloudMove.holds(within: .seconds(30), until: { daemon.store.registryID != nil }), + let session = daemon.store.registryID else { throw ActionFailure(message: CloudStrings.notConnected) } + let profile = try await space(following: session, named: name ?? machine.machine.title, services: services) + if let window, let state = services.windows.states[window] { services.windows.switchProfile(profile, in: state) } + return nil + } catch { + return ActionWorkFailure("space.newCloud", error) + } + } + } + + /// The space whose default session is `session`, else a new one following it, with the next free color. + private static func space(following session: String, named name: String, services: AppServices) async throws -> ProfileID { + let local = services.machines.local + guard let connection = local.connection else { throw DaemonError.notConnected } + let id: ProfileID + if let existing = local.store.profiles.first(where: { $0.defaultSessionID?.lowercased() == session.lowercased() }) { + id = existing.id + } else { + // A space's dot in its own color, like any other space (no icon). + let used = Set(local.store.profiles.compactMap(\.color)) + id = .generate() + _ = try await connection.createProfile(name: name, id: id, color: (GroupColor.automatic(used: used) ?? .grey).rawValue, + defaultSessionID: session, follows: [session]) + // The reply comes before the home store mirrors the space (`personal-changed`); entering it earlier would + // fall back as from a deleted space (cx-d8x5). + await local.store.mirrored(profile: id) + } + // The home daemon makes `default` follow every session it first records (put-session), so the machine's + // workspaces would show on the Mac's space too: the machine is the Cloud space's alone. + let defaultFollows = local.store.personal.follows[.defaultProfile] ?? [] + if let followed = defaultFollows.first(where: { $0.lowercased() == session.lowercased() }) { + _ = try await connection.request(SetProfileFollowsRequest(profile: .defaultProfile, + sessionIDs: defaultFollows.subtracting([followed]).sorted())) + } + return id + } +} diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/NewTab/NewTabPageMachine.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/NewTab/NewTabPageMachine.swift index 3731ebc8af89..b5ed11b4d699 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/NewTab/NewTabPageMachine.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/NewTab/NewTabPageMachine.swift @@ -17,8 +17,9 @@ struct NewTabPageMachine { /// `pane`: the pane the page opens in. With no selected tab (an empty pane) its machine /// decides: a pane of another machine uses its workspace's folder there, never this Mac's. - @MainActor init(_ services: AppServices, selected: TabModel?, pane: PaneController? = nil) { - guard let daemon = selected.map(services.machines.daemon(forTab:)) ?? pane?.daemon, !daemon.isLocal else { + /// `machine`: with neither, the machine of a new workspace whose tab is not mirrored yet. + @MainActor init(_ services: AppServices, selected: TabModel?, pane: PaneController? = nil, machine: DaemonService? = nil) { + guard let daemon = selected.map(services.machines.daemon(forTab:)) ?? pane?.daemon ?? machine, !daemon.isLocal else { // Beside an agent chat, the chat's exact folder (it has no daemon folder). (cwd, home, remoteFolders) = (NewTerminalStart.inherited(besides: selected, in: pane?.pane ?? selected.flatMap { services.locateTab(model: $0)?.1 }, services: services) ?? selected?.cwd, NSHomeDirectory(), nil) diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/NewTabPage.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/NewTabPage.swift index c2f7b3850dc5..9da3d79dc78b 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/NewTabPage.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/NewTabPage.swift @@ -181,11 +181,12 @@ enum NewTabPage { /// `window` is where the page shows (the active one when nil; a panel counts as the window /// that owns it): its dots' last pick comes first, then the saved `tabs.newTabTemplate` (cx-l1jv). static func page(_ services: AppServices, selected: TabModel?, window: NSWindow? = nil, - pane: PaneController? = nil, paletteActions: Bool = true) -> AgentPaneNewTab { + pane: PaneController? = nil, machine: DaemonService? = nil, + paletteActions: Bool = true) -> AgentPaneNewTab { let selectedID = selected?.id let hotkeys = newActions.compactMapValues { services.registry.shortcutDisplay(for: $0) } // An SSH or Cloud tab's page shows that machine's folder, never this Mac's home (cx-gaq9). - let machine = NewTabPageMachine(services, selected: selected, pane: pane) + let machine = NewTabPageMachine(services, selected: selected, pane: pane, machine: machine) var bar = omnibar(services, excluding: selectedID, paletteActions: paletteActions) // A page on another machine offers that machine's folders only (cx-k21v). if let remote = machine.remoteFolders { (bar.folders, bar.projects) = (remote, remote) } diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Onboarding/ExternalOpenController.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Onboarding/ExternalOpenController.swift index e3a786c3026e..787536f08d32 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Onboarding/ExternalOpenController.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Onboarding/ExternalOpenController.swift @@ -148,7 +148,8 @@ final class ExternalOpenController { let logger = services.daemon.logger Task { [weak self] in do { - _ = try await windows.createWorkspace(WorkspaceSpawn(), into: target) + // The file was opened from this Mac: its workspace is this Mac's, whatever space the window shows. + _ = try await windows.createWorkspace(WorkspaceSpawn(), on: windows.services.daemon, into: target) } catch { logger.error("create workspace for an opened file failed: \(String(describing: error), privacy: .public)") } diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Windows/FirstWorkspace.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Windows/FirstWorkspace.swift index 7b989aea5e52..b3adab929fe5 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Windows/FirstWorkspace.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Windows/FirstWorkspace.swift @@ -21,7 +21,8 @@ struct FirstWorkspaceLaunch { guard services.daemon.store.workspaces.needsFirstWorkspace(leftover: leftover, home: services.home.homeWorkspaceID) else { return nil } let landing = FirstWorkspaceLanding(launchIntoHome: HomeTunables.launchIntoHome.value, classic: services.home.dock.isClassic) if let home = await landing.home(ensured: services.home.homeWorkspaceID != nil, listed: { services.home.homeWorkspace?.id }) { return home } - return await manager.createWorkspace(newTabPage: true) + // This Mac's own tree is empty: its first workspace is this Mac's, whatever space the window shows. + return await manager.createWorkspace(on: manager.services.daemon, newTabPage: true) } /// Shows `workspaceID` (the one `create` decided on, before the restore placed what was asked for meanwhile) diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Windows/WindowManager+Creation.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Windows/WindowManager+Creation.swift index 50b8bf66d8ff..c5aec4684511 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Windows/WindowManager+Creation.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Windows/WindowManager+Creation.swift @@ -10,18 +10,18 @@ import CmuxNextDaemon // workspace, whichever of the command reply and the daemon delta comes // first. extension WindowManager { - /// Creates a workspace with one terminal on `daemon` (default: the local - /// daemon), placed in window `windowID` when given (see `claimNew`). + /// Creates a workspace with one terminal on `daemon` (default: the space's + /// default session, else the local daemon), placed in window `windowID` when given (see `claimNew`). /// Returns its id. func createWorkspace(cwd: String? = nil, on daemon: DaemonService? = nil, into windowID: String? = nil, frame: CGRect? = nil, newTabPage: Bool = false, inputToken: String? = nil) async -> String? { - let daemon = daemon ?? services.daemon var spawn = WorkspaceSpawn(cwd: cwd, newTabPage: newTabPage) spawn.inputToken = inputToken do { + // No daemon asked for: the space's own (a Cloud space's machine), else the local daemon (`startWorkspace`). return try await createWorkspace(spawn, on: daemon, into: windowID, frame: frame) } catch { - daemon.logger.error("create workspace failed: \(String(describing: error), privacy: .public)") + (daemon ?? services.daemon).logger.error("create workspace failed: \(String(describing: error), privacy: .public)") return nil } } diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Windows/WindowManager+Profiles.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Windows/WindowManager+Profiles.swift index ebac68b8065c..3c80c17e361f 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Windows/WindowManager+Profiles.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Windows/WindowManager+Profiles.swift @@ -39,8 +39,19 @@ extension WindowManager { // a new workspace (no empty window, REWRITE.md round 1). It is // claimed before the create command, so the switch shows it as // soon as the daemon mirrors it. + // A space naming a machine no listed machine serves (the list has not come, the account signed out) + // has nothing to show and nowhere to create: the window stays where it is. + if case .unresolved = services.machines.creation(in: profile) { return services.registry.refuse(CloudStrings.notConnected) } + let previous = state.profileID state.enterProfile(profile) recordSaver.stateDidChange(state) + // A space on a machine whose tree has not loaded (a paused Cloud machine, a link still connecting) may + // hold workspaces already: wake it and show them once its tree loads, and create one only if it has none. + if case let .machine(daemon) = services.machines.creation(in: profile), !daemon.store.isLoaded { + let windowID = state.id + Task { [services] in await enterLoaded(profile, on: daemon, in: windowID, from: previous); withExtendedLifetime(services) {} } + return + } Task { [services] in await createWorkspace(into: state.id); withExtendedLifetime(services) {} } return } @@ -49,6 +60,48 @@ extension WindowManager { select(remembered ?? visible.first, in: state) } + /// The rest of a switch into a space whose machine had not loaded: once its tree loads, the window shows the + /// space's workspaces no other window shows, else a new one. If the machine never loads, the window goes back + /// to `previous` (never left empty) with the reason. Nothing happens if the window left the space meanwhile. + private func enterLoaded(_ profile: ProfileID, on daemon: DaemonService, in windowID: String, from previous: ProfileID) async { + var failure: String? + do { + if let machine = services.machines.cloudSession(of: daemon) { try await machine.connectedForSpace() } + if await !CloudMove.holds(within: .seconds(60), until: { daemon.store.isLoaded }) { failure = CloudStrings.notConnected } + } catch { + failure = RefusalStrings.describe(error) + } + guard let state = states[windowID], state.profileID == profile else { return } + if let failure { + fallBack(preferring: previous, in: state) + return services.registry.refuse(failure) + } + let adoptable = adoptableWorkspaces(in: profile, for: windowID) + guard !adoptable.isEmpty else { + _ = await createWorkspace(into: windowID) + return + } + transition { $0.move(adoptable, to: windowID) } + select(adoptable.first, in: state) + } + + /// Leaves a Cloud space whose machine could not load, never through another wait: to `previous` when the window + /// can show something there now, else to `default` when it can, else to `default` with a new workspace on this + /// Mac. A wait into `previous` (another Cloud space that has not loaded) could fail back here and loop. + func fallBack(preferring previous: ProfileID, in state: WindowState) { + let members = registry.members(of: state.id), machines = services.machines + // Visible here, or adoptable from a closed window: both take switchProfile's non-empty path, never a wait. + for target in [previous, .defaultProfile] where target != state.profileID + && (!WindowProfiles.visible(members, profile: target, machines: machines).isEmpty + || !adoptableWorkspaces(in: target, for: state.id).isEmpty) { + return switchProfile(target, in: state) + } + state.enterProfile(.defaultProfile) + recordSaver.stateDidChange(state) + let windowID = state.id + Task { [services] in _ = await createWorkspace(on: services.daemon, into: windowID) } + } + /// Next (+1) or previous (-1) profile of the local daemon, clamped at /// the ends. Returns false when there is none that way. @discardableResult diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/WorkspaceSpawn.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/WorkspaceSpawn.swift index eed9595e4629..cfd35b14c340 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/WorkspaceSpawn.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/WorkspaceSpawn.swift @@ -1,3 +1,4 @@ +import CmuxNextActions import CmuxNextAppCore import CmuxNextAgentPane import CmuxNextBridge @@ -19,7 +20,35 @@ extension WindowManager { /// the workspace id (`WorkspaceSpawner.start`). func startWorkspace(_ spawn: WorkspaceSpawn, on daemon: DaemonService? = nil, into windowID: String? = nil, frame: CGRect? = nil) -> () async throws -> String { - WorkspaceSpawner(windows: self, spawn: spawn, daemon: daemon ?? services.daemon, windowID: windowID, frame: frame).start() + if daemon == nil { + switch spaceCreation(spawn, into: windowID) { + case .mac: break + case .unresolved: return { throw ActionFailure(message: CloudStrings.notConnected) } + case let .machine(space): + // A Cloud space creates on its machine. A paused machine or an ended link wakes on this create, + // and the workspace is made once the machine connects, never on the Mac instead. + if space.connection == nil, let machine = services.machines.cloudSession(of: space) { + return { [self] in + try await machine.connectedForSpace() + return try await WorkspaceSpawner(windows: self, spawn: spawn, daemon: space, windowID: windowID, frame: frame).start()() + } + } + return WorkspaceSpawner(windows: self, spawn: spawn, daemon: space, windowID: windowID, frame: frame).start() + } + } + return WorkspaceSpawner(windows: self, spawn: spawn, daemon: daemon ?? services.daemon, windowID: windowID, frame: frame).start() + } + + /// Where a create with no machine asked for goes: the default session of the space it is born in + /// (`spawn.profile`, else its window's, else the active window's). A create that carries something of this + /// Mac (a folder, a command, a chat or a session to open, a browser profile: Finder's New Workspace Here, + /// `cmux open