diff --git a/Packages/iOS/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileCoreRPCClient.swift b/Packages/iOS/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileCoreRPCClient.swift index 46583299b754..b215b10fbfa9 100644 --- a/Packages/iOS/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileCoreRPCClient.swift +++ b/Packages/iOS/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileCoreRPCClient.swift @@ -682,9 +682,10 @@ public final class MobileCoreRPCClient: MobileSyncing, Sendable { switch method { case "mobile.workspace.list", "workspace.list", + "mobile.task.models.list", "mobile.directory.list", "mobile.directory.search": return false - case "workspace.create": + case "workspace.create", "mobile.task.attachment.upload": return false case "workspace.action", "workspace.close": return !ticketCoverage.ticketCoversWorkspaceRequest( diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+TaskAttachments.swift b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+TaskAttachments.swift new file mode 100644 index 000000000000..e7cda3911c9c --- /dev/null +++ b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+TaskAttachments.swift @@ -0,0 +1,187 @@ +internal import CmuxMobileRPC +public import CmuxMobileShellModel +public import Foundation + +extension MobileShellComposite { + /// Whether the selected Mac instance currently advertises task attachments. + /// + /// - Parameters: + /// - macDeviceID: Physical Mac selected in the task composer. + /// - instanceTag: Exact paired app instance, when known. + /// - Returns: `true` only for a matching host capability announcement. + public func supportsTaskAttachments( + macDeviceID: String, + instanceTag: String? + ) -> Bool { + if matchesForegroundPairing( + macDeviceID: macDeviceID, + instanceTag: instanceTag + ) { + return supportedHostCapabilities.contains(Self.taskAttachmentCapability) + } + if let subscription = controlSubscriptionMatching( + macDeviceID: macDeviceID, + instanceTag: instanceTag + ) { + return subscription.supportedHostCapabilities.contains( + Self.taskAttachmentCapability + ) + } + let aliases = pairedMacAliasIDs( + for: macDeviceID, + instanceTag: instanceTag + ) + if let instanceTag { + return aliases.contains { + presenceMap.instance(deviceId: $0, tag: instanceTag)? + .capabilities.contains(Self.taskAttachmentCapability) == true + } + } + return aliases.contains { + presenceMap.soleRouteAdvertisingInstance(deviceId: $0)? + .capabilities.contains(Self.taskAttachmentCapability) == true + } + } + + /// Uploads one staged task attachment to the selected Mac in 3 MiB chunks. + /// + /// - Parameters: + /// - attachment: App-owned staged attachment file. + /// - operationID: Task submission idempotency key. + /// - macDeviceID: Target Mac device id. + /// - instanceTag: Exact paired app instance, when known. + /// - Returns: The final absolute Mac path, or a user-actionable failure. + public func uploadTaskAttachment( + _ attachment: TaskComposerAttachment, + operationID: UUID, + macDeviceID: String, + instanceTag: String? + ) async -> Result { + if !matchesForegroundPairing( + macDeviceID: macDeviceID, + instanceTag: instanceTag + ) || remoteClient == nil { + guard await switchToMac( + macDeviceID: macDeviceID, + instanceTag: instanceTag + ) else { + return .failure(.notConnected( + hostDisplayName: taskComposerTargetName( + macDeviceID: macDeviceID, + instanceTag: instanceTag + ) + )) + } + } + guard !Task.isCancelled, + let context = captureWorkspaceCreateContext(), + context.macDeviceID == macDeviceID, + instanceTag == nil || context.instanceTag == instanceTag else { + return .failure(.notConnected( + hostDisplayName: taskComposerTargetName( + macDeviceID: macDeviceID, + instanceTag: instanceTag + ) + )) + } + guard context.supportedHostCapabilities.contains( + Self.taskAttachmentCapability + ) else { + return .failure(.unsupported(hostDisplayName: context.hostDisplayName)) + } + + let data: Data + do { + data = try await loadTaskAttachmentData( + from: attachment.localStagedFileURL + ) + } catch { + return .failure(.rejected(hostDisplayName: context.hostDisplayName)) + } + guard data.count == attachment.byteCount, + data.count <= TaskComposerAttachment.maximumFileBytes else { + return .failure(.rejected(hostDisplayName: context.hostDisplayName)) + } + + let plan = MobileTaskAttachmentChunkPlan(totalByteCount: data.count) + do { + var finalPath: String? + for (index, range) in plan.ranges.enumerated() { + try Task.checkCancellation() + let isLast = index == plan.ranges.count - 1 + let params: [String: Any] = [ + "operation_id": operationID.uuidString, + "upload_id": attachment.id.uuidString, + "file_name": attachment.displayName, + "total_bytes": data.count, + "offset": range.lowerBound, + "data_b64": data.subdata(in: range).base64EncodedString(), + "last": isLast, + ] + let response = try await context.client.sendRequest( + MobileCoreRPCClient.requestData( + method: "mobile.task.attachment.upload", + params: params + ) + ) + guard context.isCurrent( + macDeviceID: foregroundMacDeviceID, + instanceTag: activeMacInstanceTag, + client: remoteClient, + generation: connectionGeneration + ), isSignedIn else { + return .failure(.notConnected( + hostDisplayName: context.hostDisplayName + )) + } + guard let object = try JSONSerialization.jsonObject(with: response) + as? [String: Any] else { + throw MobileShellConnectionError.invalidResponse + } + if isLast { + guard let path = object["path"] as? String, + path.hasPrefix("/") else { + throw MobileShellConnectionError.invalidResponse + } + finalPath = path + } + } + guard let finalPath else { + return .failure(.rejected(hostDisplayName: context.hostDisplayName)) + } + return .success(finalPath) + } catch { + if context.isCurrent( + macDeviceID: foregroundMacDeviceID, + instanceTag: activeMacInstanceTag, + client: remoteClient, + generation: connectionGeneration + ) { + handleMacAvailabilityFailureIfCurrent( + after: error, + expectedClient: context.client, + expectedGeneration: context.generation + ) + } + return .failure( + workspaceMutationFailure( + error, + hostDisplayName: context.hostDisplayName + ) + ) + } + } + + private func loadTaskAttachmentData(from url: URL) async throws -> Data { + try await withThrowingTaskGroup(of: Data.self) { group in + group.addTask(priority: .utility) { + try Task.checkCancellation() + return try Data(contentsOf: url, options: .mappedIfSafe) + } + guard let data = try await group.next() else { + throw CancellationError() + } + return data + } + } +} diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+TaskComposer.swift b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+TaskComposer.swift index 24bf4382f610..d9c2721ee1c6 100644 --- a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+TaskComposer.swift +++ b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+TaskComposer.swift @@ -247,7 +247,7 @@ extension MobileShellComposite { ) } - private func taskComposerTargetName(macDeviceID: String, instanceTag: String?) -> String { + func taskComposerTargetName(macDeviceID: String, instanceTag: String?) -> String { displayPairedMacs.first { $0.macDeviceID == macDeviceID && (instanceTag == nil || $0.instanceTag == instanceTag) diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+TaskModels.swift b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+TaskModels.swift new file mode 100644 index 000000000000..d30d0f27292b --- /dev/null +++ b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+TaskModels.swift @@ -0,0 +1,235 @@ +internal import CmuxMobileRPC +public import CmuxMobileShellModel +import Foundation + +extension MobileShellComposite { + /// Resolves a secondary control subscription for a physical Mac: the + /// exact pairing when a tag is given, otherwise any same-device pairing. + /// Mirrors the pre-MacPairingKey device-id lookup these capability + /// checks were written against. + func controlSubscriptionMatching( + macDeviceID: String, + instanceTag: String? + ) -> SecondaryMacSubscription? { + let probe = MacPairingKey(macDeviceID: macDeviceID, instanceTag: instanceTag) + if probe.normalizedInstanceTag != nil, + let exact = secondaryMacSubscriptions[probe] { + return exact + } + for key in secondaryMacSubscriptions.keys + where key.canonicalMacDeviceID == probe.canonicalMacDeviceID { + guard let subscription = secondaryMacSubscriptions[key] else { continue } + if instanceTag == nil + || key.normalizedInstanceTag == probe.normalizedInstanceTag + || subscription.authenticatedInstanceTag == instanceTag + || subscription.storedInstanceTag == instanceTag { + return subscription + } + } + return nil + } + + /// Whether the selected Mac instance advertises task model discovery. + /// + /// - Parameters: + /// - macDeviceID: Physical Mac selected in the task composer. + /// - instanceTag: Exact paired app instance, when known. + /// - Returns: `true` only for a matching host capability announcement. + public func supportsTaskModels( + macDeviceID: String, + instanceTag: String? + ) -> Bool { + if matchesForegroundPairing( + macDeviceID: macDeviceID, + instanceTag: instanceTag + ) { + return supportedHostCapabilities.contains(Self.taskModelsCapability) + } + if let subscription = controlSubscriptionMatching( + macDeviceID: macDeviceID, + instanceTag: instanceTag + ) { + return subscription.supportedHostCapabilities.contains( + Self.taskModelsCapability + ) + } + let aliases = pairedMacAliasIDs( + for: macDeviceID, + instanceTag: instanceTag + ) + if let instanceTag { + return aliases.contains { + presenceMap.instance(deviceId: $0, tag: instanceTag)? + .capabilities.contains(Self.taskModelsCapability) == true + } + } + return aliases.contains { + presenceMap.soleRouteAdvertisingInstance(deviceId: $0)? + .capabilities.contains(Self.taskModelsCapability) == true + } + } + + /// Fetches one provider's models from the selected Mac. + /// + /// - Parameters: + /// - provider: Coding-agent provider to query. + /// - macDeviceID: Physical Mac selected in the task composer. + /// - instanceTag: Exact paired app instance, when known. + /// - Returns: Models plus their discovery source. + /// - Throws: A connection or response error when discovery cannot complete. + public func fetchTaskModels( + provider: MobileTaskAgentProvider, + macDeviceID: String, + instanceTag: String? + ) async throws -> MobileTaskModelListResult { + if !matchesForegroundPairing( + macDeviceID: macDeviceID, + instanceTag: instanceTag + ) || remoteClient == nil { + guard await switchToMac( + macDeviceID: macDeviceID, + instanceTag: instanceTag + ) else { + throw MobileShellConnectionError.connectionClosed + } + } + guard !Task.isCancelled, + let context = captureWorkspaceCreateContext(), + context.macDeviceID == macDeviceID, + instanceTag == nil || context.instanceTag == instanceTag, + context.supportedHostCapabilities.contains( + Self.taskModelsCapability + ) else { + throw MobileShellConnectionError.invalidResponse + } + + do { + let response = try await context.client.sendRequest( + MobileCoreRPCClient.requestData( + method: "mobile.task.models.list", + params: ["provider": provider.rawValue] + ) + ) + guard context.isCurrent( + macDeviceID: foregroundMacDeviceID, + instanceTag: activeMacInstanceTag, + client: remoteClient, + generation: connectionGeneration + ), isSignedIn, + let object = try JSONSerialization.jsonObject(with: response) + as? [String: Any], + let rawSource = object["source"] as? String, + let source = MobileTaskModelListSource(rawValue: rawSource), + let rawModels = object["models"] as? [[String: Any]] + else { + throw MobileShellConnectionError.invalidResponse + } + var models: [MobileTaskAgentModel] = [] + models.reserveCapacity(rawModels.count) + var seenIDs: Set = [] + for rawModel in rawModels { + guard let id = rawModel["id"] as? String, + !id.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty, + let displayName = rawModel["display_name"] as? String, + !displayName.isEmpty, + seenIDs.insert(id).inserted else { + throw MobileShellConnectionError.invalidResponse + } + models.append(MobileTaskAgentModel( + id: id, + displayName: displayName + )) + } + guard !models.isEmpty else { + throw MobileShellConnectionError.invalidResponse + } + return MobileTaskModelListResult(models: models, source: source) + } catch { + if context.isCurrent( + macDeviceID: foregroundMacDeviceID, + instanceTag: activeMacInstanceTag, + client: remoteClient, + generation: connectionGeneration + ) { + handleMacAvailabilityFailureIfCurrent( + after: error, + expectedClient: context.client, + expectedGeneration: context.generation + ) + } + throw error + } + } + + /// Returns cached models synchronously for composer rendering and restore. + /// + /// - Parameters: + /// - provider: Coding-agent provider to resolve. + /// - macDeviceID: Physical Mac selected in the task composer. + /// - instanceTag: Exact paired instance; the cache intentionally remains + /// device/provider scoped so app rebuilds on one Mac share discovery. + /// - Returns: Previously fetched models, or `nil`. + public func discoveredTaskModels( + provider: MobileTaskAgentProvider, + macDeviceID: String, + instanceTag _: String? + ) -> [MobileTaskAgentModel]? { + taskModelCache[ + MobileTaskModelCacheKey( + macDeviceID: macDeviceID, + provider: provider + ) + ]?.result.models + } + + /// Refreshes and caches one provider's models when the Mac supports it. + /// + /// Failed refreshes leave an earlier valid cache entry intact. + /// + /// - Parameters: + /// - provider: Coding-agent provider to query. + /// - macDeviceID: Physical Mac selected in the task composer. + /// - instanceTag: Exact paired app instance, when known. + public func refreshTaskModels( + provider: MobileTaskAgentProvider, + macDeviceID: String, + instanceTag: String? + ) async { + guard supportsTaskModels( + macDeviceID: macDeviceID, + instanceTag: instanceTag + ) else { + return + } + guard let result = try? await fetchTaskModels( + provider: provider, + macDeviceID: macDeviceID, + instanceTag: instanceTag + ) else { + return + } + taskModelCache[ + MobileTaskModelCacheKey( + macDeviceID: macDeviceID, + provider: provider + ) + ] = MobileTaskModelCacheEntry( + result: result, + fetchedAt: runtime?.now() ?? Date() + ) + } + + /// Fetch timestamp used by package tests and cache diagnostics. + func taskModelsFetchedAt( + provider: MobileTaskAgentProvider, + macDeviceID: String, + instanceTag _: String? + ) -> Date? { + taskModelCache[ + MobileTaskModelCacheKey( + macDeviceID: macDeviceID, + provider: provider + ) + ]?.fetchedAt + } +} diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift index 645b8a68a917..8ff72a7b1d5f 100644 --- a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift +++ b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift @@ -121,6 +121,8 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { static let workspaceGroupActionsCapability = "workspace.group_actions.v1" static let workspaceCreateInGroupCapability = "workspace.create_in_group.v1", workspaceGroupCreateCapability = "workspace.group_create.v1" static let taskCreateCapability = "workspace.task_create.v1" + static let taskAttachmentCapability = "task.attachments.v1" + static let taskModelsCapability = "task.models.v1" static let chatArtifactCapability = "chat.artifact.v1" static let chatArtifactGalleryCapability = "chat.artifact.gallery.v1" static let terminalArtifactCapability = "terminal.artifact.v1" @@ -429,6 +431,8 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { @ObservationIgnored var groupCollapseStore: MobileWorkspaceGroupCollapseStore /// Device-local task templates used by the iOS task composer. @ObservationIgnored public let taskTemplateStore: (any MobileTaskTemplateStoring)? + /// Mac/provider model responses observed by the task composer. + var taskModelCache: [MobileTaskModelCacheKey: MobileTaskModelCacheEntry] = [:] /// The connected Mac's `mobile.host.status` capabilities. Feature gates are /// computed from this set so version-skew checks cannot drift from the raw /// host payload. @@ -1614,6 +1618,7 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { enqueueDraftOperation { await draftStore.clearAllDrafts() } } taskTemplateStore?.clearAllUserData() + taskModelCache.removeAll() // Drop unflushed keystroke snapshots too: an armed flush that runs // before the wipe would only write text the wipe then deletes, but the // buffer itself must not carry one account's text into the next. diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileTaskModelCacheEntry.swift b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileTaskModelCacheEntry.swift new file mode 100644 index 000000000000..fe4439e59a45 --- /dev/null +++ b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileTaskModelCacheEntry.swift @@ -0,0 +1,8 @@ +internal import CmuxMobileShellModel +internal import Foundation + +/// One discovered model response and the time the phone fetched it. +struct MobileTaskModelCacheEntry: Equatable { + let result: MobileTaskModelListResult + let fetchedAt: Date +} diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileTaskModelCacheKey.swift b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileTaskModelCacheKey.swift new file mode 100644 index 000000000000..8f4c09c7f70b --- /dev/null +++ b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileTaskModelCacheKey.swift @@ -0,0 +1,7 @@ +internal import CmuxMobileShellModel + +/// Device/provider identity for the composer's in-memory discovered-model cache. +struct MobileTaskModelCacheKey: Hashable { + let macDeviceID: String + let provider: MobileTaskAgentProvider +} diff --git a/Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileImageAttachmentPreparer.swift b/Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileImageAttachmentPreparer.swift new file mode 100644 index 000000000000..2ee2480d3eb0 --- /dev/null +++ b/Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileImageAttachmentPreparer.swift @@ -0,0 +1,122 @@ +internal import CoreGraphics +public import Foundation +internal import ImageIO + +/// Downsamples image files into bounded upload bytes without decoding a full raster. +public struct MobileImageAttachmentPreparer: Sendable { + /// Source-file ceiling checked before ImageIO opens the file. + public static let maximumRawInputBytes = 60 * 1024 * 1024 + /// Encoded upload ceiling shared by terminal and task attachments. + public static let maximumEncodedBytes = 8 * 1024 * 1024 + + private let sendMaxPixelSize = 2_048 + private let thumbnailMaxPixelSize = 168 + + /// Creates a reusable image attachment preparer. + public init() {} + + /// Prepares one file-backed image in a cancellable background child task. + /// + /// ImageIO opens the file URL lazily and downsamples before decoding, so a + /// large HEIC, panorama, or RAW source never becomes a full-size in-memory + /// raster. + /// + /// - Parameter url: A readable local image file. + /// - Returns: Bounded PNG/JPEG bytes plus a thumbnail, or `nil` when the + /// source is invalid, oversized after compression, or cancelled. + public nonisolated func prepare(url: URL) async -> MobilePreparedImageAttachment? { + guard !Task.isCancelled else { return nil } + return await withTaskGroup(of: MobilePreparedImageAttachment?.self) { group in + group.addTask(priority: .background) { + guard !Task.isCancelled, + let source = CGImageSourceCreateWithURL(url as CFURL, nil), + let payload = boundedSendPayload(from: source) else { + return nil + } + guard !Task.isCancelled else { return nil } + return MobilePreparedImageAttachment( + data: payload.data, + format: payload.format, + thumbnailData: downsampledImageData( + from: source, + maxPixelSize: thumbnailMaxPixelSize, + type: "public.png", + jpegQuality: nil + ) + ) + } + return await group.next() ?? nil + } + } + + private nonisolated func boundedSendPayload( + from source: CGImageSource + ) -> (data: Data, format: String)? { + if let png = downsampledImageData( + from: source, + maxPixelSize: sendMaxPixelSize, + type: "public.png", + jpegQuality: nil + ), png.count <= Self.maximumEncodedBytes { + return (png, "png") + } + for quality in [0.8, 0.6, 0.4] as [CGFloat] { + if let jpeg = downsampledImageData( + from: source, + maxPixelSize: sendMaxPixelSize, + type: "public.jpeg", + jpegQuality: quality + ), jpeg.count <= Self.maximumEncodedBytes { + return (jpeg, "jpg") + } + } + for maxPixelSize in [1_536, 1_024, 768] { + if let jpeg = downsampledImageData( + from: source, + maxPixelSize: maxPixelSize, + type: "public.jpeg", + jpegQuality: 0.5 + ), jpeg.count <= Self.maximumEncodedBytes { + return (jpeg, "jpg") + } + } + return nil + } + + private nonisolated func downsampledImageData( + from source: CGImageSource, + maxPixelSize: Int, + type: String, + jpegQuality: CGFloat? + ) -> Data? { + let options: [CFString: Any] = [ + kCGImageSourceCreateThumbnailFromImageAlways: true, + kCGImageSourceCreateThumbnailWithTransform: true, + kCGImageSourceShouldCacheImmediately: true, + kCGImageSourceThumbnailMaxPixelSize: maxPixelSize, + ] + guard let image = CGImageSourceCreateThumbnailAtIndex( + source, + 0, + options as CFDictionary + ) else { + return nil + } + let encoded = NSMutableData() + guard let destination = CGImageDestinationCreateWithData( + encoded as CFMutableData, + type as CFString, + 1, + nil + ) else { + return nil + } + var properties: [CFString: Any] = [:] + if let jpegQuality { + properties[kCGImageDestinationLossyCompressionQuality] = jpegQuality + } + CGImageDestinationAddImage(destination, image, properties as CFDictionary) + guard CGImageDestinationFinalize(destination) else { return nil } + return encoded as Data + } +} diff --git a/Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobilePreparedImageAttachment.swift b/Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobilePreparedImageAttachment.swift new file mode 100644 index 000000000000..fa2bae59626b --- /dev/null +++ b/Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobilePreparedImageAttachment.swift @@ -0,0 +1,23 @@ +public import Foundation + +/// Bounded image bytes and thumbnail data ready for mobile attachment staging. +public struct MobilePreparedImageAttachment: Equatable, Sendable { + /// Encoded PNG or JPEG bytes, never larger than 8 MiB. + public let data: Data + /// Lowercase extension matching ``data`` (`png` or `jpg`). + public let format: String + /// Small PNG thumbnail suitable for a 56-point attachment chip. + public let thumbnailData: Data? + + /// Creates a prepared image attachment. + /// + /// - Parameters: + /// - data: Bounded encoded image bytes. + /// - format: Lowercase encoded format. + /// - thumbnailData: Optional small PNG preview. + public init(data: Data, format: String, thumbnailData: Data?) { + self.data = data + self.format = format + self.thumbnailData = thumbnailData + } +} diff --git a/Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileTaskAgentProvider.swift b/Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileTaskAgentProvider.swift new file mode 100644 index 000000000000..5515703f011b --- /dev/null +++ b/Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileTaskAgentProvider.swift @@ -0,0 +1,272 @@ +import Foundation + +/// Known coding-agent CLIs the composer can offer model choices for, each +/// owning its curated model list and model-flag spelling. +public enum MobileTaskAgentProvider: String, CaseIterable, Sendable { + /// Anthropic's Claude Code CLI. + case claude + /// OpenAI's Codex CLI. + case codex + /// The OpenCode CLI. + case openCode = "opencode" + + /// 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 detect no provider. Detection + /// fails closed: an undetected command offers no model UI and always runs + /// byte-for-byte verbatim. + /// - Parameter command: User-authored task-template command. + public init?(command: String) { + guard let tokenRange = Self.tokenRange(in: command, from: command.startIndex) else { + return nil + } + let token = command[tokenRange] + let basename = token.split(separator: "/", omittingEmptySubsequences: true).last + switch basename { + case "claude": + self = .claude + case "codex": + self = .codex + case "opencode": + self = .openCode + default: + return nil + } + } + + /// The curated models offered for this provider, in display order. + public var models: [MobileTaskAgentModel] { + switch self { + 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"), + ] + } + } + + /// Validates a model identifier against this provider's curated list. + /// - Parameter id: CLI model identifier to validate. + /// - Returns: The matching curated model, or `nil` when it is unavailable. + public func model(id: String) -> MobileTaskAgentModel? { + models.first { $0.id == id } + } + + /// Applies a model selection to the command. + /// + /// When the first simple command already carries one of this provider's + /// model flags, every such flag's 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. Tokenization is + /// quote-aware (single quotes, double quotes, and backslash escapes + /// outside single quotes), so flag text embedded in a quoted argument is + /// never rewritten, and scanning stops at the first simple command's end: + /// a standalone `--` end-of-options token, an unquoted `;`, `|`, or `&`, + /// or a newline. Shell comments, subshells, and heredocs are not parsed. + /// - 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: "'\\''"))'" + + // Collect edits against the immutable command, then apply back to + // front so earlier ranges stay valid. + var edits: [(range: Range, replacement: String)] = [] + var searchStart = firstToken.upperBound + while let token = Self.tokenRange(in: command, from: searchStart) { + // A newline between tokens ends the first simple command. + if command[searchStart..` (`2>&1`, `>&2`, `&>file`) and `|` + /// preceded by `>` (`>|file`) are part of the simple command. `nil` when + /// the token carries no command boundary. + private static func unquotedCommandBoundaryIndex( + in token: Substring + ) -> Substring.Index? { + var inSingleQuotes = false + var inDoubleQuotes = false + var previousCharacter: Character? + var current = token.startIndex + while current < token.endIndex { + let character = token[current] + if character == "\\", !inSingleQuotes { + current = token.index(after: current) + if current < token.endIndex { + previousCharacter = token[current] + current = token.index(after: current) + } + continue + } + if character == "'", !inDoubleQuotes { + inSingleQuotes.toggle() + } else if character == "\"", !inSingleQuotes { + inDoubleQuotes.toggle() + } else if !inSingleQuotes, !inDoubleQuotes { + let next = token.index(after: current) + let nextCharacter = next < token.endIndex ? token[next] : nil + switch character { + case ";": + return current + case "|" where previousCharacter != ">": + return current + case "&" where previousCharacter != ">" && nextCharacter != ">": + return current + default: + break + } + } + previousCharacter = character + current = token.index(after: current) + } + return nil + } + + /// The next shell word starting at or after `index`. Quote-aware: single- + /// and double-quoted spans (and backslash escapes outside single quotes) + /// never end a token, so flag text embedded in a quoted argument is one + /// opaque token rather than a false flag match. An unterminated quote + /// consumes the rest of the command. + private static func tokenRange( + in command: String, + from index: String.Index + ) -> Range? { + guard let start = command[index...].firstIndex(where: { !$0.isWhitespace }) else { + return nil + } + var inSingleQuotes = false + var inDoubleQuotes = false + var current = start + while current < command.endIndex { + let character = command[current] + if character == "\\", !inSingleQuotes { + current = command.index(after: current) + if current < command.endIndex { + current = command.index(after: current) + } + continue + } + if character == "'", !inDoubleQuotes { + inSingleQuotes.toggle() + } else if character == "\"", !inSingleQuotes { + inDoubleQuotes.toggle() + } else if character.isWhitespace, !inSingleQuotes, !inDoubleQuotes { + break + } + current = command.index(after: current) + } + return start..= 0) + precondition(chunkByteCount > 0) + self.totalByteCount = totalByteCount + self.chunkByteCount = chunkByteCount + } + + /// Ordered byte ranges to send. + /// + /// An empty attachment still produces one empty terminal chunk so the host + /// can finalize and return its path. + public var ranges: [Range] { + guard totalByteCount > 0 else { return [0..<0] } + return stride(from: 0, to: totalByteCount, by: chunkByteCount).map { offset in + offset.. MobileTaskComposition { + public func compose( + template: MobileTaskTemplate, + prompt rawPrompt: String, + modelID: String? = nil, + attachmentPaths: [String] = [] + ) -> 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, + let initialCommand: String + if let modelID, let provider = MobileTaskAgentProvider(command: template.command) { + initialCommand = provider.command(applying: modelID, to: template.command) + } else { + initialCommand = template.command + } + let composition = MobileTaskComposition( + initialCommand: initialCommand, initialEnv: ["CMUX_TASK_PROMPT": prompt], title: title ) + return addingAttachmentPaths(attachmentPaths, to: composition) + } + + /// Adds uploaded Mac paths to an already captured task composition. + /// + /// This is used after uploads finish so request snapshots can preserve the + /// exact command captured before the first suspension. + /// + /// - Parameters: + /// - attachmentPaths: Absolute paths returned by the Mac. + /// - composition: Previously captured task composition. + /// - Returns: The composition augmented with attachment environment values. + public func addingAttachmentPaths( + _ attachmentPaths: [String], + to composition: MobileTaskComposition + ) -> MobileTaskComposition { + guard !attachmentPaths.isEmpty, composition.initialCommand != nil else { + return composition + } + let pathList = attachmentPaths.joined(separator: "\n") + let prompt = composition.initialEnv["CMUX_TASK_PROMPT"] ?? "" + let promptSuffix = attachmentPaths + .map { "- \($0)" } + .joined(separator: "\n") + var result = composition + result.initialEnv["CMUX_TASK_ATTACHMENTS"] = pathList + result.initialEnv["CMUX_TASK_PROMPT"] = + prompt + + "\n\nAttached files (absolute paths on this machine):\n" + + promptSuffix + return result } /// The suggested workspace title for a task prompt: its first line, capped diff --git a/Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileTaskComposerDraft.swift b/Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileTaskComposerDraft.swift index f5ada7462441..bc06b597eca5 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. @@ -26,6 +28,7 @@ public struct MobileTaskComposerDraft: Codable, Equatable, Sendable { /// Creates a restorable composer draft. public init( prompt: String, + modelID: String? = nil, templateID: MobileTaskTemplate.ID?, macDeviceID: String?, macInstanceTag: String? = nil, @@ -36,6 +39,7 @@ public struct MobileTaskComposerDraft: Codable, Equatable, Sendable { completedOperationID: UUID? = nil ) { self.prompt = prompt + self.modelID = modelID self.templateID = templateID self.macDeviceID = macDeviceID self.macInstanceTag = macInstanceTag diff --git a/Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileTaskModelAvailability.swift b/Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileTaskModelAvailability.swift new file mode 100644 index 000000000000..72723deab198 --- /dev/null +++ b/Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileTaskModelAvailability.swift @@ -0,0 +1,56 @@ +/// The effective model list and validation policy for one task template. +public struct MobileTaskModelAvailability: Sendable { + /// Provider detected from the template command. + public let provider: MobileTaskAgentProvider? + /// Models shown by the composer. + public let models: [MobileTaskAgentModel] + + /// Resolves discovered models over the provider's curated fallback. + /// + /// - Parameters: + /// - template: Selected task template. + /// - discoveredModels: Cached models returned by the selected Mac. + public init( + template: MobileTaskTemplate?, + discoveredModels: [MobileTaskAgentModel]? + ) { + provider = template.flatMap { + MobileTaskAgentProvider(command: $0.command) + } + models = discoveredModels ?? provider?.models ?? [] + } + + /// Validates an identifier against the same list the composer displays. + /// + /// A previously validated selection remains accepted if a later refresh + /// removes it, because the user explicitly selected it while it was offered. + /// + /// - Parameters: + /// - id: Candidate model identifier. + /// - previouslyValidModelID: Earlier validated selection to preserve. + /// - Returns: The accepted identifier, or `nil`. + public func validatedModelID( + _ id: String?, + previouslyValidModelID: String? = nil + ) -> String? { + guard let id else { return nil } + if models.contains(where: { $0.id == id }) { + return id + } + return id == previouslyValidModelID ? id : nil + } + + /// Resolves a validated selection for display and submission. + /// + /// If a refresh delisted the selected identifier, a raw-name model keeps + /// the explicit choice visible and valid. + /// + /// - Parameter id: A model identifier previously accepted by + /// ``validatedModelID(_:previouslyValidModelID:)``. + /// - Returns: The matching model or a raw-name representation. + public func selectedModel(id: String?) -> MobileTaskAgentModel? { + guard let id else { return nil } + return models.first { $0.id == id } + ?? MobileTaskAgentModel(id: id, displayName: id) + } +} diff --git a/Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileTaskModelListResult.swift b/Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileTaskModelListResult.swift new file mode 100644 index 000000000000..9e4837f3bab4 --- /dev/null +++ b/Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileTaskModelListResult.swift @@ -0,0 +1,20 @@ +/// Models returned by the Mac and the strategy that produced them. +public struct MobileTaskModelListResult: Equatable, Sendable { + /// Models in composer display order. + public let models: [MobileTaskAgentModel] + /// Whether the list was discovered, augmented, or a fallback. + public let source: MobileTaskModelListSource + + /// Creates a task model list result. + /// + /// - Parameters: + /// - models: Models in composer display order. + /// - source: Strategy that produced the list. + public init( + models: [MobileTaskAgentModel], + source: MobileTaskModelListSource + ) { + self.models = models + self.source = source + } +} diff --git a/Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileTaskModelListSource.swift b/Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileTaskModelListSource.swift new file mode 100644 index 000000000000..9c758edab9b3 --- /dev/null +++ b/Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileTaskModelListSource.swift @@ -0,0 +1,9 @@ +/// How the Mac produced a task provider's model list. +public enum MobileTaskModelListSource: String, Equatable, Sendable { + /// A provider command returned an authoritative dynamic list. + case discovered + /// A configured default was prepended to the curated list. + case augmented + /// Discovery was unavailable, so the curated list was returned. + case fallback +} diff --git a/Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileTaskSubmissionAttachment.swift b/Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileTaskSubmissionAttachment.swift new file mode 100644 index 000000000000..790b8f18e317 --- /dev/null +++ b/Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileTaskSubmissionAttachment.swift @@ -0,0 +1,23 @@ +public import Foundation + +/// Value-only identity for one attachment included in a task submission. +/// +/// Composer snapshots intentionally keep no file handles or local URLs. The +/// upload identifier remains stable for retries of the same logical request, +/// while the byte count makes a changed staged payload a different request. +public struct MobileTaskSubmissionAttachment: Equatable, Sendable { + /// Stable identifier used as the attachment upload id. + public let uploadID: UUID + /// Number of raw bytes in the staged attachment. + public let byteCount: Int + + /// Creates an attachment request identity. + /// + /// - Parameters: + /// - uploadID: Stable identifier used for upload retries. + /// - byteCount: Number of staged attachment bytes. + public init(uploadID: UUID, byteCount: Int) { + self.uploadID = uploadID + self.byteCount = byteCount + } +} diff --git a/Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileTaskSubmissionSnapshot.swift b/Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileTaskSubmissionSnapshot.swift index d590bb3368e0..3dbf175e8bd4 100644 --- a/Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileTaskSubmissionSnapshot.swift +++ b/Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileTaskSubmissionSnapshot.swift @@ -15,6 +15,8 @@ public struct MobileTaskSubmissionSnapshot: Equatable, Sendable { public let macInstanceTag: 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. @@ -25,6 +27,8 @@ public struct MobileTaskSubmissionSnapshot: Equatable, Sendable { public let trimmedDirectory: String /// Whether the user edited the template's suggested working directory. public let didEditDirectory: Bool + /// Value-only attachment identities captured for request equivalence. + public let attachments: [MobileTaskSubmissionAttachment] /// Stable idempotency key used for every attempt to submit this snapshot. public let operationID: UUID /// Command and environment derived from the captured template and prompt. @@ -40,33 +44,43 @@ 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. /// - macInstanceTag: Exact paired app instance to target, or `nil`. /// - directory: Working-directory text shown in the composer. /// - workspaceName: Optional workspace name shown in the composer. /// - didEditDirectory: Whether the user changed the suggested directory. + /// - attachments: Attachment upload identifiers and staged byte counts. /// - operationID: Stable idempotency key for submission retries. public init( template: MobileTaskTemplate, prompt: String, + modelID: String? = nil, macDeviceID: String, macInstanceTag: String? = nil, directory: String, workspaceName: String = "", didEditDirectory: Bool, + attachments: [MobileTaskSubmissionAttachment] = [], operationID: UUID ) { self.templateID = template.id self.macDeviceID = macDeviceID self.macInstanceTag = macInstanceTag 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.attachments = attachments 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. @@ -82,6 +96,7 @@ public struct MobileTaskSubmissionSnapshot: Equatable, Sendable { && Self.hasEqualUTF8(composition.initialEnv, other.composition.initialEnv) && Self.hasEqualUTF8(workspaceTitle, other.workspaceTitle) && Self.hasEqualUTF8(trimmedDirectory, other.trimmedDirectory) + && attachments == other.attachments } /// Rebinds an already-composed request to its resolved idempotency key. @@ -93,9 +108,11 @@ public struct MobileTaskSubmissionSnapshot: Equatable, Sendable { macDeviceID: macDeviceID, macInstanceTag: macInstanceTag, prompt: prompt, + modelID: modelID, workspaceName: workspaceName, directory: directory, didEditDirectory: didEditDirectory, + attachments: attachments, operationID: operationID, composition: composition, trimmedWorkspaceName: trimmedWorkspaceName, @@ -103,6 +120,17 @@ public struct MobileTaskSubmissionSnapshot: Equatable, Sendable { ) } + /// Applies absolute paths returned by attachment uploads to the captured composition. + /// + /// - Parameter attachmentPaths: Absolute Mac paths in attachment order. + /// - Returns: A composition with attachment environment and prompt suffix. + public func composition(attachmentPaths: [String]) -> MobileTaskComposition { + MobileTaskCommandComposer().addingAttachmentPaths( + attachmentPaths, + to: composition + ) + } + private static func hasEqualUTF8(_ lhs: String, _ rhs: String) -> Bool { lhs.utf8.elementsEqual(rhs.utf8) } @@ -135,6 +163,7 @@ public struct MobileTaskSubmissionSnapshot: Equatable, Sendable { public var draft: MobileTaskComposerDraft { MobileTaskComposerDraft( prompt: prompt, + modelID: modelID, templateID: templateID, macDeviceID: macDeviceID.isEmpty ? nil : macDeviceID, macInstanceTag: macDeviceID.isEmpty ? nil : macInstanceTag, @@ -150,9 +179,11 @@ public struct MobileTaskSubmissionSnapshot: Equatable, Sendable { macDeviceID: String, macInstanceTag: String?, prompt: String, + modelID: String?, workspaceName: String, directory: String, didEditDirectory: Bool, + attachments: [MobileTaskSubmissionAttachment], operationID: UUID, composition: MobileTaskComposition, trimmedWorkspaceName: String, @@ -162,11 +193,13 @@ public struct MobileTaskSubmissionSnapshot: Equatable, Sendable { self.macDeviceID = macDeviceID self.macInstanceTag = macInstanceTag self.prompt = prompt + self.modelID = modelID self.workspaceName = workspaceName self.trimmedWorkspaceName = trimmedWorkspaceName self.directory = directory self.trimmedDirectory = trimmedDirectory self.didEditDirectory = didEditDirectory + self.attachments = attachments self.operationID = operationID self.composition = composition } diff --git a/Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/TaskComposerAttachment.swift b/Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/TaskComposerAttachment.swift new file mode 100644 index 000000000000..ea986769ebe1 --- /dev/null +++ b/Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/TaskComposerAttachment.swift @@ -0,0 +1,64 @@ +public import Foundation + +/// One file staged in memory for the lifetime of a New Task composer session. +public struct TaskComposerAttachment: Identifiable, Equatable, Sendable { + /// The supported attachment source kinds. + public enum Kind: Equatable, Sendable { + /// An image selected from the photo library. + case image + /// A document selected from Files. + case file + } + + /// Maximum number of staged attachments in one task. + public static let maximumCount = 10 + /// Maximum encoded bytes for one staged image. + public static let maximumImageBytes = 8 * 1024 * 1024 + /// Maximum raw bytes for one staged file. + public static let maximumFileBytes = 32 * 1024 * 1024 + /// Maximum raw bytes across one task. + public static let maximumTotalBytes = 64 * 1024 * 1024 + + /// Stable upload identity retained across retries of the same task request. + public let id: UUID + /// Whether the staged value is an image or a general file. + public let kind: Kind + /// User-visible file name sent to the Mac for sanitization. + public let displayName: String + /// App-owned temporary file containing the bytes to upload. + public let localStagedFileURL: URL + /// Exact raw bytes in ``localStagedFileURL``. + public let byteCount: Int + /// Small encoded image preview, or `nil` for files. + public let thumbnailData: Data? + + /// Creates one staged composer attachment. + /// + /// - Parameters: + /// - id: Stable upload identity retained across retries. + /// - kind: Whether the attachment is an image or file. + /// - displayName: User-visible file name. + /// - localStagedFileURL: App-owned temporary file containing the bytes. + /// - byteCount: Exact number of staged raw bytes. + /// - thumbnailData: Small encoded image preview, if available. + public init( + id: UUID = UUID(), + kind: Kind, + displayName: String, + localStagedFileURL: URL, + byteCount: Int, + thumbnailData: Data? = nil + ) { + self.id = id + self.kind = kind + self.displayName = displayName + self.localStagedFileURL = localStagedFileURL + self.byteCount = byteCount + self.thumbnailData = thumbnailData + } + + /// Value-only identity captured by a task submission snapshot. + public var submissionAttachment: MobileTaskSubmissionAttachment { + MobileTaskSubmissionAttachment(uploadID: id, byteCount: byteCount) + } +} diff --git a/Packages/iOS/CmuxMobileShellModel/Tests/CmuxMobileShellModelTests/MobileTaskAgentProviderTests.swift b/Packages/iOS/CmuxMobileShellModel/Tests/CmuxMobileShellModelTests/MobileTaskAgentProviderTests.swift new file mode 100644 index 000000000000..d1cc4b22817d --- /dev/null +++ b/Packages/iOS/CmuxMobileShellModel/Tests/CmuxMobileShellModelTests/MobileTaskAgentProviderTests.swift @@ -0,0 +1,281 @@ +import Testing +@testable import CmuxMobileShellModel + +@Suite struct MobileTaskAgentProviderTests { + @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(MobileTaskAgentProvider(command: 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(MobileTaskAgentProvider(command: command) == nil) + } + + @Test func codexModelsStartWithLuna() { + #expect( + MobileTaskAgentProvider.codex.models == [ + 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 func validatesCuratedModelIdentifier() { + #expect(MobileTaskAgentProvider.claude.model(id: "claude-opus-4-8")?.displayName == "Opus 4.8") + #expect(MobileTaskAgentProvider.claude.model(id: "gpt-5.5") == 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) { + let provider = MobileTaskAgentProvider(command: command) + #expect(provider?.command(applying: modelID, to: command) == expected) + } + + @Test func singleQuoteInModelIdentifierIsShellEscaped() { + #expect( + MobileTaskAgentProvider.claude.command(applying: "one'two", to: "claude") + == "claude --model 'one'\\''two'" + ) + } + + @Test func preservesRestOfMultilineCommandByteForByte() { + let command = "claude\t-- \"$CMUX_TASK_PROMPT\"\n printf 'done' \n" + #expect( + MobileTaskAgentProvider.claude.command( + applying: "claude-sonnet-5", + to: command + ) == "claude --model 'claude-sonnet-5'\t-- \"$CMUX_TASK_PROMPT\"\n printf 'done' \n" + ) + } + + @Test( + arguments: [ + ( + "claude --model claude-sonnet-4-5 --verbose -- \"$CMUX_TASK_PROMPT\"", + "claude-opus-4-8", + "claude --model 'claude-opus-4-8' --verbose -- \"$CMUX_TASK_PROMPT\"" + ), + ( + "codex -m old-model -- \"$CMUX_TASK_PROMPT\"", + "gpt-5.6-sol", + "codex -m 'gpt-5.6-sol' -- \"$CMUX_TASK_PROMPT\"" + ), + ( + "codex --model old-model -- \"$CMUX_TASK_PROMPT\"", + "gpt-5.6-sol", + "codex --model 'gpt-5.6-sol' -- \"$CMUX_TASK_PROMPT\"" + ), + ( + "claude --model=claude-sonnet-4-5 -- \"$CMUX_TASK_PROMPT\"", + "claude-opus-4-8", + "claude --model='claude-opus-4-8' -- \"$CMUX_TASK_PROMPT\"" + ), + ] + ) + func replacesExistingModelFlagValue(command: String, modelID: String, expected: String) { + let provider = MobileTaskAgentProvider(command: command) + #expect(provider?.command(applying: modelID, to: command) == expected) + } + + @Test func appendsValueForDanglingModelFlag() { + #expect( + MobileTaskAgentProvider.claude.command(applying: "claude-opus-4-8", to: "claude --model") + == "claude --model 'claude-opus-4-8'" + ) + } + + @Test func ignoresModelFlagAfterEndOfOptions() { + let command = "claude -- --model literal-arg" + #expect( + MobileTaskAgentProvider.claude.command(applying: "claude-opus-4-8", to: command) + == "claude --model 'claude-opus-4-8' -- --model literal-arg" + ) + } + + @Test func claudeDoesNotTreatShortMAsModelFlag() { + let command = "claude -m other -- \"$CMUX_TASK_PROMPT\"" + #expect( + MobileTaskAgentProvider.claude.command(applying: "claude-opus-4-8", to: command) + == "claude --model 'claude-opus-4-8' -m other -- \"$CMUX_TASK_PROMPT\"" + ) + } + + @Test func flagTextInsideQuotedArgumentIsNotRewritten() { + let command = "claude --append-system-prompt \"Never pass --model manually\" -- \"$CMUX_TASK_PROMPT\"" + #expect( + MobileTaskAgentProvider.claude.command(applying: "claude-opus-4-8", to: command) + == "claude --model 'claude-opus-4-8' --append-system-prompt \"Never pass --model manually\" -- \"$CMUX_TASK_PROMPT\"" + ) + } + + @Test func flagTextInsideSingleQuotedArgumentIsNotRewritten() { + let command = "codex --note 'use -m sparingly' -- \"$CMUX_TASK_PROMPT\"" + #expect( + MobileTaskAgentProvider.codex.command(applying: "gpt-5.5", to: command) + == "codex -m 'gpt-5.5' --note 'use -m sparingly' -- \"$CMUX_TASK_PROMPT\"" + ) + } + + @Test func replacesEveryModelFlagBeforeEndOfOptions() { + let command = "codex -m first --model second -- \"$CMUX_TASK_PROMPT\"" + #expect( + MobileTaskAgentProvider.codex.command(applying: "gpt-5.6-sol", to: command) + == "codex -m 'gpt-5.6-sol' --model 'gpt-5.6-sol' -- \"$CMUX_TASK_PROMPT\"" + ) + } + + @Test func suppliesValueWhenFlagSitsDirectlyBeforeEndOfOptions() { + let command = "claude --model -- \"$CMUX_TASK_PROMPT\"" + #expect( + MobileTaskAgentProvider.claude.command(applying: "claude-opus-4-8", to: command) + == "claude --model 'claude-opus-4-8' -- \"$CMUX_TASK_PROMPT\"" + ) + } + + @Test func replacesQuotedFlagValueAsOneToken() { + let command = "claude --model \"old model\" -- \"$CMUX_TASK_PROMPT\"" + #expect( + MobileTaskAgentProvider.claude.command(applying: "claude-opus-4-8", to: command) + == "claude --model 'claude-opus-4-8' -- \"$CMUX_TASK_PROMPT\"" + ) + } + + @Test( + arguments: [ + ( + "claude \"$CMUX_TASK_PROMPT\"; formatter --model compact", + "claude --model 'claude-opus-4-8' \"$CMUX_TASK_PROMPT\"; formatter --model compact" + ), + ( + "claude && claude-lint --model strict", + "claude --model 'claude-opus-4-8' && claude-lint --model strict" + ), + ( + "claude | tee log --model x", + "claude --model 'claude-opus-4-8' | tee log --model x" + ), + ( + "claude\nrun-report --model compact", + "claude --model 'claude-opus-4-8'\nrun-report --model compact" + ), + ] + ) + func doesNotRewriteFlagsPastACommandBoundary(command: String, expected: String) { + #expect( + MobileTaskAgentProvider.claude.command(applying: "claude-opus-4-8", to: command) + == expected + ) + } + + @Test func suppliesValueWhenFlagDanglesAtCommandBoundary() { + let command = "claude --model\nrun-report compact" + #expect( + MobileTaskAgentProvider.claude.command(applying: "claude-opus-4-8", to: command) + == "claude --model 'claude-opus-4-8'\nrun-report compact" + ) + } + + @Test( + arguments: [ + ( + "claude \"$CMUX_TASK_PROMPT\" --model=old; notify done", + "claude \"$CMUX_TASK_PROMPT\" --model='claude-opus-4-8'; notify done" + ), + ( + "claude --model old; notify done", + "claude --model 'claude-opus-4-8'; notify done" + ), + ( + "claude --model; notify done", + "claude --model 'claude-opus-4-8'; notify done" + ), + ] + ) + func rewritesFlagAttachedToACommandSeparator(command: String, expected: String) { + #expect( + MobileTaskAgentProvider.claude.command(applying: "claude-opus-4-8", to: command) + == expected + ) + } + + @Test( + arguments: [ + ( + "codex 2>&1 --model old -- \"$CMUX_TASK_PROMPT\"", + "codex 2>&1 --model 'gpt-5.5' -- \"$CMUX_TASK_PROMPT\"" + ), + ( + "codex >&2 --model old", + "codex >&2 --model 'gpt-5.5'" + ), + ( + "codex &>log --model old", + "codex &>log --model 'gpt-5.5'" + ), + ( + "codex >|log --model old", + "codex >|log --model 'gpt-5.5'" + ), + ] + ) + func redirectionOperatorsAreNotCommandBoundaries(command: String, expected: String) { + #expect( + MobileTaskAgentProvider.codex.command(applying: "gpt-5.5", to: command) + == expected + ) + } + + @Test func commentedFlagIsNotRewritten() { + let command = "claude # --model old" + #expect( + MobileTaskAgentProvider.claude.command(applying: "claude-opus-4-8", to: command) + == "claude --model 'claude-opus-4-8' # --model old" + ) + } + + @Test func backgroundAndPipeOperatorsStillEndTheScan() { + #expect( + MobileTaskAgentProvider.codex.command(applying: "gpt-5.5", to: "codex & tail --model x") + == "codex -m 'gpt-5.5' & tail --model x" + ) + #expect( + MobileTaskAgentProvider.codex.command(applying: "gpt-5.5", to: "codex | tee --model x") + == "codex -m 'gpt-5.5' | tee --model x" + ) + } +} diff --git a/Packages/iOS/CmuxMobileShellModel/Tests/CmuxMobileShellModelTests/MobileTaskCommandComposerTests.swift b/Packages/iOS/CmuxMobileShellModel/Tests/CmuxMobileShellModelTests/MobileTaskCommandComposerTests.swift index 2396c21f2198..5292681a26b5 100644 --- a/Packages/iOS/CmuxMobileShellModel/Tests/CmuxMobileShellModelTests/MobileTaskCommandComposerTests.swift +++ b/Packages/iOS/CmuxMobileShellModel/Tests/CmuxMobileShellModelTests/MobileTaskCommandComposerTests.swift @@ -61,6 +61,123 @@ 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 attachmentPathsPopulateEnvironmentAndPromptSuffix() { + let template = MobileTaskTemplate( + name: "Codex", + icon: "agent:codex", + command: "codex -- \"$CMUX_TASK_PROMPT\"" + ) + let result = composer.compose( + template: template, + prompt: " Investigate the failure ", + attachmentPaths: ["/tmp/one.png", "/tmp/two notes.txt"] + ) + + #expect(result.initialCommand == template.command) + #expect(result.initialEnv == [ + "CMUX_TASK_ATTACHMENTS": "/tmp/one.png\n/tmp/two notes.txt", + "CMUX_TASK_PROMPT": """ + Investigate the failure + + Attached files (absolute paths on this machine): + - /tmp/one.png + - /tmp/two notes.txt + """, + ]) + } + + @Test func emptyAttachmentPathsPreservePreviousCompositionExactly() { + let template = MobileTaskTemplate( + name: "Codex", + icon: "agent:codex", + command: "codex -- \"$CMUX_TASK_PROMPT\"" + ) + let baseline = composer.compose(template: template, prompt: "Ship it") + let explicitEmpty = composer.compose( + template: template, + prompt: "Ship it", + attachmentPaths: [] + ) + + #expect(explicitEmpty == baseline) + } + + @Test func plainShellIgnoresAttachmentPaths() { + let template = MobileTaskTemplate(name: "Shell", icon: "terminal", command: "") + let result = composer.compose( + template: template, + prompt: "Inspect this", + attachmentPaths: ["/tmp/evidence.txt"] + ) + + #expect(result.initialCommand == nil) + #expect(result.initialEnv.isEmpty) + } + + @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 +216,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/MobileTaskModelAvailabilityTests.swift b/Packages/iOS/CmuxMobileShellModel/Tests/CmuxMobileShellModelTests/MobileTaskModelAvailabilityTests.swift new file mode 100644 index 000000000000..019516999d93 --- /dev/null +++ b/Packages/iOS/CmuxMobileShellModel/Tests/CmuxMobileShellModelTests/MobileTaskModelAvailabilityTests.swift @@ -0,0 +1,63 @@ +import Foundation +import Testing +@testable import CmuxMobileShellModel + +@Suite("Mobile task model availability") +struct MobileTaskModelAvailabilityTests { + private let template = MobileTaskTemplate( + id: UUID(), + name: "Codex", + icon: "terminal", + command: "codex" + ) + + @Test func discoveredModelsDriveDisplayAndValidation() { + let discovered = [ + MobileTaskAgentModel(id: "gpt-private", displayName: "gpt-private"), + ] + let availability = MobileTaskModelAvailability( + template: template, + discoveredModels: discovered + ) + + #expect(availability.models == discovered) + #expect(availability.validatedModelID("gpt-private") == "gpt-private") + #expect(availability.validatedModelID("gpt-5.6-sol") == nil) + } + + @Test func missingDiscoveryFallsBackToCuratedModels() { + let availability = MobileTaskModelAvailability( + template: template, + discoveredModels: nil + ) + + #expect(availability.models == MobileTaskAgentProvider.codex.models) + #expect( + availability.validatedModelID("gpt-5.6-sol") + == "gpt-5.6-sol" + ) + #expect(availability.validatedModelID("unknown") == nil) + } + + @Test func previouslyValidDraftSelectionSurvivesDelistingWithRawName() { + let refreshed = MobileTaskModelAvailability( + template: template, + discoveredModels: [ + MobileTaskAgentModel(id: "new-model", displayName: "New Model"), + ] + ) + + let restored = refreshed.validatedModelID( + "delisted-model", + previouslyValidModelID: "delisted-model" + ) + #expect(restored == "delisted-model") + #expect( + refreshed.selectedModel(id: restored) + == MobileTaskAgentModel( + id: "delisted-model", + displayName: "delisted-model" + ) + ) + } +} diff --git a/Packages/iOS/CmuxMobileShellModel/Tests/CmuxMobileShellModelTests/MobileTaskSubmissionSnapshotTests.swift b/Packages/iOS/CmuxMobileShellModel/Tests/CmuxMobileShellModelTests/MobileTaskSubmissionSnapshotTests.swift index c6516d114ae7..513caad277b5 100644 --- a/Packages/iOS/CmuxMobileShellModel/Tests/CmuxMobileShellModelTests/MobileTaskSubmissionSnapshotTests.swift +++ b/Packages/iOS/CmuxMobileShellModel/Tests/CmuxMobileShellModelTests/MobileTaskSubmissionSnapshotTests.swift @@ -333,6 +333,98 @@ 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)) + } + + @Test func attachmentChangesRotateRequestIdentity() { + let template = MobileTaskTemplate(name: "Codex", icon: "agent:codex", command: "codex") + let uploadID = UUID() + let withoutAttachment = snapshot(template: template) + let withAttachment = snapshot( + template: template, + attachments: [ + MobileTaskSubmissionAttachment(uploadID: uploadID, byteCount: 42), + ] + ) + let changedBytes = snapshot( + template: template, + attachments: [ + MobileTaskSubmissionAttachment(uploadID: uploadID, byteCount: 43), + ] + ) + + #expect(!withoutAttachment.isRequestEquivalent(to: withAttachment)) + #expect(!withAttachment.isRequestEquivalent(to: changedBytes)) + expectIdentityRotated(from: withoutAttachment, to: withAttachment) + } + + @Test func identicalAttachmentListsPreserveRequestIdentity() { + let template = MobileTaskTemplate(name: "Codex", icon: "agent:codex", command: "codex") + let attachments = [ + MobileTaskSubmissionAttachment(uploadID: UUID(), byteCount: 42), + MobileTaskSubmissionAttachment(uploadID: UUID(), byteCount: 99), + ] + let before = snapshot(template: template, attachments: attachments) + let after = snapshot(template: template, attachments: attachments) + + #expect(before.isRequestEquivalent(to: after)) + expectIdentityPreserved(from: before, to: after) + } + + @Test(arguments: [ + (0, 3, [0..<0]), + (6, 3, [0..<3, 3..<6]), + (7, 3, [0..<3, 3..<6, 6..<7]), + ]) + func attachmentChunkPlanBoundaries( + totalBytes: Int, + chunkBytes: Int, + expected: [Range] + ) { + let plan = MobileTaskAttachmentChunkPlan( + totalByteCount: totalBytes, + chunkByteCount: chunkBytes + ) + + #expect(plan.ranges == expected) + } + private func expectIdentityPreserved( from before: MobileTaskSubmissionSnapshot?, to after: MobileTaskSubmissionSnapshot? @@ -415,16 +507,20 @@ import Testing macDeviceID: String = "mac-a", macInstanceTag: String? = nil, directory: String = "~/cmux", - workspaceName: String = "" + workspaceName: String = "", + modelID: String? = nil, + attachments: [MobileTaskSubmissionAttachment] = [] ) -> MobileTaskSubmissionSnapshot { MobileTaskSubmissionSnapshot( template: template, prompt: prompt, + modelID: modelID, macDeviceID: macDeviceID, macInstanceTag: macInstanceTag, directory: directory, workspaceName: workspaceName, didEditDirectory: false, + attachments: attachments, operationID: UUID() ) } diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/Debug/TaskComposer/TaskComposerAccessibilityPreviewView.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/Debug/TaskComposer/TaskComposerAccessibilityPreviewView.swift index 6dedff6cfbb4..6f0f2961142b 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/Debug/TaskComposer/TaskComposerAccessibilityPreviewView.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/Debug/TaskComposer/TaskComposerAccessibilityPreviewView.swift @@ -51,6 +51,16 @@ public struct TaskComposerAccessibilityPreviewView: View { "CMUX_UITEST_TASK_COMPOSER_OPEN_DIRECTORY_PREVIEW" ] == "1" let templateStore = TaskComposerAccessibilityTemplateStore() + if environment["CMUX_UITEST_TASK_COMPOSER_LONG_PROMPT"] == "1" { + templateStore.setComposerDraft(MobileTaskComposerDraft( + prompt: Self.longPrompt, + templateID: templateStore.listTemplates().first?.id, + macDeviceID: Self.previewMac.macDeviceID, + macInstanceTag: Self.previewMac.instanceTag, + directory: "~", + didEditDirectory: false + )) + } if presentsOpenDirectory { templateStore.setLastDirectory( "/Users/ui/previous-task", @@ -198,6 +208,10 @@ public struct TaskComposerAccessibilityPreviewView: View { instanceTag: "nightly" ) + private static let longPrompt = (1...80) + .map { "Prompt line \($0): keep the editor scroll gesture inside the text canvas." } + .joined(separator: "\n") + private static let stablePreviewMac = MobilePairedMac( macDeviceID: "task-composer-preview-mac", displayName: "Preview Mac", diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileDisplaySettings.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileDisplaySettings.swift index e0589ff79d9f..d56747747ba4 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileDisplaySettings.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileDisplaySettings.swift @@ -1,4 +1,5 @@ import CMUXMobileCore +import CmuxMobileSupport import Foundation import Observation @@ -31,6 +32,8 @@ public final class MobileDisplaySettings { private static let workspacePreviewLineCountKey = "cmux.mobile.workspacePreviewLineCount" private static let unreadIndicatorLeftShiftKey = "cmux.mobile.debug.unreadIndicatorLeftShift.v2" #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 @@ -137,6 +140,26 @@ 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 { + defaults.set( + taskComposerModelPickerVariant.rawValue, + forKey: Self.taskComposerModelPickerVariantKey + ) + } + } + /// Persisted selection for the debug-only Shell icon lab. var taskComposerShellIconVariant: TaskComposerShellIconVariant { didSet { @@ -147,17 +170,27 @@ 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. var taskComposerShellIconVariant: TaskComposerShellIconVariant { .current } #endif /// Creates the display settings, seeding stored values from `defaults`. - /// - Parameter defaults: The store backing the persisted preferences. - /// Defaults to `.standard`; tests pass a scoped suite. Stored properties - /// are initialized from `defaults`; absent keys read as their default - /// (single-line titles, enabled folder taps, hidden missing files, two - /// preview lines) without a write. - public init(defaults: UserDefaults = .standard) { + /// - Parameters: + /// - defaults: The store backing the persisted preferences. + /// Defaults to `.standard`; tests pass a scoped suite. Stored properties + /// are initialized from `defaults`; absent keys read as their default + /// (single-line titles, enabled folder taps, hidden missing files, two + /// preview lines) without a write. + /// - environment: The process environment consulted for the DEBUG-only + /// task-composer lab fallbacks; tests pass an explicit dictionary. + public init( + defaults: UserDefaults = .standard, + environment: [String: String] = ProcessInfo.processInfo.environment + ) { let haptics = MobileHapticFeedback(defaults: defaults) self.defaults = defaults self.haptics = haptics @@ -179,12 +212,50 @@ public final class MobileDisplaySettings { to: Self.unreadIndicatorLeftShiftRange ) #if DEBUG + self.taskComposerLayoutStyle = defaults.string( + forKey: Self.taskComposerLayoutStyleKey + ).flatMap(TaskComposerLayoutStyle.init(rawValue:)) + ?? Self.debugDefaultTaskComposerLayoutStyle(environment: environment) + self.taskComposerModelPickerVariant = defaults.string( + forKey: Self.taskComposerModelPickerVariantKey + ).flatMap(TaskComposerModelPickerVariant.init(rawValue:)) + ?? Self.debugDefaultTaskComposerModelPickerVariant(environment: environment) self.taskComposerShellIconVariant = defaults.string( forKey: Self.taskComposerShellIconVariantKey ).flatMap(TaskComposerShellIconVariant.init(rawValue:)) ?? .current #endif } + #if DEBUG + /// The layout fallback when nothing is persisted. The task-composer + /// accessibility preview pins the shipping classic layout so the XCUITest + /// suite keeps a stable hierarchy; `CMUX_UITEST_TASK_COMPOSER_LAYOUT` opts + /// a test or screenshot run into another layout explicitly. + static func debugDefaultTaskComposerLayoutStyle( + environment: [String: String] + ) -> TaskComposerLayoutStyle { + if let style = environment["CMUX_UITEST_TASK_COMPOSER_LAYOUT"] + .flatMap(TaskComposerLayoutStyle.init(rawValue:)) { + return style + } + return UITestConfig.taskComposerPreviewEnabled(from: environment) ? .classic : .composer + } + + /// The model-picker fallback when nothing is persisted. The task-composer + /// accessibility preview pins the shipping off state (the combined variant + /// changes the agent menu's element tree); + /// `CMUX_UITEST_TASK_COMPOSER_MODEL_VARIANT` opts a test into a variant. + static func debugDefaultTaskComposerModelPickerVariant( + environment: [String: String] + ) -> TaskComposerModelPickerVariant { + if let variant = environment["CMUX_UITEST_TASK_COMPOSER_MODEL_VARIANT"] + .flatMap(TaskComposerModelPickerVariant.init(rawValue:)) { + return variant + } + return UITestConfig.taskComposerPreviewEnabled(from: environment) ? .off : .combined + } + #endif + /// Clamps a stored or assigned preview line count to the supported range. /// A static member (not a file-scope func) because the package-conventions /// linter forbids free functions in the mobile packages. diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileSettingsView.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileSettingsView.swift index 6b23ea6dbbe2..c01044c470d1 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileSettingsView.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileSettingsView.swift @@ -333,6 +333,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..c09fc51c1930 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TaskComposer/TaskComposerAgentMenu.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TaskComposer/TaskComposerAgentMenu.swift @@ -19,41 +19,19 @@ struct TaskComposerAgentMenu: View, Equatable { } } + private var selectedModel: MobileTaskAgentModel? { + guard value.modelPickerVariant.renderedVariant == .combined, + let selectedModelID = value.selectedModelID else { return nil } + return value.models.first { $0.id == selectedModelID } + ?? MobileTaskAgentModel( + id: selectedModelID, + displayName: selectedModelID + ) + } + var body: some View { Menu { - 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 { @@ -103,25 +81,29 @@ 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") } 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..8544d5f8f97e 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TaskComposer/TaskComposerAgentMenuActions.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TaskComposer/TaskComposerAgentMenuActions.swift @@ -3,6 +3,9 @@ import CmuxMobileShellModel struct TaskComposerAgentMenuActions { let selectTemplate: (MobileTaskTemplate.ID) -> Void + /// Applies a template + model choice as one atomic composer mutation so a + /// combined-menu tap reconciles the submission request exactly once. + let selectTemplateAndModel: (MobileTaskTemplate.ID, String?) -> Void let editTemplates: () -> Void } #endif 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..e9d80aa03ef9 --- /dev/null +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TaskComposer/TaskComposerAgentMenuContent.swift @@ -0,0 +1,125 @@ +#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 = template.id == value.selectedTemplateID + ? value.models + : MobileTaskAgentProvider(command: template.command)?.models ?? [] + 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.selectTemplateAndModel(template.id, nil) + } label: { + modelChoiceLabel( + L10n.string( + "mobile.taskComposer.model.default", + defaultValue: "Default" + ), + isSelected: template.id == value.selectedTemplateID + && value.selectedModelID == nil + ) + } + .accessibilityAddTraits( + template.id == value.selectedTemplateID + && value.selectedModelID == nil ? .isSelected : [] + ) + .accessibilityIdentifier( + "MobileTaskComposerAgentModel-\(template.id)-default" + ) + + ForEach(models) { model in + Button { + actions.selectTemplateAndModel(template.id, model.id) + } label: { + modelChoiceLabel( + model.displayName, + isSelected: template.id == value.selectedTemplateID + && model.id == value.selectedModelID + ) + } + .accessibilityAddTraits( + template.id == value.selectedTemplateID + && model.id == value.selectedModelID ? .isSelected : [] + ) + .accessibilityIdentifier( + "MobileTaskComposerAgentModel-\(template.id)-\(model.id)" + ) + } + } label: { + Text(template.name) + } + .accessibilityIdentifier("MobileTaskComposerAgentSubmenu-\(template.id)") + } + } + } + + /// A menu row with a visible checkmark on the current choice, mirroring + /// the native Picker treatment the submenu cannot use (its selection + /// spans template + model). + @ViewBuilder + private func modelChoiceLabel(_ title: String, isSelected: Bool) -> some View { + if isSelected { + Label(title, systemImage: "checkmark") + } else { + Text(verbatim: title) + } + } +} +#endif diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TaskComposer/TaskComposerAgentMenuValue.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TaskComposer/TaskComposerAgentMenuValue.swift index f0b97fdae95e..83b71cc0b0c0 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TaskComposer/TaskComposerAgentMenuValue.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TaskComposer/TaskComposerAgentMenuValue.swift @@ -4,6 +4,9 @@ import CmuxMobileShellModel struct TaskComposerAgentMenuValue: Equatable { let templates: [MobileTaskTemplate] let selectedTemplateID: MobileTaskTemplate.ID? + let modelPickerVariant: TaskComposerModelPickerVariant + let models: [MobileTaskAgentModel] + let selectedModelID: String? let isDisabled: Bool } #endif diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TaskComposer/TaskComposerAttachmentPickerMenu.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TaskComposer/TaskComposerAttachmentPickerMenu.swift new file mode 100644 index 000000000000..0f47452286e6 --- /dev/null +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TaskComposer/TaskComposerAttachmentPickerMenu.swift @@ -0,0 +1,62 @@ +#if os(iOS) +import CmuxMobileSupport +import SwiftUI + +/// Shared attachment-source menu rendered by classic and minimal composers. +struct TaskComposerAttachmentPickerMenu: View { + enum Style { + case circularPlus + case paperclip + } + + let style: Style + let isDisabled: Bool + let choosePhotos: () -> Void + let chooseFiles: () -> Void + + var body: some View { + Menu { + Button(action: choosePhotos) { + Label( + L10n.string( + "mobile.taskComposer.attachments.photoLibrary", + defaultValue: "Photo Library" + ), + systemImage: "photo.on.rectangle" + ) + } + Button(action: chooseFiles) { + Label( + L10n.string( + "mobile.taskComposer.attachments.chooseFiles", + defaultValue: "Choose Files" + ), + systemImage: "folder" + ) + } + } label: { + switch style { + case .circularPlus: + Image(systemName: "plus") + .font(.system(size: 15, weight: .semibold)) + .frame(width: 38, height: 38) + .background(Color.primary.opacity(0.07), in: Circle()) + .frame(width: 44, height: 44) + .contentShape(Rectangle()) + case .paperclip: + Image(systemName: "paperclip") + .font(.body.weight(.semibold)) + .frame(width: 44, height: 44) + .contentShape(Rectangle()) + } + } + .buttonStyle(.plain) + .disabled(isDisabled) + .accessibilityLabel(L10n.string( + "mobile.taskComposer.attachments.add", + defaultValue: "Add Attachment" + )) + .accessibilityIdentifier("MobileTaskComposerAttachmentButton") + } +} +#endif diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TaskComposer/TaskComposerAttachmentPickerModifier.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TaskComposer/TaskComposerAttachmentPickerModifier.swift new file mode 100644 index 000000000000..cd3eb5bff67d --- /dev/null +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TaskComposer/TaskComposerAttachmentPickerModifier.swift @@ -0,0 +1,35 @@ +#if os(iOS) +import PhotosUI +import SwiftUI +import UniformTypeIdentifiers + +/// Hosts the system photo and file pickers shared by both composer layouts. +struct TaskComposerAttachmentPickerModifier: ViewModifier { + @Binding var isPhotoPickerPresented: Bool + @Binding var photoSelection: [PhotosPickerItem] + @Binding var isFileImporterPresented: Bool + let remainingCount: Int + let selectedPhotos: ([PhotosPickerItem]) -> Void + let selectedFiles: (Result<[URL], any Error>) -> Void + + func body(content: Content) -> some View { + content + .photosPicker( + isPresented: $isPhotoPickerPresented, + selection: $photoSelection, + maxSelectionCount: max(remainingCount, 1), + matching: .images + ) + .fileImporter( + isPresented: $isFileImporterPresented, + allowedContentTypes: [.item], + allowsMultipleSelection: true, + onCompletion: selectedFiles + ) + .onChange(of: photoSelection) { _, items in + guard !items.isEmpty else { return } + selectedPhotos(items) + } + } +} +#endif diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TaskComposer/TaskComposerAttachmentStager.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TaskComposer/TaskComposerAttachmentStager.swift new file mode 100644 index 000000000000..bf5f31c1999a --- /dev/null +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TaskComposer/TaskComposerAttachmentStager.swift @@ -0,0 +1,124 @@ +#if os(iOS) +import CmuxMobileShellModel +import Foundation + +/// Copies picked files into app-owned temporary storage for one composer session. +struct TaskComposerAttachmentStager: Sendable { + enum StagingError: Error { + case imageRejected + case fileTooLarge + case unreadableFile + } + + func stageImage( + at sourceURL: URL, + originalFileName: String + ) async throws -> TaskComposerAttachment { + let sourceByteCount = try sourceFileByteCount(at: sourceURL) + guard sourceByteCount <= MobileImageAttachmentPreparer.maximumRawInputBytes else { + throw StagingError.imageRejected + } + guard let prepared = await MobileImageAttachmentPreparer().prepare(url: sourceURL), + prepared.data.count <= TaskComposerAttachment.maximumImageBytes else { + throw StagingError.imageRejected + } + let displayName = imageDisplayName( + originalFileName: originalFileName, + format: prepared.format + ) + let stagedURL = temporaryURL(fileExtension: prepared.format) + do { + try prepared.data.write(to: stagedURL, options: .atomic) + } catch { + throw StagingError.unreadableFile + } + return TaskComposerAttachment( + kind: .image, + displayName: displayName, + localStagedFileURL: stagedURL, + byteCount: prepared.data.count, + thumbnailData: prepared.thumbnailData + ) + } + + func stageFile(at sourceURL: URL) async throws -> TaskComposerAttachment { + try await withThrowingTaskGroup(of: TaskComposerAttachment.self) { group in + group.addTask(priority: .utility) { + let hasSecurityScope = sourceURL.startAccessingSecurityScopedResource() + defer { + if hasSecurityScope { + sourceURL.stopAccessingSecurityScopedResource() + } + } + let byteCount: Int + do { + let values = try sourceURL.resourceValues( + forKeys: [.fileSizeKey, .isRegularFileKey] + ) + guard values.isRegularFile == true, let size = values.fileSize else { + throw StagingError.unreadableFile + } + byteCount = size + } catch let error as StagingError { + throw error + } catch { + throw StagingError.unreadableFile + } + guard byteCount <= TaskComposerAttachment.maximumFileBytes else { + throw StagingError.fileTooLarge + } + let destination = temporaryURL( + fileExtension: sourceURL.pathExtension + ) + do { + try FileManager.default.copyItem( + at: sourceURL, + to: destination + ) + } catch { + try? FileManager.default.removeItem(at: destination) + throw StagingError.unreadableFile + } + return TaskComposerAttachment( + kind: .file, + displayName: sourceURL.lastPathComponent, + localStagedFileURL: destination, + byteCount: byteCount + ) + } + guard let attachment = try await group.next() else { + throw CancellationError() + } + return attachment + } + } + + private func sourceFileByteCount(at url: URL) throws -> Int { + guard let byteCount = try url.resourceValues( + forKeys: [.fileSizeKey] + ).fileSize else { + throw StagingError.unreadableFile + } + return byteCount + } + + private func imageDisplayName( + originalFileName: String, + format: String + ) -> String { + let original = originalFileName as NSString + let rawStem = original.deletingPathExtension + .trimmingCharacters(in: .whitespacesAndNewlines) + let stem = rawStem.isEmpty ? UUID().uuidString : rawStem + return "\(stem).\(format)" + } + + private func temporaryURL(fileExtension: String) -> URL { + let suffix = fileExtension.isEmpty ? "" : ".\(fileExtension)" + return FileManager.default.temporaryDirectory + .appendingPathComponent( + "cmux-task-attachment-\(UUID().uuidString)\(suffix)" + ) + } +} +#endif diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TaskComposer/TaskComposerAttachmentStrip.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TaskComposer/TaskComposerAttachmentStrip.swift new file mode 100644 index 000000000000..78018757910a --- /dev/null +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TaskComposer/TaskComposerAttachmentStrip.swift @@ -0,0 +1,101 @@ +#if os(iOS) +import CmuxMobileShellModel +import CmuxMobileSupport +import SwiftUI +import UIKit + +/// Shared horizontal strip of staged New Task attachments. +struct TaskComposerAttachmentStrip: View { + let attachments: [TaskComposerAttachment] + let isDisabled: Bool + let remove: (UUID) -> Void + + var body: some View { + ScrollView(.horizontal) { + HStack(spacing: 10) { + ForEach(attachments) { attachment in + chip(for: attachment) + .accessibilityElement(children: .combine) + .accessibilityLabel(attachment.displayName) + .accessibilityAction( + named: L10n.string( + "mobile.taskComposer.attachments.remove", + defaultValue: "Remove Attachment" + ) + ) { + remove(attachment.id) + } + } + } + .padding(.horizontal, 2) + } + .scrollIndicators(.hidden) + } + + @ViewBuilder + private func chip(for attachment: TaskComposerAttachment) -> some View { + ZStack(alignment: .topTrailing) { + switch attachment.kind { + case .image: + imageChip(attachment) + case .file: + fileChip(attachment) + } + + Button { + remove(attachment.id) + } label: { + Image(systemName: "xmark.circle.fill") + .font(.system(size: 18, weight: .semibold)) + .symbolRenderingMode(.palette) + .foregroundStyle(.white, .black.opacity(0.65)) + .frame(width: 22, height: 22) + } + .buttonStyle(.plain) + .disabled(isDisabled) + .accessibilityLabel(L10n.string( + "mobile.taskComposer.attachments.remove", + defaultValue: "Remove Attachment" + )) + .offset(x: 5, y: -5) + } + .padding(.top, 5) + .padding(.trailing, 5) + } + + private func imageChip(_ attachment: TaskComposerAttachment) -> some View { + Group { + if let thumbnailData = attachment.thumbnailData, + let image = UIImage(data: thumbnailData) { + Image(uiImage: image) + .resizable() + .scaledToFill() + } else { + Image(systemName: "photo") + .font(.title2) + .foregroundStyle(.secondary) + } + } + .frame(width: 56, height: 56) + .background(Color.primary.opacity(0.07)) + .clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous)) + } + + private func fileChip(_ attachment: TaskComposerAttachment) -> some View { + HStack(spacing: 7) { + Image(systemName: "doc") + .foregroundStyle(.secondary) + Text(attachment.displayName) + .font(.subheadline) + .lineLimit(1) + } + .padding(.horizontal, 10) + .frame(height: 56) + .frame(maxWidth: 190) + .background( + Color.primary.opacity(0.07), + in: RoundedRectangle(cornerRadius: 10, style: .continuous) + ) + } +} +#endif diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TaskComposer/TaskComposerContextSection.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TaskComposer/TaskComposerContextSection.swift index e17c2215c50c..0f8f34064057 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 @@ -10,10 +11,14 @@ struct TaskComposerContextSection: View { let selectedMacPairingID: String let buildLabelsByID: [String: String] let directory: String + let modelPickerVariant: TaskComposerModelPickerVariant + let models: [MobileTaskAgentModel] + let selectedModelID: String? let isDisabled: Bool let endWorkspaceNameEditing: () -> Void let selectMachine: (String, String?) -> Void let selectDirectory: () -> Void + let selectModel: (String?) -> Void var body: some View { VStack(spacing: 0) { @@ -35,6 +40,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/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..26c547ffeb48 --- /dev/null +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TaskComposer/TaskComposerMinimalLayout.swift @@ -0,0 +1,380 @@ +#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 attachments: [TaskComposerAttachment] + let showsAttachmentButton: Bool + /// Deferred builder: constructing the options sheet walks workspaces for + /// directory candidates, so it must not run on every keystroke's body + /// rebuild, only when Task Options is actually presented. + let optionsSheet: () -> TaskComposerOptionsSheet + let endEditing: () -> Void + let selectTemplate: (MobileTaskTemplate.ID) -> Void + let selectTemplateAndModel: (MobileTaskTemplate.ID, String?) -> Void + let selectModel: (String?) -> Void + let editTemplates: () -> Void + let cancel: () -> Void + let submit: () -> Void + let refreshCompletedOperation: () -> Void + let requestStartAgain: () -> Void + let chooseAttachmentPhotos: () -> Void + let chooseAttachmentFiles: () -> Void + let removeAttachment: (UUID) -> Void + + @State private var isPromptFocused = false + @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) + } + + TaskComposerPromptEditor( + text: $prompt, + isFocused: $isPromptFocused, + isDisabled: isDisabled, + accessibilityLabel: L10n.string( + "mobile.taskComposer.prompt", + defaultValue: "Prompt" + ), + accessibilityHint: promptPlaceholder + ) + .padding(.horizontal, 20) + .padding(.vertical, 18) + .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) + } + + if !attachments.isEmpty { + TaskComposerAttachmentStrip( + attachments: attachments, + isDisabled: isDisabled, + remove: removeAttachment + ) + .padding(.horizontal, 16) + } + + HStack(spacing: 10) { + if showsAttachmentButton { + TaskComposerAttachmentPickerMenu( + style: .circularPlus, + isDisabled: isDisabled, + choosePhotos: chooseAttachmentPhotos, + chooseFiles: chooseAttachmentFiles + ) + } + + optionsButton + + ScrollView(.horizontal) { + HStack(spacing: 8) { + agentPill + + if !models.isEmpty, showsStandaloneModelPill { + modelPill + } + } + } + .scrollIndicators(.hidden) + .frame(maxWidth: .infinity) + .accessibilityIdentifier("MobileTaskComposerPillScroller") + + submitButton + } + .padding(.horizontal, 16) + .frame(height: 44) + .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: { + // Adjustments glyph, not "+": the button configures the task + // (name, Mac, directory), it does not add anything. + Image(systemName: "slider.horizontal.3") + .font(.system(size: 15, weight: .semibold)) + .frame(width: 38, height: 38) + .background(Color.primary.opacity(0.07), in: Circle()) + // Keep the compact 38pt visual while honoring the composer's + // 44pt activation-target contract. + .frame(width: 44, height: 44) + .contentShape(Rectangle()) + } + .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(agentPillTitle(for: selectedTemplate)) + .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) + // A longer title must widen the capsule immediately; animating the + // frame clips the label against the stale width until it settles. + .fixedSize(horizontal: true, vertical: false) + .padding(.horizontal, 12) + .frame(minHeight: 38) + .background(Color.primary.opacity(0.07), in: Capsule()) + .frame(minHeight: 44) + .contentShape(Rectangle()) + } + .tint(Color.primary) + .disabled(isDisabled) + .accessibilityLabel(L10n.string("mobile.taskComposer.agent", defaultValue: "Agent")) + .accessibilityValue(selectedTemplate?.name ?? "") + .accessibilityHint(TaskComposerSheet.templateAccessibilityHint) + .accessibilityIdentifier("MobileTaskComposerAgentPill") + // Recreate the whole Menu when the title changes: the UIKit menu + // button otherwise animates its frame to the new width on iOS 26 and + // clips the label against the stale bounds until it settles. + .id(selectedTemplate.map(agentPillTitle(for:))) + } + + 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) + // See agentPill: adopt the new title's width immediately instead + // of animating (and clipping) into it. + .fixedSize(horizontal: true, vertical: false) + .padding(.horizontal, 12) + .frame(minHeight: 38) + .background(Color.primary.opacity(0.07), in: Capsule()) + .frame(minHeight: 44) + .contentShape(Rectangle()) + } + .tint(Color.primary) + .disabled(isDisabled) + .taskComposerModelAccessibility(valueName: selectedModelName) + .accessibilityIdentifier("MobileTaskComposerModelPill") + // See agentPill: identity-swap the Menu so the new title cannot be + // clipped by the old button frame mid-animation. + .id(selectedModelName) + } + + 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() + ) + .frame(width: 44, height: 44) + .contentShape(Rectangle()) + } + .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 func agentPillTitle(for template: MobileTaskTemplate) -> String { + template.name + } + + /// The composer layout has ONE canonical model treatment regardless of the + /// classic-layout lab variant: a dedicated pill beside the agent pill, + /// mirroring the reference composer. The agent menu stays plain (see + /// `agentMenuValue`), so the pill is the single model entry point. + private var showsStandaloneModelPill: Bool { + modelPickerVariant.renderedVariant != .off + } + + private var selectedModelName: String { + models.displayName(forSelected: selectedModelID) + } + + 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 { + // Force the plain (non-combined) agent menu: the standalone model + // pill is this layout's single model entry point, so the menu must + // not duplicate model submenus even when the classic-layout lab + // variant is `combined`. + TaskComposerAgentMenuValue( + templates: templates, + selectedTemplateID: selectedTemplateID, + modelPickerVariant: modelPickerVariant.renderedVariant == .combined + ? .separateRow + : modelPickerVariant, + models: models, + selectedModelID: selectedModelID, + isDisabled: isDisabled + ) + } + + private var agentMenuActions: TaskComposerAgentMenuActions { + TaskComposerAgentMenuActions( + selectTemplate: selectTemplate, + selectTemplateAndModel: selectTemplateAndModel, + editTemplates: editTemplates + ) + } +} +#endif 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..c0ac5e1d4c3d --- /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) + // Adopt a longer model name's width immediately; animating the + // capsule clips the label against the stale width. + .fixedSize(horizontal: true, vertical: false) + .padding(.horizontal, 10) + .frame(minHeight: 28) + .background(Color.primary.opacity(0.06), in: Capsule()) + .overlay { + Capsule() + .strokeBorder(Color.primary.opacity(0.09), lineWidth: 1) + } + // Keep the compact 28pt capsule while honoring the composer's + // 44pt activation-target contract. + .frame(minHeight: 44) + .contentShape(Rectangle()) + } + .disabled(isDisabled) + .taskComposerModelAccessibility(valueName: selectedModelName) + .accessibilityIdentifier("MobileTaskComposerModelChip") + } + + private var selectedModelName: String { + models.displayName(forSelected: selectedModelID) + } +} +#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..7aa48525e7dc --- /dev/null +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TaskComposer/TaskComposerModelContextRow.swift @@ -0,0 +1,49 @@ +#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) + .taskComposerModelAccessibility(valueName: selectedModelName) + .accessibilityIdentifier("MobileTaskComposerModelContextRow") + } + + private var selectedModelName: String { + models.displayName(forSelected: selectedModelID) + } +} +#endif diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TaskComposer/TaskComposerModelDisplay.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TaskComposer/TaskComposerModelDisplay.swift new file mode 100644 index 000000000000..d892bb172bc4 --- /dev/null +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TaskComposer/TaskComposerModelDisplay.swift @@ -0,0 +1,32 @@ +#if os(iOS) +import CmuxMobileShellModel +import CmuxMobileSupport +import SwiftUI + +extension Array where Element == MobileTaskAgentModel { + /// The selected model's display name, or the localized "Default" + /// fallback shared by every model-picker treatment. + func displayName(forSelected id: String?) -> String { + guard let id else { + return L10n.string( + "mobile.taskComposer.model.default", + defaultValue: "Default" + ) + } + return first { $0.id == id }?.displayName ?? id + } +} + +extension View { + /// The model-picker accessibility triple shared by every treatment so the + /// label, value, and hint copy cannot drift between variants. + func taskComposerModelAccessibility(valueName: String) -> some View { + accessibilityLabel(L10n.string("mobile.taskComposer.model", defaultValue: "Model")) + .accessibilityValue(valueName) + .accessibilityHint(L10n.string( + "mobile.taskComposer.model.accessibilityHint", + defaultValue: "Chooses the model this agent runs with." + )) + } +} +#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..e5e6cbef2179 --- /dev/null +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TaskComposer/TaskComposerModelPickerLabView.swift @@ -0,0 +1,102 @@ +#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(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 { + 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 layout and model 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..b7544385553e --- /dev/null +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TaskComposer/TaskComposerModelPillStrip.swift @@ -0,0 +1,73 @@ +#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 + ) + } + // Keep the compact 30pt capsule while honoring the composer's + // 44pt activation-target contract. + .frame(minHeight: 44) + .contentShape(Rectangle()) + } + .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/TaskComposerModelRefreshID.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TaskComposer/TaskComposerModelRefreshID.swift new file mode 100644 index 000000000000..297e7e263d1b --- /dev/null +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TaskComposer/TaskComposerModelRefreshID.swift @@ -0,0 +1,9 @@ +#if os(iOS) +import CmuxMobileShellModel + +/// Identity that restarts model discovery when provider or selected Mac changes. +struct TaskComposerModelRefreshID: Hashable { + let provider: MobileTaskAgentProvider? + let macPairingID: String +} +#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..4d504836a3ac --- /dev/null +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TaskComposer/TaskComposerModelRow.swift @@ -0,0 +1,57 @@ +#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: 44, alignment: .leading) + .contentShape(Rectangle()) + } + .disabled(isDisabled) + .taskComposerModelAccessibility(valueName: selectedModelName) + .accessibilityIdentifier("MobileTaskComposerModelRow") + } + + private var selectedModelName: String { + models.displayName(forSelected: selectedModelID) + } +} +#endif 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..4d54b4df9385 --- /dev/null +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TaskComposer/TaskComposerOptionsSheet.swift @@ -0,0 +1,95 @@ +#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 selectedMacPairingID: String + let buildLabelsByID: [String: 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, 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, + selectedMacPairingID: selectedMacPairingID, + buildLabelsByID: buildLabelsByID, + 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 c58b14f07af8..c2595803033c 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TaskComposer/TaskComposerPrimaryAction.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TaskComposer/TaskComposerPrimaryAction.swift @@ -25,51 +25,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/TaskComposerPromptCard.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TaskComposer/TaskComposerPromptCard.swift index 813adf8033fa..1e7a379bd9e5 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TaskComposer/TaskComposerPromptCard.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TaskComposer/TaskComposerPromptCard.swift @@ -13,26 +13,44 @@ struct TaskComposerPromptCard: View { let endEditing: () -> Void let templates: [MobileTaskTemplate] let selectedTemplateID: MobileTaskTemplate.ID? + let modelPickerVariant: TaskComposerModelPickerVariant + let models: [MobileTaskAgentModel] + let selectedModelID: String? + let attachments: [TaskComposerAttachment] + let showsAttachmentButton: Bool let selectTemplate: (MobileTaskTemplate.ID) -> Void + let selectTemplateAndModel: (MobileTaskTemplate.ID, String?) -> Void + let selectModel: (String?) -> Void let editTemplates: () -> Void + let chooseAttachmentPhotos: () -> Void + let chooseAttachmentFiles: () -> Void + let removeAttachment: (UUID) -> 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) @@ -50,11 +68,67 @@ struct TaskComposerPromptCard: View { endEditing: endEditing ) + if !attachments.isEmpty { + TaskComposerAttachmentStrip( + attachments: attachments, + isDisabled: isDisabled, + remove: removeAttachment + ) + } } .padding(14) .mobileGlassField(cornerRadius: 26) } + @ViewBuilder + private var agentRow: some View { + HStack(spacing: 8) { + if !models.isEmpty, + modelPickerVariant.renderedVariant == .trailingChip { + agentMenu + .frame(maxWidth: .infinity, alignment: .leading) + + TaskComposerModelChip( + models: models, + selectedModelID: selectedModelID, + isDisabled: isDisabled, + selectModel: selectModel + ) + } else { + agentMenu + .frame(maxWidth: .infinity, alignment: .leading) + } + + if showsAttachmentButton { + TaskComposerAttachmentPickerMenu( + style: .paperclip, + isDisabled: isDisabled, + choosePhotos: chooseAttachmentPhotos, + chooseFiles: chooseAttachmentFiles + ) + } + } + } + + private var agentMenu: some View { + TaskComposerAgentMenu( + value: TaskComposerAgentMenuValue( + templates: templates, + selectedTemplateID: selectedTemplateID, + modelPickerVariant: modelPickerVariant, + models: models, + selectedModelID: selectedModelID, + isDisabled: isDisabled + ), + actions: TaskComposerAgentMenuActions( + selectTemplate: selectTemplate, + selectTemplateAndModel: selectTemplateAndModel, + editTemplates: editTemplates + ) + ) + .equatable() + } + private var promptLineLimit: ClosedRange { dynamicTypeSize.isAccessibilitySize ? 2...6 : 5...12 } diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TaskComposer/TaskComposerPromptEditor.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TaskComposer/TaskComposerPromptEditor.swift new file mode 100644 index 000000000000..dfb96d7888a8 --- /dev/null +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TaskComposer/TaskComposerPromptEditor.swift @@ -0,0 +1,74 @@ +#if os(iOS) +import SwiftUI +import UIKit + +/// A multiline prompt editor whose UIKit scroll position is not recreated by +/// unrelated SwiftUI updates, such as changing the selected task model. +struct TaskComposerPromptEditor: UIViewRepresentable { + @Binding var text: String + @Binding var isFocused: Bool + let isDisabled: Bool + let accessibilityLabel: String + let accessibilityHint: String + + func makeCoordinator() -> TaskComposerPromptEditorCoordinator { + TaskComposerPromptEditorCoordinator(text: $text, isFocused: $isFocused) + } + + func makeUIView(context: Context) -> TaskComposerPromptTextView { + let textView = TaskComposerPromptTextView() + textView.delegate = context.coordinator + textView.font = UIFont.preferredFont(forTextStyle: .title3) + textView.adjustsFontForContentSizeCategory = true + textView.textColor = .label + textView.backgroundColor = .clear + textView.textContainerInset = UIEdgeInsets(top: 8, left: 0, bottom: 8, right: 0) + textView.textContainer.lineFragmentPadding = 5 + textView.alwaysBounceVertical = true + textView.keyboardDismissMode = .none + textView.text = text + textView.accessibilityIdentifier = "MobileTaskComposerPrompt" + textView.accessibilityLabel = accessibilityLabel + textView.accessibilityHint = accessibilityHint + textView.restoreManualContentOffset = { [weak coordinator = context.coordinator, weak textView] in + guard let textView else { return } + coordinator?.restoreManualContentOffset(in: textView) + } + updateInteractionState(of: textView) + return textView + } + + func updateUIView(_ textView: TaskComposerPromptTextView, context: Context) { + context.coordinator.update(text: $text, isFocused: $isFocused) + textView.accessibilityLabel = accessibilityLabel + textView.accessibilityHint = accessibilityHint + updateInteractionState(of: textView) + + // Assigning the same text again resets UITextView's selection/caret + // layout and can scroll an active editor back to that caret. User edits + // already changed the backing UITextView, so only external mutations + // need an assignment here. + if textView.text != text { + let selection = textView.selectedRange + textView.text = text + let textLength = (text as NSString).length + let location = min(selection.location, textLength) + textView.selectedRange = NSRange( + location: location, + length: min(selection.length, textLength - location) + ) + } + + context.coordinator.restoreManualContentOffset(in: textView) + } + + private func updateInteractionState(of textView: UITextView) { + textView.isEditable = !isDisabled + textView.isSelectable = !isDisabled + textView.isUserInteractionEnabled = !isDisabled + if isDisabled, textView.isFirstResponder { + textView.resignFirstResponder() + } + } +} +#endif diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TaskComposer/TaskComposerPromptEditorCoordinator.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TaskComposer/TaskComposerPromptEditorCoordinator.swift new file mode 100644 index 000000000000..8dedccbc6130 --- /dev/null +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TaskComposer/TaskComposerPromptEditorCoordinator.swift @@ -0,0 +1,75 @@ +#if os(iOS) +import SwiftUI +import UIKit + +/// Bridges prompt text/focus and preserves a viewport explicitly chosen by a +/// drag until the user moves the caret or resumes typing. +@MainActor +final class TaskComposerPromptEditorCoordinator: NSObject, UITextViewDelegate { + private var text: Binding + private var isFocused: Binding + private var manualContentOffset: CGPoint? + + init(text: Binding, isFocused: Binding) { + self.text = text + self.isFocused = isFocused + super.init() + } + + func update(text: Binding, isFocused: Binding) { + self.text = text + self.isFocused = isFocused + } + + func textViewDidBeginEditing(_ textView: UITextView) { + if !isFocused.wrappedValue { + isFocused.wrappedValue = true + } + } + + func textViewDidEndEditing(_ textView: UITextView) { + if isFocused.wrappedValue { + isFocused.wrappedValue = false + } + } + + func textViewDidChange(_ textView: UITextView) { + manualContentOffset = nil + let newValue = textView.text ?? "" + if text.wrappedValue != newValue { + text.wrappedValue = newValue + } + } + + func textViewDidChangeSelection(_ textView: UITextView) { + guard !textView.isTracking, !textView.isDragging, !textView.isDecelerating else { return } + manualContentOffset = nil + } + + func scrollViewWillBeginDragging(_ scrollView: UIScrollView) { + manualContentOffset = scrollView.contentOffset + } + + func scrollViewDidScroll(_ scrollView: UIScrollView) { + guard scrollView.isTracking || scrollView.isDragging || scrollView.isDecelerating else { return } + manualContentOffset = scrollView.contentOffset + } + + func restoreManualContentOffset(in textView: UITextView) { + guard let manualContentOffset else { return } + let minimumY = -textView.adjustedContentInset.top + let maximumY = max( + minimumY, + textView.contentSize.height + - textView.bounds.height + + textView.adjustedContentInset.bottom + ) + let target = CGPoint( + x: manualContentOffset.x, + y: min(max(manualContentOffset.y, minimumY), maximumY) + ) + guard abs(textView.contentOffset.y - target.y) > 0.5 else { return } + textView.setContentOffset(target, animated: false) + } +} +#endif diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TaskComposer/TaskComposerPromptTextView.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TaskComposer/TaskComposerPromptTextView.swift new file mode 100644 index 000000000000..faee75c8a5ae --- /dev/null +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TaskComposer/TaskComposerPromptTextView.swift @@ -0,0 +1,15 @@ +#if os(iOS) +import UIKit + +/// Gives the prompt coordinator one post-layout hook to restore a user-owned +/// viewport after UIKit performs caret-visibility layout. +@MainActor +final class TaskComposerPromptTextView: UITextView { + var restoreManualContentOffset: (() -> Void)? + + override func layoutSubviews() { + super.layoutSubviews() + restoreManualContentOffset?() + } +} +#endif diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TaskComposer/TaskComposerSheet+Attachments.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TaskComposer/TaskComposerSheet+Attachments.swift new file mode 100644 index 000000000000..fe3d332baa4f --- /dev/null +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TaskComposer/TaskComposerSheet+Attachments.swift @@ -0,0 +1,238 @@ +#if os(iOS) +import CmuxMobileShell +import CmuxMobileShellModel +import CmuxMobileSupport +import Foundation +import PhotosUI +import SwiftUI + +extension TaskComposerSheet { + var showsAttachmentButton: Bool { + guard selectedTemplate?.isPlainShell == false, + !selectedMacDeviceID.isEmpty else { + return false + } + return store.supportsTaskAttachments( + macDeviceID: selectedMacDeviceID, + instanceTag: selectedMacInstanceTag + ) + } + + var remainingAttachmentCount: Int { + max(TaskComposerAttachment.maximumCount - attachments.count, 0) + } + + func presentAttachmentPhotoPicker() { + guard remainingAttachmentCount > 0 else { + attachmentAlertMessage = Self.attachmentCountFailureMessage + return + } + isAttachmentPhotoPickerPresented = true + } + + func presentAttachmentFileImporter() { + guard remainingAttachmentCount > 0 else { + attachmentAlertMessage = Self.attachmentCountFailureMessage + return + } + isAttachmentFileImporterPresented = true + } + + func stageSelectedPhotos(_ items: [PhotosPickerItem]) { + attachmentStagingTask?.cancel() + attachmentStagingTask = Task { @MainActor in + defer { + attachmentPhotoSelection = [] + attachmentStagingTask = nil + } + for item in items.prefix(remainingAttachmentCount) { + guard !Task.isCancelled else { return } + do { + guard let imported = try await item.loadTransferable( + type: ImportedImageFile.self + ) else { + throw TaskComposerAttachmentStager.StagingError.imageRejected + } + defer { + try? FileManager.default.removeItem(at: imported.url) + } + let attachment = try await TaskComposerAttachmentStager() + .stageImage( + at: imported.url, + originalFileName: imported.originalFileName + ) + guard !Task.isCancelled else { + try? FileManager.default.removeItem( + at: attachment.localStagedFileURL + ) + return + } + appendAttachment(attachment) + } catch is CancellationError { + return + } catch { + attachmentAlertMessage = Self.attachmentStagingFailureMessage( + error + ) + } + } + } + } + + func stageSelectedFiles(_ result: Result<[URL], any Error>) { + guard case .success(let urls) = result else { + attachmentAlertMessage = Self.attachmentUnreadableFailureMessage + return + } + attachmentStagingTask?.cancel() + attachmentStagingTask = Task { @MainActor in + defer { attachmentStagingTask = nil } + for url in urls.prefix(remainingAttachmentCount) { + guard !Task.isCancelled else { return } + do { + let attachment = try await TaskComposerAttachmentStager() + .stageFile(at: url) + guard !Task.isCancelled else { + try? FileManager.default.removeItem( + at: attachment.localStagedFileURL + ) + return + } + appendAttachment(attachment) + } catch is CancellationError { + return + } catch { + attachmentAlertMessage = Self.attachmentStagingFailureMessage( + error + ) + } + } + } + } + + func appendAttachment(_ attachment: TaskComposerAttachment) { + guard !submissionPhase.disablesRequestEditing else { + try? FileManager.default.removeItem( + at: attachment.localStagedFileURL + ) + return + } + let totalBytes = attachments.reduce(0) { $0 + $1.byteCount } + guard attachments.count < TaskComposerAttachment.maximumCount else { + try? FileManager.default.removeItem( + at: attachment.localStagedFileURL + ) + attachmentAlertMessage = Self.attachmentCountFailureMessage + return + } + guard totalBytes + attachment.byteCount + <= TaskComposerAttachment.maximumTotalBytes else { + try? FileManager.default.removeItem( + at: attachment.localStagedFileURL + ) + attachmentAlertMessage = Self.attachmentTotalSizeFailureMessage + return + } + updateSubmissionRequest(reconcileRecovery: true) { + attachments.append(attachment) + } + } + + func removeAttachment(_ id: UUID) { + guard !submissionPhase.disablesRequestEditing, + let index = attachments.firstIndex(where: { $0.id == id }) else { + return + } + let attachment = attachments[index] + updateSubmissionRequest(reconcileRecovery: true) { + attachments.remove(at: index) + } + try? FileManager.default.removeItem( + at: attachment.localStagedFileURL + ) + } + + func removeStagedAttachmentFiles() { + for attachment in attachments { + try? FileManager.default.removeItem( + at: attachment.localStagedFileURL + ) + } + } + + func uploadAttachments( + for snapshot: MobileTaskSubmissionSnapshot + ) async -> Result<[String], MobileWorkspaceMutationFailure> { + guard snapshot.composition.initialCommand != nil else { + return .success([]) + } + let attachmentsByID = Dictionary( + uniqueKeysWithValues: attachments.map { ($0.id, $0) } + ) + var paths: [String] = [] + for identity in snapshot.attachments { + guard let attachment = attachmentsByID[identity.uploadID], + attachment.byteCount == identity.byteCount else { + return .failure(.rejected( + hostDisplayName: selectedMachine?.resolvedName + )) + } + let result = await store.uploadTaskAttachment( + attachment, + operationID: snapshot.operationID, + macDeviceID: snapshot.macDeviceID, + instanceTag: snapshot.macInstanceTag + ) + switch result { + case .success(let path): + paths.append(path) + case .failure(let failure): + return .failure(failure) + } + } + return .success(paths) + } + + static var attachmentCountFailureMessage: String { + L10n.string( + "mobile.taskComposer.attachments.limit.count", + defaultValue: "You can attach up to 10 items to a task." + ) + } + + static var attachmentTotalSizeFailureMessage: String { + L10n.string( + "mobile.taskComposer.attachments.limit.totalSize", + defaultValue: "Task attachments can use up to 64 MB in total." + ) + } + + static var attachmentUnreadableFailureMessage: String { + L10n.string( + "mobile.taskComposer.attachments.unreadable", + defaultValue: "That file couldn’t be read. Choose another file." + ) + } + + static func attachmentStagingFailureMessage(_ error: any Error) -> String { + guard let stagingError = error + as? TaskComposerAttachmentStager.StagingError else { + return attachmentUnreadableFailureMessage + } + switch stagingError { + case .imageRejected: + return L10n.string( + "mobile.taskComposer.attachments.imageRejected", + defaultValue: "That image couldn’t be compressed below 8 MB." + ) + case .fileTooLarge: + return L10n.string( + "mobile.taskComposer.attachments.fileTooLarge", + defaultValue: "Choose a file smaller than 32 MB." + ) + case .unreadableFile: + return attachmentUnreadableFailureMessage + } + } +} +#endif diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TaskComposer/TaskComposerSheet+CompletedOperationRecovery.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TaskComposer/TaskComposerSheet+CompletedOperationRecovery.swift index 6140c688661d..f6f51d31224f 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TaskComposer/TaskComposerSheet+CompletedOperationRecovery.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TaskComposer/TaskComposerSheet+CompletedOperationRecovery.swift @@ -75,13 +75,17 @@ extension TaskComposerSheet { } func workspaceCreateSpec( - for snapshot: MobileTaskSubmissionSnapshot + for snapshot: MobileTaskSubmissionSnapshot, + attachmentPaths: [String] = [] ) -> MobileWorkspaceCreateSpec { - MobileWorkspaceCreateSpec( + let composition = snapshot.composition( + attachmentPaths: attachmentPaths + ) + return MobileWorkspaceCreateSpec( title: snapshot.workspaceTitle, workingDirectory: snapshot.trimmedDirectory.isEmpty ? nil : snapshot.trimmedDirectory, - initialCommand: snapshot.composition.initialCommand, - initialEnv: snapshot.composition.initialEnv.isEmpty ? nil : snapshot.composition.initialEnv, + initialCommand: composition.initialCommand, + initialEnv: composition.initialEnv.isEmpty ? nil : composition.initialEnv, operationID: snapshot.operationID ) } diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TaskComposer/TaskComposerSheet+DraftState.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TaskComposer/TaskComposerSheet+DraftState.swift index cd98f0cc57e3..2c7e950df5ee 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TaskComposer/TaskComposerSheet+DraftState.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TaskComposer/TaskComposerSheet+DraftState.swift @@ -3,9 +3,15 @@ import CmuxMobileShellModel import Foundation extension TaskComposerSheet { - func selectTemplate(_ template: MobileTaskTemplate) { + func selectTemplate(_ template: MobileTaskTemplate, modelID: String? = nil) { + let validatedModelID = validatedModelID(modelID, for: template) updateSubmissionRequest(reconcileRecovery: true) { selectedTemplateID = template.id + selectedModelID = validatedModelID + if template.isPlainShell { + removeStagedAttachmentFiles() + attachments.removeAll() + } syncSuggestedDirectory() } } @@ -14,6 +20,13 @@ extension TaskComposerSheet { prompt = snapshot.prompt workspaceName = snapshot.workspaceName selectedTemplateID = snapshot.templateID + selectedModelID = selectedTemplate.flatMap { + validatedModelID( + snapshot.modelID, + for: $0, + previouslyValidModelID: snapshot.modelID + ) + } selectedMacDeviceID = snapshot.macDeviceID selectedMacInstanceTag = snapshot.macInstanceTag directory = snapshot.directory @@ -98,20 +111,39 @@ extension TaskComposerSheet { } func submissionSnapshot() -> MobileTaskSubmissionSnapshot? { - let candidateID = submissionIdentity.id - return submissionIdentity.resolveCurrentRequest { - makeSubmissionSnapshot(operationID: candidateID) - } + resolveCurrentRequestReconcilingHiddenModel() } - func draftSnapshot() -> MobileTaskComposerDraft { + /// Resolves the current request, re-resolving once when the Off picker + /// hides a model that a clean cached request (restored draft or adopted + /// recovery) still carries. The forced resolution runs through + /// `makeSubmissionSnapshot`, whose `selectedModel` gate strips the model, + /// and `MobileTaskSubmissionIdentity` mints a fresh operation ID for the + /// changed bytes. Applied at BOTH the submission and draft-persistence + /// boundaries so a persisted draft can never pair a model-less request + /// with an operation ID previously bound to model-bearing bytes. + private func resolveCurrentRequestReconcilingHiddenModel() -> MobileTaskSubmissionSnapshot? { let candidateID = submissionIdentity.id let resolved = submissionIdentity.resolveCurrentRequest { makeSubmissionSnapshot(operationID: candidateID) } + guard displaySettings.taskComposerModelPickerVariant.renderedVariant == .off, + resolved?.modelID != nil else { + return resolved + } + submissionIdentity.markRequestDirty() + let rotatedID = submissionIdentity.id + return submissionIdentity.resolveCurrentRequest { + makeSubmissionSnapshot(operationID: rotatedID) + } + } + + func draftSnapshot() -> MobileTaskComposerDraft { + let resolved = resolveCurrentRequestReconcilingHiddenModel() let completedOperationID = reconcileCompletedOperationRecovery(with: resolved) return MobileTaskComposerDraft( prompt: prompt, + modelID: selectedModel?.id, templateID: selectedTemplateID, macDeviceID: selectedMacDeviceID.isEmpty ? nil : selectedMacDeviceID, macInstanceTag: selectedMacDeviceID.isEmpty ? nil : selectedMacInstanceTag, @@ -128,11 +160,13 @@ extension TaskComposerSheet { return MobileTaskSubmissionSnapshot( template: selectedTemplate, prompt: prompt, + modelID: selectedModel?.id, macDeviceID: selectedMacDeviceID, macInstanceTag: selectedMacInstanceTag, directory: directory, workspaceName: workspaceName, didEditDirectory: didEditDirectory, + attachments: attachments.map(\.submissionAttachment), operationID: operationID ) } 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..2ccf409b05bb --- /dev/null +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TaskComposer/TaskComposerSheet+ModelSelection.swift @@ -0,0 +1,80 @@ +#if os(iOS) +import CmuxMobileShellModel + +extension TaskComposerSheet { + var modelAvailability: MobileTaskModelAvailability { + guard let selectedTemplate, + let provider = MobileTaskAgentProvider( + command: selectedTemplate.command + ) else { + return MobileTaskModelAvailability( + template: selectedTemplate, + discoveredModels: nil + ) + } + return MobileTaskModelAvailability( + template: selectedTemplate, + discoveredModels: store.discoveredTaskModels( + provider: provider, + macDeviceID: selectedMacDeviceID, + instanceTag: selectedMacInstanceTag + ) + ) + } + + var availableModels: [MobileTaskAgentModel] { + var models = modelAvailability.models + if let selectedModelID, + !models.contains(where: { $0.id == selectedModelID }) { + models.append(MobileTaskAgentModel( + id: selectedModelID, + displayName: selectedModelID + )) + } + return models + } + + var selectedModel: MobileTaskAgentModel? { + // With the picker Off no model UI is rendered, so a model restored + // from an earlier draft must not silently ride into snapshots, drafts, + // or the submitted command. The selection state itself is kept so + // re-enabling a variant restores the visible choice. + guard displaySettings.taskComposerModelPickerVariant.renderedVariant != .off, + let selectedModelID else { return nil } + return modelAvailability.selectedModel(id: selectedModelID) + } + + func validatedModelID( + _ id: String?, + for template: MobileTaskTemplate, + previouslyValidModelID: String? = nil + ) -> String? { + let provider = MobileTaskAgentProvider(command: template.command) + let discoveredModels = provider.flatMap { + store.discoveredTaskModels( + provider: $0, + macDeviceID: selectedMacDeviceID, + instanceTag: selectedMacInstanceTag + ) + } + return MobileTaskModelAvailability( + template: template, + discoveredModels: discoveredModels + ).validatedModelID( + id, + previouslyValidModelID: previouslyValidModelID + ) + } + + func selectModel(_ id: String?) { + guard !submissionPhase.disablesRequestEditing else { return } + guard let selectedTemplate else { return } + let validatedID = validatedModelID(id, for: selectedTemplate) + guard id == nil || validatedID != nil else { return } + guard selectedModelID != validatedID else { return } + updateSubmissionRequest(reconcileRecovery: true) { + selectedModelID = validatedID + } + } +} +#endif diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TaskComposer/TaskComposerSheet+Policies.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TaskComposer/TaskComposerSheet+Policies.swift index 40015d4c2c7a..52a5ac221271 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TaskComposer/TaskComposerSheet+Policies.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TaskComposer/TaskComposerSheet+Policies.swift @@ -73,6 +73,39 @@ extension TaskComposerSheet { ) } + static func attachmentUploadFailureMessage( + _ failure: MobileWorkspaceMutationFailure + ) -> String { + switch failure { + case .unsupported: + return L10n.string( + "mobile.taskComposer.attachments.upload.unsupported", + defaultValue: "That Mac does not support task attachments." + ) + case .notConnected: + return L10n.string( + "mobile.taskComposer.attachments.upload.notConnected", + defaultValue: "The attachments couldn’t be uploaded because that Mac is not connected." + ) + case .requestTimedOut: + return L10n.string( + "mobile.taskComposer.attachments.upload.timedOut", + defaultValue: "The Mac did not finish uploading the attachments in time." + ) + case .authorizationFailed: + return L10n.string( + "mobile.taskComposer.attachments.upload.authorization", + defaultValue: "That Mac did not authorize the attachment upload." + ) + case .busy, .rejected, .invalidWorkingDirectory, + .persistenceUnavailable, .alreadyCompleted: + return L10n.string( + "mobile.taskComposer.attachments.upload.failed", + defaultValue: "The attachments couldn’t be uploaded. Check the files and try again." + ) + } + } + static func failureMessage(_ failure: MobileWorkspaceMutationFailure) -> String { switch failure { case .notConnected: diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TaskComposer/TaskComposerSheet.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TaskComposer/TaskComposerSheet.swift index 6d6aab26e4a6..51ba1a39eedd 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TaskComposer/TaskComposerSheet.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TaskComposer/TaskComposerSheet.swift @@ -4,6 +4,7 @@ import CmuxMobileRPC import CmuxMobileShell import CmuxMobileShellModel import CmuxMobileSupport +import PhotosUI import SwiftUI import UIKit @@ -11,12 +12,14 @@ struct TaskComposerSheet: View { @Environment(\.dismiss) var dismiss @Environment(\.accessibilityReduceMotion) private var accessibilityReduceMotion @Environment(\.scenePhase) private var scenePhase + @Environment(MobileDisplaySettings.self) 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 selectedMacInstanceTag: String? @State var directory: String @@ -32,6 +35,12 @@ struct TaskComposerSheet: View { @State private var activeSubmissionSnapshot: MobileTaskSubmissionSnapshot? @State var completedOperationRecovery: TaskComposerCompletedOperationRecovery? @State var isStartAgainConfirmationPresented = false + @State var attachments: [TaskComposerAttachment] = [] + @State var isAttachmentPhotoPickerPresented = false + @State var attachmentPhotoSelection: [PhotosPickerItem] = [] + @State var isAttachmentFileImporterPresented = false + @State var attachmentStagingTask: Task? + @State var attachmentAlertMessage: String? let sessionGeneration: Int private let availableMachines: [MobilePairedMac]? @@ -127,6 +136,23 @@ 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 initialProvider = selectedTemplate.flatMap { + MobileTaskAgentProvider(command: $0.command) + } + let initialDiscoveredModels = initialProvider.flatMap { + store.discoveredTaskModels( + provider: $0, + macDeviceID: selectedMacID, + instanceTag: selectedMac?.instanceTag + ) + } + let initialModelAvailability = MobileTaskModelAvailability( + template: selectedTemplate, + discoveredModels: initialDiscoveredModels + ) + let initialModelID = (draft?.templateID == selectedTemplateID) + ? initialModelAvailability.validatedModelID(draft?.modelID) + : nil let openDirectory = Self.preferredOpenDirectory( workspaces: store.workspaces, selectedWorkspaceID: store.selectedWorkspaceID, @@ -145,10 +171,15 @@ struct TaskComposerSheet: View { templateStore: store.taskTemplateStore, openDirectory: openDirectory ) + // A draft model that fails the cached effective-list validation changes the request + // bytes, so its operation ID (and any recovery bound to it) must not + // be reused for the resulting default-model command. + let draftModelSurvivedValidation = draft?.modelID == nil || initialModelID != nil let restoredOperationID = ( draft?.templateID == selectedTemplateID && draft?.macDeviceID == (selectedMacID.isEmpty ? nil : selectedMacID) && canRestoreDraftDirectory + && draftModelSurvivedValidation ) ? draft?.operationID : nil let initialPrompt = draft?.prompt ?? "" let initialWorkspaceName = draft?.workspaceName ?? "" @@ -157,6 +188,7 @@ struct TaskComposerSheet: View { MobileTaskSubmissionSnapshot( template: $0, prompt: initialPrompt, + modelID: initialModelID, macDeviceID: selectedMacID, macInstanceTag: selectedMac?.instanceTag, directory: initialDirectory, @@ -168,6 +200,7 @@ struct TaskComposerSheet: View { let canRestoreCompletedOperation = draft?.templateID == selectedTemplateID && draft?.macDeviceID == (selectedMacID.isEmpty ? nil : selectedMacID) && canRestoreDraftDirectory + && draftModelSurvivedValidation let initialCompletedOperationRecovery = (canRestoreCompletedOperation ? draft?.completedOperationID : nil) @@ -178,6 +211,7 @@ struct TaskComposerSheet: View { _workspaceName = State(initialValue: initialWorkspaceName) _templates = State(initialValue: templates) _selectedTemplateID = State(initialValue: selectedTemplateID) + _selectedModelID = State(initialValue: initialModelID) _selectedMacDeviceID = State(initialValue: selectedMacID) _selectedMacInstanceTag = State(initialValue: selectedMac?.instanceTag) _directory = State(initialValue: initialDirectory) @@ -203,77 +237,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, - selectTemplate: selectTemplateFromPicker, - editTemplates: presentTemplateEditor - ) - - TaskComposerContextSection( - workspaceName: workspaceNameBinding, - machines: machines, - selectedMacPairingID: selectedMacPairingID, - buildLabelsByID: machineBuildLabelsByID, - directory: directory, - isDisabled: submissionPhase.disablesRequestEditing, - endWorkspaceNameEditing: resolveCompletedOperationRecoveryAfterEditing, - selectMachine: selectMachine, - selectDirectory: { isDirectoryPickerPresented = true } - ) - .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) { @@ -290,36 +259,8 @@ struct TaskComposerSheet: View { candidates: directoryCandidates, selectedPath: directory, select: selectDirectory, - searchMac: { query in - if let searchTaskDirectories { - return await searchTaskDirectories( - selectedMacDeviceID, - selectedMacInstanceTag, - query - ) - } - return await store.searchTaskDirectories( - macDeviceID: selectedMacDeviceID, - instanceTag: selectedMacInstanceTag, - query: query - ) - }, - listMac: { path, offset in - if let listTaskDirectories { - return await listTaskDirectories( - selectedMacDeviceID, - selectedMacInstanceTag, - path, - offset - ) - } - return await store.listTaskDirectories( - macDeviceID: selectedMacDeviceID, - instanceTag: selectedMacInstanceTag, - path: path, - offset: offset - ) - } + searchMac: resolvedSearchTaskDirectories, + listMac: resolvedListTaskDirectories ) .presentationDetents([.large]) .presentationDragIndicator(.visible) @@ -327,6 +268,8 @@ struct TaskComposerSheet: View { .onDisappear { // Parent-driven dismissal must cancel result application. submitTask?.cancel() + attachmentStagingTask?.cancel() + removeStagedAttachmentFiles() if shouldPersistDraftOnDisappear { persistDraft() } @@ -338,12 +281,53 @@ struct TaskComposerSheet: View { .onChange(of: machines.map(\.id)) { _, _ in validateMacSelection() } + .task(id: modelRefreshID) { + guard let provider = modelRefreshID.provider, + !selectedMacDeviceID.isEmpty else { return } + await store.refreshTaskModels( + provider: provider, + macDeviceID: selectedMacDeviceID, + instanceTag: selectedMacInstanceTag + ) + } .modifier(TaskComposerStartAgainConfirmationModifier( isPresented: $isStartAgainConfirmationPresented, confirm: confirmStartAgain )) + .modifier(TaskComposerAttachmentPickerModifier( + isPhotoPickerPresented: $isAttachmentPhotoPickerPresented, + photoSelection: $attachmentPhotoSelection, + isFileImporterPresented: $isAttachmentFileImporterPresented, + remainingCount: remainingAttachmentCount, + selectedPhotos: stageSelectedPhotos, + selectedFiles: stageSelectedFiles + )) + .alert( + L10n.string( + "mobile.taskComposer.attachments.alert.title", + defaultValue: "Couldn’t Add Attachment" + ), + isPresented: Binding( + get: { attachmentAlertMessage != nil }, + set: { isPresented in + if !isPresented { + attachmentAlertMessage = nil + } + } + ) + ) { + Button(L10n.string("mobile.common.ok", defaultValue: "OK")) { + attachmentAlertMessage = nil + } + } message: { + Text(attachmentAlertMessage ?? "") + } } .presentationDetents([.large]) + // Swipes inside the prompt belong to its scroll view. The drag + // indicator remains the explicit affordance for moving or dismissing + // the sheet, so the two vertical gestures no longer compete. + .presentationContentInteraction(.scrolls) .presentationDragIndicator(.visible) .interactiveDismissDisabled(submissionPhase.locksDismissal) .background(TaskComposerInitialFocusCoordinator( @@ -351,6 +335,192 @@ 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, + attachments: attachments, + showsAttachmentButton: showsAttachmentButton, + selectTemplate: selectTemplateFromPicker, + selectTemplateAndModel: selectTemplateAndModelFromPicker, + selectModel: selectModel, + editTemplates: presentTemplateEditor, + chooseAttachmentPhotos: presentAttachmentPhotoPicker, + chooseAttachmentFiles: presentAttachmentFileImporter, + removeAttachment: removeAttachment + ) + + TaskComposerContextSection( + workspaceName: workspaceNameBinding, + machines: machines, + selectedMacPairingID: selectedMacPairingID, + buildLabelsByID: machineBuildLabelsByID, + 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 + && attachmentStagingTask == nil, + 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 + && attachmentStagingTask == nil + && blockingCompletedOperationRecovery == nil, + failureTitle: failureTitleStyle.title, + failureText: failureText, + completedOperationRecovery: blockingCompletedOperationRecovery, + attachments: attachments, + showsAttachmentButton: showsAttachmentButton, + optionsSheet: { minimalOptionsSheet }, + endEditing: resolveCompletedOperationRecoveryAfterEditing, + selectTemplate: selectTemplateFromPicker, + selectTemplateAndModel: selectTemplateAndModelFromPicker, + selectModel: selectModel, + editTemplates: presentTemplateEditor, + cancel: cancelComposer, + submit: startSubmission, + refreshCompletedOperation: startCompletedOperationReconciliation, + requestStartAgain: { isStartAgainConfirmationPresented = true }, + chooseAttachmentPhotos: presentAttachmentPhotoPicker, + chooseAttachmentFiles: presentAttachmentFileImporter, + removeAttachment: removeAttachment + ) + } + + private var minimalOptionsSheet: TaskComposerOptionsSheet { + TaskComposerOptionsSheet( + workspaceName: workspaceNameBinding, + machines: machines, + selectedMacPairingID: selectedMacPairingID, + buildLabelsByID: machineBuildLabelsByID, + directory: directory, + // The composer layout's bottom-bar pill is the single model entry + // point; the options sheet never repeats the contextRow variant. + modelPickerVariant: .off, + models: availableModels, + selectedModelID: selectedModelID, + isDisabled: submissionPhase.disablesRequestEditing, + directoryCandidates: directoryCandidates, + endWorkspaceNameEditing: resolveCompletedOperationRecoveryAfterEditing, + selectMachine: selectMachine, + selectDirectory: selectDirectory, + selectModel: selectModel, + searchMac: resolvedSearchTaskDirectories, + listMac: resolvedListTaskDirectories + ) + } + + private func resolvedSearchTaskDirectories( + query: String + ) async -> Result { + if let searchTaskDirectories { + return await searchTaskDirectories( + selectedMacDeviceID, + selectedMacInstanceTag, + query + ) + } + return await store.searchTaskDirectories( + macDeviceID: selectedMacDeviceID, + instanceTag: selectedMacInstanceTag, + query: query + ) + } + + private func resolvedListTaskDirectories( + path: String, + offset: Int + ) async -> Result { + if let listTaskDirectories { + return await listTaskDirectories( + selectedMacDeviceID, + selectedMacInstanceTag, + path, + offset + ) + } + return await store.listTaskDirectories( + macDeviceID: selectedMacDeviceID, + instanceTag: selectedMacInstanceTag, + path: path, + offset: offset + ) + } + var selectedTemplate: MobileTaskTemplate? { selectedTemplateID.flatMap { id in templates.first { $0.id == id } } } @@ -373,6 +543,15 @@ struct TaskComposerSheet: View { ) } + private var modelRefreshID: TaskComposerModelRefreshID { + TaskComposerModelRefreshID( + provider: selectedTemplate.flatMap { + MobileTaskAgentProvider(command: $0.command) + }, + macPairingID: selectedMacPairingID + ) + } + private var machineBuildLabelsByID: [String: String] { var labels: [String: String] = [:] for mac in machines { @@ -513,10 +692,14 @@ struct TaskComposerSheet: View { } private func selectTemplateFromPicker(_ id: MobileTaskTemplate.ID) { + selectTemplateAndModelFromPicker(id, nil) + } + + private func selectTemplateAndModelFromPicker(_ id: MobileTaskTemplate.ID, _ modelID: String?) { guard !submissionPhase.disablesRequestEditing, let template = templates.first(where: { $0.id == id }) else { return } withAnimation(accessibilityReduceMotion ? nil : .snappy(duration: 0.2)) { - selectTemplate(template) + selectTemplate(template, modelID: modelID) } } @@ -525,6 +708,13 @@ struct TaskComposerSheet: View { isEditorPresented = true } + private func cancelComposer() { + submitTask?.cancel() + shouldPersistDraftOnDisappear = false + store.clearTaskComposerDraft(ifSessionGeneration: sessionGeneration) + dismiss() + } + private func selectMachine(_ macDeviceID: String, _ instanceTag: String?) { guard !submissionPhase.disablesRequestEditing, machines.contains(where: { @@ -540,6 +730,7 @@ struct TaskComposerSheet: View { func startSubmission() { resolveCompletedOperationRecoveryAfterEditing() guard submitTask == nil, + attachmentStagingTask == nil, blockingCompletedOperationRecovery == nil, submissionPhase.allowsSubmission else { return } // Once the user sends a genuinely different request, the prior @@ -570,7 +761,35 @@ struct TaskComposerSheet: View { submissionPhase = .preparing activeSubmissionSnapshot = snapshot failureText = nil - let spec = workspaceCreateSpec(for: snapshot) + let attachmentPaths: [String] + switch await uploadAttachments(for: snapshot) { + case .success(let paths): + attachmentPaths = paths + case .failure(let failure): + submissionPhase = .idle + activeSubmissionSnapshot = nil + guard !Task.isCancelled else { return } + restoreSubmittedDraft(snapshot) + _ = store.persistTaskComposerDraft( + snapshot.draft, + ifSessionGeneration: sessionGeneration + ) + submissionPhase = .retryReady + failureTitleStyle = .launchFailed + let message = Self.attachmentUploadFailureMessage(failure) + failureText = message + announceFailure(message) + return + } + guard !Task.isCancelled else { + submissionPhase = .idle + activeSubmissionSnapshot = nil + return + } + let spec = workspaceCreateSpec( + for: snapshot, + attachmentPaths: attachmentPaths + ) let result = await submitTaskComposer( snapshot.macDeviceID, snapshot.macInstanceTag, @@ -619,6 +838,7 @@ struct TaskComposerSheet: View { updateSubmissionRequest(reconcileRecovery: true) { store.taskTemplateStore?.addTemplate(template) selectedTemplateID = template.id + selectedModelID = nil syncSuggestedDirectory() } } @@ -641,6 +861,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/Sources/CmuxMobileShellUI/TerminalComposerView.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TerminalComposerView.swift index 538af2012f3d..4e8e5c5922f4 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TerminalComposerView.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TerminalComposerView.swift @@ -4,7 +4,6 @@ import CmuxMobileShell import CmuxMobileShellModel import CmuxMobileSupport import CmuxMobileTerminal -import ImageIO import PhotosUI import SwiftUI import UIKit @@ -142,8 +141,6 @@ struct TerminalComposerView: View { /// clipboard paste path and keep the bounded encode under ~8 MB. The store /// re-enforces this as the authoritative per-image cap; this constant only /// bounds the encode loop below it. - private nonisolated static let maxImageBytes = CMUXMobileShellStore.maxPendingAttachmentImageBytes - /// Cap how many images one message may carry, mirrored from the store so the /// picker's `maxSelectionCount` matches the store's authoritative count cap. /// The store enforces it atomically; this is only a pre-filter for picker UX. @@ -161,18 +158,7 @@ struct TerminalComposerView: View { /// without ever being decoded. A compressed HEIC can decode larger than its /// file size, so this is a generous multiple of the 8 MB per-image cap rather /// than the cap itself; whatever passes is still downsampled by ImageIO. - private static let maxRawInputBytes = 60 * 1024 * 1024 - - /// Max pixel dimension of the cached chip thumbnail. The chip renders at 56pt; - /// 3x covers Retina without holding the full-resolution image. - private nonisolated static let thumbnailMaxPixelSize = 168 - - /// Max pixel dimension of the SEND payload. ImageIO downsamples the picked - /// item to fit this longest edge before re-encoding, so a panorama or a - /// 48-megapixel HEIC never materializes as a full-resolution raster. 2048 px - /// keeps screenshot text legible for an agent while bounding the bytes well - /// under the per-image cap. - private nonisolated static let sendMaxPixelSize = 2048 + private static let maxRawInputBytes = MobileImageAttachmentPreparer.maximumRawInputBytes var body: some View { composerSurface @@ -568,7 +554,9 @@ struct TerminalComposerView: View { // so a giant HEIC/panorama is never materialized as a // full-resolution raster in memory. This is the expensive part and // must not block the composer's keyboard/typing. - guard let prepared = await Self.prepare(url: fileURL) else { continue } + guard let prepared = await MobileImageAttachmentPreparer().prepare(url: fileURL) else { + continue + } if Task.isCancelled { break } // The store is the single source of truth for the count/byte caps // and the session-generation guard: it re-checks the CURRENT @@ -585,7 +573,8 @@ struct TerminalComposerView: View { // actor (UIImage is not Sendable and must not cross the task // boundary). A nil/undecodable thumbnail just leaves the chip's // placeholder. - if let thumbnailData = prepared.thumbnail, let thumbnail = UIImage(data: thumbnailData) { + if let thumbnailData = prepared.thumbnailData, + let thumbnail = UIImage(data: thumbnailData) { thumbnailCache.set(thumbnail, for: id) } } @@ -595,170 +584,6 @@ struct TerminalComposerView: View { } } - /// The off-main result of preparing one picked image: the encoded bytes to - /// send, their format hint, and the small chip thumbnail as encoded PNG - /// bytes. Every field is `Sendable` value data so the whole struct can cross - /// the detached-task boundary; the chip's `UIImage` is built from - /// ``thumbnail`` on the main actor, never carried across that boundary. - private struct PreparedAttachment: Sendable { - var data: Data - var format: String - var thumbnail: Data? - } - - /// Prepare a picked image from its temp file URL for staging, entirely via - /// ImageIO and entirely off the main thread. The source is read with - /// `CGImageSourceCreateWithURL`, so the full original is never slurped into - /// memory. Both the SEND payload and the chip thumbnail are produced by - /// downsampling that source with `CGImageSourceCreateThumbnailAtIndex` and - /// re-encoding the (bounded) `CGImage`, so a large HEIC/JPEG/panorama is NEVER - /// decoded into a full-size raster and never re-encoded to a hundreds-of-MB - /// PNG just to measure it. The per-image byte cap is enforced on the bounded - /// result, downscaling further (JPEG, then progressively smaller) if needed. - /// Returns `nil` when the file is not a decodable image OR when the staging - /// task was cancelled (a re-pick, a terminal switch, or the view - /// disappearing). Every returned field is `Sendable` value data (`Data`), so - /// nothing UIKit-reference crosses back to the main actor. The caller deletes - /// the temp file. - /// - /// STRUCTURED, not detached: this runs the heavy ImageIO inside a child task - /// group at background priority, so it is a child of the caller's (staging) - /// task and cancellation PROPAGATES into it. The old `Task.detached` did not - /// inherit cancellation, so cancelling the staging task left its ImageIO jobs - /// running and fanning out temp files. `nonisolated` so the synchronous decode - /// never runs on the main actor. The cancellation check before the heavy - /// `CGImageSourceCreateWithURL` skips the decode entirely once cancelled. - private nonisolated static func prepare(url: URL) async -> PreparedAttachment? { - // Bail before launching the decode if the staging task is already cancelled. - if Task.isCancelled { return nil } - return await withTaskGroup(of: PreparedAttachment?.self) { group in - group.addTask(priority: .background) { - // Re-check inside the child task: cancellation may have landed - // between the parent check and this body starting. Skip the - // expensive CGImageSourceCreateWithURL when cancelled. - if Task.isCancelled { return nil } - // Read the image from the file URL: ImageIO maps it lazily and only - // ever decodes the downsampled thumbnail below, so the full-resolution - // raster is never materialized in memory. - guard let source = CGImageSourceCreateWithURL(url as CFURL, nil) else { return nil } - guard let (data, format) = boundedSendPayload(from: source) else { return nil } - // The send payload is the costly step; if cancellation landed while - // it ran, drop the result rather than also encoding the thumbnail. - if Task.isCancelled { return nil } - return PreparedAttachment( - data: data, - format: format, - thumbnail: downsampledImageData( - from: source, - maxPixelSize: thumbnailMaxPixelSize, - type: "public.png", - jpegQuality: nil - ) - ) - } - // Single child: its value is the prepared attachment (or nil). Awaiting - // the group here keeps the work structured under the staging task. - return await group.next() ?? nil - } - } - - /// Produce the bounded SEND payload for one picked image from its ImageIO - /// source. Downsamples to ``sendMaxPixelSize`` (longest edge) so the raster is - /// always small, then encodes under the per-image byte cap: - /// 1. PNG at the send size if it already fits (keeps screenshots crisp). - /// 2. JPEG at decreasing quality. - /// 3. Progressively smaller dimensions as a last resort. - /// Returns the encoded bytes + a lowercase format hint, or `nil` if the source - /// is undecodable. The cap is also re-enforced authoritatively in the store. - private nonisolated static func boundedSendPayload(from source: CGImageSource) -> (data: Data, format: String)? { - // PNG at the bounded send size: lossless, and for a typical screenshot it - // lands well under the cap. - if let png = downsampledImageData( - from: source, - maxPixelSize: sendMaxPixelSize, - type: "public.png", - jpegQuality: nil - ), png.count <= maxImageBytes { - return (png, "png") - } - // JPEG at the bounded send size, stepping quality down until it fits. - for quality in [0.8, 0.6, 0.4] as [CGFloat] { - if let jpeg = downsampledImageData( - from: source, - maxPixelSize: sendMaxPixelSize, - type: "public.jpeg", - jpegQuality: quality - ), jpeg.count <= maxImageBytes { - return (jpeg, "jpg") - } - } - // Still over the cap (an extreme source): shrink the dimensions as well, - // at a low-but-readable JPEG quality, until it fits. - for maxPixel in [1536, 1024, 768] { - if let jpeg = downsampledImageData( - from: source, - maxPixelSize: maxPixel, - type: "public.jpeg", - jpegQuality: 0.5 - ), jpeg.count <= maxImageBytes { - return (jpeg, "jpg") - } - } - // Last resort: return the smallest JPEG we can even if marginally over the - // bound; the store re-checks the cap and rejects it if truly too large. - if let jpeg = downsampledImageData( - from: source, - maxPixelSize: 768, - type: "public.jpeg", - jpegQuality: 0.4 - ) { - return (jpeg, "jpg") - } - return nil - } - - /// Downsample one image from an ImageIO source to a bounded longest edge and - /// re-encode it to the given UTType. `CGImageSourceCreateThumbnailAtIndex` - /// decodes only a reduced-size image (never the full raster), so this is the - /// bounded primitive both the send payload and the chip thumbnail run through. - /// Returns `Data` (Sendable) so the result can cross back to the main actor. - /// Returns `nil` if the source is undecodable or encoding fails. - /// - Parameters: - /// - maxPixelSize: The longest-edge cap, in pixels, for the downsample. - /// - type: The destination UTType identifier (`"public.png"`/`"public.jpeg"`). - /// - jpegQuality: The JPEG compression quality (0...1); `nil` for PNG. - private nonisolated static func downsampledImageData( - from source: CGImageSource, - maxPixelSize: Int, - type: String, - jpegQuality: CGFloat? - ) -> Data? { - let options: [CFString: Any] = [ - kCGImageSourceCreateThumbnailFromImageAlways: true, - kCGImageSourceCreateThumbnailWithTransform: true, - kCGImageSourceShouldCacheImmediately: true, - kCGImageSourceThumbnailMaxPixelSize: maxPixelSize, - ] - guard let cgImage = CGImageSourceCreateThumbnailAtIndex(source, 0, options as CFDictionary) else { - return nil - } - let encoded = NSMutableData() - guard let destination = CGImageDestinationCreateWithData( - encoded as CFMutableData, - type as CFString, - 1, - nil - ) else { - return nil - } - var properties: [CFString: Any] = [:] - if let jpegQuality { - properties[kCGImageDestinationLossyCompressionQuality] = jpegQuality - } - CGImageDestinationAddImage(destination, cgImage, properties as CFDictionary) - guard CGImageDestinationFinalize(destination) else { return nil } - return encoded as Data - } } /// A file-backed `Transferable` for loading a `PhotosPickerItem` as an on-disk @@ -771,8 +596,9 @@ struct TerminalComposerView: View { /// The framework deletes the import staging area, so we copy the file into our /// own temp location we control and delete after encoding (the composer's /// `defer` cleanup). `url` is `Sendable`, so the value crosses task boundaries. -struct ImportedImageFile: Transferable { +struct ImportedImageFile: Transferable, Sendable { let url: URL + let originalFileName: String static var transferRepresentation: some TransferRepresentation { FileRepresentation(contentType: .image) { imported in @@ -787,7 +613,10 @@ struct ImportedImageFile: Transferable { .appendingPathComponent("cmux-composer-import-" + name) try? FileManager.default.removeItem(at: destination) try FileManager.default.copyItem(at: received.file, to: destination) - return ImportedImageFile(url: destination) + return ImportedImageFile( + url: destination, + originalFileName: received.file.lastPathComponent + ) } } } diff --git a/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/MobileDisplaySettingsTests.swift b/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/MobileDisplaySettingsTests.swift index 3b1a061c5944..29ef66f90e3f 100644 --- a/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/MobileDisplaySettingsTests.swift +++ b/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/MobileDisplaySettingsTests.swift @@ -165,6 +165,15 @@ import Testing let settings = MobileDisplaySettings(defaults: defaults) #expect(settings.unreadIndicatorLeftShift == 1.5) #expect(defaults.object(forKey: "cmux.mobile.debug.unreadIndicatorLeftShift.v2") == 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) } @@ -194,6 +203,64 @@ 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) + 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 taskComposerPreviewPinsStableLabDefaults() throws { + // A fresh install under the accessibility preview keeps the shipping + // classic layout and no model UI so the XCUITest suite's element tree + // stays stable across lab default changes. + let defaults = try makeDefaults("previewLabDefaults") + let preview = ["CMUX_UITEST_TASK_COMPOSER_PREVIEW": "1"] + let settings = MobileDisplaySettings(defaults: defaults, environment: preview) + #expect(settings.taskComposerLayoutStyle == .classic) + #expect(settings.taskComposerModelPickerVariant == .off) + } + + @Test func taskComposerPreviewHonorsExplicitLabOverrides() throws { + let defaults = try makeDefaults("previewLabOverrides") + let environment = [ + "CMUX_UITEST_TASK_COMPOSER_PREVIEW": "1", + "CMUX_UITEST_TASK_COMPOSER_LAYOUT": "composer", + "CMUX_UITEST_TASK_COMPOSER_MODEL_VARIANT": "contextRow", + ] + let settings = MobileDisplaySettings(defaults: defaults, environment: environment) + #expect(settings.taskComposerLayoutStyle == .composer) + #expect(settings.taskComposerModelPickerVariant == .contextRow) + } + + @Test func taskComposerPreviewPrefersPersistedLabValues() throws { + // The dev screenshot recipe writes defaults then relaunches with the + // preview env; the persisted choice must win over the preview fallback. + let defaults = try makeDefaults("previewLabPersisted") + defaults.set("composer", forKey: "cmux.mobile.debug.taskComposerLayoutStyle.v1") + defaults.set( + "pillStrip", + forKey: "cmux.mobile.debug.taskComposerModelPickerVariant.v1" + ) + let preview = ["CMUX_UITEST_TASK_COMPOSER_PREVIEW": "1"] + let settings = MobileDisplaySettings(defaults: defaults, environment: preview) + #expect(settings.taskComposerLayoutStyle == .composer) + #expect(settings.taskComposerModelPickerVariant == .pillStrip) + } + @Test func shellIconVariantPersistsAndRejectsUnknownValues() throws { let defaults = try makeDefaults("shellIconVariant") let settings = MobileDisplaySettings(defaults: defaults) diff --git a/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/MobileHost/ControlCommandCoordinator+MobileHost.swift b/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/MobileHost/ControlCommandCoordinator+MobileHost.swift index bfdcbd4d3077..12a5e864c25b 100644 --- a/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/MobileHost/ControlCommandCoordinator+MobileHost.swift +++ b/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/MobileHost/ControlCommandCoordinator+MobileHost.swift @@ -25,6 +25,26 @@ internal import Foundation /// coordinator (native `ControlCallResult`) would only add a pointless type /// round-trip. extension ControlCommandCoordinator { + /// Dispatches asynchronous mobile-host worker methods. + /// + /// - Parameters: + /// - request: The decoded request envelope. + /// - context: Live app seam passed explicitly from the worker lane. + /// - Returns: The command result, or `nil` for other methods. + public nonisolated func handleMobileHostAsync( + _ request: ControlRequest, + context: (any ControlMobileHostContext)? + ) async -> ControlCallResult? { + switch request.method { + case "mobile.task.models.list": + return await context?.controlMobileTaskModelsList( + params: request.params + ) + default: + return nil + } + } + /// Dispatches the mobile-host methods the v2 control socket /// (`processV2Command`) routes here; returns `nil` for anything else so the /// core `handle(_:)` can fall through to the legacy app-side dispatcher. @@ -51,6 +71,8 @@ extension ControlCommandCoordinator { return context?.controlMobileTerminalMouse(params: request.params) case "mobile.terminal.paste", "terminal.paste": return context?.controlMobileTerminalPaste(params: request.params) + case "mobile.task.attachment.upload": + return context?.controlMobileTaskAttachmentUpload(params: request.params) case "chat.sessions.dump": return context?.controlMobileChatSessionsDump() default: diff --git a/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/MobileHost/ControlMobileHostContext.swift b/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/MobileHost/ControlMobileHostContext.swift index ecec781834ac..e2c845c91c88 100644 --- a/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/MobileHost/ControlMobileHostContext.swift +++ b/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/MobileHost/ControlMobileHostContext.swift @@ -117,9 +117,41 @@ public protocol ControlMobileHostContext: AnyObject { /// - Returns: The fully-built command result. func controlMobileTerminalPaste(params: [String: JSONValue]) -> ControlCallResult + /// `mobile.task.attachment.upload` — stage or finalize one task file chunk. + /// + /// - Parameter params: The decoded request params. + /// - Returns: The fully-built command result. + func controlMobileTaskAttachmentUpload( + params: [String: JSONValue] + ) -> ControlCallResult + + /// `mobile.task.models.list` — discover one provider's available models. + /// + /// This worker-lane witness is nonisolated and asynchronous because it may + /// read configuration files or run a bounded provider command. + /// + /// - Parameter params: The decoded request params. + /// - Returns: The fully-built command result. + nonisolated func controlMobileTaskModelsList( + params: [String: JSONValue] + ) async -> ControlCallResult + /// `chat.sessions.dump` (local debug socket) — the full chat-session registry /// dump, for diagnosing inconsistent phone-side chat state. /// /// - Returns: The fully-built command result. func controlMobileChatSessionsDump() -> ControlCallResult } + +public extension ControlMobileHostContext { + /// Default for test and partial contexts that do not expose model discovery. + nonisolated func controlMobileTaskModelsList( + params: [String: JSONValue] + ) async -> ControlCallResult { + .err( + code: "method_not_found", + message: "Task model discovery is unavailable", + data: nil + ) + } +} diff --git a/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/MobileTaskAttachments/MobileTaskAttachmentStore.swift b/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/MobileTaskAttachments/MobileTaskAttachmentStore.swift new file mode 100644 index 000000000000..9d9e79a5f64c --- /dev/null +++ b/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/MobileTaskAttachments/MobileTaskAttachmentStore.swift @@ -0,0 +1,367 @@ +public import Foundation + +/// Filesystem-backed staging and finalization for mobile task attachments. +/// +/// The store carries no shared runtime state. Upload identity, progress, and +/// completion are represented on disk, so constructing a fresh store for each +/// RPC preserves idempotency across retries and app restarts. +public struct MobileTaskAttachmentStore { + /// Maximum raw bytes in one uploaded file. + public static let maximumFileBytes = 32 * 1024 * 1024 + /// Maximum raw bytes in one base64 RPC chunk. + public static let maximumChunkBytes = 3 * 1024 * 1024 + /// Maximum attachment identities in one task operation. + public static let maximumAttachmentsPerOperation = 10 + /// Maximum declared raw bytes across one task operation. + public static let maximumOperationBytes = 64 * 1024 * 1024 + /// Age after which abandoned operation directories are pruned. + public static let retentionInterval: TimeInterval = 7 * 24 * 60 * 60 + + private struct StagingMetadata: Codable { + let fileName: String + let totalBytes: Int + } + + private struct CompletionRecord: Codable { + let fileName: String + } + + private let rootURL: URL + private let now: Date + private let fileManager: FileManager + + /// Creates a task attachment store. + /// + /// - Parameters: + /// - rootURL: Root containing per-operation attachment directories. + /// - now: Current time used for pruning. + /// - fileManager: Filesystem implementation to use. + public init( + rootURL: URL, + now: Date, + fileManager: FileManager + ) { + self.rootURL = rootURL + self.now = now + self.fileManager = fileManager + } + + /// Default cache root under a user's home directory. + /// + /// - Parameter homeDirectory: Home directory that owns the cache. + /// - Returns: `~/.cache/cmux/task-attachments`. + public static func defaultRootURL(homeDirectory: URL) -> URL { + homeDirectory + .appendingPathComponent(".cache", isDirectory: true) + .appendingPathComponent("cmux", isDirectory: true) + .appendingPathComponent("task-attachments", isDirectory: true) + } + + /// Sanitizes an untrusted file name into a safe basename. + /// + /// Separators, NULs, and control characters become underscores; leading + /// dots are removed so uploads cannot create hidden files. + /// + /// - Parameter rawName: Untrusted client-provided file name. + /// - Returns: A nonempty safe basename. + public static func sanitizedFileName(_ rawName: String) -> String { + var characters: [Character] = [] + var previousWasReplacement = false + for character in rawName { + let isUnsafe = character == "/" || character == "\\" + || character.unicodeScalars.contains { + $0.value == 0 || CharacterSet.controlCharacters.contains($0) + } + if isUnsafe { + if !previousWasReplacement { + characters.append("_") + previousWasReplacement = true + } + } else { + characters.append(character) + previousWasReplacement = false + } + } + var sanitized = String(characters) + .trimmingCharacters(in: .whitespacesAndNewlines) + while sanitized.first == "." { + sanitized.removeFirst() + } + sanitized = sanitized.trimmingCharacters(in: .whitespacesAndNewlines) + if sanitized.isEmpty { + sanitized = "attachment" + } + return String(sanitized.prefix(180)) + } + + /// Appends one chunk and finalizes the file when requested. + /// + /// - Parameter request: Validated identities plus an encoded raw chunk. + /// - Returns: Staged progress and the final path when complete. + /// - Throws: ``MobileTaskAttachmentStoreError`` for invalid input or I/O. + public func upload( + _ request: MobileTaskAttachmentUploadRequest + ) throws -> MobileTaskAttachmentUploadResult { + do { + try fileManager.createDirectory( + at: rootURL, + withIntermediateDirectories: true + ) + pruneExpiredOperations(excluding: request.operationID) + + let operationURL = operationDirectory(for: request.operationID) + let stagingURL = operationURL.appendingPathComponent(".staging", isDirectory: true) + let metadataURL = operationURL.appendingPathComponent(".metadata", isDirectory: true) + let completedURL = operationURL.appendingPathComponent(".completed", isDirectory: true) + for directory in [operationURL, stagingURL, metadataURL, completedURL] { + try fileManager.createDirectory( + at: directory, + withIntermediateDirectories: true + ) + } + try fileManager.setAttributes( + [.modificationDate: now], + ofItemAtPath: operationURL.path + ) + + if let completed = try completedResult( + uploadID: request.uploadID, + operationURL: operationURL, + completedURL: completedURL + ) { + return completed + } + + let chunk = try decodedChunk(request.dataBase64) + try validate(request: request, chunkByteCount: chunk.count) + + let uploadName = request.uploadID.uuidString.lowercased() + let stagedFileURL = stagingURL.appendingPathComponent(uploadName) + let stagedMetadataURL = metadataURL + .appendingPathComponent(uploadName) + .appendingPathExtension("json") + let sanitizedName = Self.sanitizedFileName(request.fileName) + let metadata: StagingMetadata + if fileManager.fileExists(atPath: stagedMetadataURL.path) { + metadata = try decode(StagingMetadata.self, at: stagedMetadataURL) + guard metadata.totalBytes == request.totalBytes, + metadata.fileName == sanitizedName else { + throw invalidParams("Upload metadata changed during staging") + } + } else { + try enforceOperationCaps( + operationURL: operationURL, + metadataURL: metadataURL, + addingTotalBytes: request.totalBytes + ) + metadata = StagingMetadata( + fileName: sanitizedName, + totalBytes: request.totalBytes + ) + try encode(metadata, to: stagedMetadataURL) + } + + if !fileManager.fileExists(atPath: stagedFileURL.path) { + guard request.offset == 0 else { + throw invalidParams("Attachment chunks must use contiguous offsets") + } + guard fileManager.createFile(atPath: stagedFileURL.path, contents: nil) else { + throw storageFailure("Could not create attachment staging file") + } + } + let currentBytes = try fileSize(at: stagedFileURL) + guard currentBytes == request.offset else { + throw invalidParams("Attachment chunks must use contiguous offsets") + } + guard currentBytes + chunk.count <= metadata.totalBytes else { + throw invalidParams("Attachment chunk exceeds total_bytes") + } + if !chunk.isEmpty { + let handle = try FileHandle(forWritingTo: stagedFileURL) + defer { try? handle.close() } + try handle.seekToEnd() + try handle.write(contentsOf: chunk) + } + let receivedBytes = currentBytes + chunk.count + guard request.isLast else { + return MobileTaskAttachmentUploadResult( + receivedBytes: receivedBytes, + path: nil + ) + } + guard receivedBytes == metadata.totalBytes else { + throw invalidParams("Final attachment byte count does not match total_bytes") + } + + let finalFileName = deduplicatedFileName( + metadata.fileName, + in: operationURL + ) + let finalURL = operationURL.appendingPathComponent(finalFileName) + try fileManager.moveItem(at: stagedFileURL, to: finalURL) + let record = CompletionRecord(fileName: finalFileName) + let recordURL = completedURL + .appendingPathComponent(uploadName) + .appendingPathExtension("json") + try encode(record, to: recordURL) + try? fileManager.removeItem(at: stagedMetadataURL) + return MobileTaskAttachmentUploadResult( + receivedBytes: receivedBytes, + path: finalURL.path + ) + } catch let error as MobileTaskAttachmentStoreError { + throw error + } catch { + throw storageFailure("Could not store task attachment") + } + } + + private func operationDirectory(for operationID: UUID) -> URL { + rootURL.appendingPathComponent( + operationID.uuidString.lowercased(), + isDirectory: true + ) + } + + private func completedResult( + uploadID: UUID, + operationURL: URL, + completedURL: URL + ) throws -> MobileTaskAttachmentUploadResult? { + let recordURL = completedURL + .appendingPathComponent(uploadID.uuidString.lowercased()) + .appendingPathExtension("json") + guard fileManager.fileExists(atPath: recordURL.path) else { return nil } + let record = try decode(CompletionRecord.self, at: recordURL) + let finalURL = operationURL.appendingPathComponent(record.fileName) + guard fileManager.fileExists(atPath: finalURL.path) else { + try? fileManager.removeItem(at: recordURL) + return nil + } + return MobileTaskAttachmentUploadResult( + receivedBytes: try fileSize(at: finalURL), + path: finalURL.path + ) + } + + private func decodedChunk(_ base64: String) throws -> Data { + guard let data = Data(base64Encoded: base64) else { + throw invalidParams("data_b64 is not valid base64") + } + guard data.count <= Self.maximumChunkBytes else { + throw invalidParams("Attachment chunk exceeds 3 MiB") + } + return data + } + + private func validate( + request: MobileTaskAttachmentUploadRequest, + chunkByteCount: Int + ) throws { + guard request.totalBytes >= 0, + request.totalBytes <= Self.maximumFileBytes else { + throw invalidParams("total_bytes must be between 0 and 32 MiB") + } + guard request.offset >= 0, + request.offset <= request.totalBytes, + request.offset + chunkByteCount <= request.totalBytes else { + throw invalidParams("Attachment offset is outside total_bytes") + } + } + + private func enforceOperationCaps( + operationURL: URL, + metadataURL: URL, + addingTotalBytes: Int + ) throws { + let visibleFiles = try fileManager.contentsOfDirectory( + at: operationURL, + includingPropertiesForKeys: [.isRegularFileKey, .fileSizeKey], + options: [.skipsHiddenFiles] + ) + let metadataFiles = try fileManager.contentsOfDirectory( + at: metadataURL, + includingPropertiesForKeys: nil, + options: [.skipsHiddenFiles] + ) + guard visibleFiles.count + metadataFiles.count + < Self.maximumAttachmentsPerOperation else { + throw invalidParams("An operation can contain at most 10 attachments") + } + let completedBytes = try visibleFiles.reduce(0) { total, url in + total + (try fileSize(at: url)) + } + let stagedBytes = try metadataFiles.reduce(0) { total, url in + total + (try decode(StagingMetadata.self, at: url).totalBytes) + } + guard completedBytes + stagedBytes + addingTotalBytes + <= Self.maximumOperationBytes else { + throw invalidParams("An operation can contain at most 64 MiB of attachments") + } + } + + private func deduplicatedFileName(_ fileName: String, in directory: URL) -> String { + let candidate = directory.appendingPathComponent(fileName) + guard fileManager.fileExists(atPath: candidate.path) else { return fileName } + + let source = fileName as NSString + let ext = source.pathExtension + let stem = source.deletingPathExtension.isEmpty + ? "attachment" + : source.deletingPathExtension + var suffix = 2 + while true { + let next = ext.isEmpty + ? "\(stem)-\(suffix)" + : "\(stem)-\(suffix).\(ext)" + if !fileManager.fileExists( + atPath: directory.appendingPathComponent(next).path + ) { + return next + } + suffix += 1 + } + } + + private func fileSize(at url: URL) throws -> Int { + let attributes = try fileManager.attributesOfItem(atPath: url.path) + guard let size = attributes[.size] as? NSNumber else { + throw storageFailure("Could not read attachment byte count") + } + return size.intValue + } + + private func encode(_ value: Value, to url: URL) throws { + try JSONEncoder().encode(value).write(to: url, options: .atomic) + } + + private func decode(_ type: Value.Type, at url: URL) throws -> Value { + try JSONDecoder().decode(type, from: Data(contentsOf: url)) + } + + private func pruneExpiredOperations(excluding operationID: UUID) { + let currentName = operationID.uuidString.lowercased() + let cutoff = now.addingTimeInterval(-Self.retentionInterval) + guard let entries = try? fileManager.contentsOfDirectory( + at: rootURL, + includingPropertiesForKeys: [.isDirectoryKey, .contentModificationDateKey], + options: [.skipsHiddenFiles] + ) else { return } + for entry in entries where entry.lastPathComponent != currentName { + guard let values = try? entry.resourceValues( + forKeys: [.isDirectoryKey, .contentModificationDateKey] + ), values.isDirectory == true, + let modified = values.contentModificationDate, + modified < cutoff else { continue } + try? fileManager.removeItem(at: entry) + } + } + + private func invalidParams(_ message: String) -> MobileTaskAttachmentStoreError { + MobileTaskAttachmentStoreError(code: "invalid_params", message: message) + } + + private func storageFailure(_ message: String) -> MobileTaskAttachmentStoreError { + MobileTaskAttachmentStoreError(code: "internal_error", message: message) + } +} diff --git a/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/MobileTaskAttachments/MobileTaskAttachmentStoreError.swift b/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/MobileTaskAttachments/MobileTaskAttachmentStoreError.swift new file mode 100644 index 000000000000..4ef916e4fcd9 --- /dev/null +++ b/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/MobileTaskAttachments/MobileTaskAttachmentStoreError.swift @@ -0,0 +1,17 @@ +/// A wire-ready mobile task attachment storage failure. +public struct MobileTaskAttachmentStoreError: Error, Equatable, Sendable { + /// RPC error code. + public let code: String + /// Human-readable error message. + public let message: String + + /// Creates a wire-ready attachment error. + /// + /// - Parameters: + /// - code: RPC error code. + /// - message: Human-readable error message. + public init(code: String, message: String) { + self.code = code + self.message = message + } +} diff --git a/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/MobileTaskAttachments/MobileTaskAttachmentUploadRequest.swift b/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/MobileTaskAttachments/MobileTaskAttachmentUploadRequest.swift new file mode 100644 index 000000000000..2109fd60951d --- /dev/null +++ b/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/MobileTaskAttachments/MobileTaskAttachmentUploadRequest.swift @@ -0,0 +1,47 @@ +public import Foundation + +/// One raw chunk of a mobile task attachment upload. +public struct MobileTaskAttachmentUploadRequest: Equatable, Sendable { + /// Task idempotency key whose directory owns the attachment. + public let operationID: UUID + /// Client-minted attachment identity, stable across retries. + public let uploadID: UUID + /// User-visible source file name. + public let fileName: String + /// Declared raw byte count for the complete file. + public let totalBytes: Int + /// Contiguous raw byte offset for this chunk. + public let offset: Int + /// Base64-encoded raw chunk bytes. + public let dataBase64: String + /// Whether this chunk completes the upload. + public let isLast: Bool + + /// Creates an attachment upload request. + /// + /// - Parameters: + /// - operationID: Task idempotency key. + /// - uploadID: Stable attachment upload identity. + /// - fileName: User-visible source file name. + /// - totalBytes: Complete raw file byte count. + /// - offset: Contiguous raw byte offset. + /// - dataBase64: Base64-encoded raw chunk. + /// - isLast: Whether this is the final chunk. + public init( + operationID: UUID, + uploadID: UUID, + fileName: String, + totalBytes: Int, + offset: Int, + dataBase64: String, + isLast: Bool + ) { + self.operationID = operationID + self.uploadID = uploadID + self.fileName = fileName + self.totalBytes = totalBytes + self.offset = offset + self.dataBase64 = dataBase64 + self.isLast = isLast + } +} diff --git a/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/MobileTaskAttachments/MobileTaskAttachmentUploadResult.swift b/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/MobileTaskAttachments/MobileTaskAttachmentUploadResult.swift new file mode 100644 index 000000000000..72614d3fc3fb --- /dev/null +++ b/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/MobileTaskAttachments/MobileTaskAttachmentUploadResult.swift @@ -0,0 +1,17 @@ +/// Progress returned after one mobile task attachment chunk. +public struct MobileTaskAttachmentUploadResult: Equatable, Sendable { + /// Raw bytes durably staged after this request. + public let receivedBytes: Int + /// Final absolute path when the upload completed, otherwise `nil`. + public let path: String? + + /// Creates an upload result. + /// + /// - Parameters: + /// - receivedBytes: Raw bytes durably staged. + /// - path: Final absolute path after completion. + public init(receivedBytes: Int, path: String?) { + self.receivedBytes = receivedBytes + self.path = path + } +} diff --git a/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/MobileTaskModels/MobileTaskModel.swift b/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/MobileTaskModels/MobileTaskModel.swift new file mode 100644 index 000000000000..7b4cdc0e3dba --- /dev/null +++ b/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/MobileTaskModels/MobileTaskModel.swift @@ -0,0 +1,17 @@ +/// One coding-agent model returned to the mobile task composer. +public struct MobileTaskModel: Equatable, Sendable { + /// CLI identifier passed to the provider. + public let id: String + /// Product name displayed verbatim by the composer. + public let displayName: String + + /// Creates a discovered task model. + /// + /// - Parameters: + /// - id: CLI identifier passed to the provider. + /// - displayName: Product name displayed by the composer. + public init(id: String, displayName: String) { + self.id = id + self.displayName = displayName + } +} diff --git a/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/MobileTaskModels/MobileTaskModelDiscovery.swift b/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/MobileTaskModels/MobileTaskModelDiscovery.swift new file mode 100644 index 000000000000..4d41e26c32f0 --- /dev/null +++ b/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/MobileTaskModels/MobileTaskModelDiscovery.swift @@ -0,0 +1,91 @@ +public import Foundation + +/// Actor-isolated, per-provider cache around task model discovery. +public actor MobileTaskModelDiscovery { + /// Asynchronous clock read used for cache expiry. + public typealias Now = @Sendable () async -> Date + + private struct CacheEntry { + let result: MobileTaskModelListResult + let fetchedAt: Date + } + + /// Default lifetime for one provider's discovered result. + public static let defaultCacheTTL: TimeInterval = 10 * 60 + + private let strategy: MobileTaskModelProviderStrategy + private let now: Now + private let cacheTTL: TimeInterval + private var cache: [MobileTaskModelProvider: CacheEntry] = [:] + private var inFlight: [ + MobileTaskModelProvider: Task + ] = [:] + + /// Creates a cached discovery service. + /// + /// - Parameters: + /// - strategy: Provider-specific discovery implementation. + /// - now: Injected clock read. + /// - cacheTTL: Cache lifetime in seconds. + public init( + strategy: MobileTaskModelProviderStrategy, + now: @escaping Now = { Date() }, + cacheTTL: TimeInterval = MobileTaskModelDiscovery.defaultCacheTTL + ) { + self.strategy = strategy + self.now = now + self.cacheTTL = cacheTTL + } + + /// Creates the production discovery service for the current user. + /// + /// - Parameters: + /// - homeDirectory: Current user's home directory. + /// - shellPath: Login shell used to resolve the user's CLI `PATH`. + /// - Returns: A ten-minute cached discovery actor. + public static func live( + homeDirectory: URL, + shellPath: String + ) -> MobileTaskModelDiscovery { + let shellRunner = MobileTaskModelShellRunner(shellPath: shellPath) + let strategy = MobileTaskModelProviderStrategy( + homeDirectory: homeDirectory, + commandRunner: { command, timeout in + await shellRunner.run(command: command, timeout: timeout) + }, + fileReader: { url in + await Task.detached(priority: .utility) { + try? Data(contentsOf: url) + }.value + } + ) + return MobileTaskModelDiscovery(strategy: strategy) + } + + /// Returns a fresh cached result or performs one coalesced discovery. + /// + /// - Parameter provider: Provider whose models are requested. + /// - Returns: Discovered, augmented, or curated fallback models. + public func models( + for provider: MobileTaskModelProvider + ) async -> MobileTaskModelListResult { + let currentTime = await now() + if let cached = cache[provider], + currentTime.timeIntervalSince(cached.fetchedAt) < cacheTTL { + return cached.result + } + if let pending = inFlight[provider] { + return await pending.value + } + + let strategy = self.strategy + let pending = Task { + await strategy.models(for: provider) + } + inFlight[provider] = pending + let result = await pending.value + cache[provider] = CacheEntry(result: result, fetchedAt: await now()) + inFlight[provider] = nil + return result + } +} diff --git a/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/MobileTaskModels/MobileTaskModelListResult.swift b/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/MobileTaskModels/MobileTaskModelListResult.swift new file mode 100644 index 000000000000..f2309f0fb1df --- /dev/null +++ b/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/MobileTaskModels/MobileTaskModelListResult.swift @@ -0,0 +1,20 @@ +/// A task provider's models and the strategy that produced them. +public struct MobileTaskModelListResult: Equatable, Sendable { + /// Models in composer display order. + public let models: [MobileTaskModel] + /// Whether the list was discovered, augmented, or a fallback. + public let source: MobileTaskModelListSource + + /// Creates a task model list result. + /// + /// - Parameters: + /// - models: Models in composer display order. + /// - source: Strategy that produced the list. + public init( + models: [MobileTaskModel], + source: MobileTaskModelListSource + ) { + self.models = models + self.source = source + } +} diff --git a/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/MobileTaskModels/MobileTaskModelListSource.swift b/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/MobileTaskModels/MobileTaskModelListSource.swift new file mode 100644 index 000000000000..9c758edab9b3 --- /dev/null +++ b/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/MobileTaskModels/MobileTaskModelListSource.swift @@ -0,0 +1,9 @@ +/// How the Mac produced a task provider's model list. +public enum MobileTaskModelListSource: String, Equatable, Sendable { + /// A provider command returned an authoritative dynamic list. + case discovered + /// A configured default was prepended to the curated list. + case augmented + /// Discovery was unavailable, so the curated list was returned. + case fallback +} diff --git a/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/MobileTaskModels/MobileTaskModelParser.swift b/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/MobileTaskModels/MobileTaskModelParser.swift new file mode 100644 index 000000000000..b65c7958da60 --- /dev/null +++ b/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/MobileTaskModels/MobileTaskModelParser.swift @@ -0,0 +1,72 @@ +public import Foundation + +/// Pure parsers for provider command and configuration output. +public struct MobileTaskModelParser: Sendable { + /// Creates a model parser. + public init() {} + + /// Parses nonblank OpenCode output lines as model identifiers. + /// + /// Duplicate identifiers are removed while preserving first-seen order. + /// + /// - Parameter output: Standard output from `opencode models`. + /// - Returns: Unique, nonblank model identifiers in output order. + public func openCodeModelIDs(from output: String) -> [String] { + uniqueNonemptyStrings( + output.split(separator: "\n", omittingEmptySubsequences: false) + .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } + ) + } + + /// Parses a top-level quoted `model = "..."` assignment from Codex TOML. + /// + /// - Parameter data: UTF-8 contents of `~/.codex/config.toml`. + /// - Returns: The configured nonblank model identifier, if present. + public func codexConfiguredModel(from data: Data) -> String? { + guard let text = String(data: data, encoding: .utf8) else { return nil } + for line in text.split(separator: "\n", omittingEmptySubsequences: false) { + let trimmed = line.trimmingCharacters(in: .whitespacesAndNewlines) + // A TOML table header changes the scope for every following key. + // The configured default we want is only valid before that point. + if trimmed.hasPrefix("[") { + return nil + } + guard !trimmed.hasPrefix("#"), + let equals = trimmed.firstIndex(of: "="), + trimmed[.. String? { + guard let object = try? JSONSerialization.jsonObject(with: data) + as? [String: Any], + let rawModel = object["model"] as? String else { + return nil + } + let model = rawModel.trimmingCharacters(in: .whitespacesAndNewlines) + return model.isEmpty ? nil : model + } + + private func uniqueNonemptyStrings(_ values: [String]) -> [String] { + var seen: Set = [] + return values.filter { !$0.isEmpty && seen.insert($0).inserted } + } +} diff --git a/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/MobileTaskModels/MobileTaskModelProvider.swift b/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/MobileTaskModels/MobileTaskModelProvider.swift new file mode 100644 index 000000000000..7c6d2ed7c3d4 --- /dev/null +++ b/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/MobileTaskModels/MobileTaskModelProvider.swift @@ -0,0 +1,48 @@ +/// Coding-agent providers supported by mobile task model discovery. +public enum MobileTaskModelProvider: String, CaseIterable, Sendable { + /// Anthropic's Claude Code CLI. + case claude + /// OpenAI's Codex CLI. + case codex + /// The OpenCode CLI. + case openCode = "opencode" + + /// Curated fallback models in composer display order. + public var curatedModels: [MobileTaskModel] { + switch self { + case .claude: + [ + MobileTaskModel(id: "claude-fable-5", displayName: "Fable 5"), + MobileTaskModel(id: "claude-opus-4-8", displayName: "Opus 4.8"), + MobileTaskModel(id: "claude-sonnet-5", displayName: "Sonnet 5"), + MobileTaskModel(id: "claude-haiku-4-5", displayName: "Haiku 4.5"), + ] + case .codex: + [ + MobileTaskModel(id: "gpt-5.6-luna", displayName: "GPT-5.6 Luna"), + MobileTaskModel(id: "gpt-5.6-sol", displayName: "GPT-5.6 Sol"), + MobileTaskModel(id: "gpt-5.5", displayName: "GPT-5.5"), + ] + case .openCode: + [ + MobileTaskModel( + id: "anthropic/claude-sonnet-5", + displayName: "Claude Sonnet 5" + ), + MobileTaskModel( + id: "anthropic/claude-opus-4-8", + displayName: "Claude Opus 4.8" + ), + MobileTaskModel(id: "openai/gpt-5.5", displayName: "GPT-5.5"), + ] + } + } + + /// Returns the curated display name for an identifier when one exists. + /// + /// - Parameter id: Provider model identifier. + /// - Returns: Curated display name, or the raw identifier for a novel model. + public func displayName(for id: String) -> String { + curatedModels.first { $0.id == id }?.displayName ?? id + } +} diff --git a/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/MobileTaskModels/MobileTaskModelProviderStrategy.swift b/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/MobileTaskModels/MobileTaskModelProviderStrategy.swift new file mode 100644 index 000000000000..dbba6f42afa6 --- /dev/null +++ b/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/MobileTaskModels/MobileTaskModelProviderStrategy.swift @@ -0,0 +1,105 @@ +public import Foundation + +/// Provider-specific model discovery with injected process and filesystem I/O. +public struct MobileTaskModelProviderStrategy: Sendable { + /// Runs one login-shell command under a bounded timeout. + public typealias CommandRunner = @Sendable ( + _ command: String, + _ timeout: Duration + ) async -> String? + /// Reads one configuration file without blocking the caller's executor. + public typealias FileReader = @Sendable (_ url: URL) async -> Data? + + private let homeDirectory: URL + private let commandRunner: CommandRunner + private let fileReader: FileReader + private let parser: MobileTaskModelParser + + /// Creates a provider strategy. + /// + /// - Parameters: + /// - homeDirectory: User home containing provider configuration. + /// - commandRunner: Injected bounded login-shell command runner. + /// - fileReader: Injected asynchronous file reader. + /// - parser: Pure provider-output parser. + public init( + homeDirectory: URL, + commandRunner: @escaping CommandRunner, + fileReader: @escaping FileReader, + parser: MobileTaskModelParser = MobileTaskModelParser() + ) { + self.homeDirectory = homeDirectory + self.commandRunner = commandRunner + self.fileReader = fileReader + self.parser = parser + } + + /// Discovers or augments the curated list for one provider. + /// + /// Only OpenCode executes a CLI. Codex and Claude read their configuration + /// files and prepend a configured model when one is available. + /// + /// - Parameter provider: Provider whose model list is requested. + /// - Returns: Models plus the strategy source. + public func models( + for provider: MobileTaskModelProvider + ) async -> MobileTaskModelListResult { + switch provider { + case .openCode: + let output = await commandRunner("opencode models", .seconds(5)) + let ids = output.map(parser.openCodeModelIDs(from:)) ?? [] + guard !ids.isEmpty else { + return fallback(for: provider) + } + return MobileTaskModelListResult( + models: ids.map { + MobileTaskModel( + id: $0, + displayName: provider.displayName(for: $0) + ) + }, + source: .discovered + ) + case .codex: + let url = homeDirectory + .appendingPathComponent(".codex", isDirectory: true) + .appendingPathComponent("config.toml") + let configured = await fileReader(url) + .flatMap(parser.codexConfiguredModel(from:)) + return augmented(configuredModel: configured, for: provider) + case .claude: + let url = homeDirectory + .appendingPathComponent(".claude", isDirectory: true) + .appendingPathComponent("settings.json") + let configured = await fileReader(url) + .flatMap(parser.claudeConfiguredModel(from:)) + return augmented(configuredModel: configured, for: provider) + } + } + + private func fallback( + for provider: MobileTaskModelProvider + ) -> MobileTaskModelListResult { + MobileTaskModelListResult( + models: provider.curatedModels, + source: .fallback + ) + } + + private func augmented( + configuredModel: String?, + for provider: MobileTaskModelProvider + ) -> MobileTaskModelListResult { + guard let configuredModel else { return fallback(for: provider) } + let configured = MobileTaskModel( + id: configuredModel, + displayName: provider.displayName(for: configuredModel) + ) + return MobileTaskModelListResult( + models: [configured] + provider.curatedModels.filter { + $0.id != configuredModel + }, + source: .augmented + ) + } +} diff --git a/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/MobileTaskModels/MobileTaskModelShellRunner.swift b/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/MobileTaskModels/MobileTaskModelShellRunner.swift new file mode 100644 index 000000000000..5d87d7ca7bcf --- /dev/null +++ b/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/MobileTaskModels/MobileTaskModelShellRunner.swift @@ -0,0 +1,56 @@ +import Darwin +import Foundation + +/// Runs a short command through the user's login shell with a hard deadline. +public struct MobileTaskModelShellRunner: Sendable { + private let shellPath: String + + /// Creates a login-shell command runner. + /// + /// - Parameter shellPath: Absolute shell path, normally `$SHELL` or `/bin/zsh`. + public init(shellPath: String) { + self.shellPath = shellPath + } + + /// Runs a command and captures UTF-8 standard output. + /// + /// The blocking `Process` and pipe work stays in a detached utility task. + /// A cancellable clock task sends `SIGKILL` at the requested deadline. + /// + /// - Parameters: + /// - command: Static command text passed to the login shell with `-lc`. + /// - timeout: Hard execution deadline. + /// - Returns: Standard output on a zero exit, otherwise `nil`. + public func run(command: String, timeout: Duration) async -> String? { + await Task.detached(priority: .utility) { + let process = Process() + process.executableURL = URL(fileURLWithPath: shellPath) + process.arguments = ["-lc", command] + let outputPipe = Pipe() + process.standardOutput = outputPipe + process.standardError = FileHandle.nullDevice + do { + try process.run() + } catch { + return nil + } + + let processID = process.processIdentifier + let timeoutTask = Task.detached(priority: .utility) { + do { + try await ContinuousClock().sleep(for: timeout) + } catch { + return false + } + _ = Darwin.kill(processID, SIGKILL) + return true + } + let data = outputPipe.fileHandleForReading.readDataToEndOfFile() + process.waitUntilExit() + timeoutTask.cancel() + let timedOut = await timeoutTask.value + guard !timedOut, process.terminationStatus == 0 else { return nil } + return String(data: data, encoding: .utf8) + }.value + } +} diff --git a/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Wire/ControlCommandExecutionPolicy.swift b/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Wire/ControlCommandExecutionPolicy.swift index ed9df0a4e762..33859e1841c9 100644 --- a/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Wire/ControlCommandExecutionPolicy.swift +++ b/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Wire/ControlCommandExecutionPolicy.swift @@ -95,6 +95,9 @@ public enum ControlCommandExecutionPolicy: Sendable, Equatable { "browser.profiles.delete", "browser.import.cookies", "mobile.attach_ticket.create", + // Provider discovery may read configuration or run `opencode models`; + // it must never hold the main actor while waiting for process I/O. + "mobile.task.models.list", // `mobile.terminal.set_font` only validates params and emits a push // event via thread-safe MobileHostService statics, so it runs on the worker // like the other mobile data-plane verbs. Without this entry the policy diff --git a/Packages/macOS/CmuxControlSocket/Tests/CmuxControlSocketTests/ControlCommandContextTestStubs.swift b/Packages/macOS/CmuxControlSocket/Tests/CmuxControlSocketTests/ControlCommandContextTestStubs.swift index a44e98f8bcfe..026f35760b99 100644 --- a/Packages/macOS/CmuxControlSocket/Tests/CmuxControlSocketTests/ControlCommandContextTestStubs.swift +++ b/Packages/macOS/CmuxControlSocket/Tests/CmuxControlSocketTests/ControlCommandContextTestStubs.swift @@ -567,5 +567,6 @@ extension ControlMobileHostContext { func controlMobileTerminalScroll(params: [String: JSONValue]) -> ControlCallResult { mobileHostStubResult } func controlMobileTerminalMouse(params: [String: JSONValue]) -> ControlCallResult { mobileHostStubResult } func controlMobileTerminalPaste(params: [String: JSONValue]) -> ControlCallResult { mobileHostStubResult } + func controlMobileTaskAttachmentUpload(params: [String: JSONValue]) -> ControlCallResult { mobileHostStubResult } func controlMobileChatSessionsDump() -> ControlCallResult { mobileHostStubResult } } diff --git a/Packages/macOS/CmuxControlSocket/Tests/CmuxControlSocketTests/ControlCommandCoordinatorMobileHostTests.swift b/Packages/macOS/CmuxControlSocket/Tests/CmuxControlSocketTests/ControlCommandCoordinatorMobileHostTests.swift index 82227e7415ec..6989e69173d6 100644 --- a/Packages/macOS/CmuxControlSocket/Tests/CmuxControlSocketTests/ControlCommandCoordinatorMobileHostTests.swift +++ b/Packages/macOS/CmuxControlSocket/Tests/CmuxControlSocketTests/ControlCommandCoordinatorMobileHostTests.swift @@ -55,6 +55,20 @@ private final class FakeMobileHostControlCommandContext: ControlCommandContext { record("terminal.paste", params) } + func controlMobileTaskAttachmentUpload( + params: [String: JSONValue] + ) -> ControlCallResult { + record("task.attachment.upload", params) + } + + nonisolated func controlMobileTaskModelsList( + params: [String: JSONValue] + ) async -> ControlCallResult { + await MainActor.run { + record("task.models.list", params) + } + } + func controlMobileChatSessionsDump() -> ControlCallResult { record("chat.sessions.dump", [:]) } @@ -89,6 +103,30 @@ struct ControlCommandCoordinatorMobileHostTests { #expect(context.lastMarker == "chat.sessions.dump") } + @Test func v2SurfaceRoutesTaskAttachmentUploadThroughSeam() { + let (coordinator, context) = makeCoordinator() + #expect(coordinator.handle(request("mobile.task.attachment.upload")) != nil) + #expect(context.lastMarker == "task.attachment.upload") + } + + @Test func workerSurfaceRoutesTaskModelsListThroughAsyncSeam() async { + let (coordinator, context) = makeCoordinator() + let params: [String: JSONValue] = ["provider": .string("opencode")] + #expect( + await coordinator.handleMobileHostAsync( + request("mobile.task.models.list", params), + context: context + ) != nil + ) + #expect(context.lastMarker == "task.models.list") + #expect(context.lastParams == params) + #expect( + coordinator.handleMobileHost( + request("mobile.task.models.list", params) + ) == nil + ) + } + @Test func v2SurfaceUsesPrivateHostStatusVariant() { let (coordinator, context) = makeCoordinator() #expect(coordinator.handle(request("mobile.host.status")) != nil) diff --git a/Packages/macOS/CmuxControlSocket/Tests/CmuxControlSocketTests/ControlCommandExecutionPolicyTests.swift b/Packages/macOS/CmuxControlSocket/Tests/CmuxControlSocketTests/ControlCommandExecutionPolicyTests.swift index 5fbe0df319fd..091b112fd6df 100644 --- a/Packages/macOS/CmuxControlSocket/Tests/CmuxControlSocketTests/ControlCommandExecutionPolicyTests.swift +++ b/Packages/macOS/CmuxControlSocket/Tests/CmuxControlSocketTests/ControlCommandExecutionPolicyTests.swift @@ -34,7 +34,7 @@ struct ControlCommandExecutionPolicyTests { "sidebar.custom.open", "debug.sidebar.simulate_drag", "debug.mobile.transport.disconnect", "mobile.attach_ticket.create", - "mobile.terminal.set_font", + "mobile.terminal.set_font", "mobile.task.models.list", // JavaScript-evaluating browser methods block on page JS and must // not hold the main actor (see socketWorkerMethods rationale). "browser.eval", "browser.wait", "browser.snapshot", "browser.click", @@ -58,7 +58,8 @@ struct ControlCommandExecutionPolicyTests { for method in [ "workspace.create", "browser.url.get", "browser.open_split", "browser.get.title", "browser.frame.main", - "mobile.terminal.create", "feed.jump", "vmx.create", "", + "mobile.terminal.create", "mobile.task.attachment.upload", + "feed.jump", "vmx.create", "", // Focus-intent verbs stay on the main lane until the mutations // tranche decides them deliberately. "surface.focus", "workspace.select", "pane.focus", "window.focus", @@ -141,6 +142,7 @@ struct ControlCommandExecutionPolicyTests { #expect(ControlCommandExecutionPolicy(forMethod: "system.ping") == .socketWorker(mainThreadCallable: true)) #expect(ControlCommandExecutionPolicy(forMethod: "system.capabilities") == .socketWorker(mainThreadCallable: true)) #expect(ControlCommandExecutionPolicy(forMethod: "system.top") == .socketWorker(mainThreadCallable: false)) + #expect(ControlCommandExecutionPolicy(forMethod: "mobile.task.models.list") == .socketWorker(mainThreadCallable: false)) #expect(ControlCommandExecutionPolicy(forMethod: "vm.create") == .socketWorker(mainThreadCallable: false)) } diff --git a/Packages/macOS/CmuxControlSocket/Tests/CmuxControlSocketTests/MobileTaskAttachmentStoreTests.swift b/Packages/macOS/CmuxControlSocket/Tests/CmuxControlSocketTests/MobileTaskAttachmentStoreTests.swift new file mode 100644 index 000000000000..bea4b9b76cd5 --- /dev/null +++ b/Packages/macOS/CmuxControlSocket/Tests/CmuxControlSocketTests/MobileTaskAttachmentStoreTests.swift @@ -0,0 +1,183 @@ +import Foundation +import Testing +@testable import CmuxControlSocket + +@Suite("Mobile task attachment store") +struct MobileTaskAttachmentStoreTests { + @Test func chunkedUploadFinalizesAndRetryReturnsSamePath() throws { + let fixture = try Fixture() + defer { fixture.remove() } + let operationID = UUID() + let uploadID = UUID() + + let first = try fixture.store.upload(.init( + operationID: operationID, + uploadID: uploadID, + fileName: "../.notes.txt", + totalBytes: 5, + offset: 0, + dataBase64: Data("he".utf8).base64EncodedString(), + isLast: false + )) + #expect(first.receivedBytes == 2) + #expect(first.path == nil) + + let final = try fixture.store.upload(.init( + operationID: operationID, + uploadID: uploadID, + fileName: "../.notes.txt", + totalBytes: 5, + offset: 2, + dataBase64: Data("llo".utf8).base64EncodedString(), + isLast: true + )) + let path = try #require(final.path) + #expect(URL(fileURLWithPath: path).lastPathComponent == "_.notes.txt") + #expect(try Data(contentsOf: URL(fileURLWithPath: path)) == Data("hello".utf8)) + + let retry = try fixture.store.upload(.init( + operationID: operationID, + uploadID: uploadID, + fileName: "different.txt", + totalBytes: 99, + offset: 99, + dataBase64: "", + isLast: true + )) + #expect(retry.path == path) + #expect(retry.receivedBytes == 5) + } + + @Test func duplicateNamesReceiveNumericSuffixes() throws { + let fixture = try Fixture() + defer { fixture.remove() } + let operationID = UUID() + + let first = try fixture.complete( + operationID: operationID, + uploadID: UUID(), + fileName: "report.txt", + contents: "one" + ) + let second = try fixture.complete( + operationID: operationID, + uploadID: UUID(), + fileName: "report.txt", + contents: "two" + ) + + #expect(URL(fileURLWithPath: try #require(first.path)).lastPathComponent == "report.txt") + #expect(URL(fileURLWithPath: try #require(second.path)).lastPathComponent == "report-2.txt") + } + + @Test func rejectsOutOfOrderAndOversizedRequests() throws { + let fixture = try Fixture() + defer { fixture.remove() } + + #expect(throws: MobileTaskAttachmentStoreError.self) { + try fixture.store.upload(.init( + operationID: UUID(), + uploadID: UUID(), + fileName: "bad.txt", + totalBytes: 2, + offset: 1, + dataBase64: Data("x".utf8).base64EncodedString(), + isLast: false + )) + } + #expect(throws: MobileTaskAttachmentStoreError.self) { + try fixture.store.upload(.init( + operationID: UUID(), + uploadID: UUID(), + fileName: "huge.bin", + totalBytes: MobileTaskAttachmentStore.maximumFileBytes + 1, + offset: 0, + dataBase64: "", + isLast: true + )) + } + } + + @Test func enforcesAttachmentCountAndPrunesExpiredOperations() throws { + let now = Date(timeIntervalSince1970: 2_000_000) + let fixture = try Fixture(now: now) + defer { fixture.remove() } + let operationID = UUID() + for index in 0.. MobileTaskAttachmentUploadResult { + let data = Data(contents.utf8) + return try store.upload(.init( + operationID: operationID, + uploadID: uploadID, + fileName: fileName, + totalBytes: data.count, + offset: 0, + dataBase64: data.base64EncodedString(), + isLast: true + )) + } + + func remove() { + try? FileManager.default.removeItem(at: root) + } + } +} diff --git a/Packages/macOS/CmuxControlSocket/Tests/CmuxControlSocketTests/MobileTaskModelDiscoveryTests.swift b/Packages/macOS/CmuxControlSocket/Tests/CmuxControlSocketTests/MobileTaskModelDiscoveryTests.swift new file mode 100644 index 000000000000..cc4f73c99108 --- /dev/null +++ b/Packages/macOS/CmuxControlSocket/Tests/CmuxControlSocketTests/MobileTaskModelDiscoveryTests.swift @@ -0,0 +1,55 @@ +import Foundation +import Testing +@testable import CmuxControlSocket + +private actor MobileTaskModelDiscoveryProbe { + private(set) var commandCount = 0 + private(set) var now = Date(timeIntervalSince1970: 1_000) + + func run(_ command: String, timeout: Duration) -> String? { + commandCount += 1 + return "opencode/dynamic-\(commandCount)" + } + + func currentDate() -> Date { + now + } + + func advance(by interval: TimeInterval) { + now = now.addingTimeInterval(interval) + } +} + +@Suite("Mobile task model discovery cache") +struct MobileTaskModelDiscoveryTests { + @Test func cacheUsesInjectedClockAndExpiresAfterTenMinutes() async { + let probe = MobileTaskModelDiscoveryProbe() + let strategy = MobileTaskModelProviderStrategy( + homeDirectory: URL(fileURLWithPath: "/Users/tester"), + commandRunner: { command, timeout in + await probe.run(command, timeout: timeout) + }, + fileReader: { _ in nil } + ) + let discovery = MobileTaskModelDiscovery( + strategy: strategy, + now: { await probe.currentDate() } + ) + + let first = await discovery.models(for: .openCode) + let cached = await discovery.models(for: .openCode) + #expect(first == cached) + #expect(await probe.commandCount == 1) + + await probe.advance( + by: MobileTaskModelDiscovery.defaultCacheTTL - 1 + ) + #expect(await discovery.models(for: .openCode) == first) + #expect(await probe.commandCount == 1) + + await probe.advance(by: 2) + let refreshed = await discovery.models(for: .openCode) + #expect(refreshed.models.first?.id == "opencode/dynamic-2") + #expect(await probe.commandCount == 2) + } +} diff --git a/Packages/macOS/CmuxControlSocket/Tests/CmuxControlSocketTests/MobileTaskModelParserTests.swift b/Packages/macOS/CmuxControlSocket/Tests/CmuxControlSocketTests/MobileTaskModelParserTests.swift new file mode 100644 index 000000000000..50448d34853c --- /dev/null +++ b/Packages/macOS/CmuxControlSocket/Tests/CmuxControlSocketTests/MobileTaskModelParserTests.swift @@ -0,0 +1,65 @@ +import Foundation +import Testing +@testable import CmuxControlSocket + +@Suite("Mobile task model parsing") +struct MobileTaskModelParserTests { + private let parser = MobileTaskModelParser() + + @Test func openCodeKeepsEveryNonblankLineAndDeduplicatesInOrder() { + let output = """ + + anthropic/claude-sonnet-5 + warning: unexpected output + + opencode/big-pickle + anthropic/claude-sonnet-5 + """ + #expect(parser.openCodeModelIDs(from: output) == [ + "anthropic/claude-sonnet-5", + "warning: unexpected output", + "opencode/big-pickle", + ]) + } + + @Test func codexReadsOnlyAQuotedTopLevelModelAssignment() { + let data = Data(""" + # model = "ignored" + model_provider = "openai" + model = "gpt-5.6-sol" # trailing comment + """.utf8) + #expect(parser.codexConfiguredModel(from: data) == "gpt-5.6-sol") + } + + @Test(arguments: [ + "", + "model_provider = \"openai\"", + "model = \"\"", + "model = gpt-5.6-sol", + "# model = \"gpt-5.6-sol\"", + "[profiles.work]\nmodel = \"gpt-5.6-sol\"", + ]) + func codexReturnsNilWithoutAValidModelLine(_ text: String) { + #expect(parser.codexConfiguredModel(from: Data(text.utf8)) == nil) + } + + @Test func claudeReadsBedrockStyleModelIdentifier() { + let data = Data(#"{"model":"us.anthropic.claude-opus-5","theme":"dark"}"#.utf8) + #expect( + parser.claudeConfiguredModel(from: data) + == "us.anthropic.claude-opus-5" + ) + } + + @Test(arguments: [ + Data(), + Data("{}".utf8), + Data(#"{"model":null}"#.utf8), + Data(#"{"model":" "}"#.utf8), + Data(#"{"nested":{"model":"claude-opus-5"}}"#.utf8), + Data("not json".utf8), + ]) + func claudeReturnsNilWithoutATopLevelNonblankModel(_ data: Data) { + #expect(parser.claudeConfiguredModel(from: data) == nil) + } +} diff --git a/Packages/macOS/CmuxControlSocket/Tests/CmuxControlSocketTests/MobileTaskModelProviderStrategyTests.swift b/Packages/macOS/CmuxControlSocket/Tests/CmuxControlSocketTests/MobileTaskModelProviderStrategyTests.swift new file mode 100644 index 000000000000..43b74d7bd521 --- /dev/null +++ b/Packages/macOS/CmuxControlSocket/Tests/CmuxControlSocketTests/MobileTaskModelProviderStrategyTests.swift @@ -0,0 +1,162 @@ +import Foundation +import Testing +@testable import CmuxControlSocket + +private actor MobileTaskModelStrategyProbe { + private(set) var commands: [(String, Duration)] = [] + private(set) var readPaths: [String] = [] + var commandOutput: String? + var files: [String: Data] = [:] + + func run(_ command: String, timeout: Duration) -> String? { + commands.append((command, timeout)) + return commandOutput + } + + func read(_ url: URL) -> Data? { + readPaths.append(url.path) + return files[url.path] + } +} + +@Suite("Mobile task model provider strategy") +struct MobileTaskModelProviderStrategyTests { + private let home = URL(fileURLWithPath: "/Users/tester", isDirectory: true) + + @Test func openCodeDiscoveryReplacesCuratedModelsAndKeepsCuratedNames() async { + let probe = MobileTaskModelStrategyProbe() + await probe.setCommandOutput(""" + openai/gpt-5.5 + opencode/big-pickle + """) + let strategy = makeStrategy(probe: probe) + + let result = await strategy.models(for: .openCode) + + #expect(result == MobileTaskModelListResult( + models: [ + MobileTaskModel(id: "openai/gpt-5.5", displayName: "GPT-5.5"), + MobileTaskModel( + id: "opencode/big-pickle", + displayName: "opencode/big-pickle" + ), + ], + source: .discovered + )) + let commands = await probe.commands + #expect(commands.count == 1) + #expect(commands.first?.0 == "opencode models") + #expect(commands.first?.1 == .seconds(5)) + #expect(await probe.readPaths.isEmpty) + } + + @Test func emptyOrFailedOpenCodeDiscoveryFallsBackToCuratedModels() async { + let probe = MobileTaskModelStrategyProbe() + await probe.setCommandOutput("\n \n") + let strategy = makeStrategy(probe: probe) + #expect( + await strategy.models(for: .openCode) + == MobileTaskModelListResult( + models: MobileTaskModelProvider.openCode.curatedModels, + source: .fallback + ) + ) + await probe.setCommandOutput(nil) + #expect( + await strategy.models(for: .openCode) + == MobileTaskModelListResult( + models: MobileTaskModelProvider.openCode.curatedModels, + source: .fallback + ) + ) + } + + @Test func codexPrependsNovelConfiguredModelWithoutSpawningACommand() async { + let probe = MobileTaskModelStrategyProbe() + await probe.setFile( + path: "/Users/tester/.codex/config.toml", + data: Data("model = \"gpt-private-preview\"".utf8) + ) + let result = await makeStrategy(probe: probe).models(for: .codex) + + #expect(result.source == .augmented) + #expect(result.models.map(\.id) == [ + "gpt-private-preview", + "gpt-5.6-luna", + "gpt-5.6-sol", + "gpt-5.5", + ]) + #expect(result.models.first?.displayName == "gpt-private-preview") + #expect(await probe.commands.isEmpty) + #expect(await probe.readPaths == ["/Users/tester/.codex/config.toml"]) + } + + @Test func configuredCuratedModelMovesFirstAndIsDeduplicated() async { + let codexProbe = MobileTaskModelStrategyProbe() + await codexProbe.setFile( + path: "/Users/tester/.codex/config.toml", + data: Data("model = \"gpt-5.6-sol\"".utf8) + ) + let codex = await makeStrategy(probe: codexProbe).models(for: .codex) + #expect(codex.source == .augmented) + #expect(codex.models.map(\.id) == [ + "gpt-5.6-sol", + "gpt-5.6-luna", + "gpt-5.5", + ]) + #expect(codex.models.first?.displayName == "GPT-5.6 Sol") + + let claudeProbe = MobileTaskModelStrategyProbe() + await claudeProbe.setFile( + path: "/Users/tester/.claude/settings.json", + data: Data(#"{"model":"claude-opus-4-8"}"#.utf8) + ) + let claude = await makeStrategy(probe: claudeProbe).models(for: .claude) + #expect(claude.source == .augmented) + #expect(claude.models.map(\.id) == [ + "claude-opus-4-8", + "claude-fable-5", + "claude-sonnet-5", + "claude-haiku-4-5", + ]) + #expect(claude.models.first?.displayName == "Opus 4.8") + #expect(await claudeProbe.commands.isEmpty) + } + + @Test func missingConfigurationFilesReturnCuratedFallbacks() async { + let probe = MobileTaskModelStrategyProbe() + let strategy = makeStrategy(probe: probe) + let codex = await strategy.models(for: .codex) + let claude = await strategy.models(for: .claude) + + #expect(codex.source == .fallback) + #expect(codex.models == MobileTaskModelProvider.codex.curatedModels) + #expect(claude.source == .fallback) + #expect(claude.models == MobileTaskModelProvider.claude.curatedModels) + #expect(await probe.commands.isEmpty) + } + + private func makeStrategy( + probe: MobileTaskModelStrategyProbe + ) -> MobileTaskModelProviderStrategy { + MobileTaskModelProviderStrategy( + homeDirectory: home, + commandRunner: { command, timeout in + await probe.run(command, timeout: timeout) + }, + fileReader: { url in + await probe.read(url) + } + ) + } +} + +private extension MobileTaskModelStrategyProbe { + func setCommandOutput(_ output: String?) { + commandOutput = output + } + + func setFile(path: String, data: Data) { + files[path] = data + } +} diff --git a/Sources/Mobile/MobileHostService+Capabilities.swift b/Sources/Mobile/MobileHostService+Capabilities.swift index f6b6772be8dc..3b9c3e6828e0 100644 --- a/Sources/Mobile/MobileHostService+Capabilities.swift +++ b/Sources/Mobile/MobileHostService+Capabilities.swift @@ -73,6 +73,8 @@ extension MobileHostService { // expiry only against hosts that advertise this. "workspace.mutations.account_auth.v1", "workspace.task_create.v1", + "task.attachments.v1", + "task.models.v1", "workspace.directory_browse.v1", "workspace.directory_search.v1", "workspace.directory_search.v2", diff --git a/Sources/Mobile/MobileHostService+TicketAuthorization.swift b/Sources/Mobile/MobileHostService+TicketAuthorization.swift index d6dd2da93557..b60465907edb 100644 --- a/Sources/Mobile/MobileHostService+TicketAuthorization.swift +++ b/Sources/Mobile/MobileHostService+TicketAuthorization.swift @@ -39,6 +39,7 @@ extension MobileHostService { switch request.method { case "mobile.workspace.list", "workspace.list", "mobile.workspace.changes.summary", + "mobile.task.models.list", "mobile.directory.list", "mobile.directory.search": // List-shaped reads may span the Mac's workspaces; same-account // Stack authorization remains the authoritative data-plane gate. @@ -62,6 +63,10 @@ extension MobileHostService { return ticketMacScopedWorkspaceMutationAuthorizationError(authorization: authorization) } return nil + case "mobile.task.attachment.upload": + // Task uploads share the Mac-scoped authorization class of the + // workspace.create operation they precede. + return nil case "workspace.move": return ticketMacScopedWorkspaceMutationAuthorizationError( authorization: authorization, diff --git a/Sources/TerminalController+ControlMobileHostContext.swift b/Sources/TerminalController+ControlMobileHostContext.swift index f550acde844b..299f74563eb9 100644 --- a/Sources/TerminalController+ControlMobileHostContext.swift +++ b/Sources/TerminalController+ControlMobileHostContext.swift @@ -61,6 +61,22 @@ extension TerminalController: ControlMobileHostContext { bridgeMobileResult(v2MobileTerminalPaste(params: foundationParams(params))) } + func controlMobileTaskAttachmentUpload( + params: [String: JSONValue] + ) -> ControlCallResult { + bridgeMobileResult( + v2MobileTaskAttachmentUpload(params: foundationParams(params)) + ) + } + + nonisolated func controlMobileTaskModelsList( + params: [String: JSONValue] + ) async -> ControlCallResult { + bridgeMobileResult( + await v2MobileTaskModelsList(params: foundationParams(params)) + ) + } + func controlMobileChatSessionsDump() -> ControlCallResult { bridgeMobileResult(v2ChatSessionsDump()) } @@ -69,7 +85,9 @@ extension TerminalController: ControlMobileHostContext { /// typed params. This is the exact inverse of the dispatcher's /// `request.params.mapValues { $0.foundationObject }`, so the legacy body /// receives the identical Foundation dictionary it always did. - private func foundationParams(_ params: [String: JSONValue]) -> [String: Any] { + private nonisolated func foundationParams( + _ params: [String: JSONValue] + ) -> [String: Any] { params.mapValues(\.foundationObject) } @@ -77,7 +95,9 @@ extension TerminalController: ControlMobileHostContext { /// `ControlCallResult`. The mobile bodies only build valid-JSON payloads, so /// the bridge never fails; the empty-object / `nil` fallbacks keep the /// conversion total. - private func bridgeMobileResult(_ result: V2CallResult) -> ControlCallResult { + private nonisolated func bridgeMobileResult( + _ result: V2CallResult + ) -> ControlCallResult { switch result { case let .ok(payload): return .ok(JSONValue(foundationObject: payload) ?? .object([:])) diff --git a/Sources/TerminalController+MobileTaskAttachments.swift b/Sources/TerminalController+MobileTaskAttachments.swift new file mode 100644 index 000000000000..41c44249a4b1 --- /dev/null +++ b/Sources/TerminalController+MobileTaskAttachments.swift @@ -0,0 +1,56 @@ +import CmuxControlSocket +import Foundation + +extension TerminalController { + /// Handles one `mobile.task.attachment.upload` chunk. + func v2MobileTaskAttachmentUpload(params: [String: Any]) -> V2CallResult { + guard let operationIDString = v2RawString(params, "operation_id"), + let operationID = UUID(uuidString: operationIDString), + let uploadIDString = v2RawString(params, "upload_id"), + let uploadID = UUID(uuidString: uploadIDString), + let fileName = v2RawString(params, "file_name"), + let totalBytes = v2Int(params, "total_bytes"), + let offset = v2Int(params, "offset"), + let dataBase64 = v2RawString(params, "data_b64"), + let isLast = params["last"] as? Bool else { + return .err( + code: "invalid_params", + message: "Missing or invalid attachment upload parameters", + data: nil + ) + } + let store = MobileTaskAttachmentStore( + rootURL: MobileTaskAttachmentStore.defaultRootURL( + homeDirectory: FileManager.default.homeDirectoryForCurrentUser + ), + now: Date(), + fileManager: FileManager.default + ) + do { + let result = try store.upload(MobileTaskAttachmentUploadRequest( + operationID: operationID, + uploadID: uploadID, + fileName: fileName, + totalBytes: totalBytes, + offset: offset, + dataBase64: dataBase64, + isLast: isLast + )) + var payload: [String: Any] = [ + "received_bytes": result.receivedBytes, + ] + if let path = result.path { + payload["path"] = path + } + return .ok(payload) + } catch let error as MobileTaskAttachmentStoreError { + return .err(code: error.code, message: error.message, data: nil) + } catch { + return .err( + code: "internal_error", + message: "Could not store task attachment", + data: nil + ) + } + } +} diff --git a/Sources/TerminalController+MobileTaskModels.swift b/Sources/TerminalController+MobileTaskModels.swift new file mode 100644 index 000000000000..75cb493252eb --- /dev/null +++ b/Sources/TerminalController+MobileTaskModels.swift @@ -0,0 +1,28 @@ +import CmuxControlSocket +import Foundation + +extension TerminalController { + /// Handles one `mobile.task.models.list` provider discovery request. + nonisolated func v2MobileTaskModelsList( + params: [String: Any] + ) async -> V2CallResult { + guard let rawProvider = v2RawString(params, "provider"), + let provider = MobileTaskModelProvider(rawValue: rawProvider) else { + return .err( + code: "invalid_params", + message: "provider must be claude, codex, or opencode", + data: nil + ) + } + let result = await mobileTaskModelDiscovery.models(for: provider) + return .ok([ + "models": result.models.map { + [ + "id": $0.id, + "display_name": $0.displayName, + ] + }, + "source": result.source.rawValue, + ]) + } +} diff --git a/Sources/TerminalController.swift b/Sources/TerminalController.swift index f2ed8d94272e..878e7365446d 100644 --- a/Sources/TerminalController.swift +++ b/Sources/TerminalController.swift @@ -123,6 +123,8 @@ class TerminalController { private nonisolated(unsafe) var remotePTYControllerAvailabilityGeneration: UInt64 = 0 /// One process-wide admission budget shared by every mobile connection. nonisolated let mobileTaskFilesystemJobQuota: MobileTaskFilesystemJobQuota + /// Actor-isolated ten-minute cache for mobile task model discovery. + nonisolated let mobileTaskModelDiscovery: MobileTaskModelDiscovery var tabManager: TabManager? let workspaceCreateIdempotencyCache = WorkspaceCreateIdempotencyCache(capacity: 256) /// The shared auth coordinator + account flow, injected once via @@ -373,6 +375,10 @@ class TerminalController { maximumConcurrentClaims: 32 ), mobileTaskFilesystemJobQuota: MobileTaskFilesystemJobQuota = .init(), + mobileTaskModelDiscovery: MobileTaskModelDiscovery = .live( + homeDirectory: FileManager.default.homeDirectoryForCurrentUser, + shellPath: ProcessInfo.processInfo.environment["SHELL"] ?? "/bin/zsh" + ), terminalArtifactAuthorizationStore: TerminalArtifactAuthorizationStore = .init(), remoteProxyBroker: any RemoteProxyBrokering = RemoteProxyBroker( tunnelProvider: RemoteDaemonProxyTunnelProvider(strings: .appLocalized, ptyBridgeStrings: AppRemotePTYBridgeStrings()) @@ -387,6 +393,7 @@ class TerminalController { self.socketClientCapabilityAuthority = Self.makeSocketClientCapabilityAuthority() self.socketClientPreauthorizationLimiter = socketClientPreauthorizationLimiter self.mobileTaskFilesystemJobQuota = mobileTaskFilesystemJobQuota + self.mobileTaskModelDiscovery = mobileTaskModelDiscovery self.terminalArtifactAuthorizationStore = terminalArtifactAuthorizationStore self.transport = transport self.remoteProxyBroker = remoteProxyBroker @@ -1072,6 +1079,34 @@ class TerminalController { return response } } + if request.method == "mobile.task.models.list" { + return v2AsyncResultCall( + id: request.id, + timeoutSeconds: 7 + ) { + guard let result = await self.controlCommandCoordinator + .handleMobileHostAsync( + parsedRequest, + context: self + ) else { + return .err( + code: "method_not_found", + message: "Unknown method", + data: nil + ) + } + switch result { + case .ok(let payload): + return .ok(payload.foundationObject) + case let .err(code, message, data): + return .err( + code: code, + message: message, + data: data?.foundationObject + ) + } + } + } // Coordinator-owned worker-lane bodies (the tranche-D resolution // reads): nonisolated coordinator code runs on this worker thread // — pure parse plus JSON payload build — with ONE @@ -2469,6 +2504,8 @@ class TerminalController { "mobile.host.status", "mobile.attach_ticket.create", "mobile.terminal.set_font", + "mobile.task.attachment.upload", + "mobile.task.models.list", "mobile.workspace.list", "mobile.terminal.create", "mobile.terminal.input", @@ -14071,6 +14108,10 @@ class TerminalController { ) case "workspace.create": result = await v2MobileWorkspaceCreate(params: request.params) + case "mobile.task.attachment.upload": + result = v2MobileTaskAttachmentUpload(params: request.params) + case "mobile.task.models.list": + result = await v2MobileTaskModelsList(params: request.params) case "mobile.terminal.create", "terminal.create": result = v2MobileTerminalCreate(params: request.params) case "mobile.terminal.input", "terminal.input": diff --git a/cmux.xcodeproj/project.pbxproj b/cmux.xcodeproj/project.pbxproj index d537758159ca..510fbedf74bd 100644 --- a/cmux.xcodeproj/project.pbxproj +++ b/cmux.xcodeproj/project.pbxproj @@ -2206,6 +2206,8 @@ C0DE71B10000000000000001 /* AppDelegate+AgentChatNotifications.swift in Sources D1A700000000000000000002 /* TerminalController+MobileDirectorySearch.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1A700000000000000000005 /* TerminalController+MobileDirectorySearch.swift */; }; D7AB00000000000000B001 /* TerminalController+MobileNotificationSync.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7AB00000000000000B000 /* TerminalController+MobileNotificationSync.swift */; }; D7AB00000000000000B011 /* TerminalController+MobileScrollPrefetch.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7AB00000000000000B010 /* TerminalController+MobileScrollPrefetch.swift */; }; + A77A30000000000000000002 /* TerminalController+MobileTaskAttachments.swift in Sources */ = {isa = PBXBuildFile; fileRef = A77A30000000000000000001 /* TerminalController+MobileTaskAttachments.swift */; }; + A77A31000000000000000002 /* TerminalController+MobileTaskModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = A77A31000000000000000001 /* TerminalController+MobileTaskModels.swift */; }; C7AF20000000000000000001 /* TerminalController+MobileTerminalArtifacts.swift in Sources */ = {isa = PBXBuildFile; fileRef = C7AF20000000000000000002 /* TerminalController+MobileTerminalArtifacts.swift */; }; D1FF00010000000000000002 /* TerminalController+MobileWorkspaceChanges.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1FF00010000000000000001 /* TerminalController+MobileWorkspaceChanges.swift */; }; C7A50B000000000000000012 /* TerminalController+MobileWorkspaceList.swift in Sources */ = {isa = PBXBuildFile; fileRef = C7A50B000000000000000011 /* TerminalController+MobileWorkspaceList.swift */; }; @@ -4791,6 +4793,8 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = D1A700000000000000000005 /* TerminalController+MobileDirectorySearch.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TerminalController+MobileDirectorySearch.swift"; sourceTree = ""; }; D7AB00000000000000B000 /* TerminalController+MobileNotificationSync.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TerminalController+MobileNotificationSync.swift"; sourceTree = ""; }; D7AB00000000000000B010 /* TerminalController+MobileScrollPrefetch.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TerminalController+MobileScrollPrefetch.swift"; sourceTree = ""; }; + A77A30000000000000000001 /* TerminalController+MobileTaskAttachments.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TerminalController+MobileTaskAttachments.swift"; sourceTree = ""; }; + A77A31000000000000000001 /* TerminalController+MobileTaskModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TerminalController+MobileTaskModels.swift"; sourceTree = ""; }; C7AF20000000000000000002 /* TerminalController+MobileTerminalArtifacts.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = "TerminalController+MobileTerminalArtifacts.swift"; sourceTree = ""; }; D1FF00010000000000000001 /* TerminalController+MobileWorkspaceChanges.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TerminalController+MobileWorkspaceChanges.swift"; sourceTree = ""; }; C7A50B000000000000000011 /* TerminalController+MobileWorkspaceList.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TerminalController+MobileWorkspaceList.swift"; sourceTree = ""; }; @@ -6301,6 +6305,8 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = F1100000000000000000000F /* SyntheticKeyEventFactory.swift */, ACA7C4A7000000000000000E /* TerminalController+MobileChat.swift */, C7AF10000000000000000004 /* TerminalController+MobileChatArtifacts.swift */, + A77A30000000000000000001 /* TerminalController+MobileTaskAttachments.swift */, + A77A31000000000000000001 /* TerminalController+MobileTaskModels.swift */, C7AF20000000000000000002 /* TerminalController+MobileTerminalArtifacts.swift */, C0DE00000000000000000C53 /* TerminalController+ControlWorkspaceGroupContext.swift */, 466DEB47603469A59697D418 /* TerminalController+ControlWorkspaceTodoContext.swift */, @@ -9776,6 +9782,8 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = D1A700000000000000000002 /* TerminalController+MobileDirectorySearch.swift in Sources */, D7AB00000000000000B001 /* TerminalController+MobileNotificationSync.swift in Sources */, D7AB00000000000000B011 /* TerminalController+MobileScrollPrefetch.swift in Sources */, + A77A30000000000000000002 /* TerminalController+MobileTaskAttachments.swift in Sources */, + A77A31000000000000000002 /* TerminalController+MobileTaskModels.swift in Sources */, C7AF20000000000000000001 /* TerminalController+MobileTerminalArtifacts.swift in Sources */, D1FF00010000000000000002 /* TerminalController+MobileWorkspaceChanges.swift in Sources */, C7A50B000000000000000012 /* TerminalController+MobileWorkspaceList.swift in Sources */, diff --git a/ios/cmux/Resources/Localizable.xcstrings b/ios/cmux/Resources/Localizable.xcstrings index 483decc5360c..7b6dda22cb2b 100644 --- a/ios/cmux/Resources/Localizable.xcstrings +++ b/ios/cmux/Resources/Localizable.xcstrings @@ -7005,6 +7005,329 @@ } } }, + "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 layout and model variant, then reopen New Task." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "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": "コンポーザー" + } + } + } + }, + "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.connectionFocused": { "extractionState": "manual", "localizations": { @@ -9861,6 +10184,111 @@ } } }, + "mobile.taskComposer.attachments.add": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "Add Attachment" } }, + "ja": { "stringUnit": { "state": "translated", "value": "添付ファイルを追加" } } + } + }, + "mobile.taskComposer.attachments.alert.title": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "Couldn’t Add Attachment" } }, + "ja": { "stringUnit": { "state": "translated", "value": "添付ファイルを追加できませんでした" } } + } + }, + "mobile.taskComposer.attachments.chooseFiles": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "Choose Files" } }, + "ja": { "stringUnit": { "state": "translated", "value": "ファイルを選択" } } + } + }, + "mobile.taskComposer.attachments.fileTooLarge": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "Choose a file smaller than 32 MB." } }, + "ja": { "stringUnit": { "state": "translated", "value": "32 MB未満のファイルを選択してください。" } } + } + }, + "mobile.taskComposer.attachments.imageRejected": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "That image couldn’t be compressed below 8 MB." } }, + "ja": { "stringUnit": { "state": "translated", "value": "画像を8 MB未満に圧縮できませんでした。" } } + } + }, + "mobile.taskComposer.attachments.limit.count": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "You can attach up to 10 items to a task." } }, + "ja": { "stringUnit": { "state": "translated", "value": "1つのタスクに添付できる項目は10件までです。" } } + } + }, + "mobile.taskComposer.attachments.limit.totalSize": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "Task attachments can use up to 64 MB in total." } }, + "ja": { "stringUnit": { "state": "translated", "value": "タスクの添付ファイルは合計64 MBまでです。" } } + } + }, + "mobile.taskComposer.attachments.photoLibrary": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "Photo Library" } }, + "ja": { "stringUnit": { "state": "translated", "value": "写真ライブラリ" } } + } + }, + "mobile.taskComposer.attachments.remove": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "Remove Attachment" } }, + "ja": { "stringUnit": { "state": "translated", "value": "添付ファイルを削除" } } + } + }, + "mobile.taskComposer.attachments.unreadable": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "That file couldn’t be read. Choose another file." } }, + "ja": { "stringUnit": { "state": "translated", "value": "ファイルを読み込めませんでした。別のファイルを選択してください。" } } + } + }, + "mobile.taskComposer.attachments.upload.authorization": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "That Mac did not authorize the attachment upload." } }, + "ja": { "stringUnit": { "state": "translated", "value": "Macが添付ファイルのアップロードを承認しませんでした。" } } + } + }, + "mobile.taskComposer.attachments.upload.failed": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "The attachments couldn’t be uploaded. Check the files and try again." } }, + "ja": { "stringUnit": { "state": "translated", "value": "添付ファイルをアップロードできませんでした。ファイルを確認して、もう一度お試しください。" } } + } + }, + "mobile.taskComposer.attachments.upload.notConnected": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "The attachments couldn’t be uploaded because that Mac is not connected." } }, + "ja": { "stringUnit": { "state": "translated", "value": "Macが接続されていないため、添付ファイルをアップロードできませんでした。" } } + } + }, + "mobile.taskComposer.attachments.upload.timedOut": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "The Mac did not finish uploading the attachments in time." } }, + "ja": { "stringUnit": { "state": "translated", "value": "Macでの添付ファイルのアップロードが時間内に完了しませんでした。" } } + } + }, + "mobile.taskComposer.attachments.upload.unsupported": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "That Mac does not support task attachments." } }, + "ja": { "stringUnit": { "state": "translated", "value": "そのMacはタスクの添付ファイルに対応していません。" } } + } + }, "mobile.taskComposer.agent": { "extractionState": "manual", "localizations": { @@ -9946,6 +10374,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": { @@ -11153,6 +11598,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": { @@ -11187,6 +11683,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": { @@ -11901,6 +12414,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": { diff --git a/ios/cmuxUITests/cmuxUITests.swift b/ios/cmuxUITests/cmuxUITests.swift index fffc1b7c0881..721dd4a91917 100644 --- a/ios/cmuxUITests/cmuxUITests.swift +++ b/ios/cmuxUITests/cmuxUITests.swift @@ -1730,6 +1730,148 @@ final class cmuxUITests: XCTestCase { } } + /// The Composer pill scroller must clip between its neighboring controls; + /// it must not underlap them to render a blur or fade at either edge. + @MainActor + func testTaskComposerComposerPillScrollerUsesHardEdges() throws { + let app = launchApp(mockData: false, environment: [ + "CMUX_UITEST_TASK_COMPOSER_PREVIEW": "1", + "CMUX_UITEST_TASK_COMPOSER_LAYOUT": "composer", + "CMUX_UITEST_TASK_COMPOSER_MODEL_VARIANT": "combined", + ]) + defer { app.terminate() } + + let prompt = app.descendants(matching: .any)["MobileTaskComposerPrompt"] + XCTAssertTrue(prompt.waitForExistence(timeout: 8)) + + let options = app.buttons["MobileTaskComposerOptionsButton"] + let scroller = app.scrollViews["MobileTaskComposerPillScroller"] + let submit = app.buttons["MobileTaskComposerSubmitButton"] + XCTAssertTrue(options.waitForExistence(timeout: 3)) + XCTAssertTrue(scroller.waitForExistence(timeout: 3)) + XCTAssertTrue(submit.waitForExistence(timeout: 3)) + + XCTAssertGreaterThanOrEqual( + scroller.frame.minX, + options.frame.maxX, + "The scroller must begin after the fixed options control" + ) + XCTAssertLessThanOrEqual( + scroller.frame.maxX, + submit.frame.minX, + "The scroller must end before the fixed submit control" + ) + + let agentPill = app.buttons["MobileTaskComposerAgentPill"] + XCTAssertTrue(agentPill.waitForExistence(timeout: 3)) + tap(agentPill, in: app) + tapMenuItem(app.buttons["OpenCode"], in: app) + + let modelPill = app.buttons["MobileTaskComposerModelPill"] + XCTAssertTrue(modelPill.waitForExistence(timeout: 3)) + tap(modelPill, in: app) + tapMenuItem(app.buttons["Claude Opus 4.8"], in: app) + let modelXBeforeScroll = modelPill.frame.midX + + scroller.swipeLeft(velocity: .slow) + + XCTAssertLessThan( + modelPill.frame.midX, + modelXBeforeScroll, + "The constrained pill region must remain horizontally scrollable" + ) + XCTAssertGreaterThanOrEqual(scroller.frame.minX, options.frame.maxX) + XCTAssertLessThanOrEqual(scroller.frame.maxX, submit.frame.minX) + + let attachment = XCTAttachment(screenshot: app.screenshot()) + attachment.name = "task-composer-hard-scroll-edges" + attachment.lifetime = .keepAlways + add(attachment) + } + + /// A drag that begins in an overflowing prompt belongs to the text editor; + /// it must not dismiss the keyboard or move the enclosing sheet. + @MainActor + func testTaskComposerPromptScrollDoesNotDragSheet() throws { + let app = launchApp(mockData: false, environment: [ + "CMUX_UITEST_TASK_COMPOSER_PREVIEW": "1", + "CMUX_UITEST_TASK_COMPOSER_LAYOUT": "composer", + "CMUX_UITEST_TASK_COMPOSER_LONG_PROMPT": "1", + ]) + defer { app.terminate() } + + let prompt = app.descendants(matching: .any)["MobileTaskComposerPrompt"] + XCTAssertTrue(prompt.waitForExistence(timeout: 8)) + let keyboard = app.keyboards.firstMatch + XCTAssertTrue(keyboard.waitForExistence(timeout: 3)) + let promptFrameBeforeScroll = prompt.frame + + prompt.swipeUp(velocity: .slow) + + XCTAssertTrue( + keyboard.exists, + "Scrolling the prompt must keep the editing keyboard presented" + ) + XCTAssertTrue(prompt.exists, "Scrolling the prompt must not dismiss the composer sheet") + XCTAssertEqual( + prompt.frame.minY, + promptFrameBeforeScroll.minY, + accuracy: 2, + "Scrolling the prompt must not drag the enclosing sheet" + ) + + let attachment = XCTAttachment(screenshot: app.screenshot()) + attachment.name = "task-composer-prompt-scroll-owns-drag" + attachment.lifetime = .keepAlways + add(attachment) + } + + /// Moving away from a long prompt's caret must leave the editor at the + /// user's chosen scroll position. Inserting at the visible top proves the + /// viewport did not silently return to the caret at the end of the draft. + @MainActor + func testTaskComposerPromptScrollAwayFromCaretRemainsAtTop() throws { + let app = launchApp(mockData: false, environment: [ + "CMUX_UITEST_TASK_COMPOSER_PREVIEW": "1", + "CMUX_UITEST_TASK_COMPOSER_LAYOUT": "composer", + "CMUX_UITEST_TASK_COMPOSER_LONG_PROMPT": "1", + "CMUX_UITEST_TASK_COMPOSER_MODEL_VARIANT": "combined", + ]) + defer { app.terminate() } + + let prompt = app.descendants(matching: .any)["MobileTaskComposerPrompt"] + XCTAssertTrue(prompt.waitForExistence(timeout: 8)) + XCTAssertTrue(app.keyboards.firstMatch.waitForExistence(timeout: 3)) + + for _ in 0..<3 { + prompt.swipeDown(velocity: .fast) + } + + let modelPill = app.buttons["MobileTaskComposerModelPill"] + XCTAssertTrue(modelPill.waitForExistence(timeout: 3)) + modelPill.tap() + tapMenuItem(app.buttons["Opus 4.8"], in: app) + XCTAssertTrue(app.keyboards.firstMatch.exists) + + let marker = "TOP_SCROLL_MARKER" + prompt.coordinate(withNormalizedOffset: CGVector(dx: 0.25, dy: 0.12)).tap() + prompt.typeText(marker) + + let value = try XCTUnwrap(prompt.value as? String) + let markerRange = try XCTUnwrap(value.range(of: marker)) + let markerOffset = value[..