From b93b394dad9874467681729d3b5ed3f66e42331b Mon Sep 17 00:00:00 2001 From: Abdulaziz Albahar <67667005+azooz2003-bit@users.noreply.github.com> Date: Thu, 23 Jul 2026 19:53:15 -0700 Subject: [PATCH 01/30] Add model selection lab to the New Task composer Adds a curated per-provider model catalog (Claude, Codex, OpenCode) with opt-in model-flag injection into template commands, and five UX variants for picking the model in the New Task sheet (combined agent menu, model row, trailing chip, pill strip, context row), switchable at runtime from the DEBUG-only CMUX Labs 'New Task Model Lab'. No model selected keeps template commands byte-for-byte verbatim; release builds stay off. Co-Authored-By: Claude Fable 5 --- .../MobileTaskAgentModelCatalog.swift | 135 ++++++++ .../MobileTaskCommandComposer.swift | 20 +- .../MobileTaskComposerDraft.swift | 4 + .../MobileTaskSubmissionSnapshot.swift | 15 +- .../MobileTaskAgentModelCatalogTests.swift | 85 ++++++ .../MobileTaskCommandComposerTests.swift | 65 ++++ .../MobileTaskComposerDraftTests.swift | 20 ++ .../MobileTaskSubmissionSnapshotTests.swift | 43 ++- .../MobileDisplaySettings.swift | 16 + .../MobileSettingsView.swift | 13 + .../TaskComposer/TaskComposerAgentMenu.swift | 92 +++++- .../TaskComposerAgentMenuActions.swift | 1 + .../TaskComposerAgentMenuValue.swift | 2 + .../TaskComposerContextSection.swift | 18 ++ .../TaskComposer/TaskComposerModelChip.swift | 49 +++ .../TaskComposerModelContextRow.swift | 55 ++++ .../TaskComposerModelMenuContent.swift | 39 +++ .../TaskComposerModelPickerLabView.swift | 61 ++++ .../TaskComposerModelPickerVariant.swift | 104 +++++++ .../TaskComposerModelPillStrip.swift | 70 +++++ .../TaskComposer/TaskComposerModelRow.swift | 63 ++++ .../TaskComposer/TaskComposerPromptCard.swift | 75 ++++- .../TaskComposerSheet+DraftState.swift | 8 + .../TaskComposerSheet+ModelSelection.swift | 38 +++ .../TaskComposer/TaskComposerSheet.swift | 23 +- .../MobileDisplaySettingsTests.swift | 16 + ios/cmux/Resources/Localizable.xcstrings | 289 ++++++++++++++++++ 27 files changed, 1389 insertions(+), 30 deletions(-) create mode 100644 Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileTaskAgentModelCatalog.swift create mode 100644 Packages/iOS/CmuxMobileShellModel/Tests/CmuxMobileShellModelTests/MobileTaskAgentModelCatalogTests.swift create mode 100644 Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TaskComposer/TaskComposerModelChip.swift create mode 100644 Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TaskComposer/TaskComposerModelContextRow.swift create mode 100644 Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TaskComposer/TaskComposerModelMenuContent.swift create mode 100644 Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TaskComposer/TaskComposerModelPickerLabView.swift create mode 100644 Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TaskComposer/TaskComposerModelPickerVariant.swift create mode 100644 Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TaskComposer/TaskComposerModelPillStrip.swift create mode 100644 Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TaskComposer/TaskComposerModelRow.swift create mode 100644 Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TaskComposer/TaskComposerSheet+ModelSelection.swift diff --git a/Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileTaskAgentModelCatalog.swift b/Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileTaskAgentModelCatalog.swift new file mode 100644 index 000000000000..41a905573cd2 --- /dev/null +++ b/Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileTaskAgentModelCatalog.swift @@ -0,0 +1,135 @@ +import Foundation + +/// Known coding-agent CLIs the composer can offer model choices for. +public enum MobileTaskAgentProvider: String, CaseIterable, Sendable { + /// Anthropic's Claude Code CLI. + case claude + /// OpenAI's Codex CLI. + case codex + /// The OpenCode CLI. + case openCode +} + +/// One selectable model for a coding-agent provider. +public struct MobileTaskAgentModel: Equatable, Sendable, Identifiable { + /// CLI identifier passed to the provider's model flag. + public let id: String + /// Product name displayed verbatim in the composer. + public let displayName: String + + /// Creates a selectable coding-agent model. + /// - Parameters: + /// - id: CLI identifier passed to the provider's model flag. + /// - displayName: Product name displayed verbatim in the composer. + public init(id: String, displayName: String) { + self.id = id + self.displayName = displayName + } +} + +/// Curated per-provider model lists and the CLI flag spelling used to apply one. +/// lint:allow namespace-type — the task spec requires this static catalog API. +public enum MobileTaskAgentModelCatalog { + /// Lexically detects a provider from the command's first + /// whitespace-delimited token. + /// + /// Detection ignores the token's path, but does not parse shell syntax, so + /// compound or environment-prefixed commands return `nil`. + /// - Parameter command: User-authored task-template command. + /// - Returns: The detected provider, or `nil` for an unsupported first token. + public static func provider(forCommand command: String) -> MobileTaskAgentProvider? { + guard let tokenRange = firstTokenRange(in: command) else { return nil } + let token = command[tokenRange] + let basename = token.split(separator: "/", omittingEmptySubsequences: true).last + switch basename { + case "claude": + return .claude + case "codex": + return .codex + case "opencode": + return .openCode + default: + return nil + } + } + + /// Returns the curated models available for a provider. + /// - Parameter provider: Coding-agent provider to look up. + /// - Returns: Models offered by that provider, in display order. + public static func models(for provider: MobileTaskAgentProvider) -> [MobileTaskAgentModel] { + switch provider { + case .claude: + [ + MobileTaskAgentModel(id: "claude-fable-5", displayName: "Fable 5"), + MobileTaskAgentModel(id: "claude-opus-4-8", displayName: "Opus 4.8"), + MobileTaskAgentModel(id: "claude-sonnet-5", displayName: "Sonnet 5"), + MobileTaskAgentModel(id: "claude-haiku-4-5", displayName: "Haiku 4.5"), + ] + case .codex: + [ + MobileTaskAgentModel(id: "gpt-5.6-sol", displayName: "GPT-5.6 Sol"), + MobileTaskAgentModel(id: "gpt-5.5", displayName: "GPT-5.5"), + ] + case .openCode: + [ + MobileTaskAgentModel( + id: "anthropic/claude-sonnet-5", + displayName: "Claude Sonnet 5" + ), + MobileTaskAgentModel( + id: "anthropic/claude-opus-4-8", + displayName: "Claude Opus 4.8" + ), + MobileTaskAgentModel(id: "openai/gpt-5.5", displayName: "GPT-5.5"), + ] + } + } + + /// Returns the curated models for a command's detected provider. + /// - Parameter command: User-authored task-template command. + /// - Returns: Available models, or an empty array for an unsupported command. + public static func models(forCommand command: String) -> [MobileTaskAgentModel] { + guard let provider = provider(forCommand: command) else { return [] } + return models(for: provider) + } + + /// Validates a model identifier against a command's detected provider. + /// - Parameters: + /// - id: CLI model identifier to validate. + /// - command: User-authored task-template command. + /// - Returns: The matching curated model, or `nil` when it is unavailable. + public static func model(id: String, forCommand command: String) -> MobileTaskAgentModel? { + models(forCommand: command).first { $0.id == id } + } + + /// Inserts a provider-specific model flag immediately after the command's + /// first token. + /// + /// A `nil` model identifier or unsupported command is returned unchanged. + /// Everything after the first token remains byte-for-byte identical. + /// - Parameters: + /// - modelID: CLI model identifier to single-quote for the flag value. + /// - command: User-authored task-template command. + /// - Returns: The flagged command, or the original command when not applicable. + public static func commandApplying(modelID: String?, to command: String) -> String { + guard let modelID, + let provider = provider(forCommand: command), + let tokenRange = firstTokenRange(in: command) else { + return command + } + let flag = switch provider { + case .claude, .openCode: + "--model" + case .codex: + "-m" + } + let escapedID = modelID.replacingOccurrences(of: "'", with: "'\\''") + return "\(command[.. Range? { + guard let start = command.firstIndex(where: { !$0.isWhitespace }) else { return nil } + let end = command[start...].firstIndex(where: \.isWhitespace) ?? command.endIndex + return start.. MobileTaskComposition { + public func compose( + template: MobileTaskTemplate, + prompt rawPrompt: String, + modelID: String? = nil + ) -> MobileTaskComposition { let prompt = rawPrompt.trimmingCharacters(in: .whitespacesAndNewlines) let title = Self.taskTitle(from: prompt) guard !template.isPlainShell else { return MobileTaskComposition(initialCommand: nil, initialEnv: [:], title: title) } return MobileTaskComposition( - initialCommand: template.command, + initialCommand: MobileTaskAgentModelCatalog.commandApplying( + modelID: modelID, + to: template.command + ), initialEnv: ["CMUX_TASK_PROMPT": prompt], title: title ) diff --git a/Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileTaskComposerDraft.swift b/Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileTaskComposerDraft.swift index 77fe83286dd9..a9ab4ece4cab 100644 --- a/Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileTaskComposerDraft.swift +++ b/Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileTaskComposerDraft.swift @@ -4,6 +4,8 @@ public import Foundation public struct MobileTaskComposerDraft: Codable, Equatable, Sendable { /// Prompt text exactly as entered by the user. public var prompt: String + /// Optional CLI model identifier selected for the task template. + public var modelID: String? /// Selected template, validated against current templates when restored. public var templateID: MobileTaskTemplate.ID? /// Selected Mac, validated against current paired Macs when restored. @@ -23,6 +25,7 @@ public struct MobileTaskComposerDraft: Codable, Equatable, Sendable { /// Creates a restorable composer draft. public init( prompt: String, + modelID: String? = nil, templateID: MobileTaskTemplate.ID?, macDeviceID: String?, directory: String, @@ -32,6 +35,7 @@ public struct MobileTaskComposerDraft: Codable, Equatable, Sendable { completedOperationID: UUID? = nil ) { self.prompt = prompt + self.modelID = modelID self.templateID = templateID self.macDeviceID = macDeviceID self.directory = directory diff --git a/Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileTaskSubmissionSnapshot.swift b/Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileTaskSubmissionSnapshot.swift index 3d4b9210b579..bf17190b05c4 100644 --- a/Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileTaskSubmissionSnapshot.swift +++ b/Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileTaskSubmissionSnapshot.swift @@ -12,6 +12,8 @@ public struct MobileTaskSubmissionSnapshot: Equatable, Sendable { public let macDeviceID: String /// Unmodified prompt text captured from the composer. public let prompt: String + /// Optional CLI model identifier captured from the composer. + public let modelID: String? /// Optional workspace name exactly as entered in the composer. public let workspaceName: String /// Workspace name with surrounding whitespace removed. @@ -37,6 +39,7 @@ public struct MobileTaskSubmissionSnapshot: Equatable, Sendable { /// - Parameters: /// - template: Task template selected when submission begins. /// - prompt: Prompt text to compose into the template command. + /// - modelID: Optional CLI model identifier to apply to the command. /// - macDeviceID: Identifier of the Mac that should create the task. /// - directory: Working-directory text shown in the composer. /// - workspaceName: Optional workspace name shown in the composer. @@ -45,6 +48,7 @@ public struct MobileTaskSubmissionSnapshot: Equatable, Sendable { public init( template: MobileTaskTemplate, prompt: String, + modelID: String? = nil, macDeviceID: String, directory: String, workspaceName: String = "", @@ -54,13 +58,18 @@ public struct MobileTaskSubmissionSnapshot: Equatable, Sendable { self.templateID = template.id self.macDeviceID = macDeviceID self.prompt = prompt + self.modelID = modelID self.workspaceName = workspaceName self.trimmedWorkspaceName = workspaceName.trimmingCharacters(in: .whitespacesAndNewlines) self.directory = directory self.trimmedDirectory = directory.trimmingCharacters(in: .whitespacesAndNewlines) self.didEditDirectory = didEditDirectory self.operationID = operationID - self.composition = MobileTaskCommandComposer().compose(template: template, prompt: prompt) + self.composition = MobileTaskCommandComposer().compose( + template: template, + prompt: prompt, + modelID: modelID + ) } /// Whether both snapshots produce the same `workspace.create` request. @@ -85,6 +94,7 @@ public struct MobileTaskSubmissionSnapshot: Equatable, Sendable { templateID: templateID, macDeviceID: macDeviceID, prompt: prompt, + modelID: modelID, workspaceName: workspaceName, directory: directory, didEditDirectory: didEditDirectory, @@ -127,6 +137,7 @@ public struct MobileTaskSubmissionSnapshot: Equatable, Sendable { public var draft: MobileTaskComposerDraft { MobileTaskComposerDraft( prompt: prompt, + modelID: modelID, templateID: templateID, macDeviceID: macDeviceID.isEmpty ? nil : macDeviceID, directory: directory, @@ -140,6 +151,7 @@ public struct MobileTaskSubmissionSnapshot: Equatable, Sendable { templateID: MobileTaskTemplate.ID, macDeviceID: String, prompt: String, + modelID: String?, workspaceName: String, directory: String, didEditDirectory: Bool, @@ -151,6 +163,7 @@ public struct MobileTaskSubmissionSnapshot: Equatable, Sendable { self.templateID = templateID self.macDeviceID = macDeviceID self.prompt = prompt + self.modelID = modelID self.workspaceName = workspaceName self.trimmedWorkspaceName = trimmedWorkspaceName self.directory = directory diff --git a/Packages/iOS/CmuxMobileShellModel/Tests/CmuxMobileShellModelTests/MobileTaskAgentModelCatalogTests.swift b/Packages/iOS/CmuxMobileShellModel/Tests/CmuxMobileShellModelTests/MobileTaskAgentModelCatalogTests.swift new file mode 100644 index 000000000000..1a04482c40ec --- /dev/null +++ b/Packages/iOS/CmuxMobileShellModel/Tests/CmuxMobileShellModelTests/MobileTaskAgentModelCatalogTests.swift @@ -0,0 +1,85 @@ +import Testing +@testable import CmuxMobileShellModel + +@Suite struct MobileTaskAgentModelCatalogTests { + @Test( + arguments: [ + ("claude -- \"$CMUX_TASK_PROMPT\"", MobileTaskAgentProvider.claude), + ("codex -- \"$CMUX_TASK_PROMPT\"", MobileTaskAgentProvider.codex), + ("opencode --prompt \"$CMUX_TASK_PROMPT\"", MobileTaskAgentProvider.openCode), + ("/usr/local/bin/claude -- \"$CMUX_TASK_PROMPT\"", MobileTaskAgentProvider.claude), + ] + ) + func detectsProvider(command: String, expected: MobileTaskAgentProvider) { + #expect(MobileTaskAgentModelCatalog.provider(forCommand: command) == expected) + } + + @Test( + arguments: [ + "cd x && claude -- \"$CMUX_TASK_PROMPT\"", + "FOO=1 claude -- \"$CMUX_TASK_PROMPT\"", + "claudex -- \"$CMUX_TASK_PROMPT\"", + "", + " \n\t ", + ] + ) + func rejectsUnsupportedProvider(command: String) { + #expect(MobileTaskAgentModelCatalog.provider(forCommand: command) == nil) + } + + @Test( + arguments: [ + ( + "claude -- \"$CMUX_TASK_PROMPT\"", + "claude-opus-4-8", + "claude --model 'claude-opus-4-8' -- \"$CMUX_TASK_PROMPT\"" + ), + ( + "codex -- \"$CMUX_TASK_PROMPT\"", + "gpt-5.5", + "codex -m 'gpt-5.5' -- \"$CMUX_TASK_PROMPT\"" + ), + ( + "opencode --prompt \"$CMUX_TASK_PROMPT\"", + "anthropic/claude-sonnet-5", + "opencode --model 'anthropic/claude-sonnet-5' --prompt \"$CMUX_TASK_PROMPT\"" + ), + ] + ) + func appliesProviderFlag(command: String, modelID: String, expected: String) { + #expect( + MobileTaskAgentModelCatalog.commandApplying(modelID: modelID, to: command) + == expected + ) + } + + @Test func nilModelKeepsCommandIdentical() { + let command = " \nclaude -- \"$CMUX_TASK_PROMPT\"\n" + #expect(MobileTaskAgentModelCatalog.commandApplying(modelID: nil, to: command) == command) + } + + @Test func unknownProviderKeepsCommandIdentical() { + let command = "custom-agent -- \"$CMUX_TASK_PROMPT\"" + #expect( + MobileTaskAgentModelCatalog.commandApplying(modelID: "a-model", to: command) + == command + ) + } + + @Test func singleQuoteInModelIdentifierIsShellEscaped() { + #expect( + MobileTaskAgentModelCatalog.commandApplying(modelID: "one'two", to: "claude") + == "claude --model 'one'\\''two'" + ) + } + + @Test func preservesRestOfMultilineCommandByteForByte() { + let command = "claude\t-- \"$CMUX_TASK_PROMPT\"\n printf 'done' \n" + #expect( + MobileTaskAgentModelCatalog.commandApplying( + modelID: "claude-sonnet-5", + to: command + ) == "claude --model 'claude-sonnet-5'\t-- \"$CMUX_TASK_PROMPT\"\n printf 'done' \n" + ) + } +} diff --git a/Packages/iOS/CmuxMobileShellModel/Tests/CmuxMobileShellModelTests/MobileTaskCommandComposerTests.swift b/Packages/iOS/CmuxMobileShellModel/Tests/CmuxMobileShellModelTests/MobileTaskCommandComposerTests.swift index 2396c21f2198..46a43589f830 100644 --- a/Packages/iOS/CmuxMobileShellModel/Tests/CmuxMobileShellModelTests/MobileTaskCommandComposerTests.swift +++ b/Packages/iOS/CmuxMobileShellModel/Tests/CmuxMobileShellModelTests/MobileTaskCommandComposerTests.swift @@ -61,6 +61,70 @@ import Testing } } + @Test func seededAgentCommandsApplySelectedModels() { + let seeds = MobileTaskTemplate.seedDefaults( + claudeName: "Claude", + codexName: "Codex", + openCodeName: "OpenCode", + shellName: "Shell" + ) + let cases = [ + ( + seeds[0], + "claude-opus-4-8", + "claude --model 'claude-opus-4-8' -- \"$CMUX_TASK_PROMPT\"" + ), + ( + seeds[1], + "gpt-5.5", + "codex -m 'gpt-5.5' -- \"$CMUX_TASK_PROMPT\"" + ), + ( + seeds[2], + "openai/gpt-5.5", + "opencode --model 'openai/gpt-5.5' --prompt \"$CMUX_TASK_PROMPT\"" + ), + ] + + for (template, modelID, expectedCommand) in cases { + let result = composer.compose( + template: template, + prompt: " Fix the race ", + modelID: modelID + ) + #expect(result.initialCommand == expectedCommand) + #expect(result.initialEnv == ["CMUX_TASK_PROMPT": "Fix the race"]) + #expect(result.title == "Fix the race") + } + } + + @Test func selectedModelKeepsPlainShellPlain() { + let template = MobileTaskTemplate(name: "Shell", icon: "terminal", command: "") + let result = composer.compose( + template: template, + prompt: "Run diagnostics", + modelID: "gpt-5.5" + ) + + #expect(result.initialCommand == nil) + #expect(result.initialEnv.isEmpty) + #expect(result.title == "Run diagnostics") + } + + @Test func selectedModelKeepsUnknownCommandVerbatim() { + let command = "custom-agent -- \"$CMUX_TASK_PROMPT\"" + let template = MobileTaskTemplate(name: "Custom", icon: "terminal", command: command) + let result = composer.compose( + template: template, + prompt: "Investigate", + modelID: "gpt-5.5" + ) + + #expect(result.initialCommand == command) + #expect(result.initialEnv == ["CMUX_TASK_PROMPT": "Investigate"]) + #expect(result.title == "Investigate") + } + @Test func submissionIdentityStaysStableUntilRotated() { let restoredID = UUID() var identity = MobileTaskSubmissionIdentity(id: restoredID) @@ -99,6 +163,7 @@ import Testing #expect(snapshot.composition.initialEnv == ["CMUX_TASK_PROMPT": "Fix the race"]) #expect(snapshot.draft == MobileTaskComposerDraft( prompt: "Fix the race", + modelID: nil, templateID: template.id, macDeviceID: "mac-a", directory: " ~/cmux ", diff --git a/Packages/iOS/CmuxMobileShellModel/Tests/CmuxMobileShellModelTests/MobileTaskComposerDraftTests.swift b/Packages/iOS/CmuxMobileShellModel/Tests/CmuxMobileShellModelTests/MobileTaskComposerDraftTests.swift index 2ce4cfaffc91..1a09fdca69ad 100644 --- a/Packages/iOS/CmuxMobileShellModel/Tests/CmuxMobileShellModelTests/MobileTaskComposerDraftTests.swift +++ b/Packages/iOS/CmuxMobileShellModel/Tests/CmuxMobileShellModelTests/MobileTaskComposerDraftTests.swift @@ -69,6 +69,25 @@ import Testing #expect(restored.workspaceName == "Release checklist") } + @Test func modelIdentifierSurvivesDraftRoundTrip() throws { + let draft = MobileTaskComposerDraft( + prompt: "Use the selected model", + modelID: "claude-opus-4-8", + templateID: UUID(), + macDeviceID: "mac-a", + directory: "~/Dev/cmux", + didEditDirectory: false + ) + + let restored = try JSONDecoder().decode( + MobileTaskComposerDraft.self, + from: JSONEncoder().encode(draft) + ) + + #expect(restored == draft) + #expect(restored.modelID == "claude-opus-4-8") + } + @Test func legacyDraftWithoutWorkspaceNameStillDecodes() throws { let data = try #require( """ @@ -85,6 +104,7 @@ import Testing let restored = try JSONDecoder().decode(MobileTaskComposerDraft.self, from: data) #expect(restored.workspaceName == nil) + #expect(restored.modelID == nil) #expect(restored.prompt == "Keep this draft") } } diff --git a/Packages/iOS/CmuxMobileShellModel/Tests/CmuxMobileShellModelTests/MobileTaskSubmissionSnapshotTests.swift b/Packages/iOS/CmuxMobileShellModel/Tests/CmuxMobileShellModelTests/MobileTaskSubmissionSnapshotTests.swift index 28bcf8e85a66..4c96465f7b60 100644 --- a/Packages/iOS/CmuxMobileShellModel/Tests/CmuxMobileShellModelTests/MobileTaskSubmissionSnapshotTests.swift +++ b/Packages/iOS/CmuxMobileShellModel/Tests/CmuxMobileShellModelTests/MobileTaskSubmissionSnapshotTests.swift @@ -333,6 +333,45 @@ import Testing #expect(rebound.trimmedDirectory == original.trimmedDirectory) } + @Test func selectedModelFlowsThroughCompositionRebindingAndDraft() { + let operationID = UUID() + let snapshot = MobileTaskSubmissionSnapshot( + template: MobileTaskTemplate( + name: "Claude", + icon: "agent:claude", + command: "claude -- \"$CMUX_TASK_PROMPT\"" + ), + prompt: "Ship it", + modelID: "claude-opus-4-8", + macDeviceID: "mac-a", + directory: "~/cmux", + didEditDirectory: false, + operationID: operationID + ) + let rebound = snapshot.withOperationID(UUID()) + + #expect( + snapshot.composition.initialCommand + == "claude --model 'claude-opus-4-8' -- \"$CMUX_TASK_PROMPT\"" + ) + #expect(rebound.modelID == "claude-opus-4-8") + #expect(rebound.composition == snapshot.composition) + #expect(rebound.operationID != operationID) + #expect(snapshot.draft.modelID == "claude-opus-4-8") + } + + @Test func selectedModelChangesRequestEquivalence() { + let template = MobileTaskTemplate( + name: "Claude", + icon: "agent:claude", + command: "claude -- \"$CMUX_TASK_PROMPT\"" + ) + let defaultModel = snapshot(template: template) + let selectedModel = snapshot(template: template, modelID: "claude-opus-4-8") + + #expect(!defaultModel.isRequestEquivalent(to: selectedModel)) + } + private func expectIdentityPreserved( from before: MobileTaskSubmissionSnapshot?, to after: MobileTaskSubmissionSnapshot? @@ -364,11 +403,13 @@ import Testing prompt: String = "ship it", macDeviceID: String = "mac-a", directory: String = "~/cmux", - workspaceName: String = "" + workspaceName: String = "", + modelID: String? = nil ) -> MobileTaskSubmissionSnapshot { MobileTaskSubmissionSnapshot( template: template, prompt: prompt, + modelID: modelID, macDeviceID: macDeviceID, directory: directory, workspaceName: workspaceName, diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileDisplaySettings.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileDisplaySettings.swift index ef20052d986a..1a1eae1a241e 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileDisplaySettings.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileDisplaySettings.swift @@ -31,6 +31,7 @@ public final class MobileDisplaySettings { private static let profilePictureLeftShiftKey = "cmux.mobile.debug.profilePictureLeftShift" private static let profilePictureSizeKey = "cmux.mobile.debug.profilePictureSize" #if DEBUG + private static let taskComposerModelPickerVariantKey = "cmux.mobile.debug.taskComposerModelPickerVariant.v1" private static let taskComposerShellIconVariantKey = "cmux.mobile.debug.taskComposerShellIconVariant.v1" #endif @@ -139,6 +140,16 @@ public final class MobileDisplaySettings { } #if DEBUG + /// Persisted selection for the debug-only New Task model-picker lab. + var taskComposerModelPickerVariant: TaskComposerModelPickerVariant { + didSet { + defaults.set( + taskComposerModelPickerVariant.rawValue, + forKey: Self.taskComposerModelPickerVariantKey + ) + } + } + /// Persisted selection for the debug-only Shell icon lab. var taskComposerShellIconVariant: TaskComposerShellIconVariant { didSet { @@ -149,6 +160,8 @@ public final class MobileDisplaySettings { } } #else + /// Production builds hide model selection in the New Task composer. + var taskComposerModelPickerVariant: TaskComposerModelPickerVariant { .off } /// Production builds expose only the shipping Shell icon treatment. var taskComposerShellIconVariant: TaskComposerShellIconVariant { .current } #endif @@ -187,6 +200,9 @@ public final class MobileDisplaySettings { to: Self.profilePictureSizeRange ) #if DEBUG + self.taskComposerModelPickerVariant = defaults.string( + forKey: Self.taskComposerModelPickerVariantKey + ).flatMap(TaskComposerModelPickerVariant.init(rawValue:)) ?? .combined self.taskComposerShellIconVariant = defaults.string( forKey: Self.taskComposerShellIconVariantKey ).flatMap(TaskComposerShellIconVariant.init(rawValue:)) ?? .current diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileSettingsView.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileSettingsView.swift index 97181de6a5fb..e90e5c7df343 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileSettingsView.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileSettingsView.swift @@ -316,6 +316,19 @@ struct MobileSettingsView: View { ) } .accessibilityIdentifier("MobileSettingsShellIconLab") + + NavigationLink { + TaskComposerModelPickerLabView() + } label: { + Label( + L10n.string( + "mobile.settings.modelPickerLab", + defaultValue: "New Task Model Lab" + ), + systemImage: "cpu" + ) + } + .accessibilityIdentifier("MobileSettingsModelPickerLab") } #endif diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TaskComposer/TaskComposerAgentMenu.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TaskComposer/TaskComposerAgentMenu.swift index cac8bbdfca49..c6a31de63ba6 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TaskComposer/TaskComposerAgentMenu.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TaskComposer/TaskComposerAgentMenu.swift @@ -19,9 +19,21 @@ struct TaskComposerAgentMenu: View, Equatable { } } + private var selectedModel: MobileTaskAgentModel? { + guard value.modelPickerVariant.renderedVariant == .combined, + let selectedTemplate, + let selectedModelID = value.selectedModelID else { return nil } + return MobileTaskAgentModelCatalog.model( + id: selectedModelID, + forCommand: selectedTemplate.command + ) + } + var body: some View { Menu { - if !value.templates.isEmpty { + if value.modelPickerVariant.renderedVariant == .combined { + combinedChoices + } else if !value.templates.isEmpty { Picker( L10n.string("mobile.taskComposer.agent", defaultValue: "Agent"), selection: Binding( @@ -103,25 +115,85 @@ struct TaskComposerAgentMenu: View, Equatable { // editor remains the recovery path for adding an agent. .disabled(value.isDisabled) .accessibilityLabel(L10n.string("mobile.taskComposer.agent", defaultValue: "Agent")) - .accessibilityValue(selectedTemplate?.name ?? "") + .accessibilityValue(selectedTemplate.map(title(for:)) ?? "") .accessibilityHint(TaskComposerSheet.templateAccessibilityHint) .accessibilityIdentifier("MobileTaskComposerAgentMenu") } + @ViewBuilder + private var combinedChoices: some View { + ForEach(value.templates) { template in + let models = MobileTaskAgentModelCatalog.models(forCommand: template.command) + if models.isEmpty { + Button { + actions.selectTemplate(template.id) + } label: { + Text(template.name) + } + .accessibilityAddTraits( + template.id == value.selectedTemplateID ? .isSelected : [] + ) + .accessibilityIdentifier("MobileTaskComposerAgentChoice-\(template.id)") + } else { + Menu { + Button { + actions.selectTemplate(template.id) + actions.selectModel(nil) + } label: { + Text(L10n.string( + "mobile.taskComposer.model.default", + defaultValue: "Default" + )) + } + .accessibilityAddTraits( + template.id == value.selectedTemplateID + && value.selectedModelID == nil ? .isSelected : [] + ) + .accessibilityIdentifier( + "MobileTaskComposerAgentModel-\(template.id)-default" + ) + + ForEach(models) { model in + Button { + actions.selectTemplate(template.id) + actions.selectModel(model.id) + } label: { + Text(verbatim: model.displayName) + } + .accessibilityAddTraits( + template.id == value.selectedTemplateID + && model.id == value.selectedModelID ? .isSelected : [] + ) + .accessibilityIdentifier( + "MobileTaskComposerAgentModel-\(template.id)-\(model.id)" + ) + } + } label: { + Text(template.name) + } + .accessibilityIdentifier("MobileTaskComposerAgentSubmenu-\(template.id)") + } + } + } + private func title(for template: MobileTaskTemplate) -> String { + let baseTitle: String if template.isPlainShell { - return L10n.string( + baseTitle = L10n.string( "mobile.taskComposer.promptTitle.shell", defaultValue: "Shell command" ) + } else { + baseTitle = String( + format: L10n.string( + "mobile.taskComposer.promptTitle.agentFormat", + defaultValue: "Ask %@" + ), + template.name + ) } - return String( - format: L10n.string( - "mobile.taskComposer.promptTitle.agentFormat", - defaultValue: "Ask %@" - ), - template.name - ) + guard let selectedModel else { return baseTitle } + return "\(baseTitle) · \(selectedModel.displayName)" } } #endif diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TaskComposer/TaskComposerAgentMenuActions.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TaskComposer/TaskComposerAgentMenuActions.swift index cc5a9b4dba83..712572739fdf 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TaskComposer/TaskComposerAgentMenuActions.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TaskComposer/TaskComposerAgentMenuActions.swift @@ -3,6 +3,7 @@ import CmuxMobileShellModel struct TaskComposerAgentMenuActions { let selectTemplate: (MobileTaskTemplate.ID) -> Void + let selectModel: (String?) -> Void let editTemplates: () -> Void } #endif diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TaskComposer/TaskComposerAgentMenuValue.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TaskComposer/TaskComposerAgentMenuValue.swift index f0b97fdae95e..90f6fb922680 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TaskComposer/TaskComposerAgentMenuValue.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TaskComposer/TaskComposerAgentMenuValue.swift @@ -4,6 +4,8 @@ import CmuxMobileShellModel struct TaskComposerAgentMenuValue: Equatable { let templates: [MobileTaskTemplate] let selectedTemplateID: MobileTaskTemplate.ID? + let modelPickerVariant: TaskComposerModelPickerVariant + let selectedModelID: String? let isDisabled: Bool } #endif diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TaskComposer/TaskComposerContextSection.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TaskComposer/TaskComposerContextSection.swift index 154d5c53e09a..d6064e3af66e 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TaskComposer/TaskComposerContextSection.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TaskComposer/TaskComposerContextSection.swift @@ -1,5 +1,6 @@ #if os(iOS) import CmuxMobilePairedMac +import CmuxMobileShellModel import SwiftUI /// Groups the optional workspace title with the Mac and directory that define @@ -9,10 +10,14 @@ struct TaskComposerContextSection: View { let machines: [MobilePairedMac] let selectedMacDeviceID: String let directory: String + let modelPickerVariant: TaskComposerModelPickerVariant + let models: [MobileTaskAgentModel] + let selectedModelID: String? let isDisabled: Bool let endWorkspaceNameEditing: () -> Void let selectMachine: (String) -> Void let selectDirectory: () -> Void + let selectModel: (String?) -> Void var body: some View { VStack(spacing: 0) { @@ -33,6 +38,19 @@ struct TaskComposerContextSection: View { selectMachine: selectMachine, selectDirectory: selectDirectory ) + + if !models.isEmpty, + modelPickerVariant.renderedVariant == .contextRow { + Divider() + .padding(.horizontal, 10) + + TaskComposerModelContextRow( + models: models, + selectedModelID: selectedModelID, + isDisabled: isDisabled, + selectModel: selectModel + ) + } } .background(.regularMaterial, in: RoundedRectangle(cornerRadius: 22, style: .continuous)) .overlay { diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TaskComposer/TaskComposerModelChip.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TaskComposer/TaskComposerModelChip.swift new file mode 100644 index 000000000000..d962574b24cb --- /dev/null +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TaskComposer/TaskComposerModelChip.swift @@ -0,0 +1,49 @@ +#if os(iOS) +import CmuxMobileShellModel +import CmuxMobileSupport +import SwiftUI + +/// Compact model menu displayed beside the composer agent menu. +struct TaskComposerModelChip: View { + let models: [MobileTaskAgentModel] + let selectedModelID: String? + let isDisabled: Bool + let selectModel: (String?) -> Void + + var body: some View { + Menu { + TaskComposerModelMenuContent( + models: models, + selectedModelID: selectedModelID, + selectModel: selectModel + ) + } label: { + Text(verbatim: selectedModelName) + .font(.caption.weight(.semibold)) + .foregroundStyle(Color.primary) + .lineLimit(1) + .padding(.horizontal, 10) + .frame(minHeight: 28) + .background(Color.primary.opacity(0.06), in: Capsule()) + .overlay { + Capsule() + .strokeBorder(Color.primary.opacity(0.09), lineWidth: 1) + } + .contentShape(Capsule()) + } + .disabled(isDisabled) + .accessibilityLabel(L10n.string("mobile.taskComposer.model", defaultValue: "Model")) + .accessibilityValue(selectedModelName) + .accessibilityHint(L10n.string( + "mobile.taskComposer.model.accessibilityHint", + defaultValue: "Chooses the model this agent runs with." + )) + .accessibilityIdentifier("MobileTaskComposerModelChip") + } + + private var selectedModelName: String { + models.first { $0.id == selectedModelID }?.displayName + ?? L10n.string("mobile.taskComposer.model.default", defaultValue: "Default") + } +} +#endif diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TaskComposer/TaskComposerModelContextRow.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TaskComposer/TaskComposerModelContextRow.swift new file mode 100644 index 000000000000..fc1acd46926d --- /dev/null +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TaskComposer/TaskComposerModelContextRow.swift @@ -0,0 +1,55 @@ +#if os(iOS) +import CmuxMobileShellModel +import CmuxMobileSupport +import SwiftUI + +/// Model menu styled like the route rows in the composer context card. +struct TaskComposerModelContextRow: View { + let models: [MobileTaskAgentModel] + let selectedModelID: String? + let isDisabled: Bool + let selectModel: (String?) -> Void + + var body: some View { + ZStack { + TaskComposerRouteLabel( + icon: .symbol("cpu"), + title: L10n.string("mobile.taskComposer.model", defaultValue: "Model"), + value: selectedModelName, + valueFont: .caption.weight(.semibold), + valueTruncationMode: .tail, + chevronSystemName: "chevron.up.chevron.down" + ) + .accessibilityHidden(true) + + Menu { + TaskComposerModelMenuContent( + models: models, + selectedModelID: selectedModelID, + selectModel: selectModel + ) + } label: { + Color.clear + .frame(maxWidth: .infinity, maxHeight: .infinity) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + } + .frame(maxWidth: .infinity, minHeight: 52, alignment: .leading) + .padding(10) + .disabled(isDisabled) + .accessibilityLabel(L10n.string("mobile.taskComposer.model", defaultValue: "Model")) + .accessibilityValue(selectedModelName) + .accessibilityHint(L10n.string( + "mobile.taskComposer.model.accessibilityHint", + defaultValue: "Chooses the model this agent runs with." + )) + .accessibilityIdentifier("MobileTaskComposerModelContextRow") + } + + private var selectedModelName: String { + models.first { $0.id == selectedModelID }?.displayName + ?? L10n.string("mobile.taskComposer.model.default", defaultValue: "Default") + } +} +#endif diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TaskComposer/TaskComposerModelMenuContent.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TaskComposer/TaskComposerModelMenuContent.swift new file mode 100644 index 000000000000..910cd4f2fc5f --- /dev/null +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TaskComposer/TaskComposerModelMenuContent.swift @@ -0,0 +1,39 @@ +#if os(iOS) +import CmuxMobileShellModel +import CmuxMobileSupport +import SwiftUI + +/// Shared inline model picker used by the menu-based composer variants. +struct TaskComposerModelMenuContent: View { + let models: [MobileTaskAgentModel] + let selectedModelID: String? + let selectModel: (String?) -> Void + + var body: some View { + Picker( + L10n.string("mobile.taskComposer.model", defaultValue: "Model"), + selection: Binding( + get: { selectedModelID }, + set: { id in + guard id == nil || models.contains(where: { $0.id == id }) else { return } + selectModel(id) + } + ) + ) { + Text(L10n.string( + "mobile.taskComposer.model.default", + defaultValue: "Default" + )) + .tag(String?.none) + + ForEach(models) { model in + Text(verbatim: model.displayName) + .tag(Optional(model.id)) + } + } + .pickerStyle(.inline) + .labelsHidden() + .accessibilityIdentifier("MobileTaskComposerModelPicker") + } +} +#endif diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TaskComposer/TaskComposerModelPickerLabView.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TaskComposer/TaskComposerModelPickerLabView.swift new file mode 100644 index 000000000000..28a364ebcab2 --- /dev/null +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TaskComposer/TaskComposerModelPickerLabView.swift @@ -0,0 +1,61 @@ +#if os(iOS) && DEBUG +import CmuxMobileSupport +import SwiftUI + +/// Debug-only Settings surface for selecting the New Task model-picker UX. +struct TaskComposerModelPickerLabView: View { + @Environment(MobileDisplaySettings.self) private var displaySettings + + var body: some View { + @Bindable var displaySettings = displaySettings + return List { + Section { + ForEach(TaskComposerModelPickerVariant.allCases) { variant in + Button { + displaySettings.taskComposerModelPickerVariant = variant + } label: { + HStack(spacing: 12) { + VStack(alignment: .leading, spacing: 4) { + Text(verbatim: "\(variant.code) · \(variant.title)") + .font(.body.weight(.semibold)) + .foregroundStyle(Color.primary) + Text(verbatim: variant.detail) + .font(.caption) + .foregroundStyle(Color.secondary) + } + + Spacer(minLength: 8) + + if displaySettings.taskComposerModelPickerVariant == variant { + Image(systemName: "checkmark") + .font(.body.weight(.semibold)) + .foregroundStyle(Color.accentColor) + .accessibilityHidden(true) + } + } + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .accessibilityLabel(Text(verbatim: "\(variant.code), \(variant.title)")) + .accessibilityHint(Text(verbatim: variant.detail)) + .accessibilityAddTraits( + displaySettings.taskComposerModelPickerVariant == variant ? .isSelected : [] + ) + .accessibilityIdentifier("MobileModelPickerVariant-\(variant.code)") + } + } footer: { + Text(L10n.string( + "mobile.settings.modelPickerLab.footer", + defaultValue: "Choose a variant, then reopen New Task." + )) + } + } + .navigationTitle(L10n.string( + "mobile.settings.modelPickerLab", + defaultValue: "New Task Model Lab" + )) + .navigationBarTitleDisplayMode(.inline) + .accessibilityIdentifier("MobileModelPickerLab") + } +} +#endif diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TaskComposer/TaskComposerModelPickerVariant.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TaskComposer/TaskComposerModelPickerVariant.swift new file mode 100644 index 000000000000..f8832f57d528 --- /dev/null +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TaskComposer/TaskComposerModelPickerVariant.swift @@ -0,0 +1,104 @@ +#if os(iOS) +import CmuxMobileSupport + +/// Runtime-selectable model-picker treatments used by the debug CMUX Lab. +enum TaskComposerModelPickerVariant: String, CaseIterable, Identifiable, Sendable { + case off + case combined + case separateRow + case trailingChip + case pillStrip + case contextRow + + var id: String { rawValue } + + var code: String { + switch self { + case .off: "Off" + case .combined: "A" + case .separateRow: "B" + case .trailingChip: "C" + case .pillStrip: "D" + case .contextRow: "E" + } + } + + var title: String { + switch self { + case .off: + L10n.string( + "mobile.settings.modelPickerLab.variant.off.title", + defaultValue: "Off" + ) + case .combined: + L10n.string( + "mobile.settings.modelPickerLab.variant.combined.title", + defaultValue: "Combined Agent Menu" + ) + case .separateRow: + L10n.string( + "mobile.settings.modelPickerLab.variant.separateRow.title", + defaultValue: "Model Row" + ) + case .trailingChip: + L10n.string( + "mobile.settings.modelPickerLab.variant.trailingChip.title", + defaultValue: "Model Chip" + ) + case .pillStrip: + L10n.string( + "mobile.settings.modelPickerLab.variant.pillStrip.title", + defaultValue: "Model Pills" + ) + case .contextRow: + L10n.string( + "mobile.settings.modelPickerLab.variant.contextRow.title", + defaultValue: "Context Row" + ) + } + } + + var detail: String { + switch self { + case .off: + L10n.string( + "mobile.settings.modelPickerLab.variant.off.detail", + defaultValue: "No model selection in New Task." + ) + case .combined: + L10n.string( + "mobile.settings.modelPickerLab.variant.combined.detail", + defaultValue: "Pick agent and model in one menu." + ) + case .separateRow: + L10n.string( + "mobile.settings.modelPickerLab.variant.separateRow.detail", + defaultValue: "Dedicated model row under the agent." + ) + case .trailingChip: + L10n.string( + "mobile.settings.modelPickerLab.variant.trailingChip.detail", + defaultValue: "Compact model chip on the agent row." + ) + case .pillStrip: + L10n.string( + "mobile.settings.modelPickerLab.variant.pillStrip.detail", + defaultValue: "One-tap model pills under the agent." + ) + case .contextRow: + L10n.string( + "mobile.settings.modelPickerLab.variant.contextRow.detail", + defaultValue: "Model row beside Mac and directory." + ) + } + } + + var renderedVariant: TaskComposerModelPickerVariant { + #if DEBUG + self + #else + .off + #endif + } +} +#endif diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TaskComposer/TaskComposerModelPillStrip.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TaskComposer/TaskComposerModelPillStrip.swift new file mode 100644 index 000000000000..f4f7ecf35164 --- /dev/null +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TaskComposer/TaskComposerModelPillStrip.swift @@ -0,0 +1,70 @@ +#if os(iOS) +import CmuxMobileShellModel +import CmuxMobileSupport +import SwiftUI + +/// Horizontally scrolling one-tap model choices below the composer agent row. +struct TaskComposerModelPillStrip: View { + let models: [MobileTaskAgentModel] + let selectedModelID: String? + let isDisabled: Bool + let selectModel: (String?) -> Void + + var body: some View { + ScrollView(.horizontal) { + HStack(spacing: 8) { + pill( + id: nil, + title: L10n.string( + "mobile.taskComposer.model.default", + defaultValue: "Default" + ) + ) + + ForEach(models) { model in + pill(id: model.id, title: model.displayName) + } + } + } + .scrollIndicators(.hidden) + .accessibilityLabel(L10n.string("mobile.taskComposer.model", defaultValue: "Model")) + } + + private func pill(id: String?, title: String) -> some View { + let isSelected = selectedModelID == id + return Button { + selectModel(id) + } label: { + Text(verbatim: title) + .font(.caption.weight(.semibold)) + .foregroundStyle(isSelected ? Color.accentColor : Color.primary) + .lineLimit(1) + .padding(.horizontal, 11) + .frame(minHeight: 30) + .background( + isSelected ? Color.accentColor.opacity(0.13) : Color.primary.opacity(0.045), + in: Capsule() + ) + .overlay { + Capsule() + .strokeBorder( + isSelected + ? Color.accentColor.opacity(0.65) + : Color.primary.opacity(0.08), + lineWidth: isSelected ? 1.25 : 1 + ) + } + .contentShape(Capsule()) + } + .buttonStyle(.plain) + .disabled(isDisabled) + .accessibilityLabel(Text(verbatim: title)) + .accessibilityHint(L10n.string( + "mobile.taskComposer.model.accessibilityHint", + defaultValue: "Chooses the model this agent runs with." + )) + .accessibilityAddTraits(isSelected ? .isSelected : []) + .accessibilityIdentifier("MobileTaskComposerModelPill-\(id ?? "default")") + } +} +#endif diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TaskComposer/TaskComposerModelRow.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TaskComposer/TaskComposerModelRow.swift new file mode 100644 index 000000000000..5e0c5d46606e --- /dev/null +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TaskComposer/TaskComposerModelRow.swift @@ -0,0 +1,63 @@ +#if os(iOS) +import CmuxMobileShellModel +import CmuxMobileSupport +import SwiftUI + +/// Dedicated model menu shown below the composer agent row. +struct TaskComposerModelRow: View { + let models: [MobileTaskAgentModel] + let selectedModelID: String? + let isDisabled: Bool + let selectModel: (String?) -> Void + + var body: some View { + Menu { + TaskComposerModelMenuContent( + models: models, + selectedModelID: selectedModelID, + selectModel: selectModel + ) + } label: { + HStack(spacing: 10) { + Image(systemName: "cpu") + .font(.system(size: 13, weight: .semibold)) + .foregroundStyle(Color.accentColor) + .frame(width: 26, height: 26) + .background(Color.accentColor.opacity(0.1), in: Circle()) + .accessibilityHidden(true) + + Text(L10n.string("mobile.taskComposer.model", defaultValue: "Model")) + .font(.subheadline) + .foregroundStyle(Color.secondary) + + Spacer(minLength: 8) + + Text(verbatim: selectedModelName) + .font(.subheadline.weight(.semibold)) + .foregroundStyle(Color.primary) + .lineLimit(1) + + Image(systemName: "chevron.up.chevron.down") + .font(.caption2.weight(.bold)) + .foregroundStyle(Color.secondary.opacity(0.55)) + .accessibilityHidden(true) + } + .frame(maxWidth: .infinity, minHeight: 40, alignment: .leading) + .contentShape(Rectangle()) + } + .disabled(isDisabled) + .accessibilityLabel(L10n.string("mobile.taskComposer.model", defaultValue: "Model")) + .accessibilityValue(selectedModelName) + .accessibilityHint(L10n.string( + "mobile.taskComposer.model.accessibilityHint", + defaultValue: "Chooses the model this agent runs with." + )) + .accessibilityIdentifier("MobileTaskComposerModelRow") + } + + private var selectedModelName: String { + models.first { $0.id == selectedModelID }?.displayName + ?? L10n.string("mobile.taskComposer.model.default", defaultValue: "Default") + } +} +#endif diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TaskComposer/TaskComposerPromptCard.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TaskComposer/TaskComposerPromptCard.swift index 813adf8033fa..9526ab917273 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TaskComposer/TaskComposerPromptCard.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TaskComposer/TaskComposerPromptCard.swift @@ -13,26 +13,38 @@ struct TaskComposerPromptCard: View { let endEditing: () -> Void let templates: [MobileTaskTemplate] let selectedTemplateID: MobileTaskTemplate.ID? + let modelPickerVariant: TaskComposerModelPickerVariant + let models: [MobileTaskAgentModel] + let selectedModelID: String? let selectTemplate: (MobileTaskTemplate.ID) -> Void + let selectModel: (String?) -> Void let editTemplates: () -> Void @FocusState private var isFocused: Bool var body: some View { VStack(alignment: .leading, spacing: 10) { - TaskComposerAgentMenu( - value: TaskComposerAgentMenuValue( - templates: templates, - selectedTemplateID: selectedTemplateID, - isDisabled: isDisabled - ), - actions: TaskComposerAgentMenuActions( - selectTemplate: selectTemplate, - editTemplates: editTemplates + agentRow + + if !models.isEmpty, + modelPickerVariant.renderedVariant == .separateRow { + TaskComposerModelRow( + models: models, + selectedModelID: selectedModelID, + isDisabled: isDisabled, + selectModel: selectModel ) - ) - .equatable() - .frame(maxWidth: .infinity, alignment: .leading) + } + + if !models.isEmpty, + modelPickerVariant.renderedVariant == .pillStrip { + TaskComposerModelPillStrip( + models: models, + selectedModelID: selectedModelID, + isDisabled: isDisabled, + selectModel: selectModel + ) + } TextField(placeholder, text: $prompt, axis: .vertical) .textFieldStyle(.plain) @@ -55,6 +67,45 @@ struct TaskComposerPromptCard: View { .mobileGlassField(cornerRadius: 26) } + @ViewBuilder + private var agentRow: some View { + if !models.isEmpty, + modelPickerVariant.renderedVariant == .trailingChip { + HStack(spacing: 8) { + agentMenu + .frame(maxWidth: .infinity, alignment: .leading) + + TaskComposerModelChip( + models: models, + selectedModelID: selectedModelID, + isDisabled: isDisabled, + selectModel: selectModel + ) + } + } else { + agentMenu + .frame(maxWidth: .infinity, alignment: .leading) + } + } + + private var agentMenu: some View { + TaskComposerAgentMenu( + value: TaskComposerAgentMenuValue( + templates: templates, + selectedTemplateID: selectedTemplateID, + modelPickerVariant: modelPickerVariant, + selectedModelID: selectedModelID, + isDisabled: isDisabled + ), + actions: TaskComposerAgentMenuActions( + selectTemplate: selectTemplate, + selectModel: selectModel, + editTemplates: editTemplates + ) + ) + .equatable() + } + private var promptLineLimit: ClosedRange { dynamicTypeSize.isAccessibilitySize ? 2...6 : 5...12 } diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TaskComposer/TaskComposerSheet+DraftState.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TaskComposer/TaskComposerSheet+DraftState.swift index 06b66c3c8df8..c24c03e6e919 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TaskComposer/TaskComposerSheet+DraftState.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TaskComposer/TaskComposerSheet+DraftState.swift @@ -6,6 +6,7 @@ extension TaskComposerSheet { func selectTemplate(_ template: MobileTaskTemplate) { updateSubmissionRequest(reconcileRecovery: true) { selectedTemplateID = template.id + selectedModelID = nil syncSuggestedDirectory() } } @@ -14,6 +15,11 @@ extension TaskComposerSheet { prompt = snapshot.prompt workspaceName = snapshot.workspaceName selectedTemplateID = snapshot.templateID + selectedModelID = snapshot.modelID.flatMap { id in + selectedTemplate.flatMap { + MobileTaskAgentModelCatalog.model(id: id, forCommand: $0.command)?.id + } + } selectedMacDeviceID = snapshot.macDeviceID directory = snapshot.directory didEditDirectory = snapshot.didEditDirectory @@ -111,6 +117,7 @@ extension TaskComposerSheet { let completedOperationID = reconcileCompletedOperationRecovery(with: resolved) return MobileTaskComposerDraft( prompt: prompt, + modelID: selectedModel?.id, templateID: selectedTemplateID, macDeviceID: selectedMacDeviceID.isEmpty ? nil : selectedMacDeviceID, directory: directory, @@ -126,6 +133,7 @@ extension TaskComposerSheet { return MobileTaskSubmissionSnapshot( template: selectedTemplate, prompt: prompt, + modelID: selectedModel?.id, macDeviceID: selectedMacDeviceID, directory: directory, workspaceName: workspaceName, diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TaskComposer/TaskComposerSheet+ModelSelection.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TaskComposer/TaskComposerSheet+ModelSelection.swift new file mode 100644 index 000000000000..15980600594e --- /dev/null +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TaskComposer/TaskComposerSheet+ModelSelection.swift @@ -0,0 +1,38 @@ +#if os(iOS) +import CmuxMobileShellModel + +extension TaskComposerSheet { + var availableModels: [MobileTaskAgentModel] { + guard let selectedTemplate else { return [] } + return MobileTaskAgentModelCatalog.models(forCommand: selectedTemplate.command) + } + + var selectedModel: MobileTaskAgentModel? { + guard let selectedTemplate, + let selectedModelID else { return nil } + return MobileTaskAgentModelCatalog.model( + id: selectedModelID, + forCommand: selectedTemplate.command + ) + } + + func selectModel(_ id: String?) { + guard !submissionPhase.disablesRequestEditing else { return } + let validatedID: String? + if let id { + guard let selectedTemplate, + MobileTaskAgentModelCatalog.model( + id: id, + forCommand: selectedTemplate.command + ) != nil else { return } + validatedID = id + } else { + validatedID = nil + } + guard selectedModelID != validatedID else { return } + updateSubmissionRequest(reconcileRecovery: true) { + selectedModelID = validatedID + } + } +} +#endif diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TaskComposer/TaskComposerSheet.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TaskComposer/TaskComposerSheet.swift index 24379a702045..f007d837269d 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TaskComposer/TaskComposerSheet.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TaskComposer/TaskComposerSheet.swift @@ -11,12 +11,14 @@ struct TaskComposerSheet: View { @Environment(\.dismiss) var dismiss @Environment(\.accessibilityReduceMotion) private var accessibilityReduceMotion @Environment(\.scenePhase) private var scenePhase + @Environment(MobileDisplaySettings.self) private var displaySettings @Bindable var store: CMUXMobileShellStore @State var prompt = "" @State var workspaceName = "" @State private var templates: [MobileTaskTemplate] @State var selectedTemplateID: MobileTaskTemplate.ID? + @State var selectedModelID: String? @State var selectedMacDeviceID: String @State var directory: String @State var didEditDirectory = false @@ -102,6 +104,13 @@ struct TaskComposerSheet: View { .flatMap { id in templates.contains(where: { $0.id == id }) ? id : nil } ?? templates.first?.id let selectedTemplate = selectedTemplateID.flatMap { id in templates.first { $0.id == id } } + let initialModelID = (draft?.templateID == selectedTemplateID) + ? draft?.modelID.flatMap { id in + selectedTemplate.flatMap { + MobileTaskAgentModelCatalog.model(id: id, forCommand: $0.command)?.id + } + } + : nil let openDirectory = Self.preferredOpenDirectory( workspaces: store.workspaces, selectedWorkspaceID: store.selectedWorkspaceID, @@ -132,6 +141,7 @@ struct TaskComposerSheet: View { MobileTaskSubmissionSnapshot( template: $0, prompt: initialPrompt, + modelID: initialModelID, macDeviceID: selectedMacID, directory: initialDirectory, workspaceName: initialWorkspaceName, @@ -152,6 +162,7 @@ struct TaskComposerSheet: View { _workspaceName = State(initialValue: initialWorkspaceName) _templates = State(initialValue: templates) _selectedTemplateID = State(initialValue: selectedTemplateID) + _selectedModelID = State(initialValue: initialModelID) _selectedMacDeviceID = State(initialValue: selectedMacID) _directory = State(initialValue: initialDirectory) _didEditDirectory = State(initialValue: canRestoreDraftDirectory && draft?.didEditDirectory == true) @@ -189,7 +200,11 @@ struct TaskComposerSheet: View { endEditing: resolveCompletedOperationRecoveryAfterEditing, templates: templates, selectedTemplateID: selectedTemplateID, + modelPickerVariant: displaySettings.taskComposerModelPickerVariant, + models: availableModels, + selectedModelID: selectedModelID, selectTemplate: selectTemplateFromPicker, + selectModel: selectModel, editTemplates: presentTemplateEditor ) @@ -198,10 +213,14 @@ struct TaskComposerSheet: View { machines: machines, selectedMacDeviceID: selectedMacDeviceID, directory: directory, + modelPickerVariant: displaySettings.taskComposerModelPickerVariant, + models: availableModels, + selectedModelID: selectedModelID, isDisabled: submissionPhase.disablesRequestEditing, endWorkspaceNameEditing: resolveCompletedOperationRecoveryAfterEditing, selectMachine: selectMachine, - selectDirectory: { isDirectoryPickerPresented = true } + selectDirectory: { isDirectoryPickerPresented = true }, + selectModel: selectModel ) .dynamicTypeSize(...DynamicTypeSize.xxxLarge) } @@ -552,6 +571,7 @@ struct TaskComposerSheet: View { updateSubmissionRequest(reconcileRecovery: true) { store.taskTemplateStore?.addTemplate(template) selectedTemplateID = template.id + selectedModelID = nil syncSuggestedDirectory() } } @@ -574,6 +594,7 @@ struct TaskComposerSheet: View { if let selectedTemplateID, !templates.contains(where: { $0.id == selectedTemplateID }) { self.selectedTemplateID = templates.first?.id } + selectedModelID = selectedModel?.id // Sync template edits unless the user typed the directory. syncSuggestedDirectory() } diff --git a/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/MobileDisplaySettingsTests.swift b/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/MobileDisplaySettingsTests.swift index 684826b6db50..d766f2631313 100644 --- a/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/MobileDisplaySettingsTests.swift +++ b/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/MobileDisplaySettingsTests.swift @@ -139,6 +139,12 @@ import Testing #expect(defaults.object(forKey: "cmux.mobile.debug.unreadIndicatorLeftShift.v2") == nil) #expect(defaults.object(forKey: "cmux.mobile.debug.profilePictureLeftShift") == nil) #expect(defaults.object(forKey: "cmux.mobile.debug.profilePictureSize") == nil) + #if DEBUG + #expect(settings.taskComposerModelPickerVariant == .combined) + #else + #expect(settings.taskComposerModelPickerVariant == .off) + #endif + #expect(defaults.object(forKey: "cmux.mobile.debug.taskComposerModelPickerVariant.v1") == nil) #expect(settings.taskComposerShellIconVariant == .current) #expect(defaults.object(forKey: "cmux.mobile.debug.taskComposerShellIconVariant.v1") == nil) } @@ -168,6 +174,16 @@ import Testing } #if DEBUG + @Test func modelPickerVariantPersistsAndRejectsUnknownValues() throws { + let defaults = try makeDefaults("modelPickerVariant") + let settings = MobileDisplaySettings(defaults: defaults) + settings.taskComposerModelPickerVariant = .pillStrip + #expect(MobileDisplaySettings(defaults: defaults).taskComposerModelPickerVariant == .pillStrip) + + defaults.set("removed-variant", forKey: "cmux.mobile.debug.taskComposerModelPickerVariant.v1") + #expect(MobileDisplaySettings(defaults: defaults).taskComposerModelPickerVariant == .combined) + } + @Test func shellIconVariantPersistsAndRejectsUnknownValues() throws { let defaults = try makeDefaults("shellIconVariant") let settings = MobileDisplaySettings(defaults: defaults) diff --git a/ios/cmux/Resources/Localizable.xcstrings b/ios/cmux/Resources/Localizable.xcstrings index 225e120af100..11cd54a2fb47 100644 --- a/ios/cmux/Resources/Localizable.xcstrings +++ b/ios/cmux/Resources/Localizable.xcstrings @@ -6070,6 +6070,244 @@ } } }, + "mobile.settings.modelPickerLab": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "New Task Model Lab" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "新規タスク・モデルラボ" + } + } + } + }, + "mobile.settings.modelPickerLab.footer": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Choose a variant, then reopen New Task." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "バリエーションを選び、もう一度「新規タスク」を開いてください。" + } + } + } + }, + "mobile.settings.modelPickerLab.variant.combined.detail": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Pick agent and model in one menu." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "エージェントとモデルを1つのメニューで選びます。" + } + } + } + }, + "mobile.settings.modelPickerLab.variant.combined.title": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Combined Agent Menu" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "エージェントメニューに統合" + } + } + } + }, + "mobile.settings.modelPickerLab.variant.contextRow.detail": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Model row beside Mac and directory." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "Macとディレクトリの並びにモデル行を表示します。" + } + } + } + }, + "mobile.settings.modelPickerLab.variant.contextRow.title": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Context Row" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "コンテキスト行" + } + } + } + }, + "mobile.settings.modelPickerLab.variant.off.detail": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "No model selection in New Task." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "新規タスクではモデルを選択しません。" + } + } + } + }, + "mobile.settings.modelPickerLab.variant.off.title": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Off" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "オフ" + } + } + } + }, + "mobile.settings.modelPickerLab.variant.pillStrip.detail": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "One-tap model pills under the agent." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "エージェントの下にワンタップ式のモデル選択肢を表示します。" + } + } + } + }, + "mobile.settings.modelPickerLab.variant.pillStrip.title": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Model Pills" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "モデルピル" + } + } + } + }, + "mobile.settings.modelPickerLab.variant.separateRow.detail": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Dedicated model row under the agent." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "エージェントの下にモデル専用の行を表示します。" + } + } + } + }, + "mobile.settings.modelPickerLab.variant.separateRow.title": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Model Row" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "モデル行" + } + } + } + }, + "mobile.settings.modelPickerLab.variant.trailingChip.detail": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Compact model chip on the agent row." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "エージェント行にコンパクトなモデルチップを表示します。" + } + } + } + }, + "mobile.settings.modelPickerLab.variant.trailingChip.title": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Model Chip" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "モデルチップ" + } + } + } + }, "mobile.settings.notSignedIn": { "extractionState": "manual", "localizations": { @@ -10184,6 +10422,57 @@ } } }, + "mobile.taskComposer.model": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Model" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "モデル" + } + } + } + }, + "mobile.taskComposer.model.accessibilityHint": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Chooses the model this agent runs with." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "このエージェントで使用するモデルを選択します。" + } + } + } + }, + "mobile.taskComposer.model.default": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Default" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "デフォルト" + } + } + } + }, "mobile.taskComposer.openingShell": { "extractionState": "manual", "localizations": { From 52a4d22a66582a8594a7b1cc6cd20950cc345317 Mon Sep 17 00:00:00 2001 From: Abdulaziz Albahar <67667005+azooz2003-bit@users.noreply.github.com> Date: Thu, 23 Jul 2026 22:41:55 -0700 Subject: [PATCH 02/30] Add Codex-style composer layout to the New Task sheet New minimal layout (lab-switchable, DEBUG default): full-bleed prompt canvas titled by the working directory, back chevron, and a bottom control bar with a + options sheet (name, Mac, directory), agent pill, model pill, and a circular submit button. Classic card layout stays available via CMUX Labs and renders unchanged; release builds keep classic. Adds GPT-5.6 Luna to the Codex model catalog. Co-Authored-By: Claude Fable 5 --- .../MobileTaskAgentModelCatalog.swift | 1 + .../MobileTaskAgentModelCatalogTests.swift | 10 + .../MobileDisplaySettings.swift | 16 + .../TaskComposer/TaskComposerAgentMenu.swift | 92 +---- .../TaskComposerAgentMenuContent.swift | 105 ++++++ .../TaskComposerFailureRecoveryContent.swift | 65 ++++ .../TaskComposerLayoutStyle.swift | 49 +++ .../TaskComposerMinimalLayout.swift | 321 ++++++++++++++++++ .../TaskComposerModelPickerLabView.swift | 43 ++- .../TaskComposerOptionsSheet.swift | 93 +++++ .../TaskComposerPrimaryAction.swift | 53 +-- .../TaskComposer/TaskComposerSheet.swift | 237 ++++++++----- .../MobileDisplaySettingsTests.swift | 13 + ios/cmux/Resources/Localizable.xcstrings | 140 +++++++- 14 files changed, 1023 insertions(+), 215 deletions(-) create mode 100644 Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TaskComposer/TaskComposerAgentMenuContent.swift create mode 100644 Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TaskComposer/TaskComposerFailureRecoveryContent.swift create mode 100644 Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TaskComposer/TaskComposerLayoutStyle.swift create mode 100644 Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TaskComposer/TaskComposerMinimalLayout.swift create mode 100644 Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TaskComposer/TaskComposerOptionsSheet.swift diff --git a/Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileTaskAgentModelCatalog.swift b/Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileTaskAgentModelCatalog.swift index 41a905573cd2..054db646a96f 100644 --- a/Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileTaskAgentModelCatalog.swift +++ b/Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileTaskAgentModelCatalog.swift @@ -67,6 +67,7 @@ public enum MobileTaskAgentModelCatalog { ] case .codex: [ + MobileTaskAgentModel(id: "gpt-5.6-luna", displayName: "GPT-5.6 Luna"), MobileTaskAgentModel(id: "gpt-5.6-sol", displayName: "GPT-5.6 Sol"), MobileTaskAgentModel(id: "gpt-5.5", displayName: "GPT-5.5"), ] diff --git a/Packages/iOS/CmuxMobileShellModel/Tests/CmuxMobileShellModelTests/MobileTaskAgentModelCatalogTests.swift b/Packages/iOS/CmuxMobileShellModel/Tests/CmuxMobileShellModelTests/MobileTaskAgentModelCatalogTests.swift index 1a04482c40ec..a4b730793653 100644 --- a/Packages/iOS/CmuxMobileShellModel/Tests/CmuxMobileShellModelTests/MobileTaskAgentModelCatalogTests.swift +++ b/Packages/iOS/CmuxMobileShellModel/Tests/CmuxMobileShellModelTests/MobileTaskAgentModelCatalogTests.swift @@ -27,6 +27,16 @@ import Testing #expect(MobileTaskAgentModelCatalog.provider(forCommand: command) == nil) } + @Test func codexModelsStartWithLuna() { + #expect( + MobileTaskAgentModelCatalog.models(for: .codex) == [ + MobileTaskAgentModel(id: "gpt-5.6-luna", displayName: "GPT-5.6 Luna"), + MobileTaskAgentModel(id: "gpt-5.6-sol", displayName: "GPT-5.6 Sol"), + MobileTaskAgentModel(id: "gpt-5.5", displayName: "GPT-5.5"), + ] + ) + } + @Test( arguments: [ ( diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileDisplaySettings.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileDisplaySettings.swift index 1a1eae1a241e..553750283165 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileDisplaySettings.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileDisplaySettings.swift @@ -31,6 +31,7 @@ public final class MobileDisplaySettings { private static let profilePictureLeftShiftKey = "cmux.mobile.debug.profilePictureLeftShift" private static let profilePictureSizeKey = "cmux.mobile.debug.profilePictureSize" #if DEBUG + private static let taskComposerLayoutStyleKey = "cmux.mobile.debug.taskComposerLayoutStyle.v1" private static let taskComposerModelPickerVariantKey = "cmux.mobile.debug.taskComposerModelPickerVariant.v1" private static let taskComposerShellIconVariantKey = "cmux.mobile.debug.taskComposerShellIconVariant.v1" #endif @@ -140,6 +141,16 @@ public final class MobileDisplaySettings { } #if DEBUG + /// Persisted selection for the debug-only New Task layout lab. + var taskComposerLayoutStyle: TaskComposerLayoutStyle { + didSet { + defaults.set( + taskComposerLayoutStyle.rawValue, + forKey: Self.taskComposerLayoutStyleKey + ) + } + } + /// Persisted selection for the debug-only New Task model-picker lab. var taskComposerModelPickerVariant: TaskComposerModelPickerVariant { didSet { @@ -160,6 +171,8 @@ public final class MobileDisplaySettings { } } #else + /// Production builds expose only the shipping classic New Task layout. + var taskComposerLayoutStyle: TaskComposerLayoutStyle { .classic } /// Production builds hide model selection in the New Task composer. var taskComposerModelPickerVariant: TaskComposerModelPickerVariant { .off } /// Production builds expose only the shipping Shell icon treatment. @@ -200,6 +213,9 @@ public final class MobileDisplaySettings { to: Self.profilePictureSizeRange ) #if DEBUG + self.taskComposerLayoutStyle = defaults.string( + forKey: Self.taskComposerLayoutStyleKey + ).flatMap(TaskComposerLayoutStyle.init(rawValue:)) ?? .composer self.taskComposerModelPickerVariant = defaults.string( forKey: Self.taskComposerModelPickerVariantKey ).flatMap(TaskComposerModelPickerVariant.init(rawValue:)) ?? .combined diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TaskComposer/TaskComposerAgentMenu.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TaskComposer/TaskComposerAgentMenu.swift index c6a31de63ba6..00df7a554464 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TaskComposer/TaskComposerAgentMenu.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TaskComposer/TaskComposerAgentMenu.swift @@ -31,41 +31,7 @@ struct TaskComposerAgentMenu: View, Equatable { var body: some View { Menu { - if value.modelPickerVariant.renderedVariant == .combined { - combinedChoices - } else if !value.templates.isEmpty { - Picker( - L10n.string("mobile.taskComposer.agent", defaultValue: "Agent"), - selection: Binding( - get: { value.selectedTemplateID }, - set: { id in - guard let id, - value.templates.contains(where: { $0.id == id }) else { return } - actions.selectTemplate(id) - } - ) - ) { - ForEach(value.templates) { template in - Text(template.name) - .tag(Optional(template.id)) - } - } - .pickerStyle(.inline) - .labelsHidden() - } - - Divider() - - Button(action: actions.editTemplates) { - Label( - L10n.string( - "mobile.taskComposer.agent.edit", - defaultValue: "Edit Agents" - ), - systemImage: "slider.horizontal.3" - ) - } - .accessibilityIdentifier("MobileTaskComposerEditTemplatesButton") + TaskComposerAgentMenuContent(value: value, actions: actions) } label: { HStack(spacing: 10) { if let selectedTemplate { @@ -120,62 +86,6 @@ struct TaskComposerAgentMenu: View, Equatable { .accessibilityIdentifier("MobileTaskComposerAgentMenu") } - @ViewBuilder - private var combinedChoices: some View { - ForEach(value.templates) { template in - let models = MobileTaskAgentModelCatalog.models(forCommand: template.command) - if models.isEmpty { - Button { - actions.selectTemplate(template.id) - } label: { - Text(template.name) - } - .accessibilityAddTraits( - template.id == value.selectedTemplateID ? .isSelected : [] - ) - .accessibilityIdentifier("MobileTaskComposerAgentChoice-\(template.id)") - } else { - Menu { - Button { - actions.selectTemplate(template.id) - actions.selectModel(nil) - } label: { - Text(L10n.string( - "mobile.taskComposer.model.default", - defaultValue: "Default" - )) - } - .accessibilityAddTraits( - template.id == value.selectedTemplateID - && value.selectedModelID == nil ? .isSelected : [] - ) - .accessibilityIdentifier( - "MobileTaskComposerAgentModel-\(template.id)-default" - ) - - ForEach(models) { model in - Button { - actions.selectTemplate(template.id) - actions.selectModel(model.id) - } label: { - Text(verbatim: model.displayName) - } - .accessibilityAddTraits( - template.id == value.selectedTemplateID - && model.id == value.selectedModelID ? .isSelected : [] - ) - .accessibilityIdentifier( - "MobileTaskComposerAgentModel-\(template.id)-\(model.id)" - ) - } - } label: { - Text(template.name) - } - .accessibilityIdentifier("MobileTaskComposerAgentSubmenu-\(template.id)") - } - } - } - private func title(for template: MobileTaskTemplate) -> String { let baseTitle: String if template.isPlainShell { diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TaskComposer/TaskComposerAgentMenuContent.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TaskComposer/TaskComposerAgentMenuContent.swift new file mode 100644 index 000000000000..9dee131f81c8 --- /dev/null +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TaskComposer/TaskComposerAgentMenuContent.swift @@ -0,0 +1,105 @@ +#if os(iOS) +import CmuxMobileShellModel +import CmuxMobileSupport +import SwiftUI + +/// Shared agent choices used by the classic agent row and minimal composer pill. +struct TaskComposerAgentMenuContent: View { + let value: TaskComposerAgentMenuValue + let actions: TaskComposerAgentMenuActions + + var body: some View { + if value.modelPickerVariant.renderedVariant == .combined { + combinedChoices + } else if !value.templates.isEmpty { + Picker( + L10n.string("mobile.taskComposer.agent", defaultValue: "Agent"), + selection: Binding( + get: { value.selectedTemplateID }, + set: { id in + guard let id, + value.templates.contains(where: { $0.id == id }) else { return } + actions.selectTemplate(id) + } + ) + ) { + ForEach(value.templates) { template in + Text(template.name) + .tag(Optional(template.id)) + } + } + .pickerStyle(.inline) + .labelsHidden() + } + + Divider() + + Button(action: actions.editTemplates) { + Label( + L10n.string( + "mobile.taskComposer.agent.edit", + defaultValue: "Edit Agents" + ), + systemImage: "slider.horizontal.3" + ) + } + .accessibilityIdentifier("MobileTaskComposerEditTemplatesButton") + } + + @ViewBuilder + private var combinedChoices: some View { + ForEach(value.templates) { template in + let models = MobileTaskAgentModelCatalog.models(forCommand: template.command) + if models.isEmpty { + Button { + actions.selectTemplate(template.id) + } label: { + Text(template.name) + } + .accessibilityAddTraits( + template.id == value.selectedTemplateID ? .isSelected : [] + ) + .accessibilityIdentifier("MobileTaskComposerAgentChoice-\(template.id)") + } else { + Menu { + Button { + actions.selectTemplate(template.id) + actions.selectModel(nil) + } label: { + Text(L10n.string( + "mobile.taskComposer.model.default", + defaultValue: "Default" + )) + } + .accessibilityAddTraits( + template.id == value.selectedTemplateID + && value.selectedModelID == nil ? .isSelected : [] + ) + .accessibilityIdentifier( + "MobileTaskComposerAgentModel-\(template.id)-default" + ) + + ForEach(models) { model in + Button { + actions.selectTemplate(template.id) + actions.selectModel(model.id) + } label: { + Text(verbatim: model.displayName) + } + .accessibilityAddTraits( + template.id == value.selectedTemplateID + && model.id == value.selectedModelID ? .isSelected : [] + ) + .accessibilityIdentifier( + "MobileTaskComposerAgentModel-\(template.id)-\(model.id)" + ) + } + } label: { + Text(template.name) + } + .accessibilityIdentifier("MobileTaskComposerAgentSubmenu-\(template.id)") + } + } + } +} +#endif diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TaskComposer/TaskComposerFailureRecoveryContent.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TaskComposer/TaskComposerFailureRecoveryContent.swift new file mode 100644 index 000000000000..8e834ce12b9a --- /dev/null +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TaskComposer/TaskComposerFailureRecoveryContent.swift @@ -0,0 +1,65 @@ +#if os(iOS) +import CmuxMobileSupport +import SwiftUI + +/// Shared failure and completed-operation recovery controls for both layouts. +struct TaskComposerFailureRecoveryContent: View { + let isSubmitting: Bool + let failureTitle: String + let failureText: String? + let completedOperationRecovery: TaskComposerCompletedOperationRecovery? + let refreshCompletedOperation: () -> Void + let requestStartAgain: () -> Void + + var body: some View { + VStack(spacing: 10) { + if let failureText { + TaskComposerFailureBanner(title: failureTitle, message: failureText) + } + + if let completedOperationRecovery { + HStack(spacing: 10) { + Button(action: refreshCompletedOperation) { + Group { + if isSubmitting { + ProgressView() + } else { + Text( + completedOperationRecovery.allowsStartAgain + ? L10n.string( + "mobile.taskComposer.recovery.refreshAgain", + defaultValue: "Refresh Again" + ) + : L10n.string( + "mobile.taskComposer.recovery.refresh", + defaultValue: "Refresh Workspaces" + ) + ) + } + } + .frame(maxWidth: .infinity) + } + .mobileGlassProminentButton() + .disabled(isSubmitting) + .accessibilityHint(TaskComposerSheet.recoveryRefreshAccessibilityHint) + .accessibilityIdentifier("MobileTaskComposerRefreshButton") + + if completedOperationRecovery.allowsStartAgain { + Button( + L10n.string( + "mobile.taskComposer.recovery.startAgain", + defaultValue: "Start Again" + ), + action: requestStartAgain + ) + .mobileGlassButton() + .disabled(isSubmitting) + .accessibilityHint(TaskComposerSheet.recoveryStartAgainAccessibilityHint) + .accessibilityIdentifier("MobileTaskComposerStartAgainButton") + } + } + } + } + } +} +#endif diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TaskComposer/TaskComposerLayoutStyle.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TaskComposer/TaskComposerLayoutStyle.swift new file mode 100644 index 000000000000..f5ca689fa36b --- /dev/null +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TaskComposer/TaskComposerLayoutStyle.swift @@ -0,0 +1,49 @@ +#if os(iOS) +import CmuxMobileSupport + +/// Runtime-selectable New Task layouts used by the debug CMUX Lab. +enum TaskComposerLayoutStyle: String, CaseIterable, Identifiable, Sendable { + case classic + case composer + + var id: String { rawValue } + + var title: String { + switch self { + case .classic: + L10n.string( + "mobile.settings.modelPickerLab.layout.classic.title", + defaultValue: "Classic" + ) + case .composer: + L10n.string( + "mobile.settings.modelPickerLab.layout.composer.title", + defaultValue: "Composer" + ) + } + } + + var detail: String { + switch self { + case .classic: + L10n.string( + "mobile.settings.modelPickerLab.layout.classic.detail", + defaultValue: "Card sheet with Start button." + ) + case .composer: + L10n.string( + "mobile.settings.modelPickerLab.layout.composer.detail", + defaultValue: "Full-screen prompt with a bottom control bar." + ) + } + } + + var renderedStyle: TaskComposerLayoutStyle { + #if DEBUG + self + #else + .classic + #endif + } +} +#endif diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TaskComposer/TaskComposerMinimalLayout.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TaskComposer/TaskComposerMinimalLayout.swift new file mode 100644 index 000000000000..bb70e08ff31c --- /dev/null +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TaskComposer/TaskComposerMinimalLayout.swift @@ -0,0 +1,321 @@ +#if os(iOS) +import CmuxMobileShellModel +import CmuxMobileSupport +import SwiftUI +import UIKit + +/// A full-screen prompt canvas with compact task controls above the keyboard. +struct TaskComposerMinimalLayout: View { + @Binding var prompt: String + let genericPromptPlaceholder: String + let directory: String + let isDisabled: Bool + let locksDismissal: Bool + let templates: [MobileTaskTemplate] + let selectedTemplateID: MobileTaskTemplate.ID? + let modelPickerVariant: TaskComposerModelPickerVariant + let models: [MobileTaskAgentModel] + let selectedModelID: String? + let isSubmitting: Bool + let isSubmitEnabled: Bool + let failureTitle: String + let failureText: String? + let completedOperationRecovery: TaskComposerCompletedOperationRecovery? + let optionsSheet: TaskComposerOptionsSheet + let endEditing: () -> Void + let selectTemplate: (MobileTaskTemplate.ID) -> Void + let selectModel: (String?) -> Void + let editTemplates: () -> Void + let cancel: () -> Void + let submit: () -> Void + let refreshCompletedOperation: () -> Void + let requestStartAgain: () -> Void + + @FocusState private var isPromptFocused: Bool + @State private var isOptionsPresented = false + + var body: some View { + promptCanvas + .safeAreaInset(edge: .bottom, spacing: 0) { + accessoryBar + } + .navigationTitle(navigationTitle) + .mobileInlineNavigationTitle() + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button(action: cancel) { + Image(systemName: "chevron.left") + } + .disabled(locksDismissal) + .accessibilityLabel(L10n.string( + "mobile.common.cancel", + defaultValue: "Cancel" + )) + .accessibilityIdentifier("MobileTaskComposerCancelButton") + } + } + .sheet(isPresented: $isOptionsPresented) { + optionsSheet + } + } + + private var promptCanvas: some View { + ZStack(alignment: .topLeading) { + Color(uiColor: .systemBackground) + .ignoresSafeArea() + + if prompt.isEmpty { + Text(promptPlaceholder) + .font(.title3) + .foregroundStyle(.tertiary) + .padding(.top, 27) + .padding(.horizontal, 25) + .allowsHitTesting(false) + .accessibilityHidden(true) + } + + TextEditor(text: $prompt) + .scrollContentBackground(.hidden) + .font(.title3) + .fontWeight(.regular) + .padding(.horizontal, 20) + .padding(.vertical, 18) + .focused($isPromptFocused) + .disabled(isDisabled) + .scrollDismissesKeyboard(.interactively) + .accessibilityLabel(L10n.string( + "mobile.taskComposer.prompt", + defaultValue: "Prompt" + )) + .accessibilityHint(promptPlaceholder) + .accessibilityIdentifier("MobileTaskComposerPrompt") + .taskComposerEditingCompletion( + isFocused: isPromptFocused, + endEditing: endEditing + ) + } + } + + private var accessoryBar: some View { + VStack(spacing: 10) { + if failureText != nil || completedOperationRecovery != nil { + TaskComposerFailureRecoveryContent( + isSubmitting: isSubmitting, + failureTitle: failureTitle, + failureText: failureText, + completedOperationRecovery: completedOperationRecovery, + refreshCompletedOperation: refreshCompletedOperation, + requestStartAgain: requestStartAgain + ) + .padding(.horizontal, 16) + } + + HStack(spacing: 10) { + optionsButton + + ScrollView(.horizontal) { + HStack(spacing: 8) { + agentPill + + if !models.isEmpty, + modelPickerVariant.renderedVariant != .off { + modelPill + } + } + } + .scrollIndicators(.hidden) + .frame(maxWidth: .infinity) + + submitButton + } + .padding(.horizontal, 16) + .padding(.top, 8) + .padding(.bottom, 6) + } + // Blend into the canvas like the reference composer; the keyboard + // provides the visual boundary below. + .dynamicTypeSize(...DynamicTypeSize.accessibility1) + .background(Color(uiColor: .systemBackground)) + } + + private var optionsButton: some View { + Button { + isOptionsPresented = true + } label: { + Image(systemName: "plus") + .font(.system(size: 17, weight: .semibold)) + .frame(width: 38, height: 38) + .background(Color.primary.opacity(0.07), in: Circle()) + .contentShape(Circle()) + } + .buttonStyle(.plain) + .disabled(isDisabled) + .accessibilityLabel(L10n.string( + "mobile.taskComposer.options.title", + defaultValue: "Task Options" + )) + .accessibilityIdentifier("MobileTaskComposerOptionsButton") + } + + private var agentPill: some View { + Menu { + TaskComposerAgentMenuContent( + value: agentMenuValue, + actions: agentMenuActions + ) + } label: { + HStack(spacing: 7) { + if let selectedTemplate { + TaskTemplateIcon(value: selectedTemplate.icon, size: 16) + .frame(width: 18, height: 18) + + Text(selectedTemplate.name) + .lineLimit(1) + } else { + Image(systemName: "person.crop.circle.badge.exclamationmark") + .font(.system(size: 14, weight: .semibold)) + .accessibilityHidden(true) + + Text(L10n.string( + "mobile.taskComposer.agent", + defaultValue: "Agent" + )) + } + + Image(systemName: "chevron.down") + .font(.caption2.weight(.bold)) + .foregroundStyle(.secondary) + .accessibilityHidden(true) + } + .font(.caption.weight(.semibold)) + .foregroundStyle(.primary) + .padding(.horizontal, 12) + .frame(minHeight: 38) + .background(Color.primary.opacity(0.07), in: Capsule()) + .contentShape(Capsule()) + } + .tint(Color.primary) + .disabled(isDisabled) + .accessibilityLabel(L10n.string("mobile.taskComposer.agent", defaultValue: "Agent")) + .accessibilityValue(selectedTemplate?.name ?? "") + .accessibilityHint(TaskComposerSheet.templateAccessibilityHint) + .accessibilityIdentifier("MobileTaskComposerAgentPill") + } + + private var modelPill: some View { + Menu { + TaskComposerModelMenuContent( + models: models, + selectedModelID: selectedModelID, + selectModel: selectModel + ) + } label: { + HStack(spacing: 7) { + Image(systemName: "cpu") + .font(.caption.weight(.semibold)) + .accessibilityHidden(true) + + Text(selectedModelName) + .lineLimit(1) + + Image(systemName: "chevron.down") + .font(.caption2.weight(.bold)) + .foregroundStyle(.secondary) + .accessibilityHidden(true) + } + .font(.caption.weight(.semibold)) + .foregroundStyle(.primary) + .padding(.horizontal, 12) + .frame(minHeight: 38) + .background(Color.primary.opacity(0.07), in: Capsule()) + .contentShape(Capsule()) + } + .tint(Color.primary) + .disabled(isDisabled) + .accessibilityLabel(L10n.string("mobile.taskComposer.model", defaultValue: "Model")) + .accessibilityValue(selectedModelName) + .accessibilityHint(L10n.string( + "mobile.taskComposer.model.accessibilityHint", + defaultValue: "Chooses the model this agent runs with." + )) + .accessibilityIdentifier("MobileTaskComposerModelPill") + } + + private var submitButton: some View { + Button(action: submit) { + Group { + if isSubmitting { + ProgressView() + .controlSize(.small) + .tint(isSubmitEnabled ? .white : .secondary) + } else { + Image(systemName: "arrow.up") + .font(.system(size: 16, weight: .semibold)) + } + } + .frame(width: 38, height: 38) + .foregroundStyle(isSubmitEnabled ? Color.white : Color.secondary) + .background( + isSubmitEnabled ? Color.accentColor : Color.primary.opacity(0.12), + in: Circle() + ) + .contentShape(Circle()) + } + .buttonStyle(.plain) + .disabled(isSubmitting || !isSubmitEnabled) + .accessibilityLabel(L10n.string( + "mobile.taskComposer.submit", + defaultValue: "Start Task" + )) + .accessibilityHint(TaskComposerSheet.createAccessibilityHint) + .accessibilityIdentifier("MobileTaskComposerSubmitButton") + } + + private var selectedTemplate: MobileTaskTemplate? { + selectedTemplateID.flatMap { id in templates.first { $0.id == id } } + } + + private var selectedModelName: String { + models.first { $0.id == selectedModelID }?.displayName + ?? L10n.string("mobile.taskComposer.model.default", defaultValue: "Default") + } + + private var navigationTitle: String { + guard !directory.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { + return L10n.string("mobile.taskComposer.title", defaultValue: "New Task") + } + return TaskComposerDirectoryDisplayPath(path: directory).name + } + + private var promptPlaceholder: String { + guard !directory.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { + return genericPromptPlaceholder + } + return String( + format: L10n.string( + "mobile.taskComposer.composer.promptPlaceholderFormat", + defaultValue: "Describe a coding task in %@" + ), + TaskComposerDirectoryDisplayPath(path: directory).name + ) + } + + private var agentMenuValue: TaskComposerAgentMenuValue { + TaskComposerAgentMenuValue( + templates: templates, + selectedTemplateID: selectedTemplateID, + modelPickerVariant: modelPickerVariant, + selectedModelID: selectedModelID, + isDisabled: isDisabled + ) + } + + private var agentMenuActions: TaskComposerAgentMenuActions { + TaskComposerAgentMenuActions( + selectTemplate: selectTemplate, + selectModel: selectModel, + editTemplates: editTemplates + ) + } +} +#endif diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TaskComposer/TaskComposerModelPickerLabView.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TaskComposer/TaskComposerModelPickerLabView.swift index 28a364ebcab2..e5e6cbef2179 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TaskComposer/TaskComposerModelPickerLabView.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TaskComposer/TaskComposerModelPickerLabView.swift @@ -9,6 +9,47 @@ struct TaskComposerModelPickerLabView: View { var body: some View { @Bindable var displaySettings = displaySettings return List { + Section { + ForEach(TaskComposerLayoutStyle.allCases) { style in + Button { + displaySettings.taskComposerLayoutStyle = style + } label: { + HStack(spacing: 12) { + VStack(alignment: .leading, spacing: 4) { + Text(verbatim: style.title) + .font(.body.weight(.semibold)) + .foregroundStyle(Color.primary) + Text(verbatim: style.detail) + .font(.caption) + .foregroundStyle(Color.secondary) + } + + Spacer(minLength: 8) + + if displaySettings.taskComposerLayoutStyle == style { + Image(systemName: "checkmark") + .font(.body.weight(.semibold)) + .foregroundStyle(Color.accentColor) + .accessibilityHidden(true) + } + } + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .accessibilityLabel(Text(verbatim: style.title)) + .accessibilityHint(Text(verbatim: style.detail)) + .accessibilityAddTraits( + displaySettings.taskComposerLayoutStyle == style ? .isSelected : [] + ) + .accessibilityIdentifier("MobileTaskComposerLayout-\(style.rawValue)") + } + } header: { + Text(L10n.string( + "mobile.settings.modelPickerLab.layout", + defaultValue: "Layout" + )) + } + Section { ForEach(TaskComposerModelPickerVariant.allCases) { variant in Button { @@ -46,7 +87,7 @@ struct TaskComposerModelPickerLabView: View { } footer: { Text(L10n.string( "mobile.settings.modelPickerLab.footer", - defaultValue: "Choose a variant, then reopen New Task." + defaultValue: "Choose a layout and model variant, then reopen New Task." )) } } diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TaskComposer/TaskComposerOptionsSheet.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TaskComposer/TaskComposerOptionsSheet.swift new file mode 100644 index 000000000000..65f9457d6938 --- /dev/null +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TaskComposer/TaskComposerOptionsSheet.swift @@ -0,0 +1,93 @@ +#if os(iOS) +import CmuxMobilePairedMac +import CmuxMobileRPC +import CmuxMobileShell +import CmuxMobileShellModel +import CmuxMobileSupport +import SwiftUI +import UIKit + +/// The minimal composer's workspace, Mac, directory, and contextual model controls. +struct TaskComposerOptionsSheet: View { + @Environment(\.dismiss) private var dismiss + + @Binding var workspaceName: String + let machines: [MobilePairedMac] + let selectedMacDeviceID: String + let directory: String + let modelPickerVariant: TaskComposerModelPickerVariant + let models: [MobileTaskAgentModel] + let selectedModelID: String? + let isDisabled: Bool + let directoryCandidates: [MobileTaskDirectoryCandidate] + let endWorkspaceNameEditing: () -> Void + let selectMachine: (String) -> Void + let selectDirectory: (String) -> Void + let selectModel: (String?) -> Void + let searchMac: ( + String + ) async -> Result + let listMac: ( + _ path: String, + _ offset: Int + ) async -> Result + + @State private var isDirectoryPickerPresented = false + + var body: some View { + NavigationStack { + ScrollView { + TaskComposerContextSection( + workspaceName: $workspaceName, + machines: machines, + selectedMacDeviceID: selectedMacDeviceID, + directory: directory, + modelPickerVariant: modelPickerVariant, + models: models, + selectedModelID: selectedModelID, + isDisabled: isDisabled, + endWorkspaceNameEditing: endWorkspaceNameEditing, + selectMachine: selectMachine, + selectDirectory: { isDirectoryPickerPresented = true }, + selectModel: selectModel + ) + .dynamicTypeSize(...DynamicTypeSize.xxxLarge) + .frame(maxWidth: 680) + .frame(maxWidth: .infinity) + .padding(.horizontal, 20) + .padding(.vertical, 16) + } + .background { + Color(uiColor: .systemGroupedBackground) + .ignoresSafeArea() + } + .navigationTitle(L10n.string( + "mobile.taskComposer.options.title", + defaultValue: "Task Options" + )) + .mobileInlineNavigationTitle() + .toolbar { + ToolbarItem(placement: .confirmationAction) { + Button(L10n.string("mobile.common.done", defaultValue: "Done")) { + dismiss() + } + .accessibilityIdentifier("MobileTaskComposerOptionsDoneButton") + } + } + .sheet(isPresented: $isDirectoryPickerPresented) { + TaskComposerDirectoryPickerView( + candidates: directoryCandidates, + selectedPath: directory, + select: selectDirectory, + searchMac: searchMac, + listMac: listMac + ) + .presentationDetents([.large]) + .presentationDragIndicator(.visible) + } + } + .presentationDetents([.medium]) + .presentationDragIndicator(.visible) + } +} +#endif diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TaskComposer/TaskComposerPrimaryAction.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TaskComposer/TaskComposerPrimaryAction.swift index 74f0772a3955..6013b734125c 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TaskComposer/TaskComposerPrimaryAction.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TaskComposer/TaskComposerPrimaryAction.swift @@ -23,51 +23,18 @@ struct TaskComposerPrimaryAction: View { var body: some View { VStack(spacing: 10) { - if let failureText { - TaskComposerFailureBanner(title: failureTitle, message: failureText) + if failureText != nil || completedOperationRecovery != nil { + TaskComposerFailureRecoveryContent( + isSubmitting: isSubmitting, + failureTitle: failureTitle, + failureText: failureText, + completedOperationRecovery: completedOperationRecovery, + refreshCompletedOperation: refreshCompletedOperation, + requestStartAgain: requestStartAgain + ) } - if let completedOperationRecovery { - HStack(spacing: 10) { - Button(action: refreshCompletedOperation) { - Group { - if isSubmitting { - ProgressView() - } else { - Text( - completedOperationRecovery.allowsStartAgain - ? L10n.string( - "mobile.taskComposer.recovery.refreshAgain", - defaultValue: "Refresh Again" - ) - : L10n.string( - "mobile.taskComposer.recovery.refresh", - defaultValue: "Refresh Workspaces" - ) - ) - } - } - .frame(maxWidth: .infinity) - } - .mobileGlassProminentButton() - .disabled(isSubmitting) - .accessibilityHint(TaskComposerSheet.recoveryRefreshAccessibilityHint) - .accessibilityIdentifier("MobileTaskComposerRefreshButton") - if completedOperationRecovery.allowsStartAgain { - Button( - L10n.string( - "mobile.taskComposer.recovery.startAgain", - defaultValue: "Start Again" - ), - action: requestStartAgain - ) - .mobileGlassButton() - .disabled(isSubmitting) - .accessibilityHint(TaskComposerSheet.recoveryStartAgainAccessibilityHint) - .accessibilityIdentifier("MobileTaskComposerStartAgainButton") - } - } - } else { + if completedOperationRecovery == nil { Button(action: action) { HStack(spacing: 10) { if isSubmitting { diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TaskComposer/TaskComposerSheet.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TaskComposer/TaskComposerSheet.swift index f007d837269d..7f6ec8248974 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TaskComposer/TaskComposerSheet.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TaskComposer/TaskComposerSheet.swift @@ -187,84 +187,12 @@ struct TaskComposerSheet: View { var body: some View { NavigationStack { - ZStack { - Color(uiColor: .systemGroupedBackground) - .ignoresSafeArea() - - ScrollView { - VStack(spacing: 12) { - TaskComposerPromptCard( - prompt: promptBinding, - placeholder: promptPlaceholder, - isDisabled: submissionPhase.disablesRequestEditing, - endEditing: resolveCompletedOperationRecoveryAfterEditing, - templates: templates, - selectedTemplateID: selectedTemplateID, - modelPickerVariant: displaySettings.taskComposerModelPickerVariant, - models: availableModels, - selectedModelID: selectedModelID, - selectTemplate: selectTemplateFromPicker, - selectModel: selectModel, - editTemplates: presentTemplateEditor - ) - - TaskComposerContextSection( - workspaceName: workspaceNameBinding, - machines: machines, - selectedMacDeviceID: selectedMacDeviceID, - directory: directory, - modelPickerVariant: displaySettings.taskComposerModelPickerVariant, - models: availableModels, - selectedModelID: selectedModelID, - isDisabled: submissionPhase.disablesRequestEditing, - endWorkspaceNameEditing: resolveCompletedOperationRecoveryAfterEditing, - selectMachine: selectMachine, - selectDirectory: { isDirectoryPickerPresented = true }, - selectModel: selectModel - ) - .dynamicTypeSize(...DynamicTypeSize.xxxLarge) - } - .frame(maxWidth: 680) - .frame(maxWidth: .infinity) - .padding(.horizontal, 20) - .padding(.top, 14) - .padding(.bottom, 20) - } - .scrollDismissesKeyboard(.interactively) - } - .safeAreaInset(edge: .bottom, spacing: 0) { - TaskComposerPrimaryAction( - isSubmitting: submissionPhase.showsProgress, - isEnabled: selectedMachine != nil && canLaunchSelectedTemplate, - templateIcon: selectedTemplate?.icon, - actionTitle: primaryActionTitle, - progressTitle: primaryActionProgressTitle, - caption: primaryActionCaption, - failureTitle: failureTitleStyle.title, - failureText: failureText, - completedOperationRecovery: blockingCompletedOperationRecovery, - action: startSubmission, - refreshCompletedOperation: startCompletedOperationReconciliation, - requestStartAgain: { isStartAgainConfirmationPresented = true } - ) - } - .navigationTitle(L10n.string("mobile.taskComposer.title", defaultValue: "New Task")) - .mobileInlineNavigationTitle() - .toolbar { - ToolbarItem(placement: .cancellationAction) { - Button { - submitTask?.cancel() - shouldPersistDraftOnDisappear = false - store.clearTaskComposerDraft(ifSessionGeneration: sessionGeneration) - dismiss() - } label: { - Image(systemName: "xmark") - } - // Cancellation remains safe while routing and capability - // checks run. Lock only once the create boundary commits. - .disabled(submissionPhase.locksDismissal) - .accessibilityLabel(L10n.string("mobile.common.cancel", defaultValue: "Cancel")) - .accessibilityIdentifier("MobileTaskComposerCancelButton") + Group { + switch displaySettings.taskComposerLayoutStyle.renderedStyle { + case .classic: + classicLayout + case .composer: + minimalLayout } } .sheet(isPresented: $isEditorPresented) { @@ -331,6 +259,152 @@ struct TaskComposerSheet: View { )) } + private var classicLayout: some View { + ZStack { + Color(uiColor: .systemGroupedBackground) + .ignoresSafeArea() + + ScrollView { + VStack(spacing: 12) { + TaskComposerPromptCard( + prompt: promptBinding, + placeholder: promptPlaceholder, + isDisabled: submissionPhase.disablesRequestEditing, + endEditing: resolveCompletedOperationRecoveryAfterEditing, + templates: templates, + selectedTemplateID: selectedTemplateID, + modelPickerVariant: displaySettings.taskComposerModelPickerVariant, + models: availableModels, + selectedModelID: selectedModelID, + selectTemplate: selectTemplateFromPicker, + selectModel: selectModel, + editTemplates: presentTemplateEditor + ) + + TaskComposerContextSection( + workspaceName: workspaceNameBinding, + machines: machines, + selectedMacDeviceID: selectedMacDeviceID, + directory: directory, + modelPickerVariant: displaySettings.taskComposerModelPickerVariant, + models: availableModels, + selectedModelID: selectedModelID, + isDisabled: submissionPhase.disablesRequestEditing, + endWorkspaceNameEditing: resolveCompletedOperationRecoveryAfterEditing, + selectMachine: selectMachine, + selectDirectory: { isDirectoryPickerPresented = true }, + selectModel: selectModel + ) + .dynamicTypeSize(...DynamicTypeSize.xxxLarge) + } + .frame(maxWidth: 680) + .frame(maxWidth: .infinity) + .padding(.horizontal, 20) + .padding(.top, 14) + .padding(.bottom, 20) + } + .scrollDismissesKeyboard(.interactively) + } + .safeAreaInset(edge: .bottom, spacing: 0) { + TaskComposerPrimaryAction( + isSubmitting: submissionPhase.showsProgress, + isEnabled: selectedMachine != nil && canLaunchSelectedTemplate, + templateIcon: selectedTemplate?.icon, + actionTitle: primaryActionTitle, + progressTitle: primaryActionProgressTitle, + caption: primaryActionCaption, + failureTitle: failureTitleStyle.title, + failureText: failureText, + completedOperationRecovery: blockingCompletedOperationRecovery, + action: startSubmission, + refreshCompletedOperation: startCompletedOperationReconciliation, + requestStartAgain: { isStartAgainConfirmationPresented = true } + ) + } + .navigationTitle(L10n.string("mobile.taskComposer.title", defaultValue: "New Task")) + .mobileInlineNavigationTitle() + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button(action: cancelComposer) { + Image(systemName: "xmark") + } + // Cancellation remains safe while routing and capability + // checks run. Lock only once the create boundary commits. + .disabled(submissionPhase.locksDismissal) + .accessibilityLabel(L10n.string("mobile.common.cancel", defaultValue: "Cancel")) + .accessibilityIdentifier("MobileTaskComposerCancelButton") + } + } + } + + private var minimalLayout: some View { + TaskComposerMinimalLayout( + prompt: promptBinding, + genericPromptPlaceholder: promptPlaceholder, + directory: directory, + isDisabled: submissionPhase.disablesRequestEditing, + locksDismissal: submissionPhase.locksDismissal, + templates: templates, + selectedTemplateID: selectedTemplateID, + modelPickerVariant: displaySettings.taskComposerModelPickerVariant, + models: availableModels, + selectedModelID: selectedModelID, + isSubmitting: submissionPhase.showsProgress, + isSubmitEnabled: selectedMachine != nil + && canLaunchSelectedTemplate + && submissionPhase.allowsSubmission, + failureTitle: failureTitleStyle.title, + failureText: failureText, + completedOperationRecovery: blockingCompletedOperationRecovery, + optionsSheet: minimalOptionsSheet, + endEditing: resolveCompletedOperationRecoveryAfterEditing, + selectTemplate: selectTemplateFromPicker, + selectModel: selectModel, + editTemplates: presentTemplateEditor, + cancel: cancelComposer, + submit: startSubmission, + refreshCompletedOperation: startCompletedOperationReconciliation, + requestStartAgain: { isStartAgainConfirmationPresented = true } + ) + } + + private var minimalOptionsSheet: TaskComposerOptionsSheet { + TaskComposerOptionsSheet( + workspaceName: workspaceNameBinding, + machines: machines, + selectedMacDeviceID: selectedMacDeviceID, + directory: directory, + modelPickerVariant: displaySettings.taskComposerModelPickerVariant, + models: availableModels, + selectedModelID: selectedModelID, + isDisabled: submissionPhase.disablesRequestEditing, + directoryCandidates: directoryCandidates, + endWorkspaceNameEditing: resolveCompletedOperationRecoveryAfterEditing, + selectMachine: selectMachine, + selectDirectory: selectDirectory, + selectModel: selectModel, + searchMac: { query in + if let searchTaskDirectories { + return await searchTaskDirectories(selectedMacDeviceID, query) + } + return await store.searchTaskDirectories( + macDeviceID: selectedMacDeviceID, + query: query + ) + }, + listMac: { path, offset in + if let listTaskDirectories { + return await listTaskDirectories(selectedMacDeviceID, path, offset) + } + return await store.listTaskDirectories( + macDeviceID: selectedMacDeviceID, + path: path, + offset: offset + ) + } + ) + } + var selectedTemplate: MobileTaskTemplate? { selectedTemplateID.flatMap { id in templates.first { $0.id == id } } } @@ -484,6 +558,13 @@ struct TaskComposerSheet: View { isEditorPresented = true } + private func cancelComposer() { + submitTask?.cancel() + shouldPersistDraftOnDisappear = false + store.clearTaskComposerDraft(ifSessionGeneration: sessionGeneration) + dismiss() + } + private func selectMachine(_ macDeviceID: String) { guard !submissionPhase.disablesRequestEditing, machines.contains(where: { $0.macDeviceID == macDeviceID }) else { return } diff --git a/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/MobileDisplaySettingsTests.swift b/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/MobileDisplaySettingsTests.swift index d766f2631313..4fc1c5019903 100644 --- a/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/MobileDisplaySettingsTests.swift +++ b/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/MobileDisplaySettingsTests.swift @@ -140,10 +140,13 @@ import Testing #expect(defaults.object(forKey: "cmux.mobile.debug.profilePictureLeftShift") == nil) #expect(defaults.object(forKey: "cmux.mobile.debug.profilePictureSize") == nil) #if DEBUG + #expect(settings.taskComposerLayoutStyle == .composer) #expect(settings.taskComposerModelPickerVariant == .combined) #else + #expect(settings.taskComposerLayoutStyle == .classic) #expect(settings.taskComposerModelPickerVariant == .off) #endif + #expect(defaults.object(forKey: "cmux.mobile.debug.taskComposerLayoutStyle.v1") == nil) #expect(defaults.object(forKey: "cmux.mobile.debug.taskComposerModelPickerVariant.v1") == nil) #expect(settings.taskComposerShellIconVariant == .current) #expect(defaults.object(forKey: "cmux.mobile.debug.taskComposerShellIconVariant.v1") == nil) @@ -174,6 +177,16 @@ import Testing } #if DEBUG + @Test func taskComposerLayoutStylePersistsAndRejectsUnknownValues() throws { + let defaults = try makeDefaults("taskComposerLayoutStyle") + let settings = MobileDisplaySettings(defaults: defaults) + settings.taskComposerLayoutStyle = .classic + #expect(MobileDisplaySettings(defaults: defaults).taskComposerLayoutStyle == .classic) + + defaults.set("removed-style", forKey: "cmux.mobile.debug.taskComposerLayoutStyle.v1") + #expect(MobileDisplaySettings(defaults: defaults).taskComposerLayoutStyle == .composer) + } + @Test func modelPickerVariantPersistsAndRejectsUnknownValues() throws { let defaults = try makeDefaults("modelPickerVariant") let settings = MobileDisplaySettings(defaults: defaults) diff --git a/ios/cmux/Resources/Localizable.xcstrings b/ios/cmux/Resources/Localizable.xcstrings index 11cd54a2fb47..988eb2d8d6a1 100644 --- a/ios/cmux/Resources/Localizable.xcstrings +++ b/ios/cmux/Resources/Localizable.xcstrings @@ -6093,13 +6093,98 @@ "en": { "stringUnit": { "state": "translated", - "value": "Choose a variant, then reopen New Task." + "value": "Choose a layout and model variant, then reopen New Task." } }, "ja": { "stringUnit": { "state": "translated", - "value": "バリエーションを選び、もう一度「新規タスク」を開いてください。" + "value": "レイアウトとモデルのバリエーションを選び、「新規タスク」を開き直してください。" + } + } + } + }, + "mobile.settings.modelPickerLab.layout": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Layout" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "レイアウト" + } + } + } + }, + "mobile.settings.modelPickerLab.layout.classic.detail": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Card sheet with Start button." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "開始ボタン付きのカード形式シートです。" + } + } + } + }, + "mobile.settings.modelPickerLab.layout.classic.title": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Classic" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "クラシック" + } + } + } + }, + "mobile.settings.modelPickerLab.layout.composer.detail": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Full-screen prompt with a bottom control bar." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "下部にコントロールバーを備えた全画面プロンプトです。" + } + } + } + }, + "mobile.settings.modelPickerLab.layout.composer.title": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Composer" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "コンポーザー" } } } @@ -9215,6 +9300,23 @@ } } }, + "mobile.taskComposer.composer.promptPlaceholderFormat": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Describe a coding task in %@" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "%@で行うコーディングタスクを入力" + } + } + } + }, "mobile.taskComposer.create": { "extractionState": "manual", "localizations": { @@ -10507,6 +10609,23 @@ } } }, + "mobile.taskComposer.options.title": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Task Options" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "タスクオプション" + } + } + } + }, "mobile.taskComposer.preparingWorkspace": { "extractionState": "manual", "localizations": { @@ -11221,6 +11340,23 @@ } } }, + "mobile.taskComposer.submit": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Start Task" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "タスクを開始" + } + } + } + }, "mobile.taskComposer.template.seed.claude": { "extractionState": "manual", "localizations": { From 656cf07ac169bad3f2bfebf070a78f411934f89a Mon Sep 17 00:00:00 2001 From: Abdulaziz Albahar <67667005+azooz2003-bit@users.noreply.github.com> Date: Tue, 28 Jul 2026 21:58:30 -0700 Subject: [PATCH 03/30] Address review findings on the model picker - Dissolve the MobileTaskAgentModelCatalog static namespace into MobileTaskAgentProvider (detection init, models, model(id:), command(applying:to:)) per the no-static-namespace policy. - Replace an existing --model/-m/--model= value in place instead of injecting a duplicate flag that the template's own value would override; stop scanning at the -- end-of-options token. - Pin the task-composer accessibility preview to the classic layout and Off variant on fresh installs so the XCUITest suite keeps a stable element tree; CMUX_UITEST_TASK_COMPOSER_LAYOUT/_MODEL_VARIANT opt in. - Give each lab variant exactly one placement in the composer layout: combined stays in the agent submenu, contextRow stays in Task Options, the rest collapse to the standalone bottom-bar pill. - Gate the composer submit button on blocking completed-operation recovery, matching the classic layout. - Make combined menu taps a single atomic template+model mutation. - Share the model display-name fallback and accessibility triple; share the directory search/list fallback closures. Co-Authored-By: Claude Fable 5 --- .../MobileTaskAgentModelCatalog.swift | 136 ---------------- .../MobileTaskAgentProvider.swift | 154 ++++++++++++++++++ .../MobileTaskCommandComposer.swift | 11 +- .../MobileTaskAgentModelCatalogTests.swift | 95 ----------- .../MobileTaskAgentProviderTests.swift | 137 ++++++++++++++++ .../MobileDisplaySettings.swift | 55 ++++++- .../TaskComposer/TaskComposerAgentMenu.swift | 6 +- .../TaskComposerAgentMenuActions.swift | 4 +- .../TaskComposerAgentMenuContent.swift | 8 +- .../TaskComposerMinimalLayout.swift | 29 ++-- .../TaskComposer/TaskComposerModelChip.swift | 10 +- .../TaskComposerModelContextRow.swift | 10 +- .../TaskComposerModelDisplay.swift | 27 +++ .../TaskComposer/TaskComposerModelRow.swift | 10 +- .../TaskComposer/TaskComposerPromptCard.swift | 3 +- .../TaskComposerSheet+DraftState.swift | 9 +- .../TaskComposerSheet+ModelSelection.swift | 14 +- .../TaskComposer/TaskComposerSheet.swift | 81 +++++---- .../MobileDisplaySettingsTests.swift | 38 +++++ 19 files changed, 495 insertions(+), 342 deletions(-) delete mode 100644 Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileTaskAgentModelCatalog.swift create mode 100644 Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileTaskAgentProvider.swift delete mode 100644 Packages/iOS/CmuxMobileShellModel/Tests/CmuxMobileShellModelTests/MobileTaskAgentModelCatalogTests.swift create mode 100644 Packages/iOS/CmuxMobileShellModel/Tests/CmuxMobileShellModelTests/MobileTaskAgentProviderTests.swift create mode 100644 Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TaskComposer/TaskComposerModelDisplay.swift diff --git a/Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileTaskAgentModelCatalog.swift b/Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileTaskAgentModelCatalog.swift deleted file mode 100644 index 054db646a96f..000000000000 --- a/Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileTaskAgentModelCatalog.swift +++ /dev/null @@ -1,136 +0,0 @@ -import Foundation - -/// Known coding-agent CLIs the composer can offer model choices for. -public enum MobileTaskAgentProvider: String, CaseIterable, Sendable { - /// Anthropic's Claude Code CLI. - case claude - /// OpenAI's Codex CLI. - case codex - /// The OpenCode CLI. - case openCode -} - -/// One selectable model for a coding-agent provider. -public struct MobileTaskAgentModel: Equatable, Sendable, Identifiable { - /// CLI identifier passed to the provider's model flag. - public let id: String - /// Product name displayed verbatim in the composer. - public let displayName: String - - /// Creates a selectable coding-agent model. - /// - Parameters: - /// - id: CLI identifier passed to the provider's model flag. - /// - displayName: Product name displayed verbatim in the composer. - public init(id: String, displayName: String) { - self.id = id - self.displayName = displayName - } -} - -/// Curated per-provider model lists and the CLI flag spelling used to apply one. -/// lint:allow namespace-type — the task spec requires this static catalog API. -public enum MobileTaskAgentModelCatalog { - /// Lexically detects a provider from the command's first - /// whitespace-delimited token. - /// - /// Detection ignores the token's path, but does not parse shell syntax, so - /// compound or environment-prefixed commands return `nil`. - /// - Parameter command: User-authored task-template command. - /// - Returns: The detected provider, or `nil` for an unsupported first token. - public static func provider(forCommand command: String) -> MobileTaskAgentProvider? { - guard let tokenRange = firstTokenRange(in: command) else { return nil } - let token = command[tokenRange] - let basename = token.split(separator: "/", omittingEmptySubsequences: true).last - switch basename { - case "claude": - return .claude - case "codex": - return .codex - case "opencode": - return .openCode - default: - return nil - } - } - - /// Returns the curated models available for a provider. - /// - Parameter provider: Coding-agent provider to look up. - /// - Returns: Models offered by that provider, in display order. - public static func models(for provider: MobileTaskAgentProvider) -> [MobileTaskAgentModel] { - switch provider { - case .claude: - [ - MobileTaskAgentModel(id: "claude-fable-5", displayName: "Fable 5"), - MobileTaskAgentModel(id: "claude-opus-4-8", displayName: "Opus 4.8"), - MobileTaskAgentModel(id: "claude-sonnet-5", displayName: "Sonnet 5"), - MobileTaskAgentModel(id: "claude-haiku-4-5", displayName: "Haiku 4.5"), - ] - case .codex: - [ - MobileTaskAgentModel(id: "gpt-5.6-luna", displayName: "GPT-5.6 Luna"), - MobileTaskAgentModel(id: "gpt-5.6-sol", displayName: "GPT-5.6 Sol"), - MobileTaskAgentModel(id: "gpt-5.5", displayName: "GPT-5.5"), - ] - case .openCode: - [ - MobileTaskAgentModel( - id: "anthropic/claude-sonnet-5", - displayName: "Claude Sonnet 5" - ), - MobileTaskAgentModel( - id: "anthropic/claude-opus-4-8", - displayName: "Claude Opus 4.8" - ), - MobileTaskAgentModel(id: "openai/gpt-5.5", displayName: "GPT-5.5"), - ] - } - } - - /// Returns the curated models for a command's detected provider. - /// - Parameter command: User-authored task-template command. - /// - Returns: Available models, or an empty array for an unsupported command. - public static func models(forCommand command: String) -> [MobileTaskAgentModel] { - guard let provider = provider(forCommand: command) else { return [] } - return models(for: provider) - } - - /// Validates a model identifier against a command's detected provider. - /// - Parameters: - /// - id: CLI model identifier to validate. - /// - command: User-authored task-template command. - /// - Returns: The matching curated model, or `nil` when it is unavailable. - public static func model(id: String, forCommand command: String) -> MobileTaskAgentModel? { - models(forCommand: command).first { $0.id == id } - } - - /// Inserts a provider-specific model flag immediately after the command's - /// first token. - /// - /// A `nil` model identifier or unsupported command is returned unchanged. - /// Everything after the first token remains byte-for-byte identical. - /// - Parameters: - /// - modelID: CLI model identifier to single-quote for the flag value. - /// - command: User-authored task-template command. - /// - Returns: The flagged command, or the original command when not applicable. - public static func commandApplying(modelID: String?, to command: String) -> String { - guard let modelID, - let provider = provider(forCommand: command), - let tokenRange = firstTokenRange(in: command) else { - return command - } - let flag = switch provider { - case .claude, .openCode: - "--model" - case .codex: - "-m" - } - let escapedID = modelID.replacingOccurrences(of: "'", with: "'\\''") - return "\(command[.. Range? { - guard let start = command.firstIndex(where: { !$0.isWhitespace }) else { return nil } - let end = command[start...].firstIndex(where: \.isWhitespace) ?? command.endIndex - return start.. MobileTaskAgentModel? { + models.first { $0.id == id } + } + + /// Applies a model selection to the command. + /// + /// When the command already carries one of this provider's model flags + /// (before any standalone `--` end-of-options token), its value is replaced + /// in place so the template's own spelling never overrides the explicit + /// selection. Otherwise the flag is inserted immediately after the first + /// token. Everything else remains byte-for-byte identical. Flag values are + /// matched as whitespace-delimited tokens; quoted values containing + /// whitespace are not rewritten (no model identifier contains whitespace). + /// - Parameters: + /// - modelID: CLI model identifier to single-quote for the flag value. + /// - command: User-authored task-template command. + /// - Returns: The command running the selected model. + public func command(applying modelID: String, to command: String) -> String { + guard let firstToken = Self.tokenRange(in: command, from: command.startIndex) else { + return command + } + let quotedID = "'\(modelID.replacingOccurrences(of: "'", with: "'\\''"))'" + + var searchStart = firstToken.upperBound + while let token = Self.tokenRange(in: command, from: searchStart) { + searchStart = token.upperBound + let text = command[token] + if text == "--" { break } + if modelFlagSpellings.contains(where: { text == $0 }) { + guard let value = Self.tokenRange(in: command, from: token.upperBound) else { + return "\(command) \(quotedID)" + } + return command.replacingCharacters(in: value, with: quotedID) + } + if let spelling = modelFlagSpellings.first(where: { text.hasPrefix("\($0)=") }) { + return command.replacingCharacters(in: token, with: "\(spelling)=\(quotedID)") + } + } + + let flag = modelFlagSpellings[0] + return "\(command[.. Range? { + guard let start = command[index...].firstIndex(where: { !$0.isWhitespace }) else { + return nil + } + let end = command[start...].firstIndex(where: \.isWhitespace) ?? command.endIndex + return start..