Repository navigation
iOS: compose a prompt and launch an agent workspace #8314
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
Closed
Closed
Changes from all commits
Commits
Show all changes
5 commits
Select commit
Hold shift + click to select a range
351bfb5
Add iOS prompt composer that launches an agent workspace
7405d79
Gate agent launch on capability only, not Mac-wide tickets
9a74dfb
Polish launch composer chips and button
d972602
Merge remote-tracking branch 'origin/main' into feat-ios-spark
b1ccdb0
Fix Swift 6 capture error in sidebar PR status slot view
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
60 changes: 60 additions & 0 deletions
60
Packages/iOS/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileAgentLaunchOptionsResponse.swift
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,60 @@ | ||
| public import Foundation | ||
|
|
||
| /// Typed decoder for the `mobile.agent.launch_options` RPC result: what the | ||
| /// launch composer needs before offering a launch — the coding agents this Mac | ||
| /// can run and the working directories that make sense for a new agent | ||
| /// workspace. | ||
| public struct MobileAgentLaunchOptionsResponse: Decodable, Sendable { | ||
| /// A coding agent the Mac knows how to launch with a prompt. | ||
| public struct Agent: Decodable, Sendable { | ||
| /// Stable agent identifier (`claude`, `codex`). | ||
| public let id: String | ||
| /// User-facing agent name reported by the Mac. | ||
| public let name: String | ||
| /// Whether the agent's executable resolves on the Mac right now. | ||
| public let installed: Bool | ||
|
|
||
| public init(from decoder: any Decoder) throws { | ||
| let container = try decoder.container(keyedBy: CodingKeys.self) | ||
| id = try container.decode(String.self, forKey: .id) | ||
| name = try container.decodeIfPresent(String.self, forKey: .name) ?? id | ||
| installed = try container.decodeIfPresent(Bool.self, forKey: .installed) ?? false | ||
| } | ||
|
|
||
| private enum CodingKeys: String, CodingKey { | ||
| case id | ||
| case name | ||
| case installed | ||
| } | ||
| } | ||
|
|
||
| /// A suggested working directory for the new workspace. | ||
| public struct Directory: Decodable, Sendable { | ||
| /// Absolute path on the Mac. | ||
| public let path: String | ||
| } | ||
|
|
||
| /// Launchable agents in the Mac's preferred order. | ||
| public let agents: [Agent] | ||
| /// Suggested working directories, most relevant first. | ||
| public let directories: [Directory] | ||
| /// The directory a plain workspace create would inherit, if any. | ||
| public let defaultDirectory: String? | ||
|
|
||
| public init(from decoder: any Decoder) throws { | ||
| let container = try decoder.container(keyedBy: CodingKeys.self) | ||
| agents = try container.decodeIfPresent([Agent].self, forKey: .agents) ?? [] | ||
| directories = try container.decodeIfPresent([Directory].self, forKey: .directories) ?? [] | ||
| defaultDirectory = try container.decodeIfPresent(String.self, forKey: .defaultDirectory) | ||
| } | ||
|
|
||
| private enum CodingKeys: String, CodingKey { | ||
| case agents | ||
| case directories | ||
| case defaultDirectory = "default_directory" | ||
| } | ||
|
|
||
| public static func decode(_ data: Data) throws -> MobileAgentLaunchOptionsResponse { | ||
| try JSONDecoder().decode(Self.self, from: data) | ||
| } | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
46 changes: 46 additions & 0 deletions
46
...es/iOS/CmuxMobileRPC/Tests/CmuxMobileRPCTests/MobileAgentLaunchOptionsResponseTests.swift
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,46 @@ | ||
| import CmuxMobileRPC | ||
| import Foundation | ||
| import Testing | ||
|
|
||
| @Suite("MobileAgentLaunchOptionsResponse") | ||
| struct MobileAgentLaunchOptionsResponseTests { | ||
| @Test("Decodes the full wire shape") | ||
| func decodesFullShape() throws { | ||
| let data = Data(""" | ||
| { | ||
| "agents": [ | ||
| {"id": "claude", "name": "Claude Code", "installed": true}, | ||
| {"id": "codex", "name": "Codex", "installed": false} | ||
| ], | ||
| "directories": [ | ||
| {"path": "/Users/dev/Projects/app"}, | ||
| {"path": "/Users/dev"} | ||
| ], | ||
| "default_directory": "/Users/dev/Projects/app" | ||
| } | ||
| """.utf8) | ||
| let response = try MobileAgentLaunchOptionsResponse.decode(data) | ||
| #expect(response.agents.map(\.id) == ["claude", "codex"]) | ||
| #expect(response.agents.map(\.installed) == [true, false]) | ||
| #expect(response.directories.map(\.path) == ["/Users/dev/Projects/app", "/Users/dev"]) | ||
| #expect(response.defaultDirectory == "/Users/dev/Projects/app") | ||
| } | ||
|
|
||
| @Test("Missing fields decode to safe defaults") | ||
| func decodesSparsePayload() throws { | ||
| let response = try MobileAgentLaunchOptionsResponse.decode(Data("{}".utf8)) | ||
| #expect(response.agents.isEmpty) | ||
| #expect(response.directories.isEmpty) | ||
| #expect(response.defaultDirectory == nil) | ||
| } | ||
|
|
||
| @Test("Agent name and installed default when omitted") | ||
| func agentDefaults() throws { | ||
| let data = Data(""" | ||
| {"agents": [{"id": "claude"}]} | ||
| """.utf8) | ||
| let response = try MobileAgentLaunchOptionsResponse.decode(data) | ||
| #expect(response.agents.first?.name == "claude") | ||
| #expect(response.agents.first?.installed == false) | ||
| } | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
130 changes: 130 additions & 0 deletions
130
Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+AgentLaunch.swift
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,130 @@ | ||
| internal import CmuxMobileRPC | ||
| public import CmuxMobileShellModel | ||
| internal import Foundation | ||
|
|
||
| /// The data the launch composer needs before offering a launch: the coding | ||
| /// agents the connected Mac can run and the working directories that make | ||
| /// sense for a new agent workspace. | ||
| public struct MobileAgentLaunchOptions: Sendable, Equatable { | ||
| /// A coding agent the Mac knows how to launch with a prompt. | ||
| public struct Agent: Sendable, Equatable, Identifiable { | ||
| /// Stable agent identifier (`claude`, `codex`). | ||
| public let id: String | ||
| /// User-facing agent name reported by the Mac. | ||
| public let name: String | ||
| /// Whether the agent's executable resolves on the Mac right now. | ||
| public let installed: Bool | ||
|
|
||
| public init(id: String, name: String, installed: Bool) { | ||
| self.id = id | ||
| self.name = name | ||
| self.installed = installed | ||
| } | ||
| } | ||
|
|
||
| /// Launchable agents in the Mac's preferred order. | ||
| public let agents: [Agent] | ||
| /// Suggested working directories, most relevant first. | ||
| public let directoryPaths: [String] | ||
| /// The directory a plain workspace create would inherit, if any. | ||
| public let defaultDirectory: String? | ||
|
|
||
| public init(agents: [Agent], directoryPaths: [String], defaultDirectory: String?) { | ||
| self.agents = agents | ||
| self.directoryPaths = directoryPaths | ||
| self.defaultDirectory = defaultDirectory | ||
| } | ||
| } | ||
|
|
||
| extension MobileShellComposite { | ||
| static let workspaceLaunchAgentCapability = "workspace.launch_agent.v1" | ||
|
|
||
| /// Whether the connected Mac supports the prompt-compose agent launch | ||
| /// (`mobile.workspace.launch_agent`). Capability-gated only: like | ||
| /// `workspace.create`, a non-group launch is authorized for | ||
| /// workspace-scoped attach tickets too, so the Mac-wide-ticket policy that | ||
| /// gates group mutations must not hide this feature. | ||
| public var supportsAgentLaunch: Bool { | ||
| supportedHostCapabilities.contains(Self.workspaceLaunchAgentCapability) | ||
| } | ||
|
|
||
| /// Fetches launch options from the connected Mac. Options are advisory: | ||
| /// `nil` (offline, old Mac, transient failure) leaves the composer on its | ||
| /// defaults rather than blocking composition. | ||
| public func fetchAgentLaunchOptions() async -> MobileAgentLaunchOptions? { | ||
| guard let client = remoteClient else { return nil } | ||
| do { | ||
| let data = try await client.sendRequest( | ||
| MobileCoreRPCClient.requestData(method: "mobile.agent.launch_options", params: [:]) | ||
| ) | ||
| let response = try MobileAgentLaunchOptionsResponse.decode(data) | ||
| return MobileAgentLaunchOptions( | ||
| agents: response.agents.map { | ||
| MobileAgentLaunchOptions.Agent(id: $0.id, name: $0.name, installed: $0.installed) | ||
| }, | ||
| directoryPaths: response.directories.map(\.path), | ||
| defaultDirectory: response.defaultDirectory | ||
| ) | ||
| } catch { | ||
| return nil | ||
| } | ||
| } | ||
|
|
||
| /// Creates a workspace on the connected Mac running `agentID` on `prompt`, | ||
| /// then selects it so the shell navigates straight into the live terminal. | ||
| /// Mirrors `createRemoteWorkspace`'s apply/select path so launch and plain | ||
| /// create stay one behavior. | ||
| public func launchAgentWorkspace( | ||
| prompt: String, | ||
| agentID: String?, | ||
| workingDirectory: String? | ||
| ) async -> Result<Void, MobileWorkspaceMutationFailure> { | ||
| guard let client = remoteClient else { | ||
| return .failure(.notConnected(hostDisplayName: connectedHostName)) | ||
| } | ||
| guard supportsAgentLaunch else { | ||
| return .failure(.unsupported(hostDisplayName: connectedHostName)) | ||
| } | ||
| let generation = connectionGeneration | ||
| do { | ||
| var params: [String: Any] = ["prompt": prompt] | ||
| if let agentID, !agentID.isEmpty { | ||
| params["agent"] = agentID | ||
| } | ||
| if let workingDirectory, !workingDirectory.isEmpty { | ||
| params["working_directory"] = workingDirectory | ||
| } | ||
| let resultData = try await client.sendRequest( | ||
| MobileCoreRPCClient.requestData(method: "mobile.workspace.launch_agent", params: params) | ||
| ) | ||
| let response = try MobileSyncWorkspaceListResponse.decode(resultData) | ||
| guard isCurrentRemoteOperation(client: client, generation: generation), !Task.isCancelled else { | ||
| return .success(()) | ||
| } | ||
| applyRemoteWorkspaceList(response, mergeExistingWorkspaces: true) | ||
| let createdWorkspace = response.createdWorkspaceID.map(MobileWorkspacePreview.ID.init(rawValue:)) | ||
| if let createdWorkspace { | ||
| setSelectedWorkspaceID( | ||
| rowWorkspaceID( | ||
| forRemoteWorkspaceID: createdWorkspace, | ||
| macDeviceID: foregroundMacDeviceID | ||
| ) ?? createdWorkspace | ||
| ) | ||
| } | ||
| syncSelectedTerminalForWorkspace() | ||
| if createdWorkspace != nil { | ||
| // The user launched an agent to watch it work, not to type; | ||
| // keep the keyboard down on first attach like plain create. | ||
| suppressTerminalAutoFocusOnNextAttach(for: selectedTerminalID) | ||
| } | ||
| return .success(()) | ||
| } catch { | ||
| guard generation == connectionGeneration, !Task.isCancelled else { return .success(()) } | ||
| if disconnectForAuthorizationFailureIfNeeded(error) { | ||
| return .failure(.authorizationFailed(hostDisplayName: connectedHostName)) | ||
| } | ||
| markMacConnectionUnavailableIfNeeded(after: error) | ||
| return .failure(workspaceMutationFailure(error, hostDisplayName: connectedHostName)) | ||
| } | ||
| } | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
37 changes: 37 additions & 0 deletions
37
Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/AgentLaunchComposeBar.swift
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,37 @@ | ||
| import CmuxMobileSupport | ||
| import SwiftUI | ||
|
|
||
| /// The workspace list's bottom "Ask an agent…" affordance: a glass capsule | ||
| /// that opens the full-screen agent-launch composer. Reads as an input field | ||
| /// so the home screen itself invites composing a task. | ||
| struct AgentLaunchComposeBar: View { | ||
| let action: () -> Void | ||
|
|
||
| var body: some View { | ||
| Button(action: action) { | ||
| HStack(spacing: 10) { | ||
| Image(systemName: "sparkles") | ||
| .font(.system(size: 15, weight: .semibold)) | ||
| .foregroundStyle(Color.accentColor) | ||
| Text(L10n.string("mobile.agentLaunch.bar.placeholder", defaultValue: "Ask an agent…")) | ||
| .font(.body) | ||
| .foregroundStyle(.secondary) | ||
| Spacer(minLength: 0) | ||
| Image(systemName: "arrow.up.circle.fill") | ||
| .font(.system(size: 24)) | ||
| .foregroundStyle(Color.accentColor) | ||
| } | ||
| .padding(.leading, 16) | ||
| .padding(.trailing, 8) | ||
| .frame(height: 48) | ||
| .contentShape(Capsule()) | ||
| } | ||
| .buttonStyle(.plain) | ||
| .mobileGlassPill() | ||
| .padding(.horizontal, 16) | ||
| .padding(.top, 4) | ||
| .padding(.bottom, 8) | ||
| .accessibilityLabel(L10n.string("mobile.agentLaunch.title", defaultValue: "New Agent Task")) | ||
| .accessibilityIdentifier("MobileAgentLaunchBar") | ||
| } | ||
| } |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
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 | 🟠 Major | 🏗️ Heavy lift
Do not report superseded launches as successful.
Both stale/cancelled branches return
.success(()), soAgentLaunchComposerViewclears the draft and dismisses even though this method skipped applying/selecting the created workspace. Add an explicit superseded/cancelled outcome, or return a failure that preserves the draft.Also applies to: 122-122
🤖 Prompt for AI Agents
Source: Coding guidelines