diff --git a/Packages/Shared/CMUXMobileCore/Sources/CMUXMobileCore/MobileStateSyncRecords.swift b/Packages/Shared/CMUXMobileCore/Sources/CMUXMobileCore/MobileStateSyncRecords.swift index ad4fa429714..f58bc46b06f 100644 --- a/Packages/Shared/CMUXMobileCore/Sources/CMUXMobileCore/MobileStateSyncRecords.swift +++ b/Packages/Shared/CMUXMobileCore/Sources/CMUXMobileCore/MobileStateSyncRecords.swift @@ -35,6 +35,43 @@ public struct MobileSyncCollectionID: RawRepresentable, Codable, Hashable, Senda /// `mobile.workspace.list` payload (same snake_case wire names) plus an /// explicit `sort_index` so list order syncs without positional inference. public struct WorkspaceSyncRecord: MobileSyncRecord { + /// One surface row within a workspace. + public struct Surface: Codable, Equatable, Sendable { + /// Stable surface identifier. + public let surfaceID: String + /// Open surface-kind wire string. + public let kind: String + /// User-facing surface title. + public let title: String + /// Backing file path for file-based surfaces, when reported. + public let filePath: String? + /// Bounded checklist/status payload for todo surfaces. + public let todo: MobileTodoSnapshot? + + /// Creates a surface row from its wire fields. + public init( + surfaceID: String, + kind: String, + title: String, + filePath: String?, + todo: MobileTodoSnapshot? = nil + ) { + self.surfaceID = surfaceID + self.kind = kind + self.title = title + self.filePath = filePath + self.todo = todo + } + + private enum CodingKeys: String, CodingKey { + case surfaceID = "surface_id" + case kind + case title + case filePath = "file_path" + case todo + } + } + /// One terminal row within a workspace. public struct Terminal: Codable, Equatable, Sendable { /// Stable terminal identifier. @@ -104,6 +141,9 @@ public struct WorkspaceSyncRecord: MobileSyncRecord { public let sortIndex: Int /// Terminal rows belonging to this workspace, in spatial order. public let terminals: [Terminal] + /// All surface rows belonging to this workspace, in spatial order. + /// `nil` when decoded from a Mac that predates surface inventory support. + public let surfaces: [Surface]? /// Simulator panes belonging to this workspace, in spatial order. public let simulators: [MobileSimulatorPanelDescriptor] @@ -130,6 +170,7 @@ public struct WorkspaceSyncRecord: MobileSyncRecord { hasUnread: Bool, sortIndex: Int, terminals: [Terminal], + surfaces: [Surface]? = nil, simulators: [MobileSimulatorPanelDescriptor] = [] ) { self.id = id @@ -148,6 +189,7 @@ public struct WorkspaceSyncRecord: MobileSyncRecord { self.hasUnread = hasUnread self.sortIndex = sortIndex self.terminals = terminals + self.surfaces = surfaces self.simulators = simulators } @@ -172,6 +214,7 @@ public struct WorkspaceSyncRecord: MobileSyncRecord { hasUnread = try container.decode(Bool.self, forKey: .hasUnread) sortIndex = try container.decode(Int.self, forKey: .sortIndex) terminals = try container.decode([Terminal].self, forKey: .terminals) + surfaces = try container.decodeIfPresent([Surface].self, forKey: .surfaces) simulators = try container.decodeIfPresent( [MobileSimulatorPanelDescriptor].self, forKey: .simulators @@ -195,6 +238,7 @@ public struct WorkspaceSyncRecord: MobileSyncRecord { case hasUnread = "has_unread" case sortIndex = "sort_index" case terminals + case surfaces case simulators } } diff --git a/Packages/Shared/CMUXMobileCore/Sources/CMUXMobileCore/MobileSurfaceKind.swift b/Packages/Shared/CMUXMobileCore/Sources/CMUXMobileCore/MobileSurfaceKind.swift new file mode 100644 index 00000000000..22312574e6c --- /dev/null +++ b/Packages/Shared/CMUXMobileCore/Sources/CMUXMobileCore/MobileSurfaceKind.swift @@ -0,0 +1,48 @@ +/// A mobile surface kind identified by its open wire string. +/// +/// Known kinds have static constants, while unknown raw values remain valid so +/// older clients can preserve and route surface kinds introduced by newer Macs. +public struct MobileSurfaceKind: RawRepresentable, Codable, Hashable, Sendable { + /// The surface kind's wire identifier. + public let rawValue: String + + /// Creates a surface kind from its wire identifier. + /// - Parameter rawValue: The open surface-kind string. + public init(rawValue: String) { + self.rawValue = rawValue + } + + /// Decodes the kind directly from its open wire string. + public init(from decoder: any Decoder) throws { + rawValue = try decoder.singleValueContainer().decode(String.self) + } + + /// Encodes the kind directly as its open wire string. + public func encode(to encoder: any Encoder) throws { + var container = encoder.singleValueContainer() + try container.encode(rawValue) + } + + /// A Ghostty terminal surface. + public static let terminal = MobileSurfaceKind(rawValue: "terminal") + /// A browser surface. + public static let browser = MobileSurfaceKind(rawValue: "browser") + /// A markdown preview surface. + public static let markdown = MobileSurfaceKind(rawValue: "markdown") + /// A file preview surface. + public static let filePreview = MobileSurfaceKind(rawValue: "filePreview") + /// A right-sidebar tool hosted as a surface. + public static let rightSidebarTool = MobileSurfaceKind(rawValue: "rightSidebarTool") + /// A custom sidebar hosted as a surface. + public static let customSidebar = MobileSurfaceKind(rawValue: "customSidebar") + /// An agent-session surface. + public static let agentSession = MobileSurfaceKind(rawValue: "agentSession") + /// A project surface. + public static let project = MobileSurfaceKind(rawValue: "project") + /// A browser surface owned by an extension. + public static let extensionBrowser = MobileSurfaceKind(rawValue: "extensionBrowser") + /// A workspace todo surface. + public static let todo = MobileSurfaceKind(rawValue: "todo") + /// A transient Cloud VM loading surface. + public static let cloudVMLoading = MobileSurfaceKind(rawValue: "cloudVMLoading") +} diff --git a/Packages/Shared/CMUXMobileCore/Sources/CMUXMobileCore/MobileTodoItem.swift b/Packages/Shared/CMUXMobileCore/Sources/CMUXMobileCore/MobileTodoItem.swift new file mode 100644 index 00000000000..df1f6efefc2 --- /dev/null +++ b/Packages/Shared/CMUXMobileCore/Sources/CMUXMobileCore/MobileTodoItem.swift @@ -0,0 +1,32 @@ +/// One bounded checklist item synced with a workspace todo surface. +public struct MobileTodoItem: Codable, Equatable, Identifiable, Sendable { + /// The maximum number of characters accepted for one item's normalized text. + public static let maxTextLength = 500 + + /// The Mac-owned stable item identifier. + public let id: String + /// The normalized item text. + public let text: String + /// The item's progress state. + public let state: MobileTodoItemState + /// Who created the item. + public let origin: MobileTodoItemOrigin + + /// Creates a mobile checklist item. + /// - Parameters: + /// - id: The Mac-owned stable item identifier. + /// - text: The normalized item text. + /// - state: The item's progress state. + /// - origin: Who created the item. + public init( + id: String, + text: String, + state: MobileTodoItemState, + origin: MobileTodoItemOrigin + ) { + self.id = id + self.text = text + self.state = state + self.origin = origin + } +} diff --git a/Packages/Shared/CMUXMobileCore/Sources/CMUXMobileCore/MobileTodoItemOrigin.swift b/Packages/Shared/CMUXMobileCore/Sources/CMUXMobileCore/MobileTodoItemOrigin.swift new file mode 100644 index 00000000000..2f66a7aa482 --- /dev/null +++ b/Packages/Shared/CMUXMobileCore/Sources/CMUXMobileCore/MobileTodoItemOrigin.swift @@ -0,0 +1,7 @@ +/// The creator of a mobile checklist item. +public enum MobileTodoItemOrigin: String, Codable, CaseIterable, Sendable { + /// A person created the item. + case user + /// An agent created the item. + case agent +} diff --git a/Packages/Shared/CMUXMobileCore/Sources/CMUXMobileCore/MobileTodoItemState.swift b/Packages/Shared/CMUXMobileCore/Sources/CMUXMobileCore/MobileTodoItemState.swift new file mode 100644 index 00000000000..3ab805b2a8f --- /dev/null +++ b/Packages/Shared/CMUXMobileCore/Sources/CMUXMobileCore/MobileTodoItemState.swift @@ -0,0 +1,18 @@ +/// A mobile checklist item's progress state. +public enum MobileTodoItemState: String, Codable, CaseIterable, Sendable { + /// Work has not started. + case pending + /// Work is actively progressing. + case inProgress = "in_progress" + /// Work is complete. + case completed + + /// The next state in the mobile tap cycle. + public var next: MobileTodoItemState { + switch self { + case .pending: .inProgress + case .inProgress: .completed + case .completed: .pending + } + } +} diff --git a/Packages/Shared/CMUXMobileCore/Sources/CMUXMobileCore/MobileTodoSnapshot.swift b/Packages/Shared/CMUXMobileCore/Sources/CMUXMobileCore/MobileTodoSnapshot.swift new file mode 100644 index 00000000000..b68ac69a24e --- /dev/null +++ b/Packages/Shared/CMUXMobileCore/Sources/CMUXMobileCore/MobileTodoSnapshot.swift @@ -0,0 +1,29 @@ +/// The bounded todo payload attached to a synced todo surface. +public struct MobileTodoSnapshot: Codable, Equatable, Sendable { + /// The maximum number of checklist items carried by one mobile snapshot. + public static let maxItems = 50 + + /// The effective status after applying any valid manual override. + public let status: MobileTodoStatus + /// Whether the workspace opted out of showing its status lane. + public let statusHidden: Bool + /// Checklist items in the Mac's storage order. + public let items: [MobileTodoItem] + + /// Creates a todo snapshot. + /// - Parameters: + /// - status: The effective workspace status. + /// - statusHidden: Whether status presentation is hidden. + /// - items: Checklist items in storage order. + public init(status: MobileTodoStatus, statusHidden: Bool, items: [MobileTodoItem]) { + self.status = status + self.statusHidden = statusHidden + self.items = items + } + + private enum CodingKeys: String, CodingKey { + case status + case statusHidden = "status_hidden" + case items + } +} diff --git a/Packages/Shared/CMUXMobileCore/Sources/CMUXMobileCore/MobileTodoStatus.swift b/Packages/Shared/CMUXMobileCore/Sources/CMUXMobileCore/MobileTodoStatus.swift new file mode 100644 index 00000000000..24f66a539d6 --- /dev/null +++ b/Packages/Shared/CMUXMobileCore/Sources/CMUXMobileCore/MobileTodoStatus.swift @@ -0,0 +1,20 @@ +/// A workspace's effective todo status on the mobile wire. +public enum MobileTodoStatus: String, Codable, CaseIterable, Sendable { + /// Work has not started. + case todo + /// Work is actively progressing. + case working + /// Work is waiting for attention or input. + case needsAttention = "needs-attention" + /// Work is ready for review. + case review + /// Work is complete. + case done + + /// The next status in the same cycle used by the Mac todo controls. + public var next: MobileTodoStatus { + let statuses = Self.allCases + guard let index = statuses.firstIndex(of: self) else { return .todo } + return statuses[(index + 1) % statuses.count] + } +} diff --git a/Packages/Shared/CMUXMobileCore/Tests/CMUXMobileCoreTests/MobileStateSyncFrameCodingTests.swift b/Packages/Shared/CMUXMobileCore/Tests/CMUXMobileCoreTests/MobileStateSyncFrameCodingTests.swift index 7526eac8078..7f39a32a127 100644 --- a/Packages/Shared/CMUXMobileCore/Tests/CMUXMobileCoreTests/MobileStateSyncFrameCodingTests.swift +++ b/Packages/Shared/CMUXMobileCore/Tests/CMUXMobileCoreTests/MobileStateSyncFrameCodingTests.swift @@ -29,6 +29,32 @@ struct MobileStateSyncFrameCodingTests { isReady: true, isFocused: false ) + ], + surfaces: [ + WorkspaceSyncRecord.Surface( + surfaceID: "surface-future", + kind: "simulator", + title: "iPhone 17 Pro", + filePath: nil + ), + WorkspaceSyncRecord.Surface( + surfaceID: "surface-todo", + kind: MobileSurfaceKind.todo.rawValue, + title: "Todo", + filePath: nil, + todo: MobileTodoSnapshot( + status: .needsAttention, + statusHidden: false, + items: [ + MobileTodoItem( + id: "item-1", + text: "Review the renderer", + state: .inProgress, + origin: .agent + ), + ] + ) + ) ] ) } @@ -51,6 +77,46 @@ struct MobileStateSyncFrameCodingTests { let terminals = object["terminals"] as? [[String: Any]] #expect(terminals?.first?["is_ready"] as? Bool == true) #expect(terminals?.first?["is_focused"] as? Bool == false) + let surfaces = object["surfaces"] as? [[String: Any]] + #expect(surfaces?.first?["surface_id"] as? String == "surface-future") + #expect(surfaces?.first?["kind"] as? String == "simulator") + #expect(surfaces?.first?["file_path"] == nil) + let todo = surfaces?[1]["todo"] as? [String: Any] + #expect(todo?["status"] as? String == "needs-attention") + #expect(todo?["status_hidden"] as? Bool == false) + let items = todo?["items"] as? [[String: Any]] + #expect(items?.first?["id"] as? String == "item-1") + #expect(items?.first?["state"] as? String == "in_progress") + #expect(items?.first?["origin"] as? String == "agent") + } + + @Test func mobileSurfaceKindPreservesUnknownRawValues() throws { + let kind = MobileSurfaceKind(rawValue: "simulator") + let data = try JSONEncoder().encode(kind) + #expect(String(decoding: data, as: UTF8.self) == #""simulator""#) + #expect(try JSONDecoder().decode(MobileSurfaceKind.self, from: data) == kind) + } + + @Test func workspaceRecordWithoutSurfacesDecodesAndReencodesWithoutTheField() throws { + let json = #"{"id":"ws-old","title":"old","is_selected":false,"is_pinned":false,"last_activity_at":1,"has_unread":false,"sort_index":0,"terminals":[]}"# + let decoded = try MobileSyncFrameCoder().decode( + WorkspaceSyncRecord.self, + fromJSONString: json + ) + #expect(decoded.surfaces == nil) + let object = try MobileSyncFrameCoder().jsonObject(from: decoded) + #expect(object["surfaces"] == nil) + } + + @Test func workspaceRecordRoundTripsSurfaceInventory() throws { + let decoded = try JSONDecoder().decode( + WorkspaceSyncRecord.self, + from: JSONEncoder().encode(workspace) + ) + #expect(decoded == workspace) + #expect(decoded.surfaces?.first?.kind == "simulator") + #expect(decoded.surfaces?[1].todo?.status == .needsAttention) + #expect(decoded.surfaces?[1].todo?.items.first?.state == .inProgress) } @Test func workspaceRecordDefaultsMissingDescriptionTruncatedFlagToFalse() throws { diff --git a/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Artifacts/ChatArtifactError.swift b/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Artifacts/ChatArtifactError.swift index 59a4c84032f..976d9fc112b 100644 --- a/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Artifacts/ChatArtifactError.swift +++ b/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Artifacts/ChatArtifactError.swift @@ -65,4 +65,9 @@ public enum ChatArtifactError: Error, Sendable, Equatable { case macUnreachable /// The file exceeds the inline preview size limit. case tooLarge(limitBytes: Int64) + /// The Mac answered with an error this client does not recognize. + /// + /// Distinct from ``macUnreachable``: the connection worked and the Mac + /// replied, so messaging must not blame connectivity. + case unknown(code: String?) } diff --git a/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Artifacts/PanelArtifactAuthorizationStore.swift b/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Artifacts/PanelArtifactAuthorizationStore.swift new file mode 100644 index 00000000000..843edeb85a7 --- /dev/null +++ b/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Artifacts/PanelArtifactAuthorizationStore.swift @@ -0,0 +1,126 @@ +import Foundation + +/// Retains the single canonical file currently exposed by each file-backed panel. +@MainActor +public final class PanelArtifactAuthorizationStore { + private struct GrantKey: Hashable { + let workspaceID: String + let surfaceID: String + } + + private let resolver: any ChatArtifactScope.FileSystemResolving + private var canonicalPathByGrantKey: [GrantKey: String] = [:] + + /// Creates a lifecycle-bound panel grant registry. + /// + /// - Parameter resolver: Filesystem resolver used for both grant-time and + /// request-time canonicalization. + public init( + resolver: any ChatArtifactScope.FileSystemResolving = ChatArtifactScope.FoundationResolver() + ) { + self.resolver = resolver + } + + /// Replaces one panel's grant with its current canonical file path. + /// + /// A failed canonicalization removes any previous grant for the same panel, + /// so an unavailable replacement can never preserve access to the old file. + /// + /// - Parameters: + /// - workspaceID: Workspace containing the panel. + /// - surfaceID: Stable panel surface identifier. + /// - filePath: File path currently displayed by the panel. + /// - Returns: The canonical path that was recorded, or `nil` when the path + /// could not be canonicalized. + @discardableResult + public func record( + workspaceID: String, + surfaceID: String, + filePath: String + ) -> String? { + let key = GrantKey(workspaceID: workspaceID, surfaceID: surfaceID) + guard let canonicalPath = ChatArtifactScope.canonicalizedPath( + filePath, + resolver: resolver + ) else { + canonicalPathByGrantKey.removeValue(forKey: key) + return nil + } + canonicalPathByGrantKey[key] = canonicalPath + return canonicalPath + } + + /// Invalidates the file grant for one closed or replaced panel. + /// + /// - Parameters: + /// - workspaceID: Workspace that contained the panel. + /// - surfaceID: Closed or replaced panel surface identifier. + public func invalidate(workspaceID: String, surfaceID: String) { + canonicalPathByGrantKey.removeValue( + forKey: GrantKey(workspaceID: workspaceID, surfaceID: surfaceID) + ) + } + + /// Resolves a request only when it canonicalizes to the panel's one-file grant. + /// + /// - Parameters: + /// - workspaceID: Workspace containing the panel. + /// - surfaceID: Panel authorizing the request. + /// - requestedPath: Absolute path requested by the mobile client. + /// - Returns: The canonical requested path when it exactly matches the live + /// grant, otherwise `nil`. + public func authorizedCanonicalPath( + workspaceID: String, + surfaceID: String, + requestedPath: String + ) -> String? { + let key = GrantKey(workspaceID: workspaceID, surfaceID: surfaceID) + guard let grantedPath = canonicalPathByGrantKey[key], + let requestedCanonicalPath = ChatArtifactScope.canonicalizedPath( + requestedPath, + resolver: resolver + ), + requestedCanonicalPath == grantedPath else { + return nil + } + return requestedCanonicalPath + } + + /// Resolves a request only when both the live panel path and request still + /// canonicalize to the recorded grant. + /// + /// This comparison deliberately does not replace the grant. A symlink that + /// is retargeted after grant time must revoke access until the panel + /// lifecycle records a new file, rather than silently authorizing the new + /// target during a read. + /// + /// - Parameters: + /// - workspaceID: Workspace containing the panel. + /// - surfaceID: Panel authorizing the request. + /// - currentFilePath: File path the live panel reports displaying. + /// - requestedPath: Absolute path requested by the mobile client. + /// - Returns: The canonical requested path when the live panel and request + /// both exactly match the recorded grant, otherwise `nil`. + public func authorizedCanonicalPath( + workspaceID: String, + surfaceID: String, + currentFilePath: String, + requestedPath: String + ) -> String? { + let key = GrantKey(workspaceID: workspaceID, surfaceID: surfaceID) + guard let grantedPath = canonicalPathByGrantKey[key], + let currentCanonicalPath = ChatArtifactScope.canonicalizedPath( + currentFilePath, + resolver: resolver + ), + currentCanonicalPath == grantedPath, + let requestedCanonicalPath = ChatArtifactScope.canonicalizedPath( + requestedPath, + resolver: resolver + ), + requestedCanonicalPath == grantedPath else { + return nil + } + return requestedCanonicalPath + } +} diff --git a/Packages/Shared/CmuxAgentChat/Tests/CmuxAgentChatTests/PanelArtifactAuthorizationStoreTests.swift b/Packages/Shared/CmuxAgentChat/Tests/CmuxAgentChatTests/PanelArtifactAuthorizationStoreTests.swift new file mode 100644 index 00000000000..46fa13939fa --- /dev/null +++ b/Packages/Shared/CmuxAgentChat/Tests/CmuxAgentChatTests/PanelArtifactAuthorizationStoreTests.swift @@ -0,0 +1,104 @@ +import Foundation +import Testing + +@testable import CmuxAgentChat + +@MainActor +@Suite("Panel artifact authorization") +struct PanelArtifactAuthorizationStoreTests { + @Test("re-recording replaces the old file and close invalidates the grant") + func grantFollowsPanelLifecycle() { + let store = PanelArtifactAuthorizationStore(resolver: FakeResolver()) + + store.record( + workspaceID: "workspace", + surfaceID: "surface", + filePath: "/safe/first.md" + ) + #expect(store.authorizedCanonicalPath( + workspaceID: "workspace", + surfaceID: "surface", + requestedPath: "/safe/first.md" + ) == "/safe/first.md") + + store.record( + workspaceID: "workspace", + surfaceID: "surface", + filePath: "/safe/second.md" + ) + #expect(store.authorizedCanonicalPath( + workspaceID: "workspace", + surfaceID: "surface", + requestedPath: "/safe/first.md" + ) == nil) + #expect(store.authorizedCanonicalPath( + workspaceID: "workspace", + surfaceID: "surface", + requestedPath: "/safe/second.md" + ) == "/safe/second.md") + + store.invalidate(workspaceID: "workspace", surfaceID: "surface") + #expect(store.authorizedCanonicalPath( + workspaceID: "workspace", + surfaceID: "surface", + requestedPath: "/safe/second.md" + ) == nil) + } + + @Test("a different canonical path is denied") + func pathMismatchIsDenied() { + let store = PanelArtifactAuthorizationStore(resolver: FakeResolver()) + store.record( + workspaceID: "workspace", + surfaceID: "surface", + filePath: "/safe/panel.md" + ) + + #expect(store.authorizedCanonicalPath( + workspaceID: "workspace", + surfaceID: "surface", + requestedPath: "/safe/other.md" + ) == nil) + #expect(store.authorizedCanonicalPath( + workspaceID: "other-workspace", + surfaceID: "surface", + requestedPath: "/safe/panel.md" + ) == nil) + } + + @Test("symlinks are resolved independently at grant and read time") + func symlinkTraversalIsDenied() { + let store = PanelArtifactAuthorizationStore(resolver: FakeResolver(symlinks: [ + "/safe/panel-link.md": "/safe/panel.md", + "/safe/request-link.md": "/private/secret.md", + ])) + store.record( + workspaceID: "workspace", + surfaceID: "surface", + filePath: "/safe/panel-link.md" + ) + + #expect(store.authorizedCanonicalPath( + workspaceID: "workspace", + surfaceID: "surface", + requestedPath: "/safe/request-link.md" + ) == nil) + #expect(store.authorizedCanonicalPath( + workspaceID: "workspace", + surfaceID: "surface", + requestedPath: "/safe/panel-link.md" + ) == "/safe/panel.md") + } + + private struct FakeResolver: ChatArtifactScope.FileSystemResolving { + var symlinks: [String: String] = [:] + + func resolveSymlinks(of path: String) -> String? { + ((symlinks[path] ?? path) as NSString).standardizingPath + } + + func isDirectory(_ path: String) -> Bool? { + false + } + } +} diff --git a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactConnectionHint.swift b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactConnectionHint.swift new file mode 100644 index 00000000000..110d0ef97be --- /dev/null +++ b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactConnectionHint.swift @@ -0,0 +1,38 @@ +import Foundation + +/// The host's live connection state to the Mac serving an artifact preview. +/// +/// Transport failures read very differently depending on which side dropped: +/// when the PHONE knows its own session is down or re-forming, the copy must +/// say so instead of sending the user to inspect the Mac. +public enum ChatArtifactConnectionHint: Equatable, Sendable { + /// The session looks healthy; a transport failure is unexpected. + case connected + /// The phone's session dropped and is re-establishing automatically. + case reconnecting + /// The phone is not connected to the Mac right now. + case disconnected +} + +extension ChatArtifactConnectionHint { + /// Title + message for an unreachable-style failure under this hint. + var unreachableCopy: (title: String, message: String) { + switch self { + case .connected: + ( + String(localized: "chat.artifact.mac_unreachable.title", defaultValue: "Mac unreachable", bundle: .module), + String(localized: "chat.artifact.mac_unreachable.message", defaultValue: "Check the connection to your Mac and try again.", bundle: .module) + ) + case .reconnecting: + ( + String(localized: "chat.artifact.reconnecting.title", defaultValue: "Reconnecting\u{2026}", bundle: .module), + String(localized: "chat.artifact.reconnecting.message", defaultValue: "This phone's connection to the Mac dropped and is coming back. Retry in a moment.", bundle: .module) + ) + case .disconnected: + ( + String(localized: "chat.artifact.disconnected.title", defaultValue: "Not connected", bundle: .module), + String(localized: "chat.artifact.disconnected.message", defaultValue: "This phone isn't connected to the Mac right now. Reconnect, then retry.", bundle: .module) + ) + } + } +} diff --git a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactEmbeddedMarkdown.swift b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactEmbeddedMarkdown.swift new file mode 100644 index 00000000000..1057f2c11d9 --- /dev/null +++ b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactEmbeddedMarkdown.swift @@ -0,0 +1,19 @@ +import SwiftUI + +/// Embeds document-level markdown rendering inside a host surface. +/// +/// Public wrapper over the artifact viewer's markdown route content for hosts +/// that fetch and decode their own bytes (like the panel-scoped markdown +/// surface on iOS) but should render identically to the modal viewer. +public struct ChatArtifactEmbeddedMarkdown: View { + private let markdown: String + + /// Creates an embedded markdown renderer for already-decoded text. + public init(markdown: String) { + self.markdown = markdown + } + + public var body: some View { + ChatArtifactMarkdownView(markdown: markdown) + } +} diff --git a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactEmbeddedPreview.swift b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactEmbeddedPreview.swift new file mode 100644 index 00000000000..322b8fadd5f --- /dev/null +++ b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactEmbeddedPreview.swift @@ -0,0 +1,112 @@ +import SwiftUI + +#if os(iOS) +import QuickLook +#endif + +/// Embeds one artifact preview inside a host surface. +/// +/// The modal viewer (`ChatArtifactViewerPager`) is destination-shaped: it owns +/// a navigation title, toolbar actions, paging, and a Done button. Host +/// surfaces that keep an artifact permanently on screen — like a Mac panel +/// mirrored on iOS — need the routed content alone, so this entry point mounts +/// the route view with no navigation chrome. Directory browsing is decided by +/// the loader's scope and stays unreachable for single-file scopes. +public struct ChatArtifactEmbeddedPreview: View { + private let path: String + private let scope: ChatArtifactViewerScope + private let loader: ChatArtifactLoader + private let refreshToken: String? + private let connectionHint: ChatArtifactConnectionHint + + /// Creates an embedded, chrome-free artifact preview. + /// + /// - Parameters: + /// - path: Absolute Mac host path of the previewed file. + /// - scope: The user-facing context the preview renders in. + /// - loader: The authorized artifact loader for `path`. + /// - refreshToken: Opaque descriptor-churn token. When it changes while + /// `path` stays stable, the preview re-stats and re-renders the file; + /// a `path` change always remounts and reloads. + public init( + path: String, + scope: ChatArtifactViewerScope, + loader: ChatArtifactLoader, + refreshToken: String? = nil, + connectionHint: ChatArtifactConnectionHint = .connected + ) { + self.path = path + self.scope = scope + self.loader = loader + self.refreshToken = refreshToken + self.connectionHint = connectionHint + } + + public var body: some View { + EmbeddedArtifactPage( + path: path, + scope: scope, + loader: loader, + refreshToken: refreshToken, + connectionHint: connectionHint + ) + .id(path) + } +} + +/// Path-stable page state for one embedded preview mount. +private struct EmbeddedArtifactPage: View { + let path: String + let scope: ChatArtifactViewerScope + let loader: ChatArtifactLoader + let refreshToken: String? + let connectionHint: ChatArtifactConnectionHint + + @State private var model: ChatArtifactViewerPageModel + + init( + path: String, + scope: ChatArtifactViewerScope, + loader: ChatArtifactLoader, + refreshToken: String?, + connectionHint: ChatArtifactConnectionHint + ) { + self.path = path + self.scope = scope + self.loader = loader + self.refreshToken = refreshToken + self.connectionHint = connectionHint + _model = State(initialValue: ChatArtifactViewerPageModel( + path: path, + textPreferences: ChatArtifactTextPreferences(defaults: .standard) + )) + } + + var body: some View { + let snapshot = model.snapshot + ChatArtifactViewerRouteView( + snapshot: snapshot, + scope: scope, + actions: model.actions( + loader: loader, + quickLookCanPreview: { fileURL in + #if os(iOS) + QLPreviewController.canPreview(ChatArtifactQuickLookItem( + fileURL: fileURL, + title: snapshot.displayName + )) + #else + false + #endif + } + ), + connectionHint: connectionHint, + onDone: {} + ) + .onChange(of: refreshToken) { _, _ in + // Descriptor churn with a stable path: the panel may have rewritten + // its file, so re-stat and re-route without losing the mount. + model.retry() + } + } +} diff --git a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactFailurePresentation.swift b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactFailurePresentation.swift index 60abf602628..c9dd54d5f23 100644 --- a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactFailurePresentation.swift +++ b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactFailurePresentation.swift @@ -79,6 +79,8 @@ public struct ChatArtifactFailurePresentation: Equatable, Sendable { ("chat.artifact.forbidden.message", "This file was not referenced by the conversation.") case .terminal: ("chat.artifact.forbidden.terminal_message", "This file isn't visible in the current terminal view.") + case .panel: + ("chat.artifact.forbidden.panel_message", "That file panel is no longer open on your Mac.") case .workspaceChanges: ("chat.artifact.failure.forbidden.workspace_message", "This file is no longer part of the workspace changes.") } diff --git a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactLoader.swift b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactLoader.swift index 04a933bd4b7..01770180362 100644 --- a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactLoader.swift +++ b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactLoader.swift @@ -60,6 +60,8 @@ public enum ChatArtifactLoaderScope: Hashable, Sendable { case chat(sessionID: String) /// Artifacts currently visible in one terminal surface. case terminal(workspaceID: String, surfaceID: String) + /// The single file currently displayed by one file-backed panel surface. + case panel(workspaceID: String, surfaceID: String) /// One changed-file revision in a workspace changes snapshot. case workspaceChanges(workspaceID: String, revision: String, path: String) /// Unsupported fixture/default scope. @@ -71,6 +73,8 @@ public enum ChatArtifactLoaderScope: Hashable, Sendable { return "chat:\(sessionID)" case .terminal(let workspaceID, let surfaceID): return "terminal:\(workspaceID):\(surfaceID)" + case .panel(let workspaceID, let surfaceID): + return "panel:\(workspaceID):\(surfaceID)" case .workspaceChanges(let workspaceID, let revision, let path): return "workspace-changes:\(workspaceID):\(revision):\(path)" case .unsupported: @@ -278,6 +282,53 @@ public struct ChatArtifactLoader: Sendable { ) } + /// Creates a panel-scoped closure-backed artifact loader. + /// + /// Panel authorization is a one-file allowlist, so directory browsing is + /// always disabled and cannot be enabled by a caller. + /// + /// - Parameters: + /// - panelWorkspaceID: Workspace containing the file-backed panel. + /// - panelSurfaceID: Panel surface authorizing its displayed file. + /// - supportsArtifacts: Whether the connected Mac advertises panel reads. + /// - cache: Thumbnail cache shared by rows and viewers. + /// - contentCache: Full-content cache shared by viewer routes. + /// - stat: Metadata operation for the panel's file. + /// - fetch: Whole-file compatibility operation. + /// - stream: Optional structured chunk operation. + /// - thumbnail: Thumbnail operation for the panel's file. + public init( + panelWorkspaceID: String, + panelSurfaceID: String, + supportsArtifacts: Bool, + cache: ChatArtifactThumbnailCache = ChatArtifactThumbnailCache(), + contentCache: ChatArtifactContentCache = .applicationDefault(), + diagnosticLog: DiagnosticLog? = nil, + stat: @escaping @Sendable (_ path: String) async throws -> ChatArtifactStat, + fetch: @escaping @Sendable ( + _ path: String, + _ progress: (@Sendable (_ fetchedBytes: Int64, _ totalBytes: Int64) -> Void)? + ) async throws -> Data, + stream: (@Sendable ( + _ path: String, + _ onChunk: @Sendable (ChatArtifactChunk) async throws -> Void + ) async throws -> Void)? = nil, + thumbnail: @escaping @Sendable (_ path: String, _ maxDimension: Int) async throws -> ChatArtifactThumbnail + ) { + self.init( + supportsArtifacts: supportsArtifacts, + supportsDirectoryBrowsing: false, + scope: .panel(workspaceID: panelWorkspaceID, surfaceID: panelSurfaceID), + cache: cache, + contentCache: contentCache, + diagnosticLog: diagnosticLog, + stat: stat, + fetch: fetch, + stream: stream, + thumbnail: thumbnail + ) + } + /// Creates a loader that fails artifact operations as unsupported. /// /// - Parameters: @@ -437,6 +488,8 @@ private extension ChatArtifactLoaderScope { sessionID case .terminal(_, let surfaceID): surfaceID + case .panel(_, let surfaceID): + surfaceID case .workspaceChanges(let workspaceID, _, _): workspaceID case .unsupported: diff --git a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactViewerRouteView.swift b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactViewerRouteView.swift index 06f86c75d84..129510a7cd2 100644 --- a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactViewerRouteView.swift +++ b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactViewerRouteView.swift @@ -19,6 +19,9 @@ struct ChatArtifactViewerRouteView: View { let snapshot: ChatArtifactViewerPageSnapshot let scope: ChatArtifactViewerScope let actions: ChatArtifactViewerPageActions + /// The host's live session state, so transport-failure copy can identify + /// whether the phone is disconnected or the Mac is unreachable. + var connectionHint: ChatArtifactConnectionHint = .connected let onDone: () -> Void let onImageMinimumZoomChanged: (Bool) -> Void let onImageAction: (@MainActor (ChatArtifactAction) -> Void)? @@ -31,6 +34,7 @@ struct ChatArtifactViewerRouteView: View { snapshot: ChatArtifactViewerPageSnapshot, scope: ChatArtifactViewerScope, actions: ChatArtifactViewerPageActions, + connectionHint: ChatArtifactConnectionHint = .connected, onImageMinimumZoomChanged: @escaping (Bool) -> Void = { _ in }, onImageAction: (@MainActor (ChatArtifactAction) -> Void)? = nil, onDone: @escaping () -> Void @@ -38,6 +42,7 @@ struct ChatArtifactViewerRouteView: View { self.snapshot = snapshot self.scope = scope self.actions = actions + self.connectionHint = connectionHint self.onDone = onDone self.onImageMinimumZoomChanged = onImageMinimumZoomChanged self.onImageAction = onImageAction @@ -197,9 +202,12 @@ struct ChatArtifactViewerRouteView: View { scope: scope, actualSize: actualSize ) + let copy = error == .macUnreachable + ? connectionHint.unreachableCopy + : (title: failure.title, message: failure.message) unavailableView( - title: failure.title, - message: failure.message, + title: copy.title, + message: copy.message, retry: failure.allowsRetry ) } diff --git a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactViewerScope.swift b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactViewerScope.swift index 521a942aa66..f1f0eb1d123 100644 --- a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactViewerScope.swift +++ b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactViewerScope.swift @@ -6,6 +6,9 @@ public enum ChatArtifactViewerScope: Sendable, Equatable { /// An artifact opened from a terminal surface. case terminal + /// The file displayed by a markdown or file-preview panel. + case panel + /// A base or working-tree file opened from Workspace Changes. case workspaceChanges } diff --git a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Resources/Localizable.xcstrings b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Resources/Localizable.xcstrings index 7364dd0c372..febcf4b8400 100644 --- a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Resources/Localizable.xcstrings +++ b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Resources/Localizable.xcstrings @@ -494,6 +494,40 @@ } } }, + "chat.artifact.disconnected.message": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "This phone isn't connected to the Mac right now. Reconnect, then retry." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "このiPhoneは現在Macに接続されていません。再接続してからもう一度お試しください。" + } + } + } + }, + "chat.artifact.disconnected.title": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Not connected" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "未接続" + } + } + } + }, "chat.artifact.done": { "extractionState": "manual", "localizations": { @@ -511,6 +545,40 @@ } } }, + "chat.artifact.failed.message": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Something went wrong loading this file." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "このファイルの読み込み中に問題が発生しました。" + } + } + } + }, + "chat.artifact.failed.title": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Couldn't load file" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ファイルを読み込めませんでした" + } + } + } + }, "chat.artifact.file_actions": { "extractionState": "manual", "localizations": { @@ -630,6 +698,23 @@ } } }, + "chat.artifact.forbidden.panel_message": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "This file isn't displayed by the selected panel." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "このファイルは選択したパネルに表示されていません。" + } + } + } + }, "chat.artifact.forbidden.terminal_message": { "extractionState": "manual", "localizations": { @@ -1021,6 +1106,74 @@ } } }, + "chat.artifact.not_found.message": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "This file's source is no longer available on your Mac." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "このファイルのソースはMac上で利用できなくなりました。" + } + } + } + }, + "chat.artifact.not_found.panel_message": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "That file panel is no longer open on your Mac." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "そのファイルパネルはMac上で開かれていません。" + } + } + } + }, + "chat.artifact.not_found.panel_title": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Panel closed" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "パネルは閉じられました" + } + } + } + }, + "chat.artifact.not_found.title": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Source unavailable" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ソースを利用できません" + } + } + } + }, "chat.artifact.ok": { "extractionState": "manual", "localizations": { @@ -1089,6 +1242,40 @@ } } }, + "chat.artifact.reconnecting.message": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "This phone's connection to the Mac dropped and is coming back. Retry in a moment." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "このiPhoneとMacの接続が切断され、再接続中です。しばらくしてからもう一度お試しください。" + } + } + } + }, + "chat.artifact.reconnecting.title": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Reconnecting…" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "再接続中…" + } + } + } + }, "chat.artifact.retry": { "extractionState": "manual", "localizations": { @@ -1310,6 +1497,74 @@ } } }, + "chat.artifact.unavailable.message": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "File transfer is temporarily unavailable on your Mac. Try again shortly." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "Macのファイル転送は一時的に利用できません。しばらくしてからもう一度お試しください。" + } + } + } + }, + "chat.artifact.unavailable.title": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Transfer unavailable" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "転送を利用できません" + } + } + } + }, + "chat.artifact.unsupported.message": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "The connected Mac's cmux version can't preview this file." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "接続中のMacのcmuxバージョンではこのファイルをプレビューできません。" + } + } + } + }, + "chat.artifact.unsupported.title": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Update cmux on your Mac" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "Macのcmuxをアップデートしてください" + } + } + } + }, "chat.artifact.view_file": { "extractionState": "manual", "localizations": { diff --git a/Packages/iOS/CmuxAgentChatUI/Tests/CmuxAgentChatUITests/ChatArtifactEmbeddedPreviewTests.swift b/Packages/iOS/CmuxAgentChatUI/Tests/CmuxAgentChatUITests/ChatArtifactEmbeddedPreviewTests.swift new file mode 100644 index 00000000000..6d855c9348e --- /dev/null +++ b/Packages/iOS/CmuxAgentChatUI/Tests/CmuxAgentChatUITests/ChatArtifactEmbeddedPreviewTests.swift @@ -0,0 +1,44 @@ +import Foundation +import Testing +@testable import CmuxAgentChatUI + +@MainActor +struct ChatArtifactEmbeddedPreviewTests { + @Test func constructsWithPanelScopedLoader() { + let loader = ChatArtifactLoader( + panelWorkspaceID: "ws-1", + panelSurfaceID: "surface-1", + supportsArtifacts: true, + stat: { _ in throw ChatArtifactErrorFixture.unsupported }, + fetch: { _, _ in throw ChatArtifactErrorFixture.unsupported }, + thumbnail: { _, _ in throw ChatArtifactErrorFixture.unsupported } + ) + _ = ChatArtifactEmbeddedPreview( + path: "/tmp/demo.md", + scope: .panel, + loader: loader, + refreshToken: "demo.md" + ) + // Panel scopes are one-file allowlists; the embedded host must never + // reach the folder browser. + #expect(loader.supportsDirectoryBrowsing == false) + #expect(loader.scope == .panel(workspaceID: "ws-1", surfaceID: "surface-1")) + } + + @Test func refreshTokenChurnReStatsViaRetryGeneration() { + // The embedded page maps refresh-token changes onto the page model's + // retry generation, which keys the route view's load task. + let model = ChatArtifactViewerPageModel( + path: "/tmp/demo.md", + textPreferences: ChatArtifactTextPreferences(defaults: .standard) + ) + let initial = model.snapshot.retryGeneration + model.retry() + #expect(model.snapshot.retryGeneration == initial + 1) + #expect(model.snapshot.path == "/tmp/demo.md") + } +} + +private enum ChatArtifactErrorFixture: Error { + case unsupported +} diff --git a/Packages/iOS/CmuxAgentChatUI/Tests/CmuxAgentChatUITests/ChatArtifactLoaderTests.swift b/Packages/iOS/CmuxAgentChatUI/Tests/CmuxAgentChatUITests/ChatArtifactLoaderTests.swift index a623e1310b6..2119cb8f105 100644 --- a/Packages/iOS/CmuxAgentChatUI/Tests/CmuxAgentChatUITests/ChatArtifactLoaderTests.swift +++ b/Packages/iOS/CmuxAgentChatUI/Tests/CmuxAgentChatUITests/ChatArtifactLoaderTests.swift @@ -180,6 +180,29 @@ struct ChatArtifactLoaderTests { } #expect(await source.listRequestCount() == 1) } + + @Test func panelScopeResolvesBytesAndPinsDirectoryBrowsingOff() async throws { + let source = CountingTerminalArtifactSource() + let loader = ChatArtifactLoader( + panelWorkspaceID: "workspace-1", + panelSurfaceID: "surface-1", + supportsArtifacts: true, + stat: { path in try await source.stat(path: path) }, + fetch: { path, progress in try await source.fetch(path: path, progress: progress) }, + thumbnail: { path, dimension in + try await source.thumbnail(path: path, maxDimension: dimension) + } + ) + + #expect(loader.scope == .panel(workspaceID: "workspace-1", surfaceID: "surface-1")) + #expect(loader.supportsArtifacts) + #expect(!loader.supportsDirectoryBrowsing) + #expect(try await loader.fetch(path: "/tmp/panel.md") == Data([4, 5, 6])) + #expect(try await loader.stat(path: "/tmp/panel.md").kind == .image) + await #expect(throws: ChatArtifactError.unsupported) { + try await loader.list(path: "/tmp") + } + } } private func makeContentLoader( diff --git a/Packages/iOS/CmuxAgentChatUI/Tests/CmuxAgentChatUITests/ChatArtifactViewerErrorStateTests.swift b/Packages/iOS/CmuxAgentChatUI/Tests/CmuxAgentChatUITests/ChatArtifactViewerErrorStateTests.swift new file mode 100644 index 00000000000..f04d2db8b9e --- /dev/null +++ b/Packages/iOS/CmuxAgentChatUI/Tests/CmuxAgentChatUITests/ChatArtifactViewerErrorStateTests.swift @@ -0,0 +1,45 @@ +import CmuxAgentChat +import Foundation +import Testing +@testable import CmuxAgentChatUI + +/// Every artifact failure must surface its own accurate state; only genuine +/// transport failures may present as "Mac unreachable". +@MainActor +struct ChatArtifactViewerErrorStateTests { + private func state(_ error: any Error, stat: ChatArtifactStat? = nil) -> ChatArtifactViewerState { + ChatArtifactViewerModel.state(for: error, stat: stat) + } + + @Test func everyArtifactErrorMapsToTypedFailure() { + #expect(state(ChatArtifactError.fileNotFound) == .failure(error: .fileNotFound, actualSize: nil)) + #expect(state(ChatArtifactError.forbidden) == .failure(error: .forbidden, actualSize: nil)) + #expect(state(ChatArtifactError.macUnreachable) == .failure(error: .macUnreachable, actualSize: nil)) + #expect(state(ChatArtifactError.sessionNotFound) == .failure(error: .sessionNotFound, actualSize: nil)) + #expect(state(ChatArtifactError.unsupported) == .failure(error: .unsupported, actualSize: nil)) + #expect(state(ChatArtifactError.unavailable) == .failure(error: .unavailable, actualSize: nil)) + #expect(state(ChatArtifactError.invalidParams) == .failure(error: .invalidParams, actualSize: nil)) + #expect(state(ChatArtifactError.unsupportedMedia) == .failure(error: .unsupportedMedia, actualSize: nil)) + #expect(state(ChatArtifactError.tooLarge(limitBytes: 9)) == .failure(error: .tooLarge(limitBytes: 9), actualSize: nil)) + } + + @Test func transportCopyNamesTheSideThatIsDown() { + // A phone that knows its own session dropped must not send the user + // to inspect the Mac. + let connected = ChatArtifactConnectionHint.connected.unreachableCopy + let reconnecting = ChatArtifactConnectionHint.reconnecting.unreachableCopy + let disconnected = ChatArtifactConnectionHint.disconnected.unreachableCopy + #expect(connected.title != reconnecting.title) + #expect(connected.title != disconnected.title) + #expect(reconnecting.title != disconnected.title) + #expect(!reconnecting.message.contains("Check the connection")) + #expect(!disconnected.message.contains("Check the connection")) + } + + @Test func onlyTransportErrorsClaimTheMacIsUnreachable() { + struct DecodeFailure: Error {} + // A reply that round-tripped but failed to decode is not a + // connectivity problem; it must not tell the user to check the Mac. + #expect(state(DecodeFailure()) == .failure(error: .loadFailed, actualSize: nil)) + } +} diff --git a/Packages/iOS/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileCoreRPCClient+SurfaceFocus.swift b/Packages/iOS/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileCoreRPCClient+SurfaceFocus.swift new file mode 100644 index 00000000000..05bdd54f470 --- /dev/null +++ b/Packages/iOS/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileCoreRPCClient+SurfaceFocus.swift @@ -0,0 +1,16 @@ +import Foundation + +extension MobileCoreRPCClient { + /// Requests that the paired Mac focus one of its workspace surfaces. + /// - Parameters: + /// - workspaceID: Mac-local workspace identifier. + /// - surfaceID: Mac-local surface identifier. + /// - Throws: A transport or RPC error when focus cannot be requested. + public func focusSurface(workspaceID: String, surfaceID: String) async throws { + let request = try Self.requestData( + method: "mobile.surface.focus", + params: ["workspace_id": workspaceID, "surface_id": surfaceID] + ) + _ = try await sendRequest(request) + } +} diff --git a/Packages/iOS/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileCoreRPCClient+Todo.swift b/Packages/iOS/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileCoreRPCClient+Todo.swift new file mode 100644 index 00000000000..96cd132fbfc --- /dev/null +++ b/Packages/iOS/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileCoreRPCClient+Todo.swift @@ -0,0 +1,47 @@ +public import CmuxMobileShellModel +internal import Foundation + +extension MobileCoreRPCClient { + /// Applies one workspace todo mutation on the paired Mac. + /// - Parameters: + /// - mutation: The mutation to apply through the shared Mac todo path. + /// - workspaceID: The Mac-local workspace identifier. + /// - Throws: A transport or RPC error when the mutation fails. + public func mutateTodo( + _ mutation: MobileTodoMutation, + workspaceID: String + ) async throws { + let method: String + var params: [String: Any] = ["workspace_id": workspaceID] + switch mutation { + case .add(let text): + method = "mobile.todo.add" + params["text"] = text + case .setState(let itemID, let state): + method = "mobile.todo.set_state" + params["id"] = itemID + params["state"] = state.rawValue + case .edit(let itemID, let text): + method = "mobile.todo.edit" + params["id"] = itemID + params["text"] = text + case .move(let itemID, let toIndex): + method = "mobile.todo.move" + params["id"] = itemID + params["to_index"] = toIndex + case .remove(let itemID): + method = "mobile.todo.remove" + params["id"] = itemID + case .openOnMac: + method = "mobile.todo.open" + params["focus"] = true + case .setStatus(let status): + method = "mobile.status.set" + params["status"] = status?.rawValue ?? "auto" + case .cycleStatus: + method = "mobile.status.cycle" + } + let request = try Self.requestData(method: method, params: params) + _ = try await sendRequest(request) + } +} diff --git a/Packages/iOS/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileCoreRPCClient.swift b/Packages/iOS/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileCoreRPCClient.swift index f132f65c2fe..26685266195 100644 --- a/Packages/iOS/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileCoreRPCClient.swift +++ b/Packages/iOS/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileCoreRPCClient.swift @@ -701,7 +701,9 @@ public final class MobileCoreRPCClient: MobileSyncing, Sendable { return false case "workspace.create", "mobile.task.attachment.upload": return false - case "workspace.action", "workspace.close": + case "workspace.action", "workspace.close", "mobile.surface.focus", + "mobile.panel.artifact.stat", "mobile.panel.artifact.fetch", + "mobile.panel.artifact.thumbnail": return !ticketCoverage.ticketCoversWorkspaceRequest( ticket: ticket, workspaceSelection: workspaceSelection.value diff --git a/Packages/iOS/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileSyncWorkspaceListResponse.swift b/Packages/iOS/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileSyncWorkspaceListResponse.swift index d23dcf1533f..9735aefa591 100644 --- a/Packages/iOS/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileSyncWorkspaceListResponse.swift +++ b/Packages/iOS/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileSyncWorkspaceListResponse.swift @@ -48,6 +48,8 @@ public struct MobileSyncWorkspaceListResponse: Decodable, Sendable { public let hasUnread: Bool? /// Terminals belonging to this workspace. public let terminals: [Terminal] + /// All workspace surfaces. `nil` when an older Mac omits the field. + public let surfaces: [Surface]? /// Simulator panes belonging to this workspace. public let simulators: [MobileSimulatorPanelDescriptor] @@ -67,6 +69,7 @@ public struct MobileSyncWorkspaceListResponse: Decodable, Sendable { case lastActivityAt = "last_activity_at" case hasUnread = "has_unread" case terminals + case surfaces case simulators } @@ -89,6 +92,7 @@ public struct MobileSyncWorkspaceListResponse: Decodable, Sendable { lastActivityAt: Double?, hasUnread: Bool?, terminals: [Terminal], + surfaces: [Surface]? = nil, simulators: [MobileSimulatorPanelDescriptor] = [] ) { self.id = id @@ -106,6 +110,7 @@ public struct MobileSyncWorkspaceListResponse: Decodable, Sendable { self.lastActivityAt = lastActivityAt self.hasUnread = hasUnread self.terminals = terminals + self.surfaces = surfaces self.simulators = simulators } @@ -126,6 +131,7 @@ public struct MobileSyncWorkspaceListResponse: Decodable, Sendable { lastActivityAt = try container.decodeIfPresent(Double.self, forKey: .lastActivityAt) hasUnread = try container.decodeIfPresent(Bool.self, forKey: .hasUnread) terminals = try container.decode([Terminal].self, forKey: .terminals) + surfaces = try container.decodeIfPresent([Surface].self, forKey: .surfaces) simulators = try container.decodeIfPresent( [MobileSimulatorPanelDescriptor].self, forKey: .simulators @@ -133,6 +139,43 @@ public struct MobileSyncWorkspaceListResponse: Decodable, Sendable { } } + /// A Mac-rendered surface in workspace spatial order. + public struct Surface: Decodable, Equatable, Sendable { + /// Stable Mac-local surface identifier. + public let surfaceID: String + /// Open surface-kind wire value. + public let kind: String + /// User-facing surface title. + public let title: String + /// Backing path for file-oriented surfaces, when present. + public let filePath: String? + /// Bounded checklist/status payload for todo surfaces. + public let todo: MobileTodoSnapshot? + + private enum CodingKeys: String, CodingKey { + case surfaceID = "surface_id" + case kind + case title + case filePath = "file_path" + case todo + } + + /// Creates a projected surface DTO. + public init( + surfaceID: String, + kind: String, + title: String, + filePath: String?, + todo: MobileTodoSnapshot? = nil + ) { + self.surfaceID = surfaceID + self.kind = kind + self.title = title + self.filePath = filePath + self.todo = todo + } + } + /// A workspace group section in the list response. Mirrors the iOS-facing /// subset the Mac emits (no v2 handle refs or color). Members are /// listed in the Mac's spatial (`tabs`) order. Absent on Macs old enough not diff --git a/Packages/iOS/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileWorkspacePreview+RemoteMapping.swift b/Packages/iOS/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileWorkspacePreview+RemoteMapping.swift index 112414cfce0..65b1f3fd0a8 100644 --- a/Packages/iOS/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileWorkspacePreview+RemoteMapping.swift +++ b/Packages/iOS/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileWorkspacePreview+RemoteMapping.swift @@ -22,11 +22,24 @@ extension MobileWorkspacePreview { terminals: remote.terminals.map { terminal in MobileTerminalPreview(remote: terminal) }, + surfaces: (remote.surfaces ?? []).map(MobileSurfacePreview.init(remote:)), simulators: remote.simulators ) } } +extension MobileSurfacePreview { + init(remote: MobileSyncWorkspaceListResponse.Surface) { + self.init( + id: ID(rawValue: remote.surfaceID), + kind: Kind(rawValue: remote.kind), + title: remote.title, + filePath: remote.filePath, + todo: remote.todo + ) + } +} + extension MobileWorkspaceGroupPreview { /// Build a group preview value from a remote workspace-list group entry. /// - Parameter remote: A group decoded from the RPC response. diff --git a/Packages/iOS/CmuxMobileRPC/Tests/CmuxMobileRPCTests/MobileSurfaceInventoryTests.swift b/Packages/iOS/CmuxMobileRPC/Tests/CmuxMobileRPCTests/MobileSurfaceInventoryTests.swift new file mode 100644 index 00000000000..08ebd59e430 --- /dev/null +++ b/Packages/iOS/CmuxMobileRPC/Tests/CmuxMobileRPCTests/MobileSurfaceInventoryTests.swift @@ -0,0 +1,89 @@ +import CMUXMobileCore +import CmuxMobileShellModel +import Foundation +import Testing +@testable import CmuxMobileRPC + +@Suite struct MobileSurfaceInventoryTests { + @Test func absentInventoryDecodesAsNil() throws { + let response = try MobileSyncWorkspaceListResponse.decode(Data(#"{"workspaces":[{"id":"w","title":"W","is_selected":true,"terminals":[]}]}"#.utf8)) + #expect(response.workspaces.first?.surfaces == nil) + #expect(MobileWorkspacePreview(remote: response.workspaces[0]).surfaces.isEmpty) + } + + @Test func unknownKindSurvivesProjection() throws { + let response = try MobileSyncWorkspaceListResponse.decode(Data(#"{"workspaces":[{"id":"w","title":"W","is_selected":true,"terminals":[],"surfaces":[{"surface_id":"s","kind":"future.canvas","title":"Canvas","file_path":"/tmp/a"}]}]}"#.utf8)) + let surface = try #require(MobileWorkspacePreview(remote: response.workspaces[0]).surfaces.first) + #expect(surface.kind == .other("future.canvas")) + #expect(surface.filePath == "/tmp/a") + #expect(surface.todo == nil) + } + + @Test func todoSnapshotSurvivesLegacyWorkspaceProjection() throws { + let data = Data(#"{"workspaces":[{"id":"w","title":"W","is_selected":true,"terminals":[],"surfaces":[{"surface_id":"todo-1","kind":"todo","title":"Todo","todo":{"status":"review","status_hidden":false,"items":[{"id":"item-1","text":"Ship it","state":"in_progress","origin":"user"}]}}]}]}"#.utf8) + let response = try MobileSyncWorkspaceListResponse.decode(data) + let surface = try #require(MobileWorkspacePreview(remote: response.workspaces[0]).surfaces.first) + let todo = try #require(surface.todo) + + #expect(surface.kind == .todo) + #expect(todo.status == .review) + #expect(todo.statusHidden == false) + #expect(todo.items == [ + MobileTodoItem( + id: "item-1", + text: "Ship it", + state: .inProgress, + origin: .user + ), + ]) + } + + @Test(arguments: [ + "mobile.panel.artifact.stat", + "mobile.panel.artifact.fetch", + "mobile.panel.artifact.thumbnail", + ]) + func panelArtifactRequestsRetainWorkspaceTicketAuthorization(method: String) async throws { + let route = try hostPortRoute(kind: .debugLoopback, host: "127.0.0.1", port: 58_465) + let transport = QueuedCancellationProbeTransport() + let runtime = TestMobileSyncRuntime( + transportFactory: QueuedCancellationProbeTransportFactory(transport: transport), + stackAccessToken: "test-stack-token" + ) + let ticket = try CmxAttachTicket( + workspaceID: "workspace-main", + terminalID: nil, + macDeviceID: "test-mac", + macDisplayName: "Test Mac", + routes: [route], + expiresAt: Date().addingTimeInterval(60), + authToken: "ticket-secret" + ) + let client = MobileCoreRPCClient( + runtime: runtime, + route: route, + ticket: ticket, + allowsStackAuthFallback: true + ) + let request = try MobileCoreRPCClient.requestData( + method: method, + params: [ + "workspace_id": "other-workspace", + "surface_id": "surface", + "path": "/tmp/a", + ] + ) + + let task = Task { try await client.sendRequest(request) } + let sent = try await transport.waitForSentRequestCount(1) + task.cancel() + _ = try? await task.value + + let frame = try #require(sent.first) + #expect(frame.method == method) + #expect(frame.workspaceID == "other-workspace") + #expect(frame.attachToken == nil) + #expect(frame.stackAccessToken == "test-stack-token") + #expect(frame.hasAuth) + } +} diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileChatEventSource+PanelArtifacts.swift b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileChatEventSource+PanelArtifacts.swift new file mode 100644 index 00000000000..a514d8e9896 --- /dev/null +++ b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileChatEventSource+PanelArtifacts.swift @@ -0,0 +1,118 @@ +public import CmuxAgentChat +public import Foundation + +/// Panel-scoped artifact RPCs for the one file a Mac panel currently displays. +extension MobileChatEventSource { + /// Reads metadata for a file-backed panel's displayed file. + /// + /// - Parameters: + /// - workspaceID: Workspace containing the panel. + /// - surfaceID: Markdown or file-preview panel surface. + /// - path: Absolute Mac host path from the surface descriptor. + /// - Returns: Metadata for the displayed file. + /// - Throws: ``ChatArtifactError`` when the host is unavailable, the panel + /// no longer authorizes the path, or metadata cannot be read. + public func panelArtifactStat( + workspaceID: String, + surfaceID: String, + path: String + ) async throws -> ChatArtifactStat { + guard supportsPanelArtifacts else { throw ChatArtifactError.unsupported } + return try await artifactCall( + method: "mobile.panel.artifact.stat", + params: [ + "workspace_id": workspaceID, + "surface_id": surfaceID, + "path": path, + ] + ) + } + + /// Fetches the complete displayed panel file through bounded chunks. + /// + /// - Parameters: + /// - workspaceID: Workspace containing the panel. + /// - surfaceID: Markdown or file-preview panel surface. + /// - path: Absolute Mac host path from the surface descriptor. + /// - progress: Optional callback receiving fetched and total byte counts. + /// - Returns: The displayed file's bytes. + /// - Throws: ``ChatArtifactError`` when the host is unavailable, the panel + /// no longer authorizes the path, or transfer fails. + public func panelArtifactFetch( + workspaceID: String, + surfaceID: String, + path: String, + progress: (@Sendable (_ fetchedBytes: Int64, _ totalBytes: Int64) -> Void)? + ) async throws -> Data { + guard supportsPanelArtifacts else { throw ChatArtifactError.unsupported } + return try await fetchArtifactChunks( + method: "mobile.panel.artifact.fetch", + stringParams: [ + "workspace_id": workspaceID, + "surface_id": surfaceID, + "path": path, + ], + collectsData: true, + progress: progress, + onChunk: { _ in } + ) + } + + /// Streams the displayed panel file without accumulating a second copy. + /// + /// - Parameters: + /// - workspaceID: Workspace containing the panel. + /// - surfaceID: Markdown or file-preview panel surface. + /// - path: Absolute Mac host path from the surface descriptor. + /// - onChunk: Async consumer invoked once for every received chunk. + /// - Throws: ``ChatArtifactError`` when the host is unavailable, the panel + /// no longer authorizes the path, or transfer fails. Consumer errors are + /// propagated unchanged. + public func panelArtifactFetch( + workspaceID: String, + surfaceID: String, + path: String, + onChunk: @Sendable (ChatArtifactChunk) async throws -> Void + ) async throws { + guard supportsPanelArtifacts else { throw ChatArtifactError.unsupported } + _ = try await fetchArtifactChunks( + method: "mobile.panel.artifact.fetch", + stringParams: [ + "workspace_id": workspaceID, + "surface_id": surfaceID, + "path": path, + ], + collectsData: false, + progress: nil, + onChunk: onChunk + ) + } + + /// Generates a bounded thumbnail for the displayed panel file. + /// + /// - Parameters: + /// - workspaceID: Workspace containing the panel. + /// - surfaceID: Markdown or file-preview panel surface. + /// - path: Absolute Mac host path from the surface descriptor. + /// - maxDimension: Maximum thumbnail width or height in pixels. + /// - Returns: Encoded thumbnail bytes and metadata. + /// - Throws: ``ChatArtifactError`` when the host is unavailable, the panel + /// no longer authorizes the path, or thumbnail generation fails. + public func panelArtifactThumbnail( + workspaceID: String, + surfaceID: String, + path: String, + maxDimension: Int + ) async throws -> ChatArtifactThumbnail { + guard supportsPanelArtifacts else { throw ChatArtifactError.unsupported } + return try await artifactCall( + method: "mobile.panel.artifact.thumbnail", + params: [ + "workspace_id": workspaceID, + "surface_id": surfaceID, + "path": path, + "max_dimension": maxDimension, + ] + ) + } +} diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileChatEventSource.swift b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileChatEventSource.swift index c251d4fc439..0207383715e 100644 --- a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileChatEventSource.swift +++ b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileChatEventSource.swift @@ -32,6 +32,8 @@ public actor MobileChatEventSource: ChatEventSource { public nonisolated let supportsArtifactFolders: Bool /// Whether the connected Mac supports terminal-scoped directory listing. public nonisolated let supportsTerminalArtifactList: Bool + /// Whether the connected Mac supports lifecycle-bound panel file reads. + public nonisolated let supportsPanelArtifacts: Bool /// Whether the connected Mac supports session-wide artifact gallery pages. public nonisolated let supportsArtifactGallery: Bool /// Whether raw artifact bytes may use a peer-bound Iroh application lane. @@ -46,6 +48,7 @@ public actor MobileChatEventSource: ChatEventSource { supportsArtifactGallery: Bool = false, supportsArtifactFolders: Bool = false, supportsTerminalArtifactList: Bool = false, + supportsPanelArtifacts: Bool = false, supportsArtifactLane: Bool = false, diagnosticLog: DiagnosticLog? = nil ) { @@ -55,6 +58,7 @@ public actor MobileChatEventSource: ChatEventSource { self.supportsArtifactGallery = supportsArtifactGallery self.supportsArtifactFolders = supportsArtifactFolders self.supportsTerminalArtifactList = supportsTerminalArtifactList + self.supportsPanelArtifacts = supportsPanelArtifacts self.supportsArtifactLane = supportsArtifactLane } diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+AgentChat.swift b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+AgentChat.swift index ebea6217097..90d39619def 100644 --- a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+AgentChat.swift +++ b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+AgentChat.swift @@ -32,6 +32,7 @@ extension MobileShellComposite { supportsArtifactGallery: supportsChatArtifactGallery, supportsArtifactFolders: supportsChatArtifactFolders, supportsTerminalArtifactList: supportsTerminalArtifactList, + supportsPanelArtifacts: supportsPanelArtifacts, supportsArtifactLane: supportsIrohArtifactLane, diagnosticLog: diagnosticLog ) diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+Capabilities.swift b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+Capabilities.swift index ea0d501ed31..cd29afed85e 100644 --- a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+Capabilities.swift +++ b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+Capabilities.swift @@ -137,6 +137,16 @@ extension MobileShellComposite { } /// Whether the Mac supports terminal artifact scan/stat/fetch/thumbnail RPCs. public var supportsTerminalArtifacts: Bool { supportedHostCapabilities.contains(Self.terminalArtifactCapability) } + /// Whether the Mac supports lifecycle-bound panel stat/fetch/thumbnail RPCs. + public var supportsPanelArtifacts: Bool { supportedHostCapabilities.contains(Self.panelArtifactCapability) } + + /// Whether the workspace's owning Mac can serve panel file reads. The + /// panel artifact loader always talks to the foreground Mac's chat event + /// source, so a secondary Mac's surface must stay on the fallback card + /// even when that Mac advertises the capability. + public func supportsPanelArtifacts(in workspaceID: MobileWorkspacePreview.ID) -> Bool { + workspaceMutationTarget(for: workspaceID).isForeground && supportsPanelArtifacts + } public var supportsIrohArtifactLane: Bool { supportedHostCapabilities.contains(Self.irohArtifactLaneCapability) } diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+StateSync.swift b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+StateSync.swift index d50992b6c22..099623bbe1a 100644 --- a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+StateSync.swift +++ b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+StateSync.swift @@ -391,6 +391,15 @@ extension MobileShellComposite { isReady: terminal.isReady ) }, + surfaces: record.surfaces?.map { surface in + MobileSyncWorkspaceListResponse.Surface( + surfaceID: surface.surfaceID, + kind: surface.kind, + title: surface.title, + filePath: surface.filePath, + todo: surface.todo + ) + }, simulators: record.simulators ) } diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+SurfaceFocus.swift b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+SurfaceFocus.swift new file mode 100644 index 00000000000..ff6cfcb6b6d --- /dev/null +++ b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+SurfaceFocus.swift @@ -0,0 +1,40 @@ +internal import CmuxMobileRPC +public import CmuxMobileShellModel + +extension MobileShellComposite { + public nonisolated static let surfaceFocusCapability = "surface.focus.v1" + + /// Whether the workspace's owning Mac advertises surface focus. + public func supportsSurfaceFocus(in workspaceID: MobileWorkspacePreview.ID) -> Bool { + let target = workspaceMutationTarget(for: workspaceID) + if target.isForeground { + return supportedHostCapabilities.contains(Self.surfaceFocusCapability) + } + guard let ownerKey = target.ownerKey else { return false } + return secondaryMacSubscriptions[ownerKey]?.supportedHostCapabilities.contains(Self.surfaceFocusCapability) == true + } + + /// Focuses a surface on the owning Mac. Returns false when the host is + /// unsupported or unreachable, or the RPC fails, so callers can show the + /// user that nothing happened on the Mac. + @discardableResult + public func focusSurfaceOnMac( + workspaceID: MobileWorkspacePreview.ID, + surfaceID: MobileSurfacePreview.ID + ) async -> Bool { + guard supportsSurfaceFocus(in: workspaceID), + let workspace = workspaces.first(where: { $0.id == workspaceID }) else { return false } + let target = workspaceMutationTarget(for: workspaceID) + guard let client = target.client else { return false } + do { + try await client.focusSurface( + workspaceID: workspace.rpcWorkspaceID.rawValue, + surfaceID: surfaceID.rawValue + ) + return true + } catch { + if target.isForeground { markMacConnectionUnavailableIfNeeded(after: error) } + return false + } + } +} diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+Todo.swift b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+Todo.swift new file mode 100644 index 00000000000..27442abc19b --- /dev/null +++ b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+Todo.swift @@ -0,0 +1,43 @@ +internal import CmuxMobileRPC +public import CmuxMobileShellModel + +extension MobileShellComposite { + /// Capability advertised by Macs that accept native mobile todo mutations. + public nonisolated static let todoCapability = "todo.v1" + + /// Whether the workspace's owning Mac supports native todo mutations. + public func supportsTodo(in workspaceID: MobileWorkspacePreview.ID) -> Bool { + let target = workspaceMutationTarget(for: workspaceID) + if target.isForeground { + return supportedHostCapabilities.contains(Self.todoCapability) + } + guard let ownerKey = target.ownerKey else { return false } + return secondaryMacSubscriptions[ownerKey]?.supportedHostCapabilities.contains(Self.todoCapability) == true + } + + /// Applies one todo mutation on the owning Mac and refreshes its authoritative snapshot. + /// - Parameters: + /// - mutation: The mutation to apply. + /// - workspaceID: The aggregated mobile workspace identifier. + /// - Throws: A connection or RPC error when the mutation cannot be applied. + public func performTodoMutation( + _ mutation: MobileTodoMutation, + workspaceID: MobileWorkspacePreview.ID + ) async throws { + guard supportsTodo(in: workspaceID), + let workspace = workspaces.first(where: { $0.id == workspaceID }) else { + throw MobileShellConnectionError.connectionClosed + } + let target = workspaceMutationTarget(for: workspaceID) + guard let client = target.client else { + throw MobileShellConnectionError.connectionClosed + } + do { + try await client.mutateTodo(mutation, workspaceID: workspace.rpcWorkspaceID.rawValue) + await refreshAfterWorkspaceMutation(target) + } catch { + if target.isForeground { markMacConnectionUnavailableIfNeeded(after: error) } + throw error + } + } +} diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift index ea0c25f6d3b..330c9352a98 100644 --- a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift +++ b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift @@ -141,6 +141,7 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { static let chatArtifactCapability = "chat.artifact.v1" static let chatArtifactGalleryCapability = "chat.artifact.gallery.v1" static let terminalArtifactCapability = "terminal.artifact.v1" + static let panelArtifactCapability = "panel.artifact.v1" static let irohArtifactLaneCapability = "iroh.artifact_lane.v1" static let dogfoodFeedbackCapability = "dogfood.v1" static let workspaceGroupsCapability = "workspace.groups.v1" diff --git a/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellCompositePreviewTests.swift b/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellCompositePreviewTests.swift index 968bb646643..c3ef48e43fc 100644 --- a/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellCompositePreviewTests.swift +++ b/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellCompositePreviewTests.swift @@ -37,6 +37,27 @@ import Testing #expect(!store.isReconnectingStoredMac) } + @Test func macSurfaceSelectionIsExplicitAndIndependentFromTerminalSelection() { + let store = MobileShellComposite.preview() + let terminal = MobileTerminalPreview(id: "terminal", name: "Shell") + let surface = MobileSurfacePreview(id: "surface", kind: .markdown, title: "README") + let first = MobileWorkspacePreview( + id: "first", name: "First", terminals: [terminal], surfaces: [surface] + ) + let second = MobileWorkspacePreview( + id: "second", name: "Second", terminals: [MobileTerminalPreview(id: "other", name: "Other")] + ) + store.replaceForegroundWorkspaceState([first, second]) + store.selectedWorkspaceID = first.id + #expect(store.selectedMacSurfaceID == nil) + let terminalSelection = store.selectedTerminalID + store.selectMacSurface(surface.id) + #expect(store.selectedMacSurfaceID == surface.id) + #expect(store.selectedTerminalID == terminalSelection) + store.selectedWorkspaceID = second.id + #expect(store.selectedMacSurfaceID == nil) + } + @Test func identicalForegroundStateDoesNotInvalidateWorkspaceList() async { let store = MobileShellComposite.preview() let workspace = MobileWorkspacePreview( diff --git a/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellStateSyncTests.swift b/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellStateSyncTests.swift index 3b8f85da9dc..3cdea4e6954 100644 --- a/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellStateSyncTests.swift +++ b/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellStateSyncTests.swift @@ -18,7 +18,8 @@ private func workspaceRecord( customDescription: String? = nil, customDescriptionIsTruncated: Bool = false, customColorHex: String? = nil, - sortIndex: Int + sortIndex: Int, + surfaces: [WorkspaceSyncRecord.Surface]? = nil ) -> WorkspaceSyncRecord { WorkspaceSyncRecord( id: id, @@ -36,7 +37,8 @@ private func workspaceRecord( lastActivityAt: 1.0, hasUnread: false, sortIndex: sortIndex, - terminals: [] + terminals: [], + surfaces: surfaces ) } @@ -124,7 +126,13 @@ struct MobileShellStateSyncTests { customDescription: "Release validation", customDescriptionIsTruncated: true, customColorHex: "#1565C0", - sortIndex: 0 + sortIndex: 0, + surfaces: [.init( + surfaceID: "surface-alpha", + kind: "future.canvas", + title: "Canvas", + filePath: "/tmp/canvas" + )] ), workspaceRecord(id: UUID().uuidString, title: "synced-beta", sortIndex: 1), ] @@ -150,6 +158,9 @@ struct MobileShellStateSyncTests { #expect(customizedWorkspace.actionCapabilities.supportsWorkspaceMetadata) #expect(customizedWorkspace.actionCapabilities.supportsReadStateActions) #expect(customizedWorkspace.actionCapabilities.supportsCloseActions) + let projectedSurface = try #require(customizedWorkspace.surfaces.first) + #expect(projectedSurface.kind == .other("future.canvas")) + #expect(projectedSurface.filePath == "/tmp/canvas") // A workspace.updated push must no longer trigger the legacy full-list // refetch while v2 owns the list. diff --git a/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellWorkspaceCapabilityTests.swift b/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellWorkspaceCapabilityTests.swift index 4856fc59b03..47374d1cec3 100644 --- a/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellWorkspaceCapabilityTests.swift +++ b/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellWorkspaceCapabilityTests.swift @@ -25,16 +25,19 @@ import Testing ] #expect(!store.supportsChatArtifactFolders) #expect(!store.supportsTerminalArtifactList) + #expect(!store.supportsPanelArtifacts) #expect(!store.supportsIrohArtifactLane) store.supportedHostCapabilities.formUnion([ "chat.artifact.folders.v1", "terminal.artifact.list.v1", "iroh.artifact_lane.v1", + "panel.artifact.v1", ]) #expect(store.supportsChatArtifactFolders) #expect(store.supportsTerminalArtifactList) #expect(store.supportsIrohArtifactLane) + #expect(store.supportsPanelArtifacts) } @Test func workspaceMutationCapabilitiesAreVersionAndTicketGated() async throws { diff --git a/Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MacSurfaceRenderer.swift b/Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MacSurfaceRenderer.swift new file mode 100644 index 00000000000..b7c64bec0fb --- /dev/null +++ b/Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MacSurfaceRenderer.swift @@ -0,0 +1,56 @@ +public import CMUXMobileCore + +/// The native iOS renderer chosen for one Mac surface snapshot. +/// +/// The decision is pure so the kind → renderer dispatch stays testable away +/// from SwiftUI: capability gating and payload presence both live here, and +/// the view layer switches on the result without re-deriving policy. +public enum MacSurfaceRenderer: Equatable, Sendable { + /// Native checklist and status lane backed by `todo.v1` mutations. + case todo(MobileTodoSnapshot) + /// Native panel-scoped file preview backed by `panel.artifact.v1`. + case filePreview(path: String) + /// Native panel-scoped markdown rendering backed by `panel.artifact.v1`. + case markdown(path: String) + /// Kind-specific card for surfaces that stay rendered on the Mac. + case fallbackCard + + /// Chooses the renderer for a surface given the owning Mac's capabilities. + /// + /// - Parameters: + /// - surface: The synced surface snapshot. + /// - supportsTodo: Whether the owning Mac advertises `todo.v1`. + /// - supportsPanelArtifacts: Whether the connected Mac advertises + /// `panel.artifact.v1` panel file reads. + /// - Returns: The renderer to mount; `.fallbackCard` whenever a required + /// capability or payload is missing. + public static func resolve( + surface: MobileSurfacePreview, + supportsTodo: Bool, + supportsPanelArtifacts: Bool + ) -> MacSurfaceRenderer { + switch surface.kind { + case .todo: + guard supportsTodo, let todo = surface.todo else { return .fallbackCard } + return .todo(todo) + case .filePreview: + guard supportsPanelArtifacts, let path = normalizedFilePath(surface) else { + return .fallbackCard + } + return .filePreview(path: path) + case .markdown: + guard supportsPanelArtifacts, let path = normalizedFilePath(surface) else { + return .fallbackCard + } + return .markdown(path: path) + case .terminal, .browser, .rightSidebarTool, .customSidebar, .agentSession, + .project, .extensionBrowser, .cloudVMLoading, .other: + return .fallbackCard + } + } + + private static func normalizedFilePath(_ surface: MobileSurfacePreview) -> String? { + guard let path = surface.filePath, !path.isEmpty else { return nil } + return path + } +} diff --git a/Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MacSurfaceTextDecoder.swift b/Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MacSurfaceTextDecoder.swift new file mode 100644 index 00000000000..48e6085867f --- /dev/null +++ b/Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MacSurfaceTextDecoder.swift @@ -0,0 +1,37 @@ +public import Foundation + +/// Decodes panel file bytes for native text rendering on the phone. +/// +/// Mirrors the Mac markdown panel's decode order: strict UTF-8 first, then an +/// ISO-Latin-1 reinterpretation so legacy-encoded files still render as text +/// instead of failing into an unreadable state. +public enum MacSurfaceTextDecoder { + /// The encoding a decode resolved to. + public enum Encoding: Equatable, Sendable { + case utf8 + case isoLatin1 + } + + /// One decoded text payload and the encoding that produced it. + public struct DecodedText: Equatable, Sendable { + public let text: String + public let encoding: Encoding + + public init(text: String, encoding: Encoding) { + self.text = text + self.encoding = encoding + } + } + + /// Decodes file bytes as UTF-8, falling back to ISO-Latin-1. + /// + /// ISO-Latin-1 maps every byte to a character, so the fallback always + /// succeeds and any byte payload decodes to text. + public static func decode(_ data: Data) -> DecodedText { + if let utf8 = String(data: data, encoding: .utf8) { + return DecodedText(text: utf8, encoding: .utf8) + } + let latin1 = String(data: data, encoding: .isoLatin1) ?? "" + return DecodedText(text: latin1, encoding: .isoLatin1) + } +} diff --git a/Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileSurfacePreview.swift b/Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileSurfacePreview.swift new file mode 100644 index 00000000000..0ad0645912a --- /dev/null +++ b/Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileSurfacePreview.swift @@ -0,0 +1,89 @@ +public import CMUXMobileCore +import Foundation + +/// A lightweight snapshot of any Mac-rendered workspace surface. +public struct MobileSurfacePreview: Identifiable, Equatable, Sendable { + public struct ID: RawRepresentable, Hashable, Codable, Sendable, ExpressibleByStringLiteral { + /// The Mac-local surface identifier. + public var rawValue: String + /// Creates an identifier from a wire value. + public init(rawValue: String) { self.rawValue = rawValue } + /// Creates an identifier from a string literal. + public init(stringLiteral value: String) { rawValue = value } + } + + /// Known surface kinds plus a forward-compatible unknown case. + public enum Kind: Equatable, Hashable, Sendable { + /// Known surface kinds. + case terminal, browser, markdown, filePreview, rightSidebarTool, customSidebar + /// Additional known surface kinds. + case agentSession, project, extensionBrowser, todo, cloudVMLoading + /// A kind introduced by a newer Mac. + case other(String) + + /// Classifies an open wire kind without discarding unknown values. + public init(rawValue: String) { + switch MobileSurfaceKind(rawValue: rawValue) { + case .terminal: self = .terminal + case .browser: self = .browser + case .markdown: self = .markdown + case .filePreview: self = .filePreview + case .rightSidebarTool: self = .rightSidebarTool + case .customSidebar: self = .customSidebar + case .agentSession: self = .agentSession + case .project: self = .project + case .extensionBrowser: self = .extensionBrowser + case .todo: self = .todo + case .cloudVMLoading: self = .cloudVMLoading + default: self = .other(rawValue) + } + } + + /// The original open wire value. + public var rawValue: String { + switch self { + case .terminal: MobileSurfaceKind.terminal.rawValue + case .browser: MobileSurfaceKind.browser.rawValue + case .markdown: MobileSurfaceKind.markdown.rawValue + case .filePreview: MobileSurfaceKind.filePreview.rawValue + case .rightSidebarTool: MobileSurfaceKind.rightSidebarTool.rawValue + case .customSidebar: MobileSurfaceKind.customSidebar.rawValue + case .agentSession: MobileSurfaceKind.agentSession.rawValue + case .project: MobileSurfaceKind.project.rawValue + case .extensionBrowser: MobileSurfaceKind.extensionBrowser.rawValue + case .todo: MobileSurfaceKind.todo.rawValue + case .cloudVMLoading: MobileSurfaceKind.cloudVMLoading.rawValue + case let .other(value): value + } + } + + /// Whether this kind is a terminal rendered by the existing terminal path. + public var isTerminal: Bool { self == .terminal } + } + + /// Stable Mac-local surface identifier. + public let id: ID + /// Open, forward-compatible surface kind. + public let kind: Kind + /// User-facing surface title. + public let title: String + /// Backing path for file-oriented surfaces, when supplied by the Mac. + public let filePath: String? + /// Bounded checklist/status data for a todo surface. + public let todo: MobileTodoSnapshot? + + /// Creates a surface preview from projected wire data. + public init( + id: ID, + kind: Kind, + title: String, + filePath: String? = nil, + todo: MobileTodoSnapshot? = nil + ) { + self.id = id + self.kind = kind + self.title = title + self.filePath = filePath + self.todo = todo + } +} diff --git a/Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileTodoMutation.swift b/Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileTodoMutation.swift new file mode 100644 index 00000000000..08e581c2cfc --- /dev/null +++ b/Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileTodoMutation.swift @@ -0,0 +1,21 @@ +public import CMUXMobileCore + +/// One mutation supported by the native mobile todo surface. +public enum MobileTodoMutation: Equatable, Sendable { + /// Append a user-authored pending item. + case add(text: String) + /// Set an item's state. + case setState(itemID: String, state: MobileTodoItemState) + /// Replace an item's text. + case edit(itemID: String, text: String) + /// Move an item toward a full-list index within its completion partition. + case move(itemID: String, toIndex: Int) + /// Remove an item. + case remove(itemID: String) + /// Open or focus the workspace todo pane on the Mac. + case openOnMac + /// Set a status lane, or clear the override when the value is `nil`. + case setStatus(MobileTodoStatus?) + /// Advance the effective status to the next lane. + case cycleStatus +} diff --git a/Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileWorkspacePreview.swift b/Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileWorkspacePreview.swift index 0fef22d2564..c441acebc6b 100644 --- a/Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileWorkspacePreview.swift +++ b/Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileWorkspacePreview.swift @@ -89,6 +89,8 @@ public struct MobileWorkspacePreview: Identifiable, Equatable, Sendable { public var hasUnread: Bool /// The terminals contained in the workspace, in display order. public var terminals: [MobileTerminalPreview] + /// Every Mac-rendered surface, in the Mac workspace's spatial order. + public var surfaces: [MobileSurfacePreview] /// The Simulator panes contained in the workspace, in display order. public var simulators: [MobileSimulatorPanelDescriptor] /// The owning Mac's DISTINCT color index in the aggregated list, stamped by @@ -134,6 +136,7 @@ public struct MobileWorkspacePreview: Identifiable, Equatable, Sendable { /// - lastActivityAt: When the workspace last had activity. Defaults to `nil`. /// - hasUnread: Whether the workspace has unread activity. Defaults to `false`. /// - terminals: The terminals contained in the workspace, in display order. + /// - surfaces: Every Mac-rendered surface, in spatial order. public init( id: ID, macDeviceID: String? = nil, @@ -151,6 +154,7 @@ public struct MobileWorkspacePreview: Identifiable, Equatable, Sendable { lastActivityAt: Date? = nil, hasUnread: Bool = false, terminals: [MobileTerminalPreview], + surfaces: [MobileSurfacePreview] = [], simulators: [MobileSimulatorPanelDescriptor] = [] ) { self.id = id @@ -170,6 +174,19 @@ public struct MobileWorkspacePreview: Identifiable, Equatable, Sendable { self.lastActivityAt = lastActivityAt self.hasUnread = hasUnread self.terminals = terminals + self.surfaces = surfaces self.simulators = simulators } } + +extension MobileWorkspacePreview { + /// The picker-selected non-terminal Mac surface, if it still exists. + /// + /// Terminal-kinded rows are never a Mac-surface selection (terminals have + /// their own selection axis), so this is the one lookup every call site + /// must share rather than re-filtering `surfaces` inline. + public func selectedMacSurface(id: MobileSurfacePreview.ID?) -> MobileSurfacePreview? { + guard let id else { return nil } + return surfaces.first { $0.id == id && !$0.kind.isTerminal } + } +} diff --git a/Packages/iOS/CmuxMobileShellModel/Tests/CmuxMobileShellModelTests/MacSurfaceRendererTests.swift b/Packages/iOS/CmuxMobileShellModel/Tests/CmuxMobileShellModelTests/MacSurfaceRendererTests.swift new file mode 100644 index 00000000000..33cf82d85c0 --- /dev/null +++ b/Packages/iOS/CmuxMobileShellModel/Tests/CmuxMobileShellModelTests/MacSurfaceRendererTests.swift @@ -0,0 +1,107 @@ +import CMUXMobileCore +import Testing +@testable import CmuxMobileShellModel + +struct MacSurfaceRendererTests { + private func surface( + kind: MobileSurfacePreview.Kind, + filePath: String? = nil, + todo: MobileTodoSnapshot? = nil + ) -> MobileSurfacePreview { + MobileSurfacePreview( + id: "surface-1", + kind: kind, + title: "Surface", + filePath: filePath, + todo: todo + ) + } + + private var todoSnapshot: MobileTodoSnapshot { + MobileTodoSnapshot(status: .todo, statusHidden: false, items: []) + } + + @Test func todoWithCapabilityAndSnapshotRendersNatively() { + let renderer = MacSurfaceRenderer.resolve( + surface: surface(kind: .todo, todo: todoSnapshot), + supportsTodo: true, + supportsPanelArtifacts: true + ) + #expect(renderer == .todo(todoSnapshot)) + } + + @Test func todoWithoutCapabilityFallsBackToCard() { + let renderer = MacSurfaceRenderer.resolve( + surface: surface(kind: .todo, todo: todoSnapshot), + supportsTodo: false, + supportsPanelArtifacts: true + ) + #expect(renderer == .fallbackCard) + } + + @Test func todoWithoutSnapshotFallsBackToCard() { + let renderer = MacSurfaceRenderer.resolve( + surface: surface(kind: .todo), + supportsTodo: true, + supportsPanelArtifacts: true + ) + #expect(renderer == .fallbackCard) + } + + @Test func filePreviewWithCapabilityAndPathRendersNatively() { + let renderer = MacSurfaceRenderer.resolve( + surface: surface(kind: .filePreview, filePath: "/tmp/demo.txt"), + supportsTodo: false, + supportsPanelArtifacts: true + ) + #expect(renderer == .filePreview(path: "/tmp/demo.txt")) + } + + @Test func markdownWithCapabilityAndPathRendersNatively() { + let renderer = MacSurfaceRenderer.resolve( + surface: surface(kind: .markdown, filePath: "/tmp/demo.md"), + supportsTodo: false, + supportsPanelArtifacts: true + ) + #expect(renderer == .markdown(path: "/tmp/demo.md")) + } + + @Test func fileBackedKindsWithoutCapabilityFallBackToCard() { + for kind in [MobileSurfacePreview.Kind.filePreview, .markdown] { + let renderer = MacSurfaceRenderer.resolve( + surface: surface(kind: kind, filePath: "/tmp/demo"), + supportsTodo: true, + supportsPanelArtifacts: false + ) + #expect(renderer == .fallbackCard) + } + } + + @Test func fileBackedKindsWithoutPathFallBackToCard() { + for path in [nil, ""] as [String?] { + for kind in [MobileSurfacePreview.Kind.filePreview, .markdown] { + let renderer = MacSurfaceRenderer.resolve( + surface: surface(kind: kind, filePath: path), + supportsTodo: true, + supportsPanelArtifacts: true + ) + #expect(renderer == .fallbackCard) + } + } + } + + @Test func macRenderedKindsAlwaysUseTheCard() { + let kinds: [MobileSurfacePreview.Kind] = [ + .terminal, .browser, .rightSidebarTool, .customSidebar, .agentSession, + .project, .extensionBrowser, .cloudVMLoading, .other("simulator"), + ] + for kind in kinds { + let renderer = MacSurfaceRenderer.resolve( + surface: surface(kind: kind, filePath: "/tmp/demo", todo: todoSnapshot), + supportsTodo: true, + supportsPanelArtifacts: true + ) + #expect(renderer == .fallbackCard) + } + } +} diff --git a/Packages/iOS/CmuxMobileShellModel/Tests/CmuxMobileShellModelTests/MacSurfaceTextDecoderTests.swift b/Packages/iOS/CmuxMobileShellModel/Tests/CmuxMobileShellModelTests/MacSurfaceTextDecoderTests.swift new file mode 100644 index 00000000000..4493e10fe97 --- /dev/null +++ b/Packages/iOS/CmuxMobileShellModel/Tests/CmuxMobileShellModelTests/MacSurfaceTextDecoderTests.swift @@ -0,0 +1,33 @@ +import Foundation +import Testing +@testable import CmuxMobileShellModel + +struct MacSurfaceTextDecoderTests { + @Test func decodesUTF8Text() { + let data = Data("# Hello 世界 🎉".utf8) + let decoded = MacSurfaceTextDecoder.decode(data) + #expect(decoded.encoding == .utf8) + #expect(decoded.text == "# Hello 世界 🎉") + } + + @Test func fallsBackToISOLatin1ForInvalidUTF8() { + // 0xE9 alone is invalid UTF-8 but is "é" in ISO-Latin-1. + let data = Data([0x63, 0x61, 0x66, 0xE9]) + let decoded = MacSurfaceTextDecoder.decode(data) + #expect(decoded.encoding == .isoLatin1) + #expect(decoded.text == "café") + } + + @Test func arbitraryBytesAlwaysDecode() { + let data = Data([0xFF, 0xFE, 0x00, 0x80, 0x9F]) + let decoded = MacSurfaceTextDecoder.decode(data) + #expect(decoded.encoding == .isoLatin1) + #expect(decoded.text.count == 5) + } + + @Test func emptyDataDecodesToEmptyUTF8() { + let decoded = MacSurfaceTextDecoder.decode(Data()) + #expect(decoded.encoding == .utf8) + #expect(decoded.text.isEmpty) + } +} diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/CMUXMobileRootView.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/CMUXMobileRootView.swift index 7a9d56c15bf..ff6f29e046e 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/CMUXMobileRootView.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/CMUXMobileRootView.swift @@ -146,6 +146,14 @@ struct CMUXMobileRootView: View { #endif } + private var shouldShowMacSurfaceGalleryPreview: Bool { + #if os(iOS) && DEBUG + return UITestConfig.macSurfaceGalleryPreviewPage != nil + #else + return false + #endif + } + private var shouldShowHiddenComputersPreview: Bool { #if os(iOS) && DEBUG return UITestConfig.hiddenComputersPreviewEnabled @@ -206,6 +214,14 @@ struct CMUXMobileRootView: View { #endif } + @ViewBuilder private var macSurfaceGalleryPreview: some View { + #if os(iOS) && DEBUG + MacSurfaceGalleryPreviewView() + #else + EmptyView() + #endif + } + @ViewBuilder private var changesPreview: some View { #if os(iOS) && DEBUG ChangesPreviewView() @@ -449,6 +465,8 @@ struct CMUXMobileRootView: View { terminalLayoutPreview } else if shouldShowWorkspaceListLayoutPreview { workspaceListLayoutPreview + } else if shouldShowMacSurfaceGalleryPreview { + macSurfaceGalleryPreview } else if shouldShowHiddenComputersPreview { hiddenComputersPreview } else if shouldShowStreamingChatPreview { diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MacSurfaceChrome.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MacSurfaceChrome.swift new file mode 100644 index 00000000000..54c35cef141 --- /dev/null +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MacSurfaceChrome.swift @@ -0,0 +1,105 @@ +import CmuxMobileShellModel +import CmuxMobileSupport +import SwiftUI + +#if canImport(UIKit) +import UIKit +#endif + +/// Rounded-rect kind glyph shared by surface headers, cards, and rows. +struct MacSurfaceIconBadge: View { + let kind: MobileSurfacePreview.Kind + var side: CGFloat = 34 + + var body: some View { + Image(systemName: kind.systemImage) + .font(.system(size: side * 0.52, weight: .medium)) + .foregroundStyle(kind.tint) + .frame(width: side, height: side) + .accessibilityHidden(true) + } +} + +/// Compact chrome row above a native Mac-surface renderer. +/// +/// Keeps every surface's top edge consistent: kind badge, surface title, +/// contextual subtitle, and an optional surface-specific accessory. +struct MacSurfaceHeader: View { + let kind: MobileSurfacePreview.Kind + let title: String + let subtitle: String? + @ViewBuilder var accessory: Accessory + + var body: some View { + HStack(spacing: 12) { + MacSurfaceIconBadge(kind: kind) + VStack(alignment: .leading, spacing: 1) { + Text(title) + .font(.headline) + .lineLimit(1) + if let subtitle, !subtitle.isEmpty { + Text(subtitle) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(1) + .truncationMode(.middle) + } + } + Spacer(minLength: 8) + accessory + } + .padding(.horizontal, 16) + .padding(.vertical, 10) + .background(.bar) + .overlay(alignment: .bottom) { + Divider() + } + } +} + +extension MacSurfaceHeader where Accessory == EmptyView { + init( + kind: MobileSurfacePreview.Kind, + title: String, + subtitle: String? + ) { + self.init(kind: kind, title: title, subtitle: subtitle) { EmptyView() } + } +} + +/// Centered inline state (loading complement, error, empty) for surfaces. +struct MacSurfaceMessageView: View { + let systemImage: String + let title: String + var message: String? = nil + var retry: (() -> Void)? = nil + + var body: some View { + VStack(spacing: 10) { + Image(systemName: systemImage) + .font(.system(size: 34, weight: .light)) + .foregroundStyle(.tertiary) + Text(title) + .font(.headline) + if let message { + Text(message) + .font(.subheadline) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + } + if let retry { + Button(action: retry) { + Label( + L10n.string("mobile.surface.retry", defaultValue: "Retry"), + systemImage: "arrow.clockwise" + ) + } + .buttonStyle(.borderedProminent) + .buttonBorderShape(.capsule) + .padding(.top, 4) + } + } + .padding(.horizontal, 32) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } +} diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MacSurfaceGalleryPreviewView.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MacSurfaceGalleryPreviewView.swift new file mode 100644 index 00000000000..a05ff991261 --- /dev/null +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MacSurfaceGalleryPreviewView.swift @@ -0,0 +1,211 @@ +#if canImport(UIKit) && DEBUG +import CMUXMobileCore +import CmuxAgentChat +import CmuxAgentChatUI +import CmuxMobileShellModel +import Foundation +import SwiftUI + +/// DEBUG-only fixture host for the Mac-surface renderers. +/// +/// Mounted by the root view when `CMUX_UITEST_MAC_SURFACE_GALLERY` names a +/// page (`todo`, `file`, `markdown`, `fallback`, or `picker`). It renders the +/// production surface components with stubbed data and loaders so dark/light +/// simulator screenshots don't require sign-in, pairing, or a live Mac. +/// Unlike the production host — which locks the overlay's `colorScheme` to +/// the Mac terminal theme — the gallery follows the device appearance so +/// `simctl ui appearance` exercises both palettes of the same views. +public struct MacSurfaceGalleryPreviewView: View { + private let page: String + + /// Creates the gallery for the page named in the launch environment. + public init() { + page = ProcessInfo.processInfo.environment["CMUX_UITEST_MAC_SURFACE_GALLERY"] ?? "todo" + } + + public var body: some View { + switch page { + case "file": + PanelFileSurfaceView( + surface: Self.fileSurface, + path: Self.textPath, + loader: Self.fixtureLoader, + connectionStatus: .connected + ) + case "markdown": + MarkdownSurfaceView( + surface: Self.markdownSurface, + path: Self.markdownPath, + loader: Self.fixtureLoader, + connectionStatus: .connected + ) + case "fallback": + SurfaceFallbackCardView( + surface: Self.browserSurface, + workspaceName: "Todos", + canOpenOnMac: true, + openOnMac: { true } + ) + case "picker": + pickerPage + default: + TodoSurfaceView( + surface: Self.todoSurface, + todo: Self.todoSnapshot, + mutate: { _ in } + ) + } + } + + /// Hosts the production picker in a plain chrome bar; the menu itself is + /// opened by tapping, exactly like the workspace toolbar entry point. + private var pickerPage: some View { + VStack { + HStack { + Spacer() + TerminalPickerMenu( + value: TerminalPickerMenuValue( + liveTerminals: Self.fixtureTerminals, + liveSurfaces: [ + Self.todoSurface, + Self.browserSurface, + Self.fileSurface, + Self.markdownSurface, + ], + snapshotRows: [], + selectedID: nil, + selectedMacSurfaceID: Self.todoSurface.id, + canCreateWorkspace: true, + hasActiveBrowser: false, + isChatMode: false + ), + actions: TerminalPickerMenuActions( + selectTerminal: { _ in }, + selectMacSurface: { _ in }, + createWorkspace: {}, + createTerminal: {}, + openBrowser: {}, + selectBrowserStream: { _ in }, + openTextSheet: {}, + copyDebugLogs: {}, + sendFeedback: {} + ), + terminalTheme: .monokai + ) + .padding(12) + } + Spacer() + } + .background(Color(.systemBackground)) + } + + private static let textPath = "/Users/dev/notes/iosrf-demo.txt" + private static let markdownPath = "/Users/dev/notes/iosrf-demo.md" + + private static let todoSurface = MobileSurfacePreview( + id: "gallery-todo", + kind: .todo, + title: "Todos", + todo: todoSnapshot + ) + + private static let fileSurface = MobileSurfacePreview( + id: "gallery-file", + kind: .filePreview, + title: "iosrf-demo.txt", + filePath: textPath + ) + + private static let markdownSurface = MobileSurfacePreview( + id: "gallery-markdown", + kind: .markdown, + title: "iosrf-demo.md", + filePath: markdownPath + ) + + private static let browserSurface = MobileSurfacePreview( + id: "gallery-browser", + kind: .browser, + title: "cmux.com" + ) + + private static let todoSnapshot = MobileTodoSnapshot( + status: .working, + statusHidden: false, + items: [ + MobileTodoItem(id: "1", text: "Render markdown natively on iOS", state: .completed, origin: .user), + MobileTodoItem(id: "2", text: "Ship the UX round", state: .inProgress, origin: .user), + MobileTodoItem(id: "3", text: "Polish todo rows and haptics", state: .inProgress, origin: .agent), + MobileTodoItem(id: "4", text: "Verify dark and light appearance", state: .pending, origin: .user), + MobileTodoItem(id: "5", text: "Hand off for dogfood", state: .pending, origin: .agent), + ] + ) + + private static let fixtureTerminals = [ + MobileTerminalPreview( + id: "gallery-terminal", + name: "abdulazizalbahar@MacBook-Pro:~", + currentDirectory: "~", + isReady: true, + isFocused: false + ) + ] + + private static let fixtureLoader = ChatArtifactLoader( + supportsArtifacts: true, + scope: .panel(workspaceID: "gallery-ws", surfaceID: "gallery-surface"), + stat: { path in + ChatArtifactStat( + exists: true, + isDirectory: false, + size: Int64(MacSurfaceGalleryFixtureBytes.body(for: path).count), + modifiedAt: Date(timeIntervalSince1970: 1_753_800_000), + kind: .text, + mimeType: path.hasSuffix(".md") ? "text/markdown" : "text/plain" + ) + }, + fetch: { path, progress in + let data = MacSurfaceGalleryFixtureBytes.body(for: path) + progress?(Int64(data.count), Int64(data.count)) + return data + } + ) +} + +/// Off-actor fixture bytes so the `@Sendable` loader closures can read them. +private enum MacSurfaceGalleryFixtureBytes { + static let textBody = Data(""" + cmux iOS all-surfaces UX round — panel file preview fixture. + + This body streams through the panel-scoped artifact loader and renders in + the embedded ChatArtifact text route: monospaced, selectable, scrollable. + + - filePreview surfaces render natively instead of an Open-on-Mac card. + - Loading, missing-file, forbidden, and too-large states are designed. + - The header shows the surface kind badge, title, and Open on Mac. + """.utf8) + + static let markdownBody = Data(""" + # iosrf-demo + + Markdown panels now render **natively** on iOS through the shared + document renderer. + + ## What changed + + - `markdown` surfaces fetch through the panel artifact lane. + - UTF-8 decoding falls back to ISO-Latin-1 (café, naïve, ©). + - Headings, lists, `inline code`, and block quotes match agent chat. + + > Read-only in v1: local links and images cannot resolve on the phone. + + ```swift + let renderer = MacSurfaceRenderer.resolve(surface: surface) + ``` + """.utf8) + + static func body(for path: String) -> Data { + path.hasSuffix(".md") ? markdownBody : textBody + } +} +#endif diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MarkdownSurfaceModel.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MarkdownSurfaceModel.swift new file mode 100644 index 00000000000..a45184392a8 --- /dev/null +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MarkdownSurfaceModel.swift @@ -0,0 +1,126 @@ +import CmuxAgentChat +import CmuxAgentChatUI +import CmuxMobileShellModel +import Foundation +import Observation + +/// Main-actor load state for one panel-scoped markdown surface. +@MainActor +@Observable +final class MarkdownSurfaceModel { + /// Failure vocabulary mirroring the artifact viewer's inline states. + enum Failure: Equatable { + case fileMissing + case forbidden + case macUnreachable + case tooLarge(actualSize: Int64?, limit: Int64) + /// The panel that authorized this file is no longer open. + case panelClosed + /// The Mac answered but predates the panel preview RPCs. + case macNeedsUpdate + /// The Mac's transfer service is temporarily unavailable. + case transferUnavailable + /// The Mac answered with an unrecognized or malformed error. + case loadFailed(code: String?) + } + + enum Phase: Equatable { + case loading + case loaded(text: String) + case failed(Failure) + } + + private(set) var phase: Phase = .loading + private(set) var fetchedBytes: Int64 = 0 + private(set) var totalBytes: Int64? + /// Generation of the newest `load` call. A restarted load for the SAME + /// path (retry, title churn) must also invalidate in-flight chunks from + /// the superseded stream, so staleness is guarded by generation rather + /// than path equality. + private var loadGeneration: UInt64 = 0 + private var collected = Data() + + /// Stats, streams, and decodes the panel's markdown file. + /// + /// UTF-8 with an ISO-Latin-1 fallback (`MacSurfaceTextDecoder`), so any + /// byte payload within the preview size limit renders as text. + func load(path: String, loader: ChatArtifactLoader) async { + loadGeneration &+= 1 + let generation = loadGeneration + phase = .loading + fetchedBytes = 0 + totalBytes = nil + collected = Data() + do { + let stat = try await loader.stat(path: path) + try Task.checkCancellation() + guard generation == loadGeneration else { return } + totalBytes = stat.size + let limit = ChatArtifactTransferPolicy.defaultPolicy.maxPreviewBytes + guard stat.size <= limit else { + phase = .failed(.tooLarge(actualSize: stat.size, limit: limit)) + return + } + try await loader.stream( + path: path, + modifiedAt: stat.modifiedAt, + size: stat.size + ) { chunk in + try Task.checkCancellation() + await self.receive(chunk, generation: generation) + } + try Task.checkCancellation() + guard generation == loadGeneration else { return } + phase = .loaded(text: MacSurfaceTextDecoder.decode(collected).text) + collected = Data() + } catch is CancellationError { + return + } catch { + guard !Task.isCancelled, generation == loadGeneration else { return } + phase = .failed(Self.failure(for: error)) + } + } + + private func receive(_ chunk: ChatArtifactChunk, generation: UInt64) { + guard generation == loadGeneration else { return } + collected.append(chunk.data) + totalBytes = chunk.totalSize + fetchedBytes = chunk.eof + ? chunk.totalSize + : chunk.offset + Int64(chunk.data.count) + } + + static func failure(for error: any Error) -> Failure { + guard let artifactError = error as? ChatArtifactError else { + // The Mac replied with something undecodable; connectivity was + // fine, so the message must not claim the Mac is unreachable. + return .loadFailed(code: nil) + } + switch artifactError { + case .unsupported: + return .macNeedsUpdate + case .invalidParams: + return .loadFailed(code: "invalid_params") + case .fileNotFound: + return .fileMissing + case .forbidden, .permissionDenied, .authorizationFailed, .secureConnectionRequired, .authenticationExpired: + return .forbidden + case .tooLarge(let limitBytes): + return .tooLarge(actualSize: nil, limit: limitBytes) + case .macUnreachable, .accountMismatch: + return .macUnreachable + case .sessionNotFound, .terminalNotFound, .workspaceNotFound: + return .panelClosed + case .sessionUnavailable, .unavailable, .fileChanged, .transferInterrupted, + .requestTimedOut, .connectionRecovering: + return .transferUnavailable + case .notRepository, .notDirectory, .notRegularFile, .fileReadFailed, + .unsupportedMedia, .corruptMedia, .previewFailed, .invalidResponse, + .connectionNeedsRestart, .localStorageFull, .localStorageUnavailable, + .loadFailed: + // A markdown panel path that stops decoding as text is a data + // problem on the Mac side, not connectivity. + return .loadFailed(code: nil) + } + } +} diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MarkdownSurfaceView.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MarkdownSurfaceView.swift new file mode 100644 index 00000000000..017bf197dfc --- /dev/null +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MarkdownSurfaceView.swift @@ -0,0 +1,217 @@ +import CmuxAgentChatUI +import CmuxMobileShellModel +import CmuxMobileSupport +import Foundation +import SwiftUI + +/// Native iOS renderer for a Mac markdown panel. +/// +/// Fetches the panel's file through the panel-scoped loader, decodes UTF-8 +/// with an ISO-Latin-1 fallback, and renders the shared document-level +/// markdown view. Read-only; local links and images cannot resolve on the +/// phone in v1. +struct MarkdownSurfaceView: View { + let surface: MobileSurfacePreview + let path: String + let loader: ChatArtifactLoader + let connectionStatus: MobileMacConnectionStatus + + @State private var model = MarkdownSurfaceModel() + @State private var retryCount = 0 + + var body: some View { + VStack(spacing: 0) { + MacSurfaceHeader( + kind: surface.kind, + title: surface.title, + subtitle: MacSurfaceFileContext.subtitle(title: surface.title, path: path) + ) + content + } + // Path changes reload outright; title churn with a stable path re-runs + // the load so a rewritten file re-renders (same-title edits stay stale + // until the next descriptor emission — wave-0 accepted residual). + .task(id: "\(path)\u{0}\(surface.title)\u{0}\(retryCount)") { + await model.load(path: path, loader: loader) + } + } + + @ViewBuilder + private var content: some View { + switch model.phase { + case .loading: + VStack(spacing: 12) { + ProgressView(value: progressValue) + .progressViewStyle(.linear) + .frame(maxWidth: 220) + Text(L10n.string("mobile.surface.loading", defaultValue: "Loading preview")) + .font(.subheadline) + .foregroundStyle(.secondary) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .padding() + case .loaded(let text): + ChatArtifactEmbeddedMarkdown(markdown: text) + case .failed(let failure): + failureView(failure) + } + } + + @ViewBuilder + private func failureView(_ failure: MarkdownSurfaceModel.Failure) -> some View { + switch failure { + case .fileMissing: + MacSurfaceMessageView( + systemImage: "doc.questionmark", + title: L10n.string( + "mobile.surface.fileMissing.title", + defaultValue: "File not found" + ), + message: L10n.string( + "mobile.surface.fileMissing.message", + defaultValue: "The file is no longer available on your Mac." + ) + ) + case .forbidden: + MacSurfaceMessageView( + systemImage: "lock.doc", + title: L10n.string( + "mobile.surface.forbidden.title", + defaultValue: "Preview unavailable" + ), + message: L10n.string( + "mobile.surface.forbidden.message", + defaultValue: "This file isn't displayed by the selected panel." + ) + ) + case .macUnreachable: + MacSurfaceMessageView( + systemImage: "wifi.exclamationmark", + title: unreachableTitle, + message: unreachableMessage, + retry: { retryCount += 1 } + ) + case .tooLarge(let actualSize, let limit): + MacSurfaceMessageView( + systemImage: "doc.badge.ellipsis", + title: L10n.string( + "mobile.surface.tooLarge.title", + defaultValue: "File too large to preview" + ), + message: tooLargeMessage(actualSize: actualSize, limit: limit) + ) + case .panelClosed: + MacSurfaceMessageView( + systemImage: "rectangle.slash", + title: L10n.string( + "mobile.surface.panelClosed.title", + defaultValue: "Panel closed" + ), + message: L10n.string( + "mobile.surface.panelClosed.message", + defaultValue: "That file panel is no longer open on your Mac." + ) + ) + case .macNeedsUpdate: + MacSurfaceMessageView( + systemImage: "arrow.down.circle", + title: L10n.string( + "mobile.surface.macNeedsUpdate.title", + defaultValue: "Update cmux on your Mac" + ), + message: L10n.string( + "mobile.surface.macNeedsUpdate.message", + defaultValue: "The connected Mac's cmux version can't preview this file." + ) + ) + case .transferUnavailable: + MacSurfaceMessageView( + systemImage: "arrow.triangle.2.circlepath", + title: L10n.string( + "mobile.surface.transferUnavailable.title", + defaultValue: "Transfer unavailable" + ), + message: L10n.string( + "mobile.surface.transferUnavailable.message", + defaultValue: "File transfer is temporarily unavailable on your Mac. Try again shortly." + ), + retry: { retryCount += 1 } + ) + case .loadFailed(let code): + MacSurfaceMessageView( + systemImage: "exclamationmark.triangle", + title: L10n.string( + "mobile.surface.loadFailed.title", + defaultValue: "Couldn't load file" + ), + message: loadFailedMessage(code: code), + retry: { retryCount += 1 } + ) + } + } + + private var progressValue: Double? { + guard let total = model.totalBytes, total > 0 else { return nil } + return Double(model.fetchedBytes) / Double(total) + } + + /// Transport-failure copy names the side that is actually down: the + /// phone's own dropped/reforming session reads as such, and only a + /// healthy-looking session blames the path to the Mac. + private var unreachableTitle: String { + switch connectionStatus { + case .connected: + L10n.string("mobile.surface.macUnreachable.title", defaultValue: "Mac unreachable") + case .reconnecting: + L10n.string("mobile.surface.reconnecting.title", defaultValue: "Reconnecting\u{2026}") + case .unavailable: + L10n.string("mobile.surface.disconnected.title", defaultValue: "Not connected") + } + } + + private var unreachableMessage: String { + switch connectionStatus { + case .connected: + L10n.string( + "mobile.surface.macUnreachable.message", + defaultValue: "Check the connection to your Mac and try again." + ) + case .reconnecting: + L10n.string( + "mobile.surface.reconnecting.message", + defaultValue: "This phone's connection to the Mac dropped and is coming back. Retry in a moment." + ) + case .unavailable: + L10n.string( + "mobile.surface.disconnected.message", + defaultValue: "This phone isn't connected to the Mac right now. Reconnect, then retry." + ) + } + } + + private func loadFailedMessage(code: String?) -> String { + let base = L10n.string( + "mobile.surface.loadFailed.message", + defaultValue: "Something went wrong loading this file." + ) + guard let code, !code.isEmpty else { return base } + return base + " (\(code))" + } + + private func tooLargeMessage(actualSize: Int64?, limit: Int64) -> String { + let limitText = ByteCountFormatter.string(fromByteCount: limit, countStyle: .file) + guard let actualSize else { + let format = L10n.string( + "mobile.surface.tooLarge.limitFormat", + defaultValue: "This preview is limited to %@." + ) + return String.localizedStringWithFormat(format, limitText) + } + let actualText = ByteCountFormatter.string(fromByteCount: actualSize, countStyle: .file) + let format = L10n.string( + "mobile.surface.tooLarge.messageFormat", + defaultValue: "This file is %1$@; previews are limited to %2$@." + ) + return String.localizedStringWithFormat(format, actualText, limitText) + } +} diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileMacConnectionStatus+ArtifactHint.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileMacConnectionStatus+ArtifactHint.swift new file mode 100644 index 00000000000..6beb1dec452 --- /dev/null +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileMacConnectionStatus+ArtifactHint.swift @@ -0,0 +1,14 @@ +import CmuxAgentChatUI +import CmuxMobileShellModel + +extension MobileMacConnectionStatus { + /// The artifact viewer's connection hint, so transport-failure copy says + /// which side is down instead of always pointing the user at the Mac. + var artifactConnectionHint: ChatArtifactConnectionHint { + switch self { + case .connected: .connected + case .reconnecting: .reconnecting + case .unavailable: .disconnected + } + } +} diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileSurfacePreview+Presentation.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileSurfacePreview+Presentation.swift new file mode 100644 index 00000000000..54228b40fb5 --- /dev/null +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileSurfacePreview+Presentation.swift @@ -0,0 +1,108 @@ +import CmuxMobileShellModel +import CmuxMobileSupport +import SwiftUI + +extension MobileSurfacePreview.Kind { + var systemImage: String { + switch self { + case .terminal: "terminal" + case .todo: "checklist" + case .markdown: "doc.richtext" + case .filePreview: "doc.text.magnifyingglass" + case .browser: "globe" + case .agentSession: "bubble.left.and.text.bubble.right" + case .project: "hammer" + case .customSidebar: "sidebar.right" + case .rightSidebarTool: "wrench.and.screwdriver" + case .extensionBrowser: "puzzlepiece.extension" + case .cloudVMLoading: "icloud" + case .other: "rectangle.dashed" + } + } + + var displayName: String { + switch self { + case .terminal: L10n.string("mobile.surface.kind.terminal", defaultValue: "Terminal") + case .browser: L10n.string("mobile.surface.kind.browser", defaultValue: "Browser") + case .markdown: L10n.string("mobile.surface.kind.markdown", defaultValue: "Markdown") + case .filePreview: L10n.string("mobile.surface.kind.filePreview", defaultValue: "File Preview") + case .rightSidebarTool: L10n.string("mobile.surface.kind.rightSidebarTool", defaultValue: "Sidebar Tool") + case .customSidebar: L10n.string("mobile.surface.kind.customSidebar", defaultValue: "Custom Sidebar") + case .agentSession: L10n.string("mobile.surface.kind.agentSession", defaultValue: "Agent Session") + case .project: L10n.string("mobile.surface.kind.project", defaultValue: "Project") + case .extensionBrowser: L10n.string("mobile.surface.kind.extensionBrowser", defaultValue: "Extension Browser") + case .todo: L10n.string("mobile.surface.kind.todo", defaultValue: "Todo") + case .cloudVMLoading: L10n.string("mobile.surface.kind.cloudVM", defaultValue: "Cloud VM") + case .other: L10n.string("mobile.surface.kind.other", defaultValue: "Other Surface") + } + } + + /// Accent used for the kind glyph in badges, cards, and picker rows. + var tint: Color { + switch self { + case .terminal: .green + case .todo: .blue + case .markdown: .indigo + case .filePreview: .teal + case .browser: .blue + case .agentSession: .purple + case .project: .orange + case .customSidebar: .cyan + case .rightSidebarTool: .gray + case .extensionBrowser: .pink + case .cloudVMLoading: .cyan + case .other: .gray + } + } + + /// One-line card copy explaining where this surface actually lives. + var fallbackExplainer: String { + switch self { + case .browser: + L10n.string( + "mobile.surface.explainer.browser", + defaultValue: "This browser tab is open in cmux on your Mac." + ) + case .agentSession: + L10n.string( + "mobile.surface.explainer.agentSession", + defaultValue: "This agent session is running in cmux on your Mac." + ) + case .project: + L10n.string( + "mobile.surface.explainer.project", + defaultValue: "This pane browses the project's files on your Mac." + ) + case .customSidebar: + L10n.string( + "mobile.surface.explainer.customSidebar", + defaultValue: "This panel is drawn by a sidebar extension on your Mac." + ) + case .rightSidebarTool: + L10n.string( + "mobile.surface.explainer.rightSidebarTool", + defaultValue: "This tool lives in the right sidebar on your Mac." + ) + case .extensionBrowser: + L10n.string( + "mobile.surface.explainer.extensionBrowser", + defaultValue: "This extension view opens in cmux on your Mac." + ) + case .cloudVMLoading: + L10n.string( + "mobile.surface.explainer.cloudVM", + defaultValue: "This Cloud VM is still starting up on your Mac." + ) + case .other: + L10n.string( + "mobile.surface.explainer.other", + defaultValue: "This surface needs a newer version of the iOS app." + ) + case .terminal, .todo, .markdown, .filePreview: + L10n.string( + "mobile.surface.explainer.generic", + defaultValue: "This view is rendered by cmux on your Mac." + ) + } + } +} diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileTodoStatus+Presentation.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileTodoStatus+Presentation.swift new file mode 100644 index 00000000000..36b7ee94d78 --- /dev/null +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileTodoStatus+Presentation.swift @@ -0,0 +1,43 @@ +import CMUXMobileCore +import CmuxMobileSupport +import SwiftUI + +extension MobileTodoStatus { + var systemImage: String { + switch self { + case .todo: "circle" + case .working: "circle.dotted" + case .needsAttention: "exclamationmark.circle.fill" + case .review: "eye.circle.fill" + case .done: "checkmark.circle.fill" + } + } + + var displayName: String { + switch self { + case .todo: + L10n.string("mobile.todo.status.todo", defaultValue: "Todo") + case .working: + L10n.string("mobile.todo.status.working", defaultValue: "Working") + case .needsAttention: + L10n.string("mobile.todo.status.needsAttention", defaultValue: "Needs Attention") + case .review: + L10n.string("mobile.todo.status.review", defaultValue: "Review") + case .done: + L10n.string("mobile.todo.status.done", defaultValue: "Done") + } + } + + /// Lane accents matching the Mac sidebar's status glyph palette. + var tint: Color { + switch self { + case .todo: .secondary + case .working: .accentColor + // Loudest lane: full-strength attention accent between orange and red. + case .needsAttention: Color(red: 1.0, green: 0.42, blue: 0.2) + case .review: .green + // Muted gray-green so finished lanes read as settled, not celebratory. + case .done: Color(red: 0.45, green: 0.62, blue: 0.5) + } + } +} diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/PanelFileSurfaceView.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/PanelFileSurfaceView.swift new file mode 100644 index 00000000000..898450187b0 --- /dev/null +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/PanelFileSurfaceView.swift @@ -0,0 +1,49 @@ +import CmuxAgentChatUI +import CmuxMobileShellModel +import SwiftUI + +/// Native iOS renderer for a Mac file-preview panel. +/// +/// Routes the panel's displayed file through the shared artifact preview +/// (text with syntax highlighting, image, PDF, media, Quick Look) using the +/// panel-scoped loader; read-only in v1. The embedded preview owns its +/// loading and failure states, so this view only contributes the surface +/// chrome. +struct PanelFileSurfaceView: View { + let surface: MobileSurfacePreview + let path: String + let loader: ChatArtifactLoader + let connectionStatus: MobileMacConnectionStatus + + var body: some View { + VStack(spacing: 0) { + MacSurfaceHeader( + kind: surface.kind, + title: surface.title, + subtitle: MacSurfaceFileContext.subtitle(title: surface.title, path: path) + ) + ChatArtifactEmbeddedPreview( + path: path, + scope: .panel, + loader: loader, + refreshToken: surface.title, + connectionHint: connectionStatus.artifactConnectionHint + ) + } + } +} + +/// Derives the header subtitle for file-backed surfaces. +enum MacSurfaceFileContext { + /// The file name when the title doesn't already show it, otherwise the + /// parent directory name so a duplicated line never appears. + static func subtitle(title: String, path: String) -> String? { + let url = URL(fileURLWithPath: path) + let fileName = url.lastPathComponent + guard !fileName.isEmpty else { return nil } + if title != fileName { return fileName } + let parent = url.deletingLastPathComponent().lastPathComponent + guard !parent.isEmpty, parent != "/" else { return nil } + return parent + } +} diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/SurfaceFallbackCardView.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/SurfaceFallbackCardView.swift new file mode 100644 index 00000000000..e9e6477ff93 --- /dev/null +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/SurfaceFallbackCardView.swift @@ -0,0 +1,131 @@ +import CmuxMobileShellModel +import CmuxMobileSupport +import SwiftUI + +#if canImport(UIKit) +import UIKit +#endif + +/// Card for a surface that remains rendered by the paired Mac. +/// +/// Kind-specific glyph and copy plus the one action that always works: +/// raising the surface in cmux on the Mac. +struct SurfaceFallbackCardView: View { + let surface: MobileSurfacePreview + let workspaceName: String + let canOpenOnMac: Bool + let openOnMac: () async -> Bool + + @State private var isFocusing = false + @State private var focusFailed = false + @State private var focusTask: Task? + + var body: some View { + VStack(spacing: 0) { + Spacer(minLength: 24) + + ZStack { + Circle() + .fill(surface.kind.tint.opacity(0.14)) + .frame(width: 88, height: 88) + Circle() + .stroke(surface.kind.tint.opacity(0.22), lineWidth: 1) + .frame(width: 88, height: 88) + Image(systemName: surface.kind.systemImage) + .font(.system(size: 36, weight: .medium)) + .foregroundStyle(surface.kind.tint) + } + .accessibilityHidden(true) + .padding(.bottom, 20) + + Text(surface.title) + .font(.title3.weight(.semibold)) + .lineLimit(2) + .padding(.bottom, 4) + + Text(contextLine) + .font(.subheadline) + .foregroundStyle(.secondary) + .lineLimit(1) + .truncationMode(.middle) + .padding(.bottom, 14) + + Text(surface.kind.fallbackExplainer) + .font(.subheadline) + .foregroundStyle(.secondary) + .padding(.horizontal, 36) + .padding(.bottom, 24) + + Button { + focusTask?.cancel() + focusTask = Task { await runFocus() } + } label: { + HStack(spacing: 8) { + if isFocusing { + ProgressView() + .controlSize(.small) + } else { + Image(systemName: "macwindow.badge.plus") + } + Text(L10n.string("mobile.surface.openOnMac", defaultValue: "Open on Mac")) + } + .font(.body.weight(.medium)) + .padding(.horizontal, 6) + .padding(.vertical, 2) + } + .buttonStyle(.borderedProminent) + .buttonBorderShape(.capsule) + .controlSize(.large) + .disabled(!canOpenOnMac || isFocusing) + + ZStack { + // Reserved line so the failure message never reflows the card. + Text(verbatim: " ").font(.footnote) + if focusFailed { + Label( + L10n.string( + "mobile.surface.openOnMacFailed", + defaultValue: "Couldn't reach your Mac. Try again." + ), + systemImage: "exclamationmark.triangle.fill" + ) + .font(.footnote) + .foregroundStyle(.red) + .transition(.opacity.combined(with: .move(edge: .top))) + } + } + .padding(.top, 12) + + Spacer(minLength: 24) + } + .multilineTextAlignment(.center) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .onDisappear { focusTask?.cancel() } + } + + /// "Kind · In “Workspace”" so the card names where the surface lives. + private var contextLine: String { + let kindName = surface.kind.displayName + let trimmedWorkspace = workspaceName.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmedWorkspace.isEmpty else { return kindName } + let format = L10n.string( + "mobile.surface.workspaceContextFormat", + defaultValue: "%1$@ · In “%2$@”" + ) + return String.localizedStringWithFormat(format, kindName, trimmedWorkspace) + } + + @MainActor + private func runFocus() async { + isFocusing = true + withAnimation(.snappy) { focusFailed = false } + let succeeded = await openOnMac() + guard !Task.isCancelled else { return } + isFocusing = false + guard !succeeded else { return } + #if canImport(UIKit) + UINotificationFeedbackGenerator().notificationOccurred(.error) + #endif + withAnimation(.snappy) { focusFailed = true } + } +} diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TerminalPickerMenu.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TerminalPickerMenu.swift index e9ae12309ea..df92bf3b82b 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TerminalPickerMenu.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TerminalPickerMenu.swift @@ -42,21 +42,34 @@ struct TerminalPickerMenu: View, Equatable { @ViewBuilder private var menuContent: some View { Section(L10n.string("mobile.terminal.picker.title", defaultValue: "Terminals")) { - ForEach(value.rows) { terminal in - Button { - actions.selectTerminal(terminal.id) - } label: { - Label( - terminal.name, - systemImage: terminal.id == value.selectedID - && !value.hasActiveBrowser - && value.activeBrowserStreamPanelID == nil - && value.activeSimulatorStreamPanelID == nil - ? "checkmark.circle.fill" - : "terminal" - ) + ForEach(value.terminalRows) { terminal in + // Toggle rows get the native leading checkmark while the kind + // glyph stays on the trailing edge, matching system pickers. + Toggle(isOn: Binding( + get: { terminal.id == value.checkedRowID }, + set: { _ in + if let id = terminal.terminalID { actions.selectTerminal(id) } + } + )) { + Label(terminal.name, systemImage: "terminal") + } + .accessibilityIdentifier("MobileTerminalMenuItem-\(terminal.terminalID?.rawValue ?? "")") + } + } + + if !value.macSurfaceRows.isEmpty { + Section(L10n.string("mobile.surface.section", defaultValue: "Mac Surfaces")) { + ForEach(value.macSurfaceRows) { surface in + Toggle(isOn: Binding( + get: { surface.id == value.checkedRowID }, + set: { _ in + if let id = surface.macSurfaceID { actions.selectMacSurface(id) } + } + )) { + Label(surface.name, systemImage: surface.surfaceKind.systemImage) + } + .accessibilityIdentifier("MobileMacSurfaceMenuItem-\(surface.macSurfaceID?.rawValue ?? "")") } - .accessibilityIdentifier("MobileTerminalMenuItem-\(terminal.id.rawValue)") } } diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TerminalPickerMenuActions.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TerminalPickerMenuActions.swift index b676694ff46..cf24d05c69b 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TerminalPickerMenuActions.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TerminalPickerMenuActions.swift @@ -3,6 +3,7 @@ import CmuxMobileShellModel /// User actions emitted by ``TerminalPickerMenu`` without exposing mutable stores to its row subtree. struct TerminalPickerMenuActions { let selectTerminal: (MobileTerminalPreview.ID) -> Void + let selectMacSurface: (MobileSurfacePreview.ID) -> Void let createWorkspace: () -> Void let createTerminal: () -> Void let openBrowser: () -> Void diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TerminalPickerMenuValue.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TerminalPickerMenuValue.swift index 4bb9ba0d97b..3837b6ee56f 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TerminalPickerMenuValue.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TerminalPickerMenuValue.swift @@ -4,6 +4,7 @@ import CmuxMobileShellModel struct TerminalPickerMenuValue: Equatable { let rows: [TerminalPickerMenuRow] let selectedID: MobileTerminalPreview.ID? + let selectedMacSurfaceID: MobileSurfacePreview.ID? let selectedName: String? let canCreateWorkspace: Bool let hasActiveBrowser: Bool @@ -17,8 +18,10 @@ struct TerminalPickerMenuValue: Equatable { init( liveTerminals: [MobileTerminalPreview], + liveSurfaces: [MobileSurfacePreview] = [], snapshotRows: [TerminalPickerMenuRow], selectedID: MobileTerminalPreview.ID?, + selectedMacSurfaceID: MobileSurfacePreview.ID? = nil, canCreateWorkspace: Bool, hasActiveBrowser: Bool, isChatMode: Bool, @@ -29,12 +32,17 @@ struct TerminalPickerMenuValue: Equatable { supportsSimulatorStream: Bool = false, activeSimulatorStreamPanelID: String? = nil ) { - rows = snapshotRows.isEmpty + let resolvedRows = snapshotRows.isEmpty ? liveTerminals.map(TerminalPickerMenuRow.init) + + liveSurfaces.filter { !$0.kind.isTerminal }.map(TerminalPickerMenuRow.init) : snapshotRows - let selection = rows.resolvedTerminalPickerSelection(selectedID: selectedID) + rows = resolvedRows + let selection = resolvedRows.resolvedTerminalPickerSelection(selectedID: selectedID) self.selectedID = selection?.id - selectedName = selection?.name + self.selectedMacSurfaceID = selectedMacSurfaceID + selectedName = selectedMacSurfaceID.flatMap { id in + resolvedRows.first(where: { $0.id == .macSurface(id) })?.name + } ?? selection?.name self.canCreateWorkspace = canCreateWorkspace self.hasActiveBrowser = hasActiveBrowser self.isChatMode = isChatMode @@ -45,4 +53,34 @@ struct TerminalPickerMenuValue: Equatable { self.supportsSimulatorStream = supportsSimulatorStream self.activeSimulatorStreamPanelID = activeSimulatorStreamPanelID } + + /// The single row that carries the checkmark. Nil while the phone-local + /// browser or a Mac browser stream overlays the workspace (the stream row + /// draws its own check from `activeBrowserStreamPanelID`); a Mac-surface + /// selection whose row has disappeared falls back to the resolved + /// terminal, matching `selectedName`. + var checkedRowID: TerminalPickerMenuRow.ID? { + if hasActiveBrowser || activeBrowserStreamPanelID != nil || activeSimulatorStreamPanelID != nil { return nil } + if let selectedMacSurfaceID, + rows.contains(where: { $0.id == .macSurface(selectedMacSurfaceID) }) { + return .macSurface(selectedMacSurfaceID) + } + return selectedID.map(TerminalPickerMenuRow.ID.terminal) + } + + var terminalRows: [TerminalPickerMenuRow] { + rows.filter { if case .terminal = $0.id { true } else { false } } + } + + /// Mac-surface rows for the "Mac Surfaces" section. Browser panes are + /// excluded whenever the Mac supports browser streaming — they get their + /// own "Mac Browsers" section — and only fall back to a surface row on + /// Macs without streaming. Filtered here (not at row construction) so + /// snapshot-built rows obey the same policy as live ones. + var macSurfaceRows: [TerminalPickerMenuRow] { + rows.filter { + guard case .macSurface = $0.id else { return false } + return !(supportsBrowserStream && $0.surfaceKind == .browser) + } + } } diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TodoStatusMenu.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TodoStatusMenu.swift new file mode 100644 index 00000000000..0d979bc6ace --- /dev/null +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TodoStatusMenu.swift @@ -0,0 +1,82 @@ +import CMUXMobileCore +import CmuxMobileSupport +import SwiftUI + +/// Tinted status-lane chip and picker for the native todo surface header. +struct TodoStatusMenu: View { + let status: MobileTodoStatus + let statusHidden: Bool + let isEnabled: Bool + let setStatus: (MobileTodoStatus?) -> Void + + var body: some View { + Menu { + Section(L10n.string("mobile.todo.status.menuTitle", defaultValue: "Status")) { + Button { + setStatus(nil) + } label: { + Label( + L10n.string("mobile.todo.status.automatic", defaultValue: "Automatic"), + systemImage: "sparkle" + ) + } + ForEach(MobileTodoStatus.allCases, id: \.self) { lane in + Toggle(isOn: Binding( + get: { !statusHidden && lane == status }, + set: { _ in setStatus(lane) } + )) { + Label(lane.displayName, systemImage: lane.systemImage) + } + } + } + } label: { + HStack(spacing: 5) { + Image(systemName: chipSystemImage) + .font(.caption.weight(.semibold)) + // The chip is sized to the widest possible title up front, so + // switching lanes (or the menu's first presentation) never + // clips the label while the capsule resizes. + ZStack { + ForEach(Self.sizingTitles, id: \.self) { title in + Text(title) + .font(.caption.weight(.semibold)) + .hidden() + } + Text(chipTitle) + .font(.caption.weight(.semibold)) + .lineLimit(1) + } + Image(systemName: "chevron.up.chevron.down") + .font(.system(size: 8, weight: .bold)) + .opacity(0.7) + } + .padding(.horizontal, 10) + .padding(.vertical, 6) + .foregroundStyle(chipTint) + .background(Capsule().fill(chipTint.opacity(0.15))) + .contentShape(Capsule()) + } + .disabled(!isEnabled) + .accessibilityLabel(L10n.string("mobile.todo.status.choose", defaultValue: "Choose status")) + .accessibilityValue(chipTitle) + } + + private var chipTitle: String { + statusHidden + ? L10n.string("mobile.todo.status.hidden", defaultValue: "No Status") + : status.displayName + } + + private var chipSystemImage: String { + statusHidden ? "circle.slash" : status.systemImage + } + + private var chipTint: Color { + statusHidden ? .secondary : status.tint + } + + /// Every title the chip can present, for width reservation. + static let sizingTitles: [String] = + MobileTodoStatus.allCases.map(\.displayName) + + [L10n.string("mobile.todo.status.hidden", defaultValue: "No Status")] +} diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TodoSurfaceModel.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TodoSurfaceModel.swift new file mode 100644 index 00000000000..7235fa0baf9 --- /dev/null +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TodoSurfaceModel.swift @@ -0,0 +1,147 @@ +import CMUXMobileCore +import CmuxMobileShellModel +import Foundation +import Observation + +/// Main-actor state for one optimistic native todo surface. +@MainActor +@Observable +final class TodoSurfaceModel { + private let mutate: @MainActor (MobileTodoMutation) async throws -> Void + private var deferredAuthoritativeSnapshot: MobileTodoSnapshot? + private(set) var snapshot: MobileTodoSnapshot + private(set) var pendingRequestID: UUID? + private(set) var showsMutationError = false + + init( + snapshot: MobileTodoSnapshot, + mutate: @escaping @MainActor (MobileTodoMutation) async throws -> Void + ) { + self.snapshot = snapshot + self.mutate = mutate + } + + var isMutationPending: Bool { pendingRequestID != nil } + + /// Reconciles a live host snapshot, deferring it while an optimistic request is in flight. + func reconcile(_ authoritative: MobileTodoSnapshot) { + if pendingRequestID != nil { + deferredAuthoritativeSnapshot = authoritative + } else { + snapshot = authoritative + } + } + + /// Applies one optimistic mutation and rolls it back if the Mac rejects it. + @discardableResult + func perform(_ mutation: MobileTodoMutation) async -> Bool { + guard pendingRequestID == nil, + let optimistic = applying(mutation, to: snapshot) else { return false } + let requestID = UUID() + let previous = snapshot + pendingRequestID = requestID + deferredAuthoritativeSnapshot = nil + showsMutationError = false + snapshot = optimistic + do { + try await mutate(mutation) + guard pendingRequestID == requestID else { return true } + if let authoritative = deferredAuthoritativeSnapshot { + snapshot = authoritative + } + pendingRequestID = nil + deferredAuthoritativeSnapshot = nil + return true + } catch { + guard pendingRequestID == requestID else { return false } + snapshot = deferredAuthoritativeSnapshot ?? previous + pendingRequestID = nil + deferredAuthoritativeSnapshot = nil + showsMutationError = true + return false + } + } + + func dismissMutationError() { + showsMutationError = false + } + + private func applying( + _ mutation: MobileTodoMutation, + to snapshot: MobileTodoSnapshot + ) -> MobileTodoSnapshot? { + var status = snapshot.status + var statusHidden = snapshot.statusHidden + var items = snapshot.items + switch mutation { + case .add(let rawText): + guard items.count < MobileTodoSnapshot.maxItems, + let text = normalizedText(rawText) else { return nil } + items.append(MobileTodoItem( + id: UUID().uuidString, + text: text, + state: .pending, + origin: .user + )) + case .setState(let itemID, let nextState): + guard let index = items.firstIndex(where: { $0.id == itemID }) else { return nil } + let previousItem = items[index] + let wasCompleted = previousItem.state == .completed + let updated = MobileTodoItem( + id: previousItem.id, + text: previousItem.text, + state: nextState, + origin: previousItem.origin + ) + items[index] = updated + if wasCompleted != (nextState == .completed) { + items.remove(at: index) + let firstCompleted = items.firstIndex(where: { $0.state == .completed }) ?? items.endIndex + if nextState == .completed { + items.append(updated) + } else { + items.insert(updated, at: firstCompleted) + } + } + case .edit(let itemID, let rawText): + guard let index = items.firstIndex(where: { $0.id == itemID }), + let text = normalizedText(rawText) else { return nil } + let item = items[index] + items[index] = MobileTodoItem(id: item.id, text: text, state: item.state, origin: item.origin) + case .move(let itemID, let toIndex): + guard let index = items.firstIndex(where: { $0.id == itemID }) else { return nil } + let item = items.remove(at: index) + let incomplete = items.filter { $0.state != .completed } + let completed = items.filter { $0.state == .completed } + if item.state == .completed { + let localIndex = min(max(toIndex - incomplete.count, 0), completed.count) + var reordered = completed + reordered.insert(item, at: localIndex) + items = incomplete + reordered + } else { + let localIndex = min(max(toIndex, 0), incomplete.count) + var reordered = incomplete + reordered.insert(item, at: localIndex) + items = reordered + completed + } + case .remove(let itemID): + guard let index = items.firstIndex(where: { $0.id == itemID }) else { return nil } + items.remove(at: index) + case .openOnMac: + break + case .setStatus(let nextStatus): + status = nextStatus ?? status + statusHidden = false + case .cycleStatus: + status = status.next + statusHidden = false + } + return MobileTodoSnapshot(status: status, statusHidden: statusHidden, items: items) + } + + private func normalizedText(_ text: String) -> String? { + let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return nil } + return String(trimmed.prefix(MobileTodoItem.maxTextLength)) + } +} diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TodoSurfaceRowView.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TodoSurfaceRowView.swift new file mode 100644 index 00000000000..826d11453a6 --- /dev/null +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TodoSurfaceRowView.swift @@ -0,0 +1,134 @@ +import CMUXMobileCore +import CmuxMobileSupport +import SwiftUI + +#if canImport(UIKit) +import UIKit +#endif + +/// Closure bundle for an immutable mobile todo row snapshot. +struct TodoSurfaceRowActions { + let cycleState: () -> Void + let edit: (String) -> Void + let move: (String, Int) -> Void + let remove: () -> Void +} + +/// One immutable checklist row with local-only inline editing state. +struct TodoSurfaceRowView: View { + let item: MobileTodoItem + let displayIndex: Int + let isEnabled: Bool + let actions: TodoSurfaceRowActions + + @State private var isEditing = false + @State private var draft = "" + @FocusState private var editorFocused: Bool + + var body: some View { + HStack(alignment: .firstTextBaseline, spacing: 12) { + Button { + #if canImport(UIKit) + UIImpactFeedbackGenerator(style: .light).impactOccurred() + #endif + actions.cycleState() + } label: { + Image(systemName: stateSystemImage) + .font(.title3) + .foregroundStyle(stateTint) + .contentTransition(.symbolEffect(.replace)) + .animation(.snappy, value: item.state) + .frame(width: 26, height: 26) + } + .buttonStyle(.plain) + .disabled(!isEnabled) + .accessibilityLabel(stateActionLabel) + + if isEditing { + TextField( + L10n.string("mobile.todo.item.editPlaceholder", defaultValue: "Item text"), + text: $draft, + axis: .vertical + ) + .focused($editorFocused) + .lineLimit(1...6) + .onSubmit(commitEdit) + .onChange(of: editorFocused) { _, focused in + if !focused { commitEdit() } + } + } else { + Text(item.text) + .strikethrough(item.state == .completed, color: .secondary) + .foregroundStyle(item.state == .completed ? .secondary : .primary) + .fixedSize(horizontal: false, vertical: true) + .animation(.snappy, value: item.state) + } + + Spacer(minLength: 0) + } + .padding(.vertical, 4) + .contentShape(Rectangle()) + .onTapGesture { + if !isEditing { beginEdit() } + } + .draggable(item.id) + .dropDestination(for: String.self) { draggedIDs, _ in + guard isEnabled, let draggedID = draggedIDs.first else { return false } + actions.move(draggedID, displayIndex) + return true + } + .swipeActions(edge: .trailing, allowsFullSwipe: true) { + Button(role: .destructive, action: actions.remove) { + Label( + L10n.string("mobile.todo.item.delete", defaultValue: "Delete"), + systemImage: "trash" + ) + } + .disabled(!isEnabled) + } + } + + private var stateSystemImage: String { + switch item.state { + case .pending: "circle" + case .inProgress: "circle.lefthalf.filled" + case .completed: "checkmark.circle.fill" + } + } + + private var stateTint: AnyShapeStyle { + switch item.state { + case .pending: AnyShapeStyle(.tertiary) + case .inProgress: AnyShapeStyle(.tint) + case .completed: AnyShapeStyle(MobileTodoStatus.done.tint) + } + } + + private var stateActionLabel: String { + switch item.state.next { + case .pending: + L10n.string("mobile.todo.item.markPending", defaultValue: "Mark as pending") + case .inProgress: + L10n.string("mobile.todo.item.markInProgress", defaultValue: "Mark as in progress") + case .completed: + L10n.string("mobile.todo.item.markCompleted", defaultValue: "Mark as completed") + } + } + + private func beginEdit() { + guard isEnabled else { return } + draft = item.text + isEditing = true + editorFocused = true + } + + private func commitEdit() { + guard isEditing else { return } + let text = draft.trimmingCharacters(in: .whitespacesAndNewlines) + isEditing = false + editorFocused = false + if !text.isEmpty, text != item.text { + actions.edit(text) + } + } +} diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TodoSurfaceView.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TodoSurfaceView.swift new file mode 100644 index 00000000000..115177a7ab1 --- /dev/null +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TodoSurfaceView.swift @@ -0,0 +1,182 @@ +import CMUXMobileCore +import CmuxMobileShellModel +import CmuxMobileSupport +import SwiftUI + +#if canImport(UIKit) +import UIKit +#endif + +/// Native iOS renderer for a Mac workspace todo surface. +struct TodoSurfaceView: View { + let surface: MobileSurfacePreview + @State private var model: TodoSurfaceModel + @State private var pendingItemText = "" + @FocusState private var composerFocused: Bool + + init( + surface: MobileSurfacePreview, + todo: MobileTodoSnapshot, + mutate: @escaping @MainActor (MobileTodoMutation) async throws -> Void + ) { + self.surface = surface + _model = State(initialValue: TodoSurfaceModel(snapshot: todo, mutate: mutate)) + } + + var body: some View { + let snapshot = model.snapshot + VStack(spacing: 0) { + MacSurfaceHeader( + kind: .todo, + title: surface.title, + subtitle: progressSubtitle(snapshot) + ) { + TodoStatusMenu( + status: snapshot.status, + statusHidden: snapshot.statusHidden, + isEnabled: !model.isMutationPending, + setStatus: { run(.setStatus($0)) } + ) + } + if let progress = completionProgress(snapshot) { + ProgressView(value: progress) + .progressViewStyle(.linear) + .tint(progress >= 1 ? MobileTodoStatus.done.tint : nil) + .animation(.snappy, value: progress) + } + + if snapshot.items.isEmpty { + MacSurfaceMessageView( + systemImage: "checklist", + title: L10n.string("mobile.todo.empty.title", defaultValue: "No items yet"), + message: L10n.string( + "mobile.todo.empty.message", + defaultValue: "Anything you add here stays in sync with your Mac." + ) + ) + } else { + List { + ForEach(Array(snapshot.items.enumerated()), id: \.element.id) { index, item in + TodoSurfaceRowView( + item: item, + displayIndex: index, + isEnabled: !model.isMutationPending, + actions: TodoSurfaceRowActions( + cycleState: { run(.setState(itemID: item.id, state: item.state.next)) }, + edit: { run(.edit(itemID: item.id, text: $0)) }, + move: { draggedID, targetIndex in + run(.move(itemID: draggedID, toIndex: targetIndex)) + }, + remove: { run(.remove(itemID: item.id)) } + ) + ) + .listRowBackground(Color.clear) + .listRowSeparator(.hidden) + } + } + .listStyle(.plain) + .scrollContentBackground(.hidden) + .scrollDismissesKeyboard(.interactively) + .animation(.snappy, value: snapshot.items) + } + + composer + } + .alert( + L10n.string("mobile.todo.updateFailed.title", defaultValue: "Couldn’t Update Checklist"), + isPresented: Binding( + get: { model.showsMutationError }, + set: { if !$0 { model.dismissMutationError() } } + ) + ) { + Button(L10n.string("mobile.common.ok", defaultValue: "OK"), role: .cancel) { + model.dismissMutationError() + } + } message: { + Text(L10n.string( + "mobile.todo.updateFailed.message", + defaultValue: "Your change was undone. Try again." + )) + } + .onChange(of: model.showsMutationError) { _, showsError in + guard showsError else { return } + #if canImport(UIKit) + UINotificationFeedbackGenerator().notificationOccurred(.error) + #endif + } + .onChange(of: surface.todo) { _, authoritative in + if let authoritative { model.reconcile(authoritative) } + } + } + + private var composer: some View { + HStack(spacing: 10) { + TextField( + L10n.string("mobile.todo.addPlaceholder", defaultValue: "New checklist item"), + text: $pendingItemText, + axis: .vertical + ) + .lineLimit(1...4) + .focused($composerFocused) + .onSubmit(addPendingItem) + .submitLabel(.done) + + Button(action: addPendingItem) { + Image(systemName: "arrow.up") + .font(.footnote.weight(.bold)) + .foregroundStyle(canAddPendingItem ? Color.white : Color.secondary) + .frame(width: 28, height: 28) + .background( + Circle().fill(canAddPendingItem ? AnyShapeStyle(.tint) : AnyShapeStyle(.quaternary)) + ) + } + .buttonStyle(.plain) + .disabled(!canAddPendingItem) + .animation(.snappy, value: canAddPendingItem) + .accessibilityLabel(L10n.string("mobile.todo.add", defaultValue: "Add checklist item")) + } + .padding(.leading, 16) + .padding(.trailing, 6) + .padding(.vertical, 6) + .mobileGlassField(cornerRadius: 22) + .padding(.horizontal, 16) + .padding(.top, 8) + .padding(.bottom, 10) + } + + private func progressSubtitle(_ snapshot: MobileTodoSnapshot) -> String? { + guard !snapshot.items.isEmpty else { return nil } + let done = snapshot.items.count(where: { $0.state == .completed }) + let format = L10n.string( + "mobile.todo.progressFormat", + defaultValue: "%1$d of %2$d done" + ) + return String.localizedStringWithFormat(format, done, snapshot.items.count) + } + + private func completionProgress(_ snapshot: MobileTodoSnapshot) -> Double? { + guard !snapshot.items.isEmpty else { return nil } + let done = snapshot.items.count(where: { $0.state == .completed }) + return Double(done) / Double(snapshot.items.count) + } + + private var canAddPendingItem: Bool { + !model.isMutationPending + && model.snapshot.items.count < MobileTodoSnapshot.maxItems + && !pendingItemText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + } + + private func addPendingItem() { + guard canAddPendingItem else { return } + let text = pendingItemText + pendingItemText = "" + #if canImport(UIKit) + UIImpactFeedbackGenerator(style: .light).impactOccurred() + #endif + run(.add(text: text)) + } + + private func run(_ mutation: MobileTodoMutation) { + Task { await model.perform(mutation) } + } +} diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceActiveSurface.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceActiveSurface.swift index 47e1b2923d3..6666ad707db 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceActiveSurface.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceActiveSurface.swift @@ -1,3 +1,4 @@ +import CmuxMobileShellModel import Foundation enum WorkspaceActiveSurface: Equatable { @@ -6,13 +7,15 @@ enum WorkspaceActiveSurface: Equatable { case browser case browserStream case simulatorStream + case macSurface(MobileSurfacePreview) static func derive( isChatMode: Bool, hasChosenChatSession: Bool, hasActiveBrowser: Bool, hasActiveBrowserStream: Bool = false, - hasActiveSimulatorStream: Bool = false + hasActiveSimulatorStream: Bool = false, + selectedMacSurface: MobileSurfacePreview? = nil ) -> Self { if isChatMode, hasChosenChatSession { return .chat @@ -26,6 +29,7 @@ enum WorkspaceActiveSurface: Equatable { if hasActiveSimulatorStream { return .simulatorStream } + if let selectedMacSurface { return .macSurface(selectedMacSurface) } return .terminal } diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceDetailView+DerivedState.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceDetailView+DerivedState.swift index 2ae94e8e4cf..070ae482fb5 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceDetailView+DerivedState.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceDetailView+DerivedState.swift @@ -9,6 +9,9 @@ extension WorkspaceDetailView { } var selectedToolbarSubtitle: String? { + if let surface = workspace.selectedMacSurface(id: store.selectedMacSurfaceID) { + return surface.title + } guard let selectedTerminalID = store.selectedTerminalID else { return nil } return workspace.terminals.first { $0.id == selectedTerminalID }?.name } diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceDetailView+MenuState.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceDetailView+MenuState.swift index 023076fa2cf..851fd68d6ba 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceDetailView+MenuState.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceDetailView+MenuState.swift @@ -1,18 +1,40 @@ import CmuxMobileShellModel struct TerminalPickerMenuRow: Identifiable, Equatable { - let id: MobileTerminalPreview.ID + enum ID: Hashable { + case terminal(MobileTerminalPreview.ID) + case macSurface(MobileSurfacePreview.ID) + } + let id: ID let name: String + let surfaceKind: MobileSurfacePreview.Kind init(_ terminal: MobileTerminalPreview) { - id = terminal.id + id = .terminal(terminal.id) name = terminal.name + surfaceKind = .terminal + } + + init(_ surface: MobileSurfacePreview) { + id = .macSurface(surface.id) + name = surface.title + surfaceKind = surface.kind + } + + var terminalID: MobileTerminalPreview.ID? { + guard case let .terminal(id) = id else { return nil } + return id + } + + var macSurfaceID: MobileSurfacePreview.ID? { + guard case let .macSurface(id) = id else { return nil } + return id } } /// Structural change token for the native menu; title churn must not rebuild an open picker. struct TerminalPickerMenuMembership: Equatable { - let ids: [MobileTerminalPreview.ID] + let ids: [TerminalPickerMenuRow.ID] init(_ rows: [TerminalPickerMenuRow]) { ids = rows.map(\.id) @@ -24,17 +46,19 @@ extension Collection where Element == TerminalPickerMenuRow { selectedID: MobileTerminalPreview.ID? ) -> (id: MobileTerminalPreview.ID, name: String)? { if let selectedID, - let selected = first(where: { $0.id == selectedID }) { - return (id: selected.id, name: selected.name) + let selected = first(where: { $0.id == .terminal(selectedID) }) { + return (id: selectedID, name: selected.name) } - guard let first else { return nil } - return (id: first.id, name: first.name) + guard let first = first(where: { if case .terminal = $0.id { true } else { false } }), + case let .terminal(id) = first.id else { return nil } + return (id: id, name: first.name) } } extension WorkspaceDetailView { var terminalPickerLiveRows: [TerminalPickerMenuRow] { workspace.terminals.map(TerminalPickerMenuRow.init) + + workspace.surfaces.filter { !$0.kind.isTerminal }.map(TerminalPickerMenuRow.init) } var terminalPickerLiveMembership: TerminalPickerMenuMembership { diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceDetailView+PanelArtifacts.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceDetailView+PanelArtifacts.swift new file mode 100644 index 00000000000..3d3320a04ad --- /dev/null +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceDetailView+PanelArtifacts.swift @@ -0,0 +1,48 @@ +import CmuxAgentChatUI +import CmuxMobileShell + +extension WorkspaceDetailView { + /// Builds the non-browsable loader used by file-backed panel renderers. + func panelArtifactLoader(workspaceID: String, surfaceID: String) -> ChatArtifactLoader { + guard let source = store.makeChatEventSource() else { + return .unsupported(cache: terminalArtifactThumbnailCache) + } + return ChatArtifactLoader( + panelWorkspaceID: workspaceID, + panelSurfaceID: surfaceID, + supportsArtifacts: source.supportsPanelArtifacts, + cache: terminalArtifactThumbnailCache, + stat: { path in + try await source.panelArtifactStat( + workspaceID: workspaceID, + surfaceID: surfaceID, + path: path + ) + }, + fetch: { path, progress in + try await source.panelArtifactFetch( + workspaceID: workspaceID, + surfaceID: surfaceID, + path: path, + progress: progress + ) + }, + stream: { path, onChunk in + try await source.panelArtifactFetch( + workspaceID: workspaceID, + surfaceID: surfaceID, + path: path, + onChunk: onChunk + ) + }, + thumbnail: { path, maxDimension in + try await source.panelArtifactThumbnail( + workspaceID: workspaceID, + surfaceID: surfaceID, + path: path, + maxDimension: maxDimension + ) + } + ) + } +} diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceDetailView+Surfaces.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceDetailView+Surfaces.swift index a2011aaa2fb..b4be98fc61e 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceDetailView+Surfaces.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceDetailView+Surfaces.swift @@ -2,6 +2,7 @@ import CMUXMobileCore import CmuxMobileBrowser import CmuxMobileBrowserStream import CmuxMobileShell +import CmuxMobileShellModel import CmuxMobileTerminal import SwiftUI @@ -37,6 +38,14 @@ extension WorkspaceDetailView { } else if surface == .simulatorStream, let simulator = activeSimulatorStream { simulatorStreamContent(simulator) .background(store.activeTerminalTheme.terminalBackgroundColor) + } else if case let .macSurface(macSurface) = surface { + macSurfaceContent(macSurface) + .background(store.activeTerminalTheme.terminalBackgroundColor) + // System colors, materials, and list backgrounds must + // resolve against the terminal theme the surface sits on, + // not the device appearance, or rows flash white over a + // dark theme (and vice versa). + .environment(\.colorScheme, store.activeTerminalTheme.terminalColorScheme) } } .safeAreaInset(edge: .top, spacing: 0) { @@ -67,6 +76,62 @@ extension WorkspaceDetailView { } #if os(iOS) + /// Kind → renderer dispatch for the selected non-terminal Mac surface. + /// + /// `MacSurfaceRenderer.resolve` owns the gating policy (capability + + /// payload presence); unhandled kinds stay on the fallback card. + @ViewBuilder + func macSurfaceContent(_ macSurface: MobileSurfacePreview) -> some View { + let renderer = MacSurfaceRenderer.resolve( + surface: macSurface, + supportsTodo: store.supportsTodo(in: workspace.id), + supportsPanelArtifacts: store.supportsPanelArtifacts(in: workspace.id) + ) + let openOnMac: () async -> Bool = { [store, workspaceID = workspace.id, surfaceID = macSurface.id] in + await store.focusSurfaceOnMac(workspaceID: workspaceID, surfaceID: surfaceID) + } + let canOpenOnMac = store.supportsSurfaceFocus(in: workspace.id) + switch renderer { + case .todo(let todo): + TodoSurfaceView( + surface: macSurface, + todo: todo + ) { mutation in + try await store.performTodoMutation(mutation, workspaceID: workspace.id) + } + .id(macSurface.id.rawValue) + case .filePreview(let path): + PanelFileSurfaceView( + surface: macSurface, + path: path, + loader: panelArtifactLoader( + workspaceID: workspace.rpcWorkspaceID.rawValue, + surfaceID: macSurface.id.rawValue + ), + connectionStatus: effectiveConnectionStatus + ) + .id(macSurface.id.rawValue) + case .markdown(let path): + MarkdownSurfaceView( + surface: macSurface, + path: path, + loader: panelArtifactLoader( + workspaceID: workspace.rpcWorkspaceID.rawValue, + surfaceID: macSurface.id.rawValue + ), + connectionStatus: effectiveConnectionStatus + ) + .id(macSurface.id.rawValue) + case .fallbackCard: + SurfaceFallbackCardView( + surface: macSurface, + workspaceName: workspace.name, + canOpenOnMac: canOpenOnMac, + openOnMac: openOnMac + ) + } + } + @ViewBuilder func browserContent(_ browser: BrowserSurfaceState) -> some View { MobileBrowserPane( diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceDetailView.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceDetailView.swift index 2a0bae2e015..28c1b2ee890 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceDetailView.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceDetailView.swift @@ -170,7 +170,8 @@ struct WorkspaceDetailView: View { hasChosenChatSession: chosenChatSession != nil, hasActiveBrowser: activeBrowser != nil, hasActiveBrowserStream: activeBrowserStream != nil, - hasActiveSimulatorStream: activeSimulatorStream != nil + hasActiveSimulatorStream: activeSimulatorStream != nil, + selectedMacSurface: workspace.selectedMacSurface(id: store.selectedMacSurfaceID) ) } #endif @@ -687,8 +688,10 @@ struct WorkspaceDetailView: View { TerminalPickerMenu( value: TerminalPickerMenuValue( liveTerminals: workspace.terminals, + liveSurfaces: workspace.surfaces, snapshotRows: terminalPickerRows, selectedID: store.selectedTerminalID, + selectedMacSurfaceID: store.selectedMacSurfaceID, canCreateWorkspace: canCreateWorkspace, hasActiveBrowser: activeBrowser != nil, isChatMode: isChatMode, @@ -701,6 +704,7 @@ struct WorkspaceDetailView: View { ), actions: TerminalPickerMenuActions( selectTerminal: selectTerminalFromPicker, + selectMacSurface: selectMacSurfaceFromPicker, createWorkspace: createWorkspaceFromToolbar, createTerminal: createTerminalFromToolbar, openBrowser: openBrowserFromToolbar, @@ -953,6 +957,7 @@ struct WorkspaceDetailView: View { browserStore.closeBrowser(for: workspace.id.rawValue) stopActiveBrowserStream() stopActiveSimulatorStream() + store.selectedMacSurfaceID = nil createTerminal() } @@ -991,6 +996,7 @@ struct WorkspaceDetailView: View { store.recordAppEvent(.browserCreateSucceeded, correlationID: workspaceID) stopActiveBrowserStream() stopActiveSimulatorStream() + store.selectedMacSurfaceID = nil } private func selectBrowserStreamFromToolbar(_ panelID: String, dismissKeyboard: Bool = true) { @@ -1000,6 +1006,7 @@ struct WorkspaceDetailView: View { browserCreateRequest = nil browserStore.closeBrowser(for: workspace.id.rawValue) stopActiveSimulatorStream() + store.selectedMacSurfaceID = nil if let previous = activeBrowserStream, previous.id != panelID { Task { await store.stopMobileBrowserStream(panelID: previous.id) } } @@ -1011,6 +1018,7 @@ struct WorkspaceDetailView: View { dismissTerminalKeyboardForChrome() browserStore.closeBrowser(for: workspace.id.rawValue) stopActiveBrowserStream() + store.selectedMacSurfaceID = nil let workspaceID = workspace.rpcWorkspaceID.rawValue let previousPanelID: String? = activeSimulatorStream.flatMap { $0.id == panelID ? nil : $0.id @@ -1057,6 +1065,7 @@ struct WorkspaceDetailView: View { browserStore.closeBrowser(for: workspace.id.rawValue) stopActiveBrowserStream() stopActiveSimulatorStream() + store.selectedMacSurfaceID = nil // Switching from the picker is chrome, not a typing intent, so the // newly-selected surface must not grab the keyboard on attach. The // store suppresses the target's autofocus (and is a no-op when it is @@ -1065,6 +1074,18 @@ struct WorkspaceDetailView: View { store.selectTerminalFromChrome(terminalID) } + private func selectMacSurfaceFromPicker(_ surfaceID: MobileSurfacePreview.ID) { + dismissTerminalKeyboardForChrome() + browserCreateRequest = nil + browserStore.closeBrowser(for: workspace.id.rawValue) + stopActiveBrowserStream() + // Streams outrank Mac surfaces in `WorkspaceActiveSurface.derive`, so + // a selected Simulator stream must be cleared before the Mac surface + // can become visible. + stopActiveSimulatorStream() + store.selectMacSurface(surfaceID) + } + func dismissTerminalKeyboardForChrome() { // Resign the terminal's hidden text input first so the surface clears // its keyboard geometry and recomputes full-height before chrome covers diff --git a/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/TerminalPickerMenuValueTests.swift b/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/TerminalPickerMenuValueTests.swift index 518085bed42..851b247fe79 100644 --- a/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/TerminalPickerMenuValueTests.swift +++ b/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/TerminalPickerMenuValueTests.swift @@ -75,6 +75,91 @@ import Testing #expect(noTerminals.selectedName == nil) } + @Test func nonTerminalSurfacesAppearInTheirOwnRowsAndCanBeSelected() { + let surface = MobileSurfacePreview(id: "surface-1", kind: .markdown, title: "README") + let value = TerminalPickerMenuValue( + liveTerminals: [MobileTerminalPreview(id: "terminal-1", name: "Shell")], + liveSurfaces: [ + MobileSurfacePreview(id: "terminal-1", kind: .terminal, title: "Shell"), + surface, + ], + snapshotRows: [], + selectedID: "terminal-1", + selectedMacSurfaceID: surface.id, + canCreateWorkspace: true, + hasActiveBrowser: false, + isChatMode: false + ) + #expect(value.terminalRows.count == 1) + #expect(value.macSurfaceRows == [TerminalPickerMenuRow(surface)]) + #expect(value.selectedName == "README") + } + + @Test func exactlyOneRowCarriesTheCheckmark() { + let terminal = MobileTerminalPreview(id: "terminal-1", name: "Shell") + let surface = MobileSurfacePreview(id: "surface-1", kind: .todo, title: "Todos") + func value( + selectedMacSurfaceID: MobileSurfacePreview.ID?, + hasActiveBrowser: Bool = false, + liveSurfaces: [MobileSurfacePreview]? = nil + ) -> TerminalPickerMenuValue { + TerminalPickerMenuValue( + liveTerminals: [terminal], + liveSurfaces: liveSurfaces ?? [ + MobileSurfacePreview(id: "terminal-1", kind: .terminal, title: "Shell"), + surface, + ], + snapshotRows: [], + selectedID: terminal.id, + selectedMacSurfaceID: selectedMacSurfaceID, + canCreateWorkspace: true, + hasActiveBrowser: hasActiveBrowser, + isChatMode: false + ) + } + + // Mac surface selected: its row is checked, the terminal row is not. + #expect(value(selectedMacSurfaceID: surface.id).checkedRowID == .macSurface(surface.id)) + // No surface selection: the resolved terminal is checked. + #expect(value(selectedMacSurfaceID: nil).checkedRowID == .terminal(terminal.id)) + // Phone browser overlay owns the screen: nothing is checked. + #expect(value(selectedMacSurfaceID: surface.id, hasActiveBrowser: true).checkedRowID == nil) + // Stale surface selection (row gone) falls back to the terminal row. + #expect( + value( + selectedMacSurfaceID: surface.id, + liveSurfaces: [MobileSurfacePreview(id: "terminal-1", kind: .terminal, title: "Shell")] + ).checkedRowID == .terminal(terminal.id) + ) + } + + @Test func browserSurfacesLeaveMacSurfacesWhenTheMacStreamsBrowsers() { + let browser = MobileSurfacePreview(id: "surface-web", kind: .browser, title: "cmux.com") + let markdown = MobileSurfacePreview(id: "surface-md", kind: .markdown, title: "README") + func value(snapshotRows: [TerminalPickerMenuRow], supportsBrowserStream: Bool) -> TerminalPickerMenuValue { + TerminalPickerMenuValue( + liveTerminals: [], + liveSurfaces: [browser, markdown], + snapshotRows: snapshotRows, + selectedID: nil, + canCreateWorkspace: true, + hasActiveBrowser: false, + isChatMode: false, + supportsBrowserStream: supportsBrowserStream + ) + } + + // Live rows and snapshot rows must obey the same policy: with browser + // streaming, the pane lives in "Mac Browsers", not "Mac Surfaces". + let snapshot = [TerminalPickerMenuRow(browser), TerminalPickerMenuRow(markdown)] + for rows in [[], snapshot] { + let streaming = value(snapshotRows: rows, supportsBrowserStream: true) + #expect(streaming.macSurfaceRows.map(\.id) == [.macSurface(markdown.id)]) + let legacyMac = value(snapshotRows: rows, supportsBrowserStream: false) + #expect(legacyMac.macSurfaceRows.map(\.id) == [.macSurface(browser.id), .macSurface(markdown.id)]) + } + } + private func menuValue( liveTerminals: [MobileTerminalPreview], snapshotRows: [TerminalPickerMenuRow], diff --git a/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/TodoSurfaceModelTests.swift b/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/TodoSurfaceModelTests.swift new file mode 100644 index 00000000000..a1a05d55d00 --- /dev/null +++ b/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/TodoSurfaceModelTests.swift @@ -0,0 +1,80 @@ +import CMUXMobileCore +import CmuxMobileShellModel +import Testing + +@testable import CmuxMobileShellUI + +@MainActor +@Suite struct TodoSurfaceModelTests { + @Test func successfulMutationsStayOptimisticAndRespectCompletionPartitions() async { + let pendingID = "pending" + let secondPendingID = "second-pending" + let completedID = "completed" + var received: [MobileTodoMutation] = [] + let model = TodoSurfaceModel( + snapshot: MobileTodoSnapshot( + status: .todo, + statusHidden: true, + items: [ + item(id: pendingID, state: .pending), + item(id: secondPendingID, state: .inProgress), + item(id: completedID, state: .completed), + ] + ), + mutate: { received.append($0) } + ) + + #expect(await model.perform(.setState(itemID: pendingID, state: .completed))) + #expect(model.snapshot.items.map(\.id) == [secondPendingID, completedID, pendingID]) + + #expect(await model.perform(.move(itemID: pendingID, toIndex: 1))) + #expect(model.snapshot.items.map(\.id) == [secondPendingID, pendingID, completedID]) + + #expect(await model.perform(.edit(itemID: secondPendingID, text: " Edited "))) + #expect(model.snapshot.items.first?.text == "Edited") + + #expect(await model.perform(.remove(itemID: completedID))) + #expect(model.snapshot.items.map(\.id) == [secondPendingID, pendingID]) + + #expect(await model.perform(.setStatus(.review))) + #expect(model.snapshot.status == .review) + #expect(model.snapshot.statusHidden == false) + #expect(await model.perform(.cycleStatus)) + #expect(model.snapshot.status == .done) + #expect(received.count == 6) + } + + @Test func failedMutationRollsBackAndPresentsAnError() async { + let original = MobileTodoSnapshot( + status: .working, + statusHidden: false, + items: [item(id: "item", state: .pending)] + ) + let model = TodoSurfaceModel(snapshot: original) { _ in + throw CancellationError() + } + + #expect(await model.perform(.edit(itemID: "item", text: "Changed")) == false) + #expect(model.snapshot == original) + #expect(model.showsMutationError) + #expect(model.pendingRequestID == nil) + } + + @Test func invalidOrOverLimitAddsNeverReachTheRPCMutation() async { + var mutationCount = 0 + let items = (0..<50).map { item(id: "item-\($0)", state: .pending) } + let model = TodoSurfaceModel( + snapshot: MobileTodoSnapshot(status: .todo, statusHidden: false, items: items), + mutate: { _ in mutationCount += 1 } + ) + + #expect(await model.perform(.add(text: "One too many")) == false) + #expect(await model.perform(.add(text: " ")) == false) + #expect(mutationCount == 0) + #expect(model.snapshot.items == items) + } + + private func item(id: String, state: MobileTodoItemState) -> MobileTodoItem { + MobileTodoItem(id: id, text: id, state: state, origin: .user) + } +} diff --git a/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/WorkspaceActiveSurfaceTests.swift b/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/WorkspaceActiveSurfaceTests.swift index d53339eea2c..815f98b3521 100644 --- a/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/WorkspaceActiveSurfaceTests.swift +++ b/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/WorkspaceActiveSurfaceTests.swift @@ -1,4 +1,5 @@ import Testing +import CmuxMobileShellModel @testable import CmuxMobileShellUI @Suite struct WorkspaceActiveSurfaceTests { @@ -26,6 +27,22 @@ import Testing ) == .terminal) } + @Test func explicitMacSurfaceIsBelowBrowserAndAboveTerminal() { + let surface = MobileSurfacePreview(id: "surface", kind: .markdown, title: "README") + #expect(WorkspaceActiveSurface.derive( + isChatMode: false, + hasChosenChatSession: false, + hasActiveBrowser: false, + selectedMacSurface: surface + ) == .macSurface(surface)) + #expect(WorkspaceActiveSurface.derive( + isChatMode: false, + hasChosenChatSession: false, + hasActiveBrowser: true, + selectedMacSurface: surface + ) == .browser) + } + @Test func browserStreamActivatesWhenNoLocalBrowserIsOpen() { #expect(WorkspaceActiveSurface.derive( isChatMode: false, @@ -35,6 +52,17 @@ import Testing ) == .browserStream) } + @Test func browserStreamOverlaysASelectedMacSurface() { + let surface = MobileSurfacePreview(id: "surface", kind: .markdown, title: "README") + #expect(WorkspaceActiveSurface.derive( + isChatMode: false, + hasChosenChatSession: false, + hasActiveBrowser: false, + hasActiveBrowserStream: true, + selectedMacSurface: surface + ) == .browserStream) + } + @Test func simulatorStreamActivatesWhenNoBrowserSurfaceIsOpen() { #expect(WorkspaceActiveSurface.derive( isChatMode: false, @@ -45,6 +73,18 @@ import Testing ) == .simulatorStream) } + @Test func simulatorStreamOverlaysASelectedMacSurface() { + let surface = MobileSurfacePreview(id: "surface", kind: .markdown, title: "README") + #expect(WorkspaceActiveSurface.derive( + isChatMode: false, + hasChosenChatSession: false, + hasActiveBrowser: false, + hasActiveBrowserStream: false, + hasActiveSimulatorStream: true, + selectedMacSurface: surface + ) == .simulatorStream) + } + @Test func chromeReturnRefocusesTheSelectedTerminal() { #expect(WorkspaceActiveSurface.chromeReturnRefocusTerminalID( selectedTerminalID: "terminal-1", diff --git a/Packages/iOS/CmuxMobileSupport/Sources/CmuxMobileSupport/UITestConfig.swift b/Packages/iOS/CmuxMobileSupport/Sources/CmuxMobileSupport/UITestConfig.swift index fd8b9bee471..ae211959b93 100644 --- a/Packages/iOS/CmuxMobileSupport/Sources/CmuxMobileSupport/UITestConfig.swift +++ b/Packages/iOS/CmuxMobileSupport/Sources/CmuxMobileSupport/UITestConfig.swift @@ -242,6 +242,23 @@ public struct UITestConfig { #endif } + /// The selected page of the standalone Mac-surface renderer gallery. + /// + /// When `CMUX_UITEST_MAC_SURFACE_GALLERY` names a page (`todo`, `file`, + /// `markdown`, `fallback`, or `picker`), the root view renders that + /// production surface component with fixture data and a stub loader, so + /// dark/light simulator screenshots don't require sign-in, Mac pairing, + /// or a live connection. DEBUG-only. + public static var macSurfaceGalleryPreviewPage: String? { + #if DEBUG + let value = ProcessInfo.processInfo.environment["CMUX_UITEST_MAC_SURFACE_GALLERY"] + guard let value, !value.isEmpty else { return nil } + return value + #else + return nil + #endif + } + /// Whether the standalone streaming-chat preview is enabled. /// /// When `CMUX_UITEST_STREAMING_CHAT_PREVIEW=1`, the root view renders a diff --git a/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Wire/ControlCommandExecutionPolicy.swift b/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Wire/ControlCommandExecutionPolicy.swift index c6aeaae69bd..b9f3e46402a 100644 --- a/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Wire/ControlCommandExecutionPolicy.swift +++ b/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Wire/ControlCommandExecutionPolicy.swift @@ -104,6 +104,13 @@ public enum ControlCommandExecutionPolicy: Sendable, Equatable { // routes it to the main-actor processV2Command switch, which lacks the // case, and the control socket returns method_not_found. "mobile.terminal.set_font", + // Panel artifact reads are mobile data-plane file IO for non-terminal + // surfaces. Keep them on the worker lane so markdown/file-preview panes + // reach TerminalController's mobile.panel.artifact.* dispatcher instead + // of the main-actor switch returning method_not_found. + "mobile.panel.artifact.stat", + "mobile.panel.artifact.fetch", + "mobile.panel.artifact.thumbnail", "system.top", "system.memory", // `surface.read_text` reads a terminal's visible or full-scrollback diff --git a/Packages/macOS/CmuxControlSocket/Tests/CmuxControlSocketTests/ControlCommandExecutionPolicyTests.swift b/Packages/macOS/CmuxControlSocket/Tests/CmuxControlSocketTests/ControlCommandExecutionPolicyTests.swift index 4b094905fe6..8f4c0de04ea 100644 --- a/Packages/macOS/CmuxControlSocket/Tests/CmuxControlSocketTests/ControlCommandExecutionPolicyTests.swift +++ b/Packages/macOS/CmuxControlSocket/Tests/CmuxControlSocketTests/ControlCommandExecutionPolicyTests.swift @@ -35,6 +35,8 @@ struct ControlCommandExecutionPolicyTests { "debug.sidebar.simulate_drag", "debug.mobile.transport.disconnect", "debug.window.screenshot", "mobile.attach_ticket.create", "mobile.terminal.set_font", "mobile.task.models.list", + "mobile.panel.artifact.stat", "mobile.panel.artifact.fetch", + "mobile.panel.artifact.thumbnail", // 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", @@ -143,6 +145,9 @@ struct ControlCommandExecutionPolicyTests { #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: "mobile.panel.artifact.stat") == .socketWorker(mainThreadCallable: false)) + #expect(ControlCommandExecutionPolicy(forMethod: "mobile.panel.artifact.fetch") == .socketWorker(mainThreadCallable: false)) + #expect(ControlCommandExecutionPolicy(forMethod: "mobile.panel.artifact.thumbnail") == .socketWorker(mainThreadCallable: false)) #expect(ControlCommandExecutionPolicy(forMethod: "vm.create") == .socketWorker(mainThreadCallable: false)) } diff --git a/Resources/Localizable.xcstrings b/Resources/Localizable.xcstrings index 10dd0be0392..7f1f149bfaf 100644 --- a/Resources/Localizable.xcstrings +++ b/Resources/Localizable.xcstrings @@ -137803,6 +137803,91 @@ } } }, + "mobile.panel.artifact.error.forbidden": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "That file is not currently shown in this panel." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "そのファイルは現在このパネルに表示されていません。" + } + } + } + }, + "mobile.panel.artifact.error.internal": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "cmux couldn't complete the panel file request." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "パネルのファイル要求を完了できませんでした。" + } + } + } + }, + "mobile.panel.artifact.error.invalidParams": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "cmux couldn't tell which panel or file was requested." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "どのパネルのどのファイルが要求されたか判別できませんでした。" + } + } + } + }, + "mobile.panel.artifact.error.methodNotFound": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "cmux doesn't recognize that panel file request." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "そのパネルのファイル要求を認識できませんでした。" + } + } + } + }, + "mobile.panel.artifact.error.panelNotFound": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "That file panel is no longer available." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "そのファイルパネルは利用できなくなりました。" + } + } + } + }, "mobile.workspaceChanges.error.invalidContentRequest": { "extractionState": "manual", "localizations": { diff --git a/Sources/Mobile/MobileHostService+Capabilities.swift b/Sources/Mobile/MobileHostService+Capabilities.swift index 954a1747624..72035d1ef95 100644 --- a/Sources/Mobile/MobileHostService+Capabilities.swift +++ b/Sources/Mobile/MobileHostService+Capabilities.swift @@ -190,7 +190,11 @@ extension MobileHostService { "terminal.viewport.v1", "terminal.artifact.v1", "terminal.artifact.list.v1", + "panel.artifact.v1", "workspace.actions.v1", + "workspace.surfaces.v1", + "surface.focus.v1", + "todo.v1", Self.workspaceChangesCapability, "workspace.metadata.v1", "workspace.read_state.v1", diff --git a/Sources/Mobile/MobileHostService+TicketAuthorization.swift b/Sources/Mobile/MobileHostService+TicketAuthorization.swift index 671de585565..375cef515e7 100644 --- a/Sources/Mobile/MobileHostService+TicketAuthorization.swift +++ b/Sources/Mobile/MobileHostService+TicketAuthorization.swift @@ -86,7 +86,12 @@ extension MobileHostService { authorization: authorization, workspaceSelection: workspaceSelection.value ) - case "workspace.action", "workspace.close": + case "workspace.action", "workspace.close", "mobile.surface.focus", + "mobile.todo.add", "mobile.todo.set_state", "mobile.todo.edit", + "mobile.todo.move", "mobile.todo.remove", "mobile.todo.open", + "mobile.status.set", "mobile.status.cycle", + "mobile.panel.artifact.stat", "mobile.panel.artifact.fetch", + "mobile.panel.artifact.thumbnail": return ticketWorkspaceAuthorizationError(authorization: authorization, workspaceSelection: workspaceSelection.value) case "workspace.group.action", "workspace.group.create": return ticketMacScopedWorkspaceMutationAuthorizationError(authorization: authorization) diff --git a/Sources/Mobile/MobileStateSync.swift b/Sources/Mobile/MobileStateSync.swift index cdf9780b751..cff62c33ed6 100644 --- a/Sources/Mobile/MobileStateSync.swift +++ b/Sources/Mobile/MobileStateSync.swift @@ -238,6 +238,7 @@ final class MobileStateSyncHost { hasUnread: notificationStore?.workspaceIsUnread(forTabId: workspace.id) ?? false, sortIndex: sortIndex, terminals: terminals, + surfaces: controller.mobileSurfaceDescriptors(in: workspace), simulators: simulators ) } diff --git a/Sources/Mobile/MobileWorkspaceListObserver.swift b/Sources/Mobile/MobileWorkspaceListObserver.swift index 5b0fe9ce489..8c34aa81cf2 100644 --- a/Sources/Mobile/MobileWorkspaceListObserver.swift +++ b/Sources/Mobile/MobileWorkspaceListObserver.swift @@ -345,6 +345,7 @@ final class MobileWorkspaceListObserver { // sub-model, so a pure todo mutation would otherwise never // re-emit to external listeners. workspace.todoState.$statusOverride.map { _ in () }.eraseToAnyPublisher(), + workspace.todoState.$statusHidden.map { _ in () }.eraseToAnyPublisher(), workspace.todoState.$checklist.map { _ in () }.eraseToAnyPublisher(), workspace.currentDirectoryChangeRevisionPublisher() .map { _ in () } @@ -519,6 +520,7 @@ final class MobileWorkspaceListObserver { // Todo mutations change the list-facing shape; without these the // hash-diff would suppress the re-emit the publishers above fire. hasher.combine(workspace.todoState.statusOverride) + hasher.combine(workspace.todoState.statusHidden) hasher.combine(workspace.todoState.checklist) // Hash every panelDirectories entry (including ids not yet in // `panels`) so a directory update is detected even before its panel diff --git a/Sources/Panels/Panel.swift b/Sources/Panels/Panel.swift index 949705ab227..ecf410db02c 100644 --- a/Sources/Panels/Panel.swift +++ b/Sources/Panels/Panel.swift @@ -3,7 +3,7 @@ import Combine import AppKit /// Type of panel content -public enum PanelType: String, Codable, Sendable { +public enum PanelType: String, Codable, CaseIterable, Sendable { case terminal case browser case markdown diff --git a/Sources/TerminalController+MobileSurfaces.swift b/Sources/TerminalController+MobileSurfaces.swift new file mode 100644 index 00000000000..468afc1e72e --- /dev/null +++ b/Sources/TerminalController+MobileSurfaces.swift @@ -0,0 +1,445 @@ +import CMUXMobileCore +import CmuxAgentChat +import CmuxControlSocket +import Foundation + +/// Mobile surface inventory and focus support kept outside `TerminalController.swift`. +extension TerminalController { + /// Maps an app panel kind to the shared, open mobile wire vocabulary. + func mobileSurfaceKind(for panelType: PanelType) -> MobileSurfaceKind { + switch panelType { + case .terminal: + return .terminal + case .browser: + return .browser + case .markdown: + return .markdown + case .filePreview: + return .filePreview + case .rightSidebarTool: + return .rightSidebarTool + case .customSidebar: + return .customSidebar + case .agentSession: + return .agentSession + case .project: + return .project + case .extensionBrowser: + return .extensionBrowser + case .workspaceTodo: + return .todo + case .cloudVMLoading: + return .cloudVMLoading + case .simulator: + // Open wire vocabulary: phones without a native renderer show the + // fallback card for this kind (design: unknown kinds stay cards). + return MobileSurfaceKind(rawValue: "simulator") + case .mobilePairing: + return MobileSurfaceKind(rawValue: "mobilePairing") + case .accountSignIn: + return MobileSurfaceKind(rawValue: "accountSignIn") + } + } + + /// Builds the stable, spatially ordered mobile descriptors for every panel. + func mobileSurfaceDescriptors(in workspace: Workspace) -> [WorkspaceSyncRecord.Surface] { + orderedPanels(in: workspace).map { panel in + let filePath: String? + switch panel { + case let markdown as MarkdownPanel: + filePath = markdown.filePath + case let filePreview as FilePreviewPanel: + filePath = filePreview.filePath + default: + filePath = nil + } + if let filePath { + panelArtifactAuthorizationStore.record( + workspaceID: workspace.id.uuidString, + surfaceID: panel.id.uuidString, + filePath: filePath + ) + } else { + panelArtifactAuthorizationStore.invalidate( + workspaceID: workspace.id.uuidString, + surfaceID: panel.id.uuidString + ) + } + return WorkspaceSyncRecord.Surface( + surfaceID: panel.id.uuidString, + kind: mobileSurfaceKind(for: panel.panelType).rawValue, + title: workspace.panelTitle(panelId: panel.id) ?? panel.displayTitle, + filePath: filePath, + todo: panel.panelType == .workspaceTodo ? mobileTodoSnapshot(in: workspace) : nil + ) + } + } + + /// Projects the workspace-owned todo model without carrying Mac-local attachments. + func mobileTodoSnapshot(in workspace: Workspace) -> MobileTodoSnapshot { + let status: MobileTodoStatus = switch workspace.effectiveTaskStatus { + case .todo: .todo + case .working: .working + case .needsAttention: .needsAttention + case .review: .review + case .done: .done + } + return MobileTodoSnapshot( + status: status, + statusHidden: workspace.todoState.statusHidden, + items: workspace.todoState.checklist.map { item in + let state: MobileTodoItemState = switch item.state { + case .pending: .pending + case .inProgress: .inProgress + case .completed: .completed + } + let origin: MobileTodoItemOrigin = switch item.origin { + case .user: .user + case .agent: .agent + } + return MobileTodoItem( + id: item.id.uuidString, + text: item.text, + state: state, + origin: origin + ) + } + ) + } + + /// Bridges a typed todo snapshot into the legacy workspace-list JSON shape. + func mobileTodoPayload(_ todo: MobileTodoSnapshot) -> [String: Any] { + [ + "status": todo.status.rawValue, + "status_hidden": todo.statusHidden, + "items": todo.items.map { item in + [ + "id": item.id, + "text": item.text, + "state": item.state.rawValue, + "origin": item.origin.rawValue, + ] + }, + ] + } + + /// Focuses a Mac surface through the same mutation witness as `surface.focus`. + func v2MobileSurfaceFocus(params: [String: Any]) -> V2CallResult { + guard let workspaceID = v2UUID(params, "workspace_id") else { + return .err(code: "invalid_params", message: "Missing or invalid workspace_id", data: nil) + } + guard let surfaceID = v2UUID(params, "surface_id") else { + return .err(code: "invalid_params", message: "Missing or invalid surface_id", data: nil) + } + + let routing = ControlRoutingSelectors( + hasWindowIDParam: false, + windowID: nil, + groupID: nil, + workspaceID: workspaceID, + surfaceID: surfaceID, + paneID: nil + ) + guard controlSurfaceRoutingResolvesTabManager(routing: routing) else { + return .err(code: "unavailable", message: "Workspace context is unavailable", data: nil) + } + switch controlSurfaceFocus(routing: routing, surfaceID: surfaceID) { + case .tabManagerUnavailable: + return .err(code: "unavailable", message: "Workspace context is unavailable", data: nil) + case .workspaceNotFound: + return .err(code: "not_found", message: "Workspace not found", data: nil) + case let .surfaceNotFound(id): + return .err( + code: "not_found", + message: "Surface not found", + data: ["surface_id": id.uuidString] + ) + case let .focused(windowID, focusedWorkspaceID, focusedSurfaceID): + return .ok([ + "workspace_id": focusedWorkspaceID.uuidString, + "surface_id": focusedSurfaceID.uuidString, + "window_id": v2OrNull(windowID?.uuidString), + ]) + } + } + + /// Dispatches lifecycle-bound file reads for markdown and file-preview panels. + func v2MobilePanelArtifactDispatch( + method: String, + params: [String: Any], + executionContext: MobileHostRPCExecutionContext? = nil + ) async -> V2CallResult { + switch method { + case "mobile.panel.artifact.stat": + return await v2MobilePanelArtifactStat(params: params) + case "mobile.panel.artifact.fetch": + return await v2MobilePanelArtifactFetch( + params: params, + executionContext: executionContext + ) + case "mobile.panel.artifact.thumbnail": + return await v2MobilePanelArtifactThumbnail(params: params) + default: + return .err( + code: "method_not_found", + message: String( + localized: "mobile.panel.artifact.error.methodNotFound", + defaultValue: "cmux doesn't recognize that panel file request." + ), + data: nil + ) + } + } + + func v2MobilePanelArtifactStat(params: [String: Any]) async -> V2CallResult { + let resolution = mobilePanelArtifactCanonicalPath(params: params) + guard let canonicalPath = resolution.canonicalPath else { + return resolution.failure ?? mobilePanelArtifactInternalError() + } + do { + let stat = try await Task.detached(priority: .utility) { + try ArtifactByteReader().stat(path: canonicalPath) + }.value + return mobilePanelArtifactResult(stat) + } catch ArtifactByteReader.Error.fileNotFound { + return mobilePanelArtifactFileError( + code: "file_not_found", + key: "mobile.chat.artifact.error.fileNotFound", + defaultValue: "That file is no longer available on the Mac.", + path: v2RawString(params, "path") + ) + } catch ArtifactByteReader.Error.unsupportedMedia { + return mobilePanelArtifactFileError( + code: "unsupported_media", + key: "mobile.chat.artifact.error.unsupportedMedia", + defaultValue: "This file type cannot be previewed.", + path: v2RawString(params, "path") + ) + } catch { + return mobilePanelArtifactFileError( + code: "file_not_found", + key: "mobile.chat.artifact.error.fileNotFound", + defaultValue: "That file is no longer available on the Mac.", + path: v2RawString(params, "path") + ) + } + } + + func v2MobilePanelArtifactFetch( + params: [String: Any], + executionContext: MobileHostRPCExecutionContext? = nil + ) async -> V2CallResult { + let resolution = mobilePanelArtifactCanonicalPath(params: params) + guard let canonicalPath = resolution.canonicalPath else { + return resolution.failure ?? mobilePanelArtifactInternalError() + } + let offset = max(0, Int64(v2Int(params, "offset") ?? 0)) + let length = ChatArtifactTransferPolicy.defaultPolicy + .clampedChunkLength(v2Int(params, "length")) + do { + if v2RawString(params, "transport") == "iroh_artifact_v1" { + guard let executionContext else { + return mobilePanelArtifactFileError( + code: "unsupported_transport", + key: "mobile.chat.artifact.error.irohTransportUnavailable", + defaultValue: "Artifact transfer requires an authenticated session.", + path: nil + ) + } + return mobilePanelArtifactResult( + try await executionContext.issueArtifactTransfer( + canonicalPath: canonicalPath + ) + ) + } + let chunk = try await Task.detached(priority: .utility) { + try ArtifactByteReader().fetch( + path: canonicalPath, + offset: offset, + length: length + ) + }.value + return mobilePanelArtifactResult(chunk) + } catch let error as MobileHostIrohArtifactTransferRegistry.Error { + switch error.issueFailure { + case .fileNotFound: + return mobilePanelArtifactFileError( + code: "file_not_found", + key: "mobile.chat.artifact.error.fileNotFound", + defaultValue: "That file is no longer available on the Mac.", + path: v2RawString(params, "path") + ) + case .unavailable: + return mobilePanelArtifactFileError( + code: "unavailable", + key: "mobile.chat.artifact.error.transferUnavailable", + defaultValue: "Artifact transfer is temporarily unavailable.", + path: nil + ) + } + } catch ArtifactByteReader.Error.fileNotFound { + return mobilePanelArtifactFileError( + code: "file_not_found", + key: "mobile.chat.artifact.error.fileNotFound", + defaultValue: "That file is no longer available on the Mac.", + path: v2RawString(params, "path") + ) + } catch { + return mobilePanelArtifactFileError( + code: "file_not_found", + key: "mobile.chat.artifact.error.fileNotFound", + defaultValue: "That file is no longer available on the Mac.", + path: v2RawString(params, "path") + ) + } + } + + func v2MobilePanelArtifactThumbnail(params: [String: Any]) async -> V2CallResult { + let resolution = mobilePanelArtifactCanonicalPath(params: params) + guard let canonicalPath = resolution.canonicalPath else { + return resolution.failure ?? mobilePanelArtifactInternalError() + } + let maxDimension = min(max(v2Int(params, "max_dimension") ?? 512, 64), 1024) + do { + let thumbnail = try await Task.detached(priority: .utility) { + try ArtifactByteReader().thumbnail( + path: canonicalPath, + maxDimension: maxDimension + ) + }.value + return mobilePanelArtifactResult(thumbnail) + } catch ArtifactByteReader.Error.fileNotFound { + return mobilePanelArtifactFileError( + code: "file_not_found", + key: "mobile.chat.artifact.error.fileNotFound", + defaultValue: "That file is no longer available on the Mac.", + path: v2RawString(params, "path") + ) + } catch { + return mobilePanelArtifactFileError( + code: "unsupported_media", + key: "mobile.chat.artifact.error.unsupportedMedia", + defaultValue: "This file type cannot be previewed.", + path: v2RawString(params, "path") + ) + } + } + + private func mobilePanelArtifactCanonicalPath( + params: [String: Any] + ) -> (canonicalPath: String?, failure: V2CallResult?) { + guard let requestedWorkspaceID = v2UUID(params, "workspace_id"), + let requestedSurfaceID = v2UUID(params, "surface_id"), + let requestedPath = mobileNonEmpty(v2RawString(params, "path")) else { + return (nil, .err( + code: "invalid_params", + message: String( + localized: "mobile.panel.artifact.error.invalidParams", + defaultValue: "cmux couldn't tell which panel or file was requested." + ), + data: nil + )) + } + guard let resolved = mobileResolveWorkspaceAndSurface( + params: params, + requireTerminal: false + ), let resolvedSurfaceID = resolved.surfaceId, + resolved.workspace.id == requestedWorkspaceID, + resolvedSurfaceID == requestedSurfaceID, + let panel = resolved.workspace.panels[resolvedSurfaceID] else { + return (nil, .err( + code: "not_found", + message: String( + localized: "mobile.panel.artifact.error.panelNotFound", + defaultValue: "That file panel is no longer available." + ), + data: nil + )) + } + + let currentFilePath: String? + switch panel { + case let markdown as MarkdownPanel: + currentFilePath = markdown.filePath + case let filePreview as FilePreviewPanel: + currentFilePath = filePreview.filePath + default: + currentFilePath = nil + } + guard let currentFilePath else { + panelArtifactAuthorizationStore.invalidate( + workspaceID: requestedWorkspaceID.uuidString, + surfaceID: requestedSurfaceID.uuidString + ) + return (nil, .err( + code: "not_found", + message: String( + localized: "mobile.panel.artifact.error.panelNotFound", + defaultValue: "That file panel is no longer available." + ), + data: nil + )) + } + + guard let canonicalPath = panelArtifactAuthorizationStore.authorizedCanonicalPath( + workspaceID: requestedWorkspaceID.uuidString, + surfaceID: requestedSurfaceID.uuidString, + currentFilePath: currentFilePath, + requestedPath: requestedPath + ) else { + // A vanished file also fails authorization (its grant drops when + // canonicalization fails), but the phone must hear the accurate + // story: the file is gone, not that access was denied. + if !FileManager.default.fileExists(atPath: currentFilePath) { + return (nil, mobilePanelArtifactFileError( + code: "file_not_found", + key: "mobile.chat.artifact.error.fileNotFound", + defaultValue: "That file is no longer available on the Mac.", + path: requestedPath + )) + } + return (nil, .err( + code: "forbidden", + message: String( + localized: "mobile.panel.artifact.error.forbidden", + defaultValue: "That file is not currently shown in this panel." + ), + data: ["path": requestedPath] + )) + } + return (canonicalPath, nil) + } + + private func mobilePanelArtifactResult(_ value: T) -> V2CallResult { + let coding = ChatWireCoding() + guard let data = try? coding.encode(value), + let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { + return mobilePanelArtifactInternalError() + } + return .ok(object) + } + + private func mobilePanelArtifactFileError( + code: String, + key: StaticString, + defaultValue: String.LocalizationValue, + path: String? + ) -> V2CallResult { + .err( + code: code, + message: String(localized: key, defaultValue: defaultValue), + data: path.map { ["path": $0] } + ) + } + + private func mobilePanelArtifactInternalError() -> V2CallResult { + .err( + code: "internal_error", + message: String( + localized: "mobile.panel.artifact.error.internal", + defaultValue: "cmux couldn't complete the panel file request." + ), + data: nil + ) + } +} diff --git a/Sources/TerminalController+MobileTodos.swift b/Sources/TerminalController+MobileTodos.swift new file mode 100644 index 00000000000..d30de807d80 --- /dev/null +++ b/Sources/TerminalController+MobileTodos.swift @@ -0,0 +1,48 @@ +import CmuxControlSocket +import Foundation + +/// Mobile todo verbs adapted onto the existing control-socket todo coordinator. +extension TerminalController { + /// Dispatches a mobile todo verb through the shared workspace-todo command path. + func v2MobileTodoDispatch(method: String, params: [String: Any]) -> V2CallResult { + guard let controlMethod = mobileTodoControlMethod(method) else { + return .err( + code: "method_not_found", + message: "Unknown mobile todo method", + data: ["method": method] + ) + } + guard case .object(var typedParams)? = JSONValue(foundationObject: params) else { + return .err(code: "invalid_params", message: "Invalid todo parameters", data: nil) + } + if case .string("in_progress")? = typedParams["state"] { + // Mobile uses snake_case while the established Mac todo wire is frozen as in-progress. + typedParams["state"] = .string("in-progress") + } + let request = ControlRequest(id: .null, method: controlMethod, params: typedParams) + guard let result = controlCommandCoordinator.handle(request) else { + return .err(code: "internal", message: "Todo command was not handled", data: nil) + } + switch result { + case .ok(let payload): + return .ok(payload.foundationObject) + case .err(let code, let message, let data): + return .err(code: code, message: message, data: data?.foundationObject) + } + } + + /// Maps the mobile namespace onto the shared control-socket todo namespace. + func mobileTodoControlMethod(_ method: String) -> String? { + switch method { + case "mobile.todo.add": "workspace.todo.add" + case "mobile.todo.set_state": "workspace.todo.set_state" + case "mobile.todo.edit": "workspace.todo.edit" + case "mobile.todo.move": "workspace.todo.move" + case "mobile.todo.remove": "workspace.todo.remove" + case "mobile.todo.open": "workspace.todo.open" + case "mobile.status.set": "workspace.status.set" + case "mobile.status.cycle": "workspace.status.cycle" + default: nil + } + } +} diff --git a/Sources/TerminalController+MobileWorkspaceList.swift b/Sources/TerminalController+MobileWorkspaceList.swift index 4945bf6331d..762867f2f34 100644 --- a/Sources/TerminalController+MobileWorkspaceList.swift +++ b/Sources/TerminalController+MobileWorkspaceList.swift @@ -249,6 +249,18 @@ extension TerminalController { } else { simulators = [] } + let surfaces = mobileSurfaceDescriptors(in: workspace).map { surface -> [String: Any] in + var payload: [String: Any] = [ + "surface_id": surface.surfaceID, + "kind": surface.kind, + "title": surface.title, + "file_path": v2OrNull(surface.filePath), + ] + if let todo = surface.todo { + payload["todo"] = mobileTodoPayload(todo) + } + return payload + } let store = notificationStore ?? AppDelegate.shared?.notificationStore let latestNotification = store?.latestNotification(forTabId: workspace.id) @@ -288,6 +300,7 @@ extension TerminalController { // show an iMessage-style unread dot. "has_unread": store?.workspaceIsUnread(forTabId: workspace.id) ?? false, "terminals": terminals, + "surfaces": surfaces, "simulators": simulators ] } diff --git a/Sources/Workspace+PanelLifecycle.swift b/Sources/Workspace+PanelLifecycle.swift index fe00ff65404..4e27b735abb 100644 --- a/Sources/Workspace+PanelLifecycle.swift +++ b/Sources/Workspace+PanelLifecycle.swift @@ -450,7 +450,10 @@ extension Workspace { discardBrowserPanelSubscription(panelId: panelId, panel: panel) removeBrowserOpenTabSuggestionIfNeeded(panel: panel, panelId: panelId) if cleanupControllerSurfaceState { - TerminalController.shared.cleanupSurfaceState(surfaceIds: [panelId, tabId?.uuid].compactMap { $0 }) + TerminalController.shared.cleanupSurfaceState( + surfaceIds: [panelId, tabId?.uuid].compactMap { $0 }, + workspaceID: id + ) } if !preservesTerminalForTransfer { terminalStartupRestoreCoordinator.discardPendingRestoreForPanelTeardown( diff --git a/Sources/Workspace+SurfaceNavigation.swift b/Sources/Workspace+SurfaceNavigation.swift index 14f8d06b79b..da8e7651166 100644 --- a/Sources/Workspace+SurfaceNavigation.swift +++ b/Sources/Workspace+SurfaceNavigation.swift @@ -177,7 +177,12 @@ extension Workspace { /// Surface-kind mapping used by workspace state snapshots. func surfaceKind(for panel: any Panel) -> String { - switch panel.panelType { + Self.surfaceKind(for: panel.panelType) + } + + /// Surface-kind mapping used by snapshots and mobile mapping parity tests. + static func surfaceKind(for panelType: PanelType) -> String { + switch panelType { case .terminal: return SurfaceKind.terminal.rawValue case .browser: diff --git a/cmux.xcodeproj/project.pbxproj b/cmux.xcodeproj/project.pbxproj index e1bd1e4574c..5c5901da741 100644 --- a/cmux.xcodeproj/project.pbxproj +++ b/cmux.xcodeproj/project.pbxproj @@ -1449,6 +1449,7 @@ C0DE71B10000000000000001 /* AppDelegate+AgentChatNotifications.swift in Sources F33000000000000000000012 /* MobileSimulatorStreamSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = F33000000000000000000002 /* MobileSimulatorStreamSession.swift */; }; F33000000000000000000013 /* MobileSimulatorWireEncoder.swift in Sources */ = {isa = PBXBuildFile; fileRef = F33000000000000000000003 /* MobileSimulatorWireEncoder.swift */; }; 7BD32764E8A31FA15DCCF257 /* MobileStateSync.swift in Sources */ = {isa = PBXBuildFile; fileRef = A2DBE587F52C8A3B2A2CFCB8 /* MobileStateSync.swift */; }; + 1055FA010000000000000001 /* MobileSurfaceKindMappingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1055FA010000000000000002 /* MobileSurfaceKindMappingTests.swift */; }; D1B800000000000000000001 /* MobileTaskDirectoryListItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1B800000000000000000011 /* MobileTaskDirectoryListItem.swift */; }; D1B800000000000000000002 /* MobileTaskDirectoryListPage.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1B800000000000000000012 /* MobileTaskDirectoryListPage.swift */; }; D1B800000000000000000003 /* MobileTaskDirectoryListService.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1B800000000000000000013 /* MobileTaskDirectoryListService.swift */; }; @@ -2319,9 +2320,11 @@ C0DE71B10000000000000001 /* AppDelegate+AgentChatNotifications.swift in Sources D1F0A0040000000000000001 /* TerminalController+MobilePhonePushSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1F0A0040000000000000002 /* TerminalController+MobilePhonePushSettings.swift */; }; D7AB00000000000000B011 /* TerminalController+MobileScrollPrefetch.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7AB00000000000000B010 /* TerminalController+MobileScrollPrefetch.swift */; }; F33000000000000000000014 /* TerminalController+MobileSimulator.swift in Sources */ = {isa = PBXBuildFile; fileRef = F33000000000000000000004 /* TerminalController+MobileSimulator.swift */; }; + C7AF30000000000000000001 /* TerminalController+MobileSurfaces.swift in Sources */ = {isa = PBXBuildFile; fileRef = C7AF30000000000000000002 /* TerminalController+MobileSurfaces.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 */; }; + C7AF31000000000000000001 /* TerminalController+MobileTodos.swift in Sources */ = {isa = PBXBuildFile; fileRef = C7AF31000000000000000002 /* TerminalController+MobileTodos.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 */; }; D7AB0000000000000000000B /* TerminalController+MoveTabToNewWorkspace.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7AB0000000000000000000C /* TerminalController+MoveTabToNewWorkspace.swift */; }; @@ -4229,6 +4232,7 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = F33000000000000000000002 /* MobileSimulatorStreamSession.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MobileSimulatorStreamSession.swift; sourceTree = ""; }; F33000000000000000000003 /* MobileSimulatorWireEncoder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MobileSimulatorWireEncoder.swift; sourceTree = ""; }; A2DBE587F52C8A3B2A2CFCB8 /* MobileStateSync.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = MobileStateSync.swift; sourceTree = ""; }; + 1055FA010000000000000002 /* MobileSurfaceKindMappingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MobileSurfaceKindMappingTests.swift; sourceTree = ""; }; D1B800000000000000000011 /* MobileTaskDirectoryListItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MobileTaskDirectoryListItem.swift; sourceTree = ""; }; D1B800000000000000000012 /* MobileTaskDirectoryListPage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MobileTaskDirectoryListPage.swift; sourceTree = ""; }; D1B800000000000000000013 /* MobileTaskDirectoryListService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MobileTaskDirectoryListService.swift; sourceTree = ""; }; @@ -5085,9 +5089,11 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = D1F0A0040000000000000002 /* TerminalController+MobilePhonePushSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TerminalController+MobilePhonePushSettings.swift"; sourceTree = ""; }; D7AB00000000000000B010 /* TerminalController+MobileScrollPrefetch.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TerminalController+MobileScrollPrefetch.swift"; sourceTree = ""; }; F33000000000000000000004 /* TerminalController+MobileSimulator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TerminalController+MobileSimulator.swift"; sourceTree = ""; }; + C7AF30000000000000000002 /* TerminalController+MobileSurfaces.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TerminalController+MobileSurfaces.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 = ""; }; + C7AF31000000000000000002 /* TerminalController+MobileTodos.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TerminalController+MobileTodos.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 = ""; }; D7AB0000000000000000000C /* TerminalController+MoveTabToNewWorkspace.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TerminalController+MoveTabToNewWorkspace.swift"; sourceTree = ""; }; @@ -6743,12 +6749,14 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = A7C0F0020000000000000001 /* TerminalController+MobileAttachTicket.swift */, F1100000000000000000000E /* TerminalController+MobileBrowser.swift */, F33000000000000000000004 /* TerminalController+MobileSimulator.swift */, + C7AF30000000000000000002 /* TerminalController+MobileSurfaces.swift */, F1100000000000000000000F /* SyntheticKeyEventFactory.swift */, ACA7C4A7000000000000000E /* TerminalController+MobileChat.swift */, C7AF10000000000000000004 /* TerminalController+MobileChatArtifacts.swift */, A77A30000000000000000001 /* TerminalController+MobileTaskAttachments.swift */, A77A31000000000000000001 /* TerminalController+MobileTaskModels.swift */, C7AF20000000000000000002 /* TerminalController+MobileTerminalArtifacts.swift */, + C7AF31000000000000000002 /* TerminalController+MobileTodos.swift */, C0DE00000000000000000C53 /* TerminalController+ControlWorkspaceGroupContext.swift */, 466DEB47603469A59697D418 /* TerminalController+ControlWorkspaceTodoContext.swift */, C0DE00000000000000000D83 /* TerminalController+ControlPaneDock.swift */, @@ -8200,6 +8208,7 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = B37A0000000000000000000C /* FileExplorerStateModePersistenceTests.swift */, BC39DE4B96D1931C52AF7D68 /* SidebarOrderingTests.swift */, 86544CEFA1CA33CA9225FB6E /* MobileWorkspaceListFidelityTests.swift */, + 1055FA010000000000000002 /* MobileSurfaceKindMappingTests.swift */, 645645000000000000000001 /* NumberedShortcutSwapTests.swift */, B09C007F42697761B5F1A2AB /* OmnibarAndToolsTests.swift */, D2C075029771815DD5DA1332 /* NotificationAndMenuBarTests.swift */, @@ -10389,9 +10398,11 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = D1F0A0040000000000000001 /* TerminalController+MobilePhonePushSettings.swift in Sources */, D7AB00000000000000B011 /* TerminalController+MobileScrollPrefetch.swift in Sources */, F33000000000000000000014 /* TerminalController+MobileSimulator.swift in Sources */, + C7AF30000000000000000001 /* TerminalController+MobileSurfaces.swift in Sources */, A77A30000000000000000002 /* TerminalController+MobileTaskAttachments.swift in Sources */, A77A31000000000000000002 /* TerminalController+MobileTaskModels.swift in Sources */, C7AF20000000000000000001 /* TerminalController+MobileTerminalArtifacts.swift in Sources */, + C7AF31000000000000000001 /* TerminalController+MobileTodos.swift in Sources */, D1FF00010000000000000002 /* TerminalController+MobileWorkspaceChanges.swift in Sources */, C7A50B000000000000000012 /* TerminalController+MobileWorkspaceList.swift in Sources */, D7AB0000000000000000000B /* TerminalController+MoveTabToNewWorkspace.swift in Sources */, @@ -11327,6 +11338,7 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = C0DE73840000000000000001 /* MobileHostWorkspaceTicketAuthorizationTests.swift in Sources */, 9B08F916D7FF7626C6820727 /* MobilePairingConnectionTransitionTests.swift in Sources */, F33200000000000000000002 /* MobileSimulatorReaderAttachmentTests.swift in Sources */, + 1055FA010000000000000001 /* MobileSurfaceKindMappingTests.swift in Sources */, D1B800000000000000000005 /* MobileTaskDirectoryListServiceTests.swift in Sources */, D1A700000000000000000003 /* MobileTaskDirectorySearchServiceTests.swift in Sources */, F5A700000000000000000001 /* MobileTaskFilesystemJobQuotaTests.swift in Sources */, diff --git a/cmuxTests/MobileHostConnectionLifecycleTests.swift b/cmuxTests/MobileHostConnectionLifecycleTests.swift index 3e2e2e0df82..bbc347bbf5e 100644 --- a/cmuxTests/MobileHostConnectionLifecycleTests.swift +++ b/cmuxTests/MobileHostConnectionLifecycleTests.swift @@ -590,6 +590,9 @@ extension MobileHostAuthorizationTests { #expect(capabilities.contains("workspace.close.v1")) #expect(capabilities.contains("workspace.move.v1")) #expect(capabilities.contains("workspace.group_actions.v1")) + #expect(capabilities.contains("workspace.surfaces.v1")) + #expect(capabilities.contains("surface.focus.v1")) + #expect(capabilities.contains("panel.artifact.v1")) #expect(Set(capabilities).isSuperset(of: [ "workspace.task_create.v1", MobileHostService.terminalInputOrderedCapability, diff --git a/cmuxTests/MobileHostWorkspaceTicketAuthorizationTests.swift b/cmuxTests/MobileHostWorkspaceTicketAuthorizationTests.swift index c64630fb85b..fb0d03c6ecd 100644 --- a/cmuxTests/MobileHostWorkspaceTicketAuthorizationTests.swift +++ b/cmuxTests/MobileHostWorkspaceTicketAuthorizationTests.swift @@ -312,6 +312,30 @@ struct MobileHostWorkspaceTicketAuthorizationTests { ("workspace.action", ["workspace_id": "other-workspace", "action": "rename"], "forbidden"), ("workspace.close", ["workspace_id": "workspace"], nil), ("workspace.close", ["workspace_id": "other-workspace"], "forbidden"), + ("mobile.surface.focus", ["workspace_id": "workspace", "surface_id": "surface"], nil), + ("mobile.surface.focus", ["workspace_id": "other-workspace", "surface_id": "surface"], "forbidden"), + ("mobile.todo.add", ["workspace_id": "workspace", "text": "item"], nil), + ("mobile.todo.add", ["workspace_id": "other-workspace", "text": "item"], "forbidden"), + ("mobile.todo.set_state", ["workspace_id": "workspace", "id": "item", "state": "completed"], nil), + ("mobile.todo.set_state", ["workspace_id": "other-workspace", "id": "item", "state": "completed"], "forbidden"), + ("mobile.todo.edit", ["workspace_id": "workspace", "id": "item", "text": "edited"], nil), + ("mobile.todo.edit", ["workspace_id": "other-workspace", "id": "item", "text": "edited"], "forbidden"), + ("mobile.todo.move", ["workspace_id": "workspace", "id": "item", "to_index": "0"], nil), + ("mobile.todo.move", ["workspace_id": "other-workspace", "id": "item", "to_index": "0"], "forbidden"), + ("mobile.todo.remove", ["workspace_id": "workspace", "id": "item"], nil), + ("mobile.todo.remove", ["workspace_id": "other-workspace", "id": "item"], "forbidden"), + ("mobile.todo.open", ["workspace_id": "workspace"], nil), + ("mobile.todo.open", ["workspace_id": "other-workspace"], "forbidden"), + ("mobile.status.set", ["workspace_id": "workspace", "status": "done"], nil), + ("mobile.status.set", ["workspace_id": "other-workspace", "status": "done"], "forbidden"), + ("mobile.status.cycle", ["workspace_id": "workspace"], nil), + ("mobile.status.cycle", ["workspace_id": "other-workspace"], "forbidden"), + ("mobile.panel.artifact.stat", ["workspace_id": "workspace", "surface_id": "surface", "path": "/tmp/a"], nil), + ("mobile.panel.artifact.stat", ["workspace_id": "other-workspace", "surface_id": "surface", "path": "/tmp/a"], "forbidden"), + ("mobile.panel.artifact.fetch", ["workspace_id": "workspace", "surface_id": "surface", "path": "/tmp/a"], nil), + ("mobile.panel.artifact.fetch", ["workspace_id": "other-workspace", "surface_id": "surface", "path": "/tmp/a"], "forbidden"), + ("mobile.panel.artifact.thumbnail", ["workspace_id": "workspace", "surface_id": "surface", "path": "/tmp/a"], nil), + ("mobile.panel.artifact.thumbnail", ["workspace_id": "other-workspace", "surface_id": "surface", "path": "/tmp/a"], "forbidden"), ] for testCase in cases { diff --git a/cmuxTests/MobileSurfaceKindMappingTests.swift b/cmuxTests/MobileSurfaceKindMappingTests.swift new file mode 100644 index 00000000000..605d9409686 --- /dev/null +++ b/cmuxTests/MobileSurfaceKindMappingTests.swift @@ -0,0 +1,41 @@ +import CMUXMobileCore +import Testing + +#if canImport(cmux_DEV) +@testable import cmux_DEV +#elseif canImport(cmux) +@testable import cmux +#endif + +@MainActor +@Suite struct MobileSurfaceKindMappingTests { + /// The canonical PanelType -> wire-kind vocabulary. Both mapping + /// functions must produce these exact strings; asserting only parity + /// would pass when both drift to the same wrong value. + private static let canonicalKinds: [PanelType: String] = [ + .terminal: "terminal", + .browser: "browser", + .markdown: "markdown", + .filePreview: "filePreview", + .rightSidebarTool: "rightSidebarTool", + .customSidebar: "customSidebar", + .agentSession: "agentSession", + .project: "project", + .extensionBrowser: "extensionBrowser", + .workspaceTodo: "todo", + .cloudVMLoading: "cloudVMLoading", + ] + + @Test func everyPanelTypeMapsToItsCanonicalWireKind() throws { + #expect(Self.canonicalKinds.count == PanelType.allCases.count) + let controller = TerminalController.shared + for panelType in PanelType.allCases { + let canonical = try #require( + Self.canonicalKinds[panelType], + "no canonical kind declared for \(panelType.rawValue)" + ) + #expect(controller.mobileSurfaceKind(for: panelType).rawValue == canonical) + #expect(Workspace.surfaceKind(for: panelType) == canonical) + } + } +} diff --git a/cmuxTests/TerminalControllerSocketSecurityTests.swift b/cmuxTests/TerminalControllerSocketSecurityTests.swift index f35595b33ba..7f122871a4f 100644 --- a/cmuxTests/TerminalControllerSocketSecurityTests.swift +++ b/cmuxTests/TerminalControllerSocketSecurityTests.swift @@ -754,6 +754,39 @@ final class TerminalControllerSocketSecurityTests { } } + @Test func testMobilePanelArtifactMethodsRunOnSocketWorker() async throws { + let socketPath = makeSocketPath("panel-artifact-worker") + let tabManager = TabManager() + TerminalController.shared.start( + tabManager: tabManager, + socketPath: socketPath, + accessMode: .allowAll + ) + try waitForSocket(at: socketPath) + + for method in [ + "mobile.panel.artifact.stat", + "mobile.panel.artifact.fetch", + "mobile.panel.artifact.thumbnail", + ] { + let requestLine = try makeV2RequestLine(method: method, params: [:]) + let mainEnvelope = try decodeV2Envelope(TerminalController.shared.handleSocketLine(requestLine)) + let mainError = try XCTUnwrap(mainEnvelope["error"] as? [String: Any], method) + XCTAssertEqual(mainError["code"] as? String, "invalid_dispatch", method) + + let workerEnvelope = try await sendV2RequestAsync( + method: method, + params: [:], + to: socketPath + ) + let workerError = try XCTUnwrap(workerEnvelope["error"] as? [String: Any], method) + XCTAssertNotEqual(workerError["code"] as? String, "invalid_dispatch", method) + XCTAssertNotEqual(workerError["code"] as? String, "method_not_found", method) + XCTAssertNotEqual(workerError["code"] as? String, "internal_error", method) + XCTAssertEqual(workerError["code"] as? String, "invalid_params", method) + } + } + @Test func testV1PingRunsOnWorkerLaneAndStaysMainThreadCallable() async throws { let socketPath = makeSocketPath("v1-ping") let tabManager = TabManager() @@ -921,6 +954,9 @@ final class TerminalControllerSocketSecurityTests { "terminal.replay", "mobile.terminal.viewport", "terminal.viewport", + "mobile.panel.artifact.stat", + "mobile.panel.artifact.fetch", + "mobile.panel.artifact.thumbnail", "mobile.events.subscribe", "mobile.events.unsubscribe", ] diff --git a/ios/cmux/Resources/Localizable.xcstrings b/ios/cmux/Resources/Localizable.xcstrings index 78cc7f9f305..c1fdd4568c5 100644 --- a/ios/cmux/Resources/Localizable.xcstrings +++ b/ios/cmux/Resources/Localizable.xcstrings @@ -19724,6 +19724,74 @@ }, "mobile.accessibility.notSelected": {"extractionState":"manual","localizations":{"en":{"stringUnit":{"state":"translated","value":"not selected"}},"ja":{"stringUnit":{"state":"translated","value":"未選択"}}}}, "mobile.accessibility.selected": {"extractionState":"manual","localizations":{"en":{"stringUnit":{"state":"translated","value":"selected"}},"ja":{"stringUnit":{"state":"translated","value":"選択済み"}}}}, + "mobile.surface.disconnected.message": {"extractionState":"manual","localizations":{"en":{"stringUnit":{"state":"translated","value":"This phone isn't connected to the Mac right now. Reconnect, then retry."}},"ja":{"stringUnit":{"state":"translated","value":"このiPhoneは現在Macに接続されていません。再接続してから再試行してください。"}}}}, + "mobile.surface.disconnected.title": {"extractionState":"manual","localizations":{"en":{"stringUnit":{"state":"translated","value":"Not connected"}},"ja":{"stringUnit":{"state":"translated","value":"未接続"}}}}, + "mobile.surface.explainer.agentSession": {"extractionState":"manual","localizations":{"en":{"stringUnit":{"state":"translated","value":"This agent session is running in cmux on your Mac."}},"ja":{"stringUnit":{"state":"translated","value":"このエージェントセッションはMacのcmuxで実行中です。"}}}}, + "mobile.surface.explainer.browser": {"extractionState":"manual","localizations":{"en":{"stringUnit":{"state":"translated","value":"This browser tab is open in cmux on your Mac."}},"ja":{"stringUnit":{"state":"translated","value":"このブラウザタブはMacのcmuxで開かれています。"}}}}, + "mobile.surface.explainer.cloudVM": {"extractionState":"manual","localizations":{"en":{"stringUnit":{"state":"translated","value":"This Cloud VM is still starting up on your Mac."}},"ja":{"stringUnit":{"state":"translated","value":"このCloud VMはMac上でまだ起動中です。"}}}}, + "mobile.surface.explainer.customSidebar": {"extractionState":"manual","localizations":{"en":{"stringUnit":{"state":"translated","value":"This panel is drawn by a sidebar extension on your Mac."}},"ja":{"stringUnit":{"state":"translated","value":"このパネルはMacのサイドバー拡張機能で描画されています。"}}}}, + "mobile.surface.explainer.extensionBrowser": {"extractionState":"manual","localizations":{"en":{"stringUnit":{"state":"translated","value":"This extension view opens in cmux on your Mac."}},"ja":{"stringUnit":{"state":"translated","value":"この拡張機能ビューはMacのcmuxで開かれています。"}}}}, + "mobile.surface.explainer.generic": {"extractionState":"manual","localizations":{"en":{"stringUnit":{"state":"translated","value":"This view is rendered by cmux on your Mac."}},"ja":{"stringUnit":{"state":"translated","value":"このビューはMacのcmuxでレンダリングされています。"}}}}, + "mobile.surface.explainer.other": {"extractionState":"manual","localizations":{"en":{"stringUnit":{"state":"translated","value":"This surface needs a newer version of the iOS app."}},"ja":{"stringUnit":{"state":"translated","value":"このサーフェスには新しいバージョンのiOSアプリが必要です。"}}}}, + "mobile.surface.explainer.project": {"extractionState":"manual","localizations":{"en":{"stringUnit":{"state":"translated","value":"This pane browses the project's files on your Mac."}},"ja":{"stringUnit":{"state":"translated","value":"このペインはMac上のプロジェクトファイルを参照します。"}}}}, + "mobile.surface.explainer.rightSidebarTool": {"extractionState":"manual","localizations":{"en":{"stringUnit":{"state":"translated","value":"This tool lives in the right sidebar on your Mac."}},"ja":{"stringUnit":{"state":"translated","value":"このツールはMacの右サイドバーにあります。"}}}}, + "mobile.surface.fileMissing.message": {"extractionState":"manual","localizations":{"en":{"stringUnit":{"state":"translated","value":"The file is no longer available on your Mac."}},"ja":{"stringUnit":{"state":"translated","value":"そのファイルはMac上で利用できなくなりました。"}}}}, + "mobile.surface.fileMissing.title": {"extractionState":"manual","localizations":{"en":{"stringUnit":{"state":"translated","value":"File not found"}},"ja":{"stringUnit":{"state":"translated","value":"ファイルが見つかりません"}}}}, + "mobile.surface.forbidden.message": {"extractionState":"manual","localizations":{"en":{"stringUnit":{"state":"translated","value":"This file isn't displayed by the selected panel."}},"ja":{"stringUnit":{"state":"translated","value":"このファイルは選択したパネルに表示されていません。"}}}}, + "mobile.surface.forbidden.title": {"extractionState":"manual","localizations":{"en":{"stringUnit":{"state":"translated","value":"Preview unavailable"}},"ja":{"stringUnit":{"state":"translated","value":"プレビューできません"}}}}, + "mobile.surface.kind.agentSession": {"extractionState":"manual","localizations":{"en":{"stringUnit":{"state":"translated","value":"Agent Session"}},"ja":{"stringUnit":{"state":"translated","value":"エージェントセッション"}}}}, + "mobile.surface.kind.browser": {"extractionState":"manual","localizations":{"en":{"stringUnit":{"state":"translated","value":"Browser"}},"ja":{"stringUnit":{"state":"translated","value":"ブラウザ"}}}}, + "mobile.surface.kind.cloudVM": {"extractionState":"manual","localizations":{"en":{"stringUnit":{"state":"translated","value":"Cloud VM"}},"ja":{"stringUnit":{"state":"translated","value":"Cloud VM"}}}}, + "mobile.surface.kind.customSidebar": {"extractionState":"manual","localizations":{"en":{"stringUnit":{"state":"translated","value":"Custom Sidebar"}},"ja":{"stringUnit":{"state":"translated","value":"カスタムサイドバー"}}}}, + "mobile.surface.kind.extensionBrowser": {"extractionState":"manual","localizations":{"en":{"stringUnit":{"state":"translated","value":"Extension Browser"}},"ja":{"stringUnit":{"state":"translated","value":"拡張機能ブラウザ"}}}}, + "mobile.surface.kind.filePreview": {"extractionState":"manual","localizations":{"en":{"stringUnit":{"state":"translated","value":"File Preview"}},"ja":{"stringUnit":{"state":"translated","value":"ファイルプレビュー"}}}}, + "mobile.surface.kind.markdown": {"extractionState":"manual","localizations":{"en":{"stringUnit":{"state":"translated","value":"Markdown"}},"ja":{"stringUnit":{"state":"translated","value":"Markdown"}}}}, + "mobile.surface.kind.other": {"extractionState":"manual","localizations":{"en":{"stringUnit":{"state":"translated","value":"Other Surface"}},"ja":{"stringUnit":{"state":"translated","value":"その他のサーフェス"}}}}, + "mobile.surface.kind.project": {"extractionState":"manual","localizations":{"en":{"stringUnit":{"state":"translated","value":"Project"}},"ja":{"stringUnit":{"state":"translated","value":"プロジェクト"}}}}, + "mobile.surface.kind.rightSidebarTool": {"extractionState":"manual","localizations":{"en":{"stringUnit":{"state":"translated","value":"Sidebar Tool"}},"ja":{"stringUnit":{"state":"translated","value":"サイドバーツール"}}}}, + "mobile.surface.kind.terminal": {"extractionState":"manual","localizations":{"en":{"stringUnit":{"state":"translated","value":"Terminal"}},"ja":{"stringUnit":{"state":"translated","value":"ターミナル"}}}}, + "mobile.surface.kind.todo": {"extractionState":"manual","localizations":{"en":{"stringUnit":{"state":"translated","value":"Todo"}},"ja":{"stringUnit":{"state":"translated","value":"ToDo"}}}}, + "mobile.surface.loadFailed.message": {"extractionState":"manual","localizations":{"en":{"stringUnit":{"state":"translated","value":"Something went wrong loading this file."}},"ja":{"stringUnit":{"state":"translated","value":"このファイルの読み込み中に問題が発生しました。"}}}}, + "mobile.surface.loadFailed.title": {"extractionState":"manual","localizations":{"en":{"stringUnit":{"state":"translated","value":"Couldn't load file"}},"ja":{"stringUnit":{"state":"translated","value":"ファイルを読み込めませんでした"}}}}, + "mobile.surface.loading": {"extractionState":"manual","localizations":{"en":{"stringUnit":{"state":"translated","value":"Loading preview"}},"ja":{"stringUnit":{"state":"translated","value":"プレビューを読み込み中"}}}}, + "mobile.surface.macNeedsUpdate.message": {"extractionState":"manual","localizations":{"en":{"stringUnit":{"state":"translated","value":"The connected Mac's cmux version can't preview this file."}},"ja":{"stringUnit":{"state":"translated","value":"接続中のMacのcmuxバージョンではこのファイルをプレビューできません。"}}}}, + "mobile.surface.macNeedsUpdate.title": {"extractionState":"manual","localizations":{"en":{"stringUnit":{"state":"translated","value":"Update cmux on your Mac"}},"ja":{"stringUnit":{"state":"translated","value":"Macのcmuxをアップデートしてください"}}}}, + "mobile.surface.macUnreachable.message": {"extractionState":"manual","localizations":{"en":{"stringUnit":{"state":"translated","value":"Check the connection to your Mac and try again."}},"ja":{"stringUnit":{"state":"translated","value":"Macへの接続を確認して、もう一度お試しください。"}}}}, + "mobile.surface.macUnreachable.title": {"extractionState":"manual","localizations":{"en":{"stringUnit":{"state":"translated","value":"Mac unreachable"}},"ja":{"stringUnit":{"state":"translated","value":"Macに接続できません"}}}}, + "mobile.surface.openOnMac": {"extractionState":"manual","localizations":{"en":{"stringUnit":{"state":"translated","value":"Open on Mac"}},"ja":{"stringUnit":{"state":"translated","value":"Macで開く"}}}}, + "mobile.surface.openOnMacFailed": {"extractionState":"manual","localizations":{"en":{"stringUnit":{"state":"translated","value":"Couldn't reach your Mac. Try again."}},"ja":{"stringUnit":{"state":"translated","value":"Macに接続できませんでした。もう一度お試しください。"}}}}, + "mobile.surface.panelClosed.message": {"extractionState":"manual","localizations":{"en":{"stringUnit":{"state":"translated","value":"That file panel is no longer open on your Mac."}},"ja":{"stringUnit":{"state":"translated","value":"そのファイルパネルはMacで開かれていません。"}}}}, + "mobile.surface.panelClosed.title": {"extractionState":"manual","localizations":{"en":{"stringUnit":{"state":"translated","value":"Panel closed"}},"ja":{"stringUnit":{"state":"translated","value":"パネルは閉じられました"}}}}, + "mobile.surface.reconnecting.message": {"extractionState":"manual","localizations":{"en":{"stringUnit":{"state":"translated","value":"This phone's connection to the Mac dropped and is coming back. Retry in a moment."}},"ja":{"stringUnit":{"state":"translated","value":"このiPhoneのMacへの接続が切断され、再接続中です。少し待ってから再試行してください。"}}}}, + "mobile.surface.reconnecting.title": {"extractionState":"manual","localizations":{"en":{"stringUnit":{"state":"translated","value":"Reconnecting…"}},"ja":{"stringUnit":{"state":"translated","value":"再接続中…"}}}}, + "mobile.surface.retry": {"extractionState":"manual","localizations":{"en":{"stringUnit":{"state":"translated","value":"Retry"}},"ja":{"stringUnit":{"state":"translated","value":"再試行"}}}}, + "mobile.surface.tooLarge.limitFormat": {"extractionState":"manual","localizations":{"en":{"stringUnit":{"state":"translated","value":"This preview is limited to %@."}},"ja":{"stringUnit":{"state":"translated","value":"このプレビューは%@までです。"}}}}, + "mobile.surface.tooLarge.messageFormat": {"extractionState":"manual","localizations":{"en":{"stringUnit":{"state":"translated","value":"This file is %1$@; previews are limited to %2$@."}},"ja":{"stringUnit":{"state":"translated","value":"このファイルは%1$@です。プレビューは%2$@までです。"}}}}, + "mobile.surface.tooLarge.title": {"extractionState":"manual","localizations":{"en":{"stringUnit":{"state":"translated","value":"File too large to preview"}},"ja":{"stringUnit":{"state":"translated","value":"ファイルが大きすぎてプレビューできません"}}}}, + "mobile.surface.transferUnavailable.message": {"extractionState":"manual","localizations":{"en":{"stringUnit":{"state":"translated","value":"File transfer is temporarily unavailable on your Mac. Try again shortly."}},"ja":{"stringUnit":{"state":"translated","value":"Macでファイル転送を一時的に利用できません。少し待ってから再試行してください。"}}}}, + "mobile.surface.transferUnavailable.title": {"extractionState":"manual","localizations":{"en":{"stringUnit":{"state":"translated","value":"Transfer unavailable"}},"ja":{"stringUnit":{"state":"translated","value":"転送できません"}}}}, + "mobile.surface.workspaceContextFormat": {"extractionState":"manual","localizations":{"en":{"stringUnit":{"state":"translated","value":"%1$@ · In “%2$@”"}},"ja":{"stringUnit":{"state":"translated","value":"%1$@ · “%2$@”内"}}}}, + "mobile.todo.add": {"extractionState":"manual","localizations":{"en":{"stringUnit":{"state":"translated","value":"Add checklist item"}},"ja":{"stringUnit":{"state":"translated","value":"チェックリスト項目を追加"}}}}, + "mobile.todo.addPlaceholder": {"extractionState":"manual","localizations":{"en":{"stringUnit":{"state":"translated","value":"New checklist item"}},"ja":{"stringUnit":{"state":"translated","value":"新しいチェックリスト項目"}}}}, + "mobile.todo.empty.message": {"extractionState":"manual","localizations":{"en":{"stringUnit":{"state":"translated","value":"Anything you add here stays in sync with your Mac."}},"ja":{"stringUnit":{"state":"translated","value":"ここに追加した内容はMacと同期されます。"}}}}, + "mobile.todo.empty.title": {"extractionState":"manual","localizations":{"en":{"stringUnit":{"state":"translated","value":"No items yet"}},"ja":{"stringUnit":{"state":"translated","value":"項目はまだありません"}}}}, + "mobile.todo.item.delete": {"extractionState":"manual","localizations":{"en":{"stringUnit":{"state":"translated","value":"Delete"}},"ja":{"stringUnit":{"state":"translated","value":"削除"}}}}, + "mobile.todo.item.editPlaceholder": {"extractionState":"manual","localizations":{"en":{"stringUnit":{"state":"translated","value":"Item text"}},"ja":{"stringUnit":{"state":"translated","value":"項目のテキスト"}}}}, + "mobile.todo.item.markCompleted": {"extractionState":"manual","localizations":{"en":{"stringUnit":{"state":"translated","value":"Mark as completed"}},"ja":{"stringUnit":{"state":"translated","value":"完了にする"}}}}, + "mobile.todo.item.markInProgress": {"extractionState":"manual","localizations":{"en":{"stringUnit":{"state":"translated","value":"Mark as in progress"}},"ja":{"stringUnit":{"state":"translated","value":"進行中にする"}}}}, + "mobile.todo.item.markPending": {"extractionState":"manual","localizations":{"en":{"stringUnit":{"state":"translated","value":"Mark as pending"}},"ja":{"stringUnit":{"state":"translated","value":"保留にする"}}}}, + "mobile.todo.progressFormat": {"extractionState":"manual","localizations":{"en":{"stringUnit":{"state":"translated","value":"%1$d of %2$d done"}},"ja":{"stringUnit":{"state":"translated","value":"%2$d件中%1$d件完了"}}}}, + "mobile.todo.status.automatic": {"extractionState":"manual","localizations":{"en":{"stringUnit":{"state":"translated","value":"Automatic"}},"ja":{"stringUnit":{"state":"translated","value":"自動"}}}}, + "mobile.todo.status.choose": {"extractionState":"manual","localizations":{"en":{"stringUnit":{"state":"translated","value":"Choose status"}},"ja":{"stringUnit":{"state":"translated","value":"ステータスを選択"}}}}, + "mobile.todo.status.done": {"extractionState":"manual","localizations":{"en":{"stringUnit":{"state":"translated","value":"Done"}},"ja":{"stringUnit":{"state":"translated","value":"完了"}}}}, + "mobile.todo.status.hidden": {"extractionState":"manual","localizations":{"en":{"stringUnit":{"state":"translated","value":"No Status"}},"ja":{"stringUnit":{"state":"translated","value":"ステータスなし"}}}}, + "mobile.todo.status.menuTitle": {"extractionState":"manual","localizations":{"en":{"stringUnit":{"state":"translated","value":"Status"}},"ja":{"stringUnit":{"state":"translated","value":"ステータス"}}}}, + "mobile.todo.status.needsAttention": {"extractionState":"manual","localizations":{"en":{"stringUnit":{"state":"translated","value":"Needs Attention"}},"ja":{"stringUnit":{"state":"translated","value":"要確認"}}}}, + "mobile.todo.status.review": {"extractionState":"manual","localizations":{"en":{"stringUnit":{"state":"translated","value":"Review"}},"ja":{"stringUnit":{"state":"translated","value":"レビュー"}}}}, + "mobile.todo.status.todo": {"extractionState":"manual","localizations":{"en":{"stringUnit":{"state":"translated","value":"Todo"}},"ja":{"stringUnit":{"state":"translated","value":"ToDo"}}}}, + "mobile.todo.status.working": {"extractionState":"manual","localizations":{"en":{"stringUnit":{"state":"translated","value":"Working"}},"ja":{"stringUnit":{"state":"translated","value":"作業中"}}}}, + "mobile.todo.updateFailed.message": {"extractionState":"manual","localizations":{"en":{"stringUnit":{"state":"translated","value":"Your change was undone. Try again."}},"ja":{"stringUnit":{"state":"translated","value":"変更を元に戻しました。もう一度お試しください。"}}}}, + "mobile.todo.updateFailed.title": {"extractionState":"manual","localizations":{"en":{"stringUnit":{"state":"translated","value":"Couldn’t Update Checklist"}},"ja":{"stringUnit":{"state":"translated","value":"チェックリストを更新できませんでした"}}}}, "mobile.notifications.awayExplanation": {"extractionState":"manual","localizations":{"en":{"stringUnit":{"state":"translated","value":"Only When Away sends after the Mac is locked, asleep, or inactive."}},"ja":{"stringUnit":{"state":"translated","value":"「離席中のみ」では、Macがロック中、スリープ中、または操作されていないときに送信します。"}}}}, "mobile.notifications.hideContent": {"extractionState":"manual","localizations":{"en":{"stringUnit":{"state":"translated","value":"Hide Notification Content"}},"ja":{"stringUnit":{"state":"translated","value":"通知内容を非表示"}}}}, "mobile.notifications.macForwarding": {"extractionState":"manual","localizations":{"en":{"stringUnit":{"state":"translated","value":"Forward Alerts from This Mac"}},"ja":{"stringUnit":{"state":"translated","value":"このMacから通知を転送"}}}},