diff --git a/Packages/Shared/CMUXMobileCore/Sources/CMUXMobileCore/MobileStateSyncRecords.swift b/Packages/Shared/CMUXMobileCore/Sources/CMUXMobileCore/MobileStateSyncRecords.swift index f58bc46b06f1..ad4fa4297140 100644 --- a/Packages/Shared/CMUXMobileCore/Sources/CMUXMobileCore/MobileStateSyncRecords.swift +++ b/Packages/Shared/CMUXMobileCore/Sources/CMUXMobileCore/MobileStateSyncRecords.swift @@ -35,43 +35,6 @@ 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. @@ -141,9 +104,6 @@ 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] @@ -170,7 +130,6 @@ public struct WorkspaceSyncRecord: MobileSyncRecord { hasUnread: Bool, sortIndex: Int, terminals: [Terminal], - surfaces: [Surface]? = nil, simulators: [MobileSimulatorPanelDescriptor] = [] ) { self.id = id @@ -189,7 +148,6 @@ public struct WorkspaceSyncRecord: MobileSyncRecord { self.hasUnread = hasUnread self.sortIndex = sortIndex self.terminals = terminals - self.surfaces = surfaces self.simulators = simulators } @@ -214,7 +172,6 @@ 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 @@ -238,7 +195,6 @@ 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 deleted file mode 100644 index 22312574e6c7..000000000000 --- a/Packages/Shared/CMUXMobileCore/Sources/CMUXMobileCore/MobileSurfaceKind.swift +++ /dev/null @@ -1,48 +0,0 @@ -/// 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 deleted file mode 100644 index df1f6efefc2a..000000000000 --- a/Packages/Shared/CMUXMobileCore/Sources/CMUXMobileCore/MobileTodoItem.swift +++ /dev/null @@ -1,32 +0,0 @@ -/// 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 deleted file mode 100644 index 2f66a7aa482c..000000000000 --- a/Packages/Shared/CMUXMobileCore/Sources/CMUXMobileCore/MobileTodoItemOrigin.swift +++ /dev/null @@ -1,7 +0,0 @@ -/// 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 deleted file mode 100644 index 3ab805b2a8fe..000000000000 --- a/Packages/Shared/CMUXMobileCore/Sources/CMUXMobileCore/MobileTodoItemState.swift +++ /dev/null @@ -1,18 +0,0 @@ -/// 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 deleted file mode 100644 index b68ac69a24ef..000000000000 --- a/Packages/Shared/CMUXMobileCore/Sources/CMUXMobileCore/MobileTodoSnapshot.swift +++ /dev/null @@ -1,29 +0,0 @@ -/// 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 deleted file mode 100644 index 24f66a539d61..000000000000 --- a/Packages/Shared/CMUXMobileCore/Sources/CMUXMobileCore/MobileTodoStatus.swift +++ /dev/null @@ -1,20 +0,0 @@ -/// 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 7f39a32a127e..7526eac80785 100644 --- a/Packages/Shared/CMUXMobileCore/Tests/CMUXMobileCoreTests/MobileStateSyncFrameCodingTests.swift +++ b/Packages/Shared/CMUXMobileCore/Tests/CMUXMobileCoreTests/MobileStateSyncFrameCodingTests.swift @@ -29,32 +29,6 @@ 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 - ), - ] - ) - ) ] ) } @@ -77,46 +51,6 @@ 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 976d9fc112b3..59a4c84032f8 100644 --- a/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Artifacts/ChatArtifactError.swift +++ b/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Artifacts/ChatArtifactError.swift @@ -65,9 +65,4 @@ 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 deleted file mode 100644 index 843edeb85a78..000000000000 --- a/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Artifacts/PanelArtifactAuthorizationStore.swift +++ /dev/null @@ -1,126 +0,0 @@ -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 deleted file mode 100644 index 46fa13939fa8..000000000000 --- a/Packages/Shared/CmuxAgentChat/Tests/CmuxAgentChatTests/PanelArtifactAuthorizationStoreTests.swift +++ /dev/null @@ -1,104 +0,0 @@ -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 deleted file mode 100644 index 110d0ef97be1..000000000000 --- a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactConnectionHint.swift +++ /dev/null @@ -1,38 +0,0 @@ -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 deleted file mode 100644 index 1057f2c11d9f..000000000000 --- a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactEmbeddedMarkdown.swift +++ /dev/null @@ -1,19 +0,0 @@ -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 deleted file mode 100644 index 322b8fadd5f2..000000000000 --- a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactEmbeddedPreview.swift +++ /dev/null @@ -1,112 +0,0 @@ -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 c9dd54d5f23c..60abf602628f 100644 --- a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactFailurePresentation.swift +++ b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactFailurePresentation.swift @@ -79,8 +79,6 @@ 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 017701803620..04a933bd4b71 100644 --- a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactLoader.swift +++ b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactLoader.swift @@ -60,8 +60,6 @@ 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. @@ -73,8 +71,6 @@ 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: @@ -282,53 +278,6 @@ 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: @@ -488,8 +437,6 @@ 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 129510a7cd27..06f86c75d84d 100644 --- a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactViewerRouteView.swift +++ b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactViewerRouteView.swift @@ -19,9 +19,6 @@ 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)? @@ -34,7 +31,6 @@ 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 @@ -42,7 +38,6 @@ struct ChatArtifactViewerRouteView: View { self.snapshot = snapshot self.scope = scope self.actions = actions - self.connectionHint = connectionHint self.onDone = onDone self.onImageMinimumZoomChanged = onImageMinimumZoomChanged self.onImageAction = onImageAction @@ -202,12 +197,9 @@ struct ChatArtifactViewerRouteView: View { scope: scope, actualSize: actualSize ) - let copy = error == .macUnreachable - ? connectionHint.unreachableCopy - : (title: failure.title, message: failure.message) unavailableView( - title: copy.title, - message: copy.message, + title: failure.title, + message: failure.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 f1f0eb1d123b..521a942aa669 100644 --- a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactViewerScope.swift +++ b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactViewerScope.swift @@ -6,9 +6,6 @@ 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 febcf4b84000..7364dd0c3723 100644 --- a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Resources/Localizable.xcstrings +++ b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Resources/Localizable.xcstrings @@ -494,40 +494,6 @@ } } }, - "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": { @@ -545,40 +511,6 @@ } } }, - "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": { @@ -698,23 +630,6 @@ } } }, - "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": { @@ -1106,74 +1021,6 @@ } } }, - "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": { @@ -1242,40 +1089,6 @@ } } }, - "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": { @@ -1497,74 +1310,6 @@ } } }, - "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 deleted file mode 100644 index 6d855c9348ec..000000000000 --- a/Packages/iOS/CmuxAgentChatUI/Tests/CmuxAgentChatUITests/ChatArtifactEmbeddedPreviewTests.swift +++ /dev/null @@ -1,44 +0,0 @@ -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 2119cb8f1055..a623e1310b60 100644 --- a/Packages/iOS/CmuxAgentChatUI/Tests/CmuxAgentChatUITests/ChatArtifactLoaderTests.swift +++ b/Packages/iOS/CmuxAgentChatUI/Tests/CmuxAgentChatUITests/ChatArtifactLoaderTests.swift @@ -180,29 +180,6 @@ 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 deleted file mode 100644 index f04d2db8b9e3..000000000000 --- a/Packages/iOS/CmuxAgentChatUI/Tests/CmuxAgentChatUITests/ChatArtifactViewerErrorStateTests.swift +++ /dev/null @@ -1,45 +0,0 @@ -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 deleted file mode 100644 index 05bdd54f4708..000000000000 --- a/Packages/iOS/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileCoreRPCClient+SurfaceFocus.swift +++ /dev/null @@ -1,16 +0,0 @@ -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 deleted file mode 100644 index 96cd132fbfcb..000000000000 --- a/Packages/iOS/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileCoreRPCClient+Todo.swift +++ /dev/null @@ -1,47 +0,0 @@ -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 26685266195d..f132f65c2fed 100644 --- a/Packages/iOS/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileCoreRPCClient.swift +++ b/Packages/iOS/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileCoreRPCClient.swift @@ -701,9 +701,7 @@ public final class MobileCoreRPCClient: MobileSyncing, Sendable { return false case "workspace.create", "mobile.task.attachment.upload": return false - case "workspace.action", "workspace.close", "mobile.surface.focus", - "mobile.panel.artifact.stat", "mobile.panel.artifact.fetch", - "mobile.panel.artifact.thumbnail": + case "workspace.action", "workspace.close": 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 9735aefa5915..d23dcf1533f3 100644 --- a/Packages/iOS/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileSyncWorkspaceListResponse.swift +++ b/Packages/iOS/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileSyncWorkspaceListResponse.swift @@ -48,8 +48,6 @@ 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] @@ -69,7 +67,6 @@ public struct MobileSyncWorkspaceListResponse: Decodable, Sendable { case lastActivityAt = "last_activity_at" case hasUnread = "has_unread" case terminals - case surfaces case simulators } @@ -92,7 +89,6 @@ public struct MobileSyncWorkspaceListResponse: Decodable, Sendable { lastActivityAt: Double?, hasUnread: Bool?, terminals: [Terminal], - surfaces: [Surface]? = nil, simulators: [MobileSimulatorPanelDescriptor] = [] ) { self.id = id @@ -110,7 +106,6 @@ public struct MobileSyncWorkspaceListResponse: Decodable, Sendable { self.lastActivityAt = lastActivityAt self.hasUnread = hasUnread self.terminals = terminals - self.surfaces = surfaces self.simulators = simulators } @@ -131,7 +126,6 @@ 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 @@ -139,43 +133,6 @@ 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 65b1f3fd0a8a..112414cfce0d 100644 --- a/Packages/iOS/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileWorkspacePreview+RemoteMapping.swift +++ b/Packages/iOS/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileWorkspacePreview+RemoteMapping.swift @@ -22,24 +22,11 @@ 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 deleted file mode 100644 index 08ebd59e430b..000000000000 --- a/Packages/iOS/CmuxMobileRPC/Tests/CmuxMobileRPCTests/MobileSurfaceInventoryTests.swift +++ /dev/null @@ -1,89 +0,0 @@ -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 deleted file mode 100644 index a514d8e98965..000000000000 --- a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileChatEventSource+PanelArtifacts.swift +++ /dev/null @@ -1,118 +0,0 @@ -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 0207383715e2..c251d4fc4393 100644 --- a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileChatEventSource.swift +++ b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileChatEventSource.swift @@ -32,8 +32,6 @@ 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. @@ -48,7 +46,6 @@ public actor MobileChatEventSource: ChatEventSource { supportsArtifactGallery: Bool = false, supportsArtifactFolders: Bool = false, supportsTerminalArtifactList: Bool = false, - supportsPanelArtifacts: Bool = false, supportsArtifactLane: Bool = false, diagnosticLog: DiagnosticLog? = nil ) { @@ -58,7 +55,6 @@ 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 90d39619def6..ebea6217097f 100644 --- a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+AgentChat.swift +++ b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+AgentChat.swift @@ -32,7 +32,6 @@ 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 cd29afed85ed..ea0d501ed31f 100644 --- a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+Capabilities.swift +++ b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+Capabilities.swift @@ -137,16 +137,6 @@ 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 099623bbe1a3..d50992b6c220 100644 --- a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+StateSync.swift +++ b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+StateSync.swift @@ -391,15 +391,6 @@ 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 deleted file mode 100644 index ff6cfcb6b6d4..000000000000 --- a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+SurfaceFocus.swift +++ /dev/null @@ -1,40 +0,0 @@ -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+TerminalOutputDelivery.swift b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+TerminalOutputDelivery.swift index c80c39306049..8dac4cef4f0a 100644 --- a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+TerminalOutputDelivery.swift +++ b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+TerminalOutputDelivery.swift @@ -389,7 +389,8 @@ extension MobileShellComposite { ) } - private func deliverTerminalOutput( + @discardableResult + func deliverTerminalOutput( _ delivery: TerminalOutputDelivery, surfaceID: String, bypassReplayBarrier: Bool = false @@ -455,7 +456,8 @@ extension MobileShellComposite { sourceRenderGridFrame: immediate.sourceRenderGridFrame, endSequence: immediate.endSequence, requiresVerifiedReplay: requiresVerifiedReplayApplication(for: immediate), - terminalConfigTheme: immediate.terminalConfigTheme + terminalConfigTheme: immediate.terminalConfigTheme, + replayBarrierFailedOpen: immediate.replayBarrierFailedOpen ) ) } @@ -564,7 +566,8 @@ extension MobileShellComposite { sourceRenderGridFrame: next.sourceRenderGridFrame, endSequence: next.endSequence, requiresVerifiedReplay: requiresVerifiedReplayApplication(for: next), - terminalConfigTheme: next.terminalConfigTheme + terminalConfigTheme: next.terminalConfigTheme, + replayBarrierFailedOpen: next.replayBarrierFailedOpen )) } diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+TerminalReplayLifecycle.swift b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+TerminalReplayLifecycle.swift index e36dcb691a9c..99dacfffe37c 100644 --- a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+TerminalReplayLifecycle.swift +++ b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+TerminalReplayLifecycle.swift @@ -273,6 +273,15 @@ extension MobileShellComposite { pendingTerminalByteEndSeqBySurfaceID.removeValue(forKey: surfaceID) pendingTerminalInputDroppedRenderGridSurfaceIDs.remove(surfaceID) MobileDebugLog.anchormux("terminal.output.replay_barrier_fail_open surface=\(surfaceID) reason=\(reason)") + // The consumer may be holding a frozen verified-replay presentation + // for a replay that will now never arrive. Deliver the fail-open + // marker through the ordered output stream so it abandons that + // presentation and resumes live rendering instead of staying frozen + // until the surface is remounted. + _ = deliverTerminalOutput( + TerminalOutputDelivery(replayBarrierFailedOpen: true), + surfaceID: surfaceID + ) return true } diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+Todo.swift b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+Todo.swift deleted file mode 100644 index 27442abc19b8..000000000000 --- a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+Todo.swift +++ /dev/null @@ -1,43 +0,0 @@ -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 330c9352a98e..ea0c25f6d3b6 100644 --- a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift +++ b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift @@ -141,7 +141,6 @@ 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/Sources/CmuxMobileShell/TerminalOutputDelivery.swift b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/TerminalOutputDelivery.swift index b98fd32a164c..818ac599cdda 100644 --- a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/TerminalOutputDelivery.swift +++ b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/TerminalOutputDelivery.swift @@ -21,6 +21,10 @@ struct TerminalOutputDelivery: Equatable, Sendable { var replacementScope: ReplacementScope? var viewportPolicy: MobileTerminalOutputViewportPolicy? var endSequence: UInt64? + /// Control marker delivered after a replay barrier failed open. It is a + /// nonreplaceable barrier so a queued render-grid chunk can never absorb + /// it, and it carries no bytes. + var replayBarrierFailedOpen = false var replaceable: Bool { replacementScope != nil @@ -39,6 +43,15 @@ struct TerminalOutputDelivery: Equatable, Sendable { self.endSequence = endSequence } + /// Creates the fail-open control marker chunk. + init(replayBarrierFailedOpen: Bool) { + self.payload = .bytes(Data()) + self.replacementScope = nil + self.viewportPolicy = nil + self.endSequence = nil + self.replayBarrierFailedOpen = replayBarrierFailedOpen + } + init(theme frame: MobileTerminalRenderGridFrame) { self.payload = .theme(frame) self.replacementScope = .terminalTheme diff --git a/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellCompositePreviewTests.swift b/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellCompositePreviewTests.swift index c3ef48e43fcf..968bb6466439 100644 --- a/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellCompositePreviewTests.swift +++ b/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellCompositePreviewTests.swift @@ -37,27 +37,6 @@ 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 3cdea4e69544..3b8f85da9dc4 100644 --- a/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellStateSyncTests.swift +++ b/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellStateSyncTests.swift @@ -18,8 +18,7 @@ private func workspaceRecord( customDescription: String? = nil, customDescriptionIsTruncated: Bool = false, customColorHex: String? = nil, - sortIndex: Int, - surfaces: [WorkspaceSyncRecord.Surface]? = nil + sortIndex: Int ) -> WorkspaceSyncRecord { WorkspaceSyncRecord( id: id, @@ -37,8 +36,7 @@ private func workspaceRecord( lastActivityAt: 1.0, hasUnread: false, sortIndex: sortIndex, - terminals: [], - surfaces: surfaces + terminals: [] ) } @@ -126,13 +124,7 @@ struct MobileShellStateSyncTests { customDescription: "Release validation", customDescriptionIsTruncated: true, customColorHex: "#1565C0", - sortIndex: 0, - surfaces: [.init( - surfaceID: "surface-alpha", - kind: "future.canvas", - title: "Canvas", - filePath: "/tmp/canvas" - )] + sortIndex: 0 ), workspaceRecord(id: UUID().uuidString, title: "synced-beta", sortIndex: 1), ] @@ -158,9 +150,6 @@ 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 47374d1cec3d..4856fc59b037 100644 --- a/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellWorkspaceCapabilityTests.swift +++ b/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellWorkspaceCapabilityTests.swift @@ -25,19 +25,16 @@ 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 deleted file mode 100644 index b7c64bec0fb6..000000000000 --- a/Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MacSurfaceRenderer.swift +++ /dev/null @@ -1,56 +0,0 @@ -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 deleted file mode 100644 index 48e6085867f9..000000000000 --- a/Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MacSurfaceTextDecoder.swift +++ /dev/null @@ -1,37 +0,0 @@ -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 deleted file mode 100644 index 0ad0645912a2..000000000000 --- a/Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileSurfacePreview.swift +++ /dev/null @@ -1,89 +0,0 @@ -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/MobileTerminalOutputSinking.swift b/Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileTerminalOutputSinking.swift index 9ef194cc1b97..b6acb10756e0 100644 --- a/Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileTerminalOutputSinking.swift +++ b/Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileTerminalOutputSinking.swift @@ -33,6 +33,11 @@ public struct MobileTerminalOutputChunk: Sendable { public let requiresVerifiedReplay: Bool /// Raw Ghostty defaults that must be installed before this chunk's VT replay. public let terminalConfigTheme: TerminalTheme? + /// Control marker: the sink failed a replay barrier open, so the replay + /// this consumer is holding a frozen presentation for will never arrive. + /// The consumer must abandon that frozen presentation, drop stale replay + /// ordering hints, and resume live rendering. Carries no bytes. + public let replayBarrierFailedOpen: Bool /// Creates one backpressured terminal-output chunk. /// @@ -44,6 +49,7 @@ public struct MobileTerminalOutputChunk: Sendable { /// - endSequence: Terminal byte high-water mark represented by the chunk. /// - requiresVerifiedReplay: Whether the verified replay path is required. /// - terminalConfigTheme: Raw Ghostty defaults paired with the bytes. + /// - replayBarrierFailedOpen: Whether this is a barrier fail-open marker. public init( data: Data, streamToken: UUID, @@ -51,7 +57,8 @@ public struct MobileTerminalOutputChunk: Sendable { sourceRenderGridFrame: MobileTerminalRenderGridFrame? = nil, endSequence: UInt64? = nil, requiresVerifiedReplay: Bool = false, - terminalConfigTheme: TerminalTheme? = nil + terminalConfigTheme: TerminalTheme? = nil, + replayBarrierFailedOpen: Bool = false ) { self.data = data self.streamToken = streamToken @@ -60,6 +67,7 @@ public struct MobileTerminalOutputChunk: Sendable { self.endSequence = endSequence self.requiresVerifiedReplay = requiresVerifiedReplay self.terminalConfigTheme = terminalConfigTheme + self.replayBarrierFailedOpen = replayBarrierFailedOpen } } diff --git a/Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileTodoMutation.swift b/Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileTodoMutation.swift deleted file mode 100644 index 08e581c2cfcf..000000000000 --- a/Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileTodoMutation.swift +++ /dev/null @@ -1,21 +0,0 @@ -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 c441acebc6bf..0fef22d25645 100644 --- a/Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileWorkspacePreview.swift +++ b/Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileWorkspacePreview.swift @@ -89,8 +89,6 @@ 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 @@ -136,7 +134,6 @@ 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, @@ -154,7 +151,6 @@ public struct MobileWorkspacePreview: Identifiable, Equatable, Sendable { lastActivityAt: Date? = nil, hasUnread: Bool = false, terminals: [MobileTerminalPreview], - surfaces: [MobileSurfacePreview] = [], simulators: [MobileSimulatorPanelDescriptor] = [] ) { self.id = id @@ -174,19 +170,6 @@ 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 deleted file mode 100644 index 33cf82d85c0a..000000000000 --- a/Packages/iOS/CmuxMobileShellModel/Tests/CmuxMobileShellModelTests/MacSurfaceRendererTests.swift +++ /dev/null @@ -1,107 +0,0 @@ -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 deleted file mode 100644 index 4493e10fe97a..000000000000 --- a/Packages/iOS/CmuxMobileShellModel/Tests/CmuxMobileShellModelTests/MacSurfaceTextDecoderTests.swift +++ /dev/null @@ -1,33 +0,0 @@ -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 ff6f29e046ee..7a9d56c15bf0 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/CMUXMobileRootView.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/CMUXMobileRootView.swift @@ -146,14 +146,6 @@ 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 @@ -214,14 +206,6 @@ 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() @@ -465,8 +449,6 @@ 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/GhosttySurfaceRepresentable.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/GhosttySurfaceRepresentable.swift index 5a4c57675979..1ce47df006a3 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/GhosttySurfaceRepresentable.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/GhosttySurfaceRepresentable.swift @@ -349,6 +349,19 @@ struct GhosttySurfaceRepresentable: UIViewRepresentable { ) let latencyApplyStart = MobileLatencyTrace.captureTime() #endif + if chunk.replayBarrierFailedOpen { + // The sink gave up on the replay this surface froze + // for. Drop the stale ordering hints and the frozen + // presentation so the live renderer resumes; the next + // full baseline re-verifies through the normal path. + self.verifiedReplayState.failOpen() + surfaceView.failOpenVerifiedReplayPresentation() + store.terminalOutputDidProcess( + surfaceID: surfaceID, + streamToken: chunk.streamToken + ) + continue + } switch terminalOutputApplicationPath( for: chunk, expectedSurfaceID: surfaceID diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MacSurfaceChrome.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MacSurfaceChrome.swift deleted file mode 100644 index 54c35cef1413..000000000000 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MacSurfaceChrome.swift +++ /dev/null @@ -1,105 +0,0 @@ -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 deleted file mode 100644 index a05ff991261e..000000000000 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MacSurfaceGalleryPreviewView.swift +++ /dev/null @@ -1,211 +0,0 @@ -#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 deleted file mode 100644 index a45184392a82..000000000000 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MarkdownSurfaceModel.swift +++ /dev/null @@ -1,126 +0,0 @@ -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 deleted file mode 100644 index 017bf197dfcc..000000000000 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MarkdownSurfaceView.swift +++ /dev/null @@ -1,217 +0,0 @@ -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 deleted file mode 100644 index 6beb1dec452d..000000000000 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileMacConnectionStatus+ArtifactHint.swift +++ /dev/null @@ -1,14 +0,0 @@ -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 deleted file mode 100644 index 54228b40fb56..000000000000 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileSurfacePreview+Presentation.swift +++ /dev/null @@ -1,108 +0,0 @@ -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 deleted file mode 100644 index 36b7ee94d789..000000000000 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileTodoStatus+Presentation.swift +++ /dev/null @@ -1,43 +0,0 @@ -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 deleted file mode 100644 index 898450187b0a..000000000000 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/PanelFileSurfaceView.swift +++ /dev/null @@ -1,49 +0,0 @@ -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 deleted file mode 100644 index e9e6477ff933..000000000000 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/SurfaceFallbackCardView.swift +++ /dev/null @@ -1,131 +0,0 @@ -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 df92bf3b82ba..e9ae12309eaa 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TerminalPickerMenu.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TerminalPickerMenu.swift @@ -42,34 +42,21 @@ struct TerminalPickerMenu: View, Equatable { @ViewBuilder private var menuContent: some View { Section(L10n.string("mobile.terminal.picker.title", defaultValue: "Terminals")) { - 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 ?? "")") + 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" + ) } + .accessibilityIdentifier("MobileTerminalMenuItem-\(terminal.id.rawValue)") } } diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TerminalPickerMenuActions.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TerminalPickerMenuActions.swift index cf24d05c69ba..b676694ff464 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TerminalPickerMenuActions.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TerminalPickerMenuActions.swift @@ -3,7 +3,6 @@ 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 3837b6ee56f2..4bb9ba0d97b2 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TerminalPickerMenuValue.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TerminalPickerMenuValue.swift @@ -4,7 +4,6 @@ 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 @@ -18,10 +17,8 @@ struct TerminalPickerMenuValue: Equatable { init( liveTerminals: [MobileTerminalPreview], - liveSurfaces: [MobileSurfacePreview] = [], snapshotRows: [TerminalPickerMenuRow], selectedID: MobileTerminalPreview.ID?, - selectedMacSurfaceID: MobileSurfacePreview.ID? = nil, canCreateWorkspace: Bool, hasActiveBrowser: Bool, isChatMode: Bool, @@ -32,17 +29,12 @@ struct TerminalPickerMenuValue: Equatable { supportsSimulatorStream: Bool = false, activeSimulatorStreamPanelID: String? = nil ) { - let resolvedRows = snapshotRows.isEmpty + rows = snapshotRows.isEmpty ? liveTerminals.map(TerminalPickerMenuRow.init) - + liveSurfaces.filter { !$0.kind.isTerminal }.map(TerminalPickerMenuRow.init) : snapshotRows - rows = resolvedRows - let selection = resolvedRows.resolvedTerminalPickerSelection(selectedID: selectedID) + let selection = rows.resolvedTerminalPickerSelection(selectedID: selectedID) self.selectedID = selection?.id - self.selectedMacSurfaceID = selectedMacSurfaceID - selectedName = selectedMacSurfaceID.flatMap { id in - resolvedRows.first(where: { $0.id == .macSurface(id) })?.name - } ?? selection?.name + selectedName = selection?.name self.canCreateWorkspace = canCreateWorkspace self.hasActiveBrowser = hasActiveBrowser self.isChatMode = isChatMode @@ -53,34 +45,4 @@ 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 deleted file mode 100644 index 0d979bc6ace0..000000000000 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TodoStatusMenu.swift +++ /dev/null @@ -1,82 +0,0 @@ -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 deleted file mode 100644 index 7235fa0baf9f..000000000000 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TodoSurfaceModel.swift +++ /dev/null @@ -1,147 +0,0 @@ -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 deleted file mode 100644 index 826d11453a6c..000000000000 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TodoSurfaceRowView.swift +++ /dev/null @@ -1,134 +0,0 @@ -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 deleted file mode 100644 index 115177a7ab19..000000000000 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TodoSurfaceView.swift +++ /dev/null @@ -1,182 +0,0 @@ -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/VerifiedTerminalReplayStateMachine.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/VerifiedTerminalReplayStateMachine.swift index dff8555baf91..5f737e738f99 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/VerifiedTerminalReplayStateMachine.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/VerifiedTerminalReplayStateMachine.swift @@ -50,8 +50,17 @@ final class VerifiedTerminalReplayStateMachine { guard phase != .recovering || frame.full else { return rejectFrame() } + // The viewport floor orders steady-state captures against a grid-size + // acknowledgement. A FULL frame arriving in recovery is the + // authoritative baseline this machine itself requested; the Mac may + // have claimed its capture identity concurrently with the + // acknowledgement that minted the floor, so refusing it here starves + // recovery forever (the render stays frozen until remount). Content + // safety is unaffected: ``complete(transactionID:observedFrame:)`` + // still verifies the observed grid before anything is revealed. if let floor = viewportRenderRevisionFloors[frame.renderEpoch], - frame.renderRevision <= floor { + frame.renderRevision <= floor, + !(phase == .recovering && frame.full) { return rejectFrame() } @@ -130,6 +139,22 @@ final class VerifiedTerminalReplayStateMachine { return .reveal } + /// Re-admits authoritative recovery after the delivery layer failed a + /// replay barrier open. + /// + /// Ordering hints accumulated for the abandoned barrier (viewport floors + /// and the in-flight transaction) describe replays that will never + /// arrive; keeping them starves every future baseline and freezes the + /// terminal until the surface is remounted. Recovery still requires a + /// full frame and ``complete(transactionID:observedFrame:)`` still + /// verifies the observed grid, so nothing unverified becomes visible. + func failOpen() { + guard phase != .invalidated else { return } + activeTransaction = nil + viewportRenderRevisionFloors.removeAll() + phase = .recovering + } + /// Invalidates any in-flight verification and returns an overlay token for /// output that verified transport refused before it could form a frame. func rejectUnverifiedOutput() -> UInt64 { diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceActiveSurface.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceActiveSurface.swift index 6666ad707db3..47e1b2923d3a 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceActiveSurface.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceActiveSurface.swift @@ -1,4 +1,3 @@ -import CmuxMobileShellModel import Foundation enum WorkspaceActiveSurface: Equatable { @@ -7,15 +6,13 @@ 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, - selectedMacSurface: MobileSurfacePreview? = nil + hasActiveSimulatorStream: Bool = false ) -> Self { if isChatMode, hasChosenChatSession { return .chat @@ -29,7 +26,6 @@ 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 070ae482fb51..2ae94e8e4cf0 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceDetailView+DerivedState.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceDetailView+DerivedState.swift @@ -9,9 +9,6 @@ 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 851fd68d6ba7..023076fa2cfa 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceDetailView+MenuState.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceDetailView+MenuState.swift @@ -1,40 +1,18 @@ import CmuxMobileShellModel struct TerminalPickerMenuRow: Identifiable, Equatable { - enum ID: Hashable { - case terminal(MobileTerminalPreview.ID) - case macSurface(MobileSurfacePreview.ID) - } - let id: ID + let id: MobileTerminalPreview.ID let name: String - let surfaceKind: MobileSurfacePreview.Kind init(_ terminal: MobileTerminalPreview) { - id = .terminal(terminal.id) + id = 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: [TerminalPickerMenuRow.ID] + let ids: [MobileTerminalPreview.ID] init(_ rows: [TerminalPickerMenuRow]) { ids = rows.map(\.id) @@ -46,19 +24,17 @@ extension Collection where Element == TerminalPickerMenuRow { selectedID: MobileTerminalPreview.ID? ) -> (id: MobileTerminalPreview.ID, name: String)? { if let selectedID, - let selected = first(where: { $0.id == .terminal(selectedID) }) { - return (id: selectedID, name: selected.name) + let selected = first(where: { $0.id == selectedID }) { + return (id: selected.id, name: selected.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) + guard let first else { return nil } + return (id: first.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 deleted file mode 100644 index 3d3320a04adf..000000000000 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceDetailView+PanelArtifacts.swift +++ /dev/null @@ -1,48 +0,0 @@ -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 b4be98fc61e0..a2011aaa2fb3 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceDetailView+Surfaces.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceDetailView+Surfaces.swift @@ -2,7 +2,6 @@ import CMUXMobileCore import CmuxMobileBrowser import CmuxMobileBrowserStream import CmuxMobileShell -import CmuxMobileShellModel import CmuxMobileTerminal import SwiftUI @@ -38,14 +37,6 @@ 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) { @@ -76,62 +67,6 @@ 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 28c1b2ee890b..2a0bae2e015a 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceDetailView.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceDetailView.swift @@ -170,8 +170,7 @@ struct WorkspaceDetailView: View { hasChosenChatSession: chosenChatSession != nil, hasActiveBrowser: activeBrowser != nil, hasActiveBrowserStream: activeBrowserStream != nil, - hasActiveSimulatorStream: activeSimulatorStream != nil, - selectedMacSurface: workspace.selectedMacSurface(id: store.selectedMacSurfaceID) + hasActiveSimulatorStream: activeSimulatorStream != nil ) } #endif @@ -688,10 +687,8 @@ 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, @@ -704,7 +701,6 @@ struct WorkspaceDetailView: View { ), actions: TerminalPickerMenuActions( selectTerminal: selectTerminalFromPicker, - selectMacSurface: selectMacSurfaceFromPicker, createWorkspace: createWorkspaceFromToolbar, createTerminal: createTerminalFromToolbar, openBrowser: openBrowserFromToolbar, @@ -957,7 +953,6 @@ struct WorkspaceDetailView: View { browserStore.closeBrowser(for: workspace.id.rawValue) stopActiveBrowserStream() stopActiveSimulatorStream() - store.selectedMacSurfaceID = nil createTerminal() } @@ -996,7 +991,6 @@ struct WorkspaceDetailView: View { store.recordAppEvent(.browserCreateSucceeded, correlationID: workspaceID) stopActiveBrowserStream() stopActiveSimulatorStream() - store.selectedMacSurfaceID = nil } private func selectBrowserStreamFromToolbar(_ panelID: String, dismissKeyboard: Bool = true) { @@ -1006,7 +1000,6 @@ 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) } } @@ -1018,7 +1011,6 @@ 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 @@ -1065,7 +1057,6 @@ 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 @@ -1074,18 +1065,6 @@ 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/MobileInjectedAttachStartupTests.swift b/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/MobileInjectedAttachStartupTests.swift index b02e22644cbb..ef2a56b3cef4 100644 --- a/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/MobileInjectedAttachStartupTests.swift +++ b/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/MobileInjectedAttachStartupTests.swift @@ -79,7 +79,10 @@ struct MobileInjectedAttachStartupTests { of: MobilePairingURLConnectionResult.self ) - #expect(coordinator.startInjectedAttach( + // Bound outside #expect: the macro's expression capture requires + // @Sendable arguments, which these intentionally MainActor-bound + // closures are not. + let startedInitialAttach = coordinator.startInjectedAttach( attachURL: attachURL, prepare: {}, connect: { rawURL in @@ -91,14 +94,15 @@ struct MobileInjectedAttachStartupTests { onCompletion: { completion in connectionFinished.continuation.yield(completion.result) } - )) + ) + #expect(startedInitialAttach) for await _ in connectionStarted.stream.prefix(1) {} // A reconstructed root asks startup to run again. The app-lifetime // coordinator must retain the original task and consume this duplicate // request without starting a replacement connection. - #expect(coordinator.startInjectedAttach( + let consumedDuplicateAttach = coordinator.startInjectedAttach( attachURL: attachURL, prepare: {}, connect: { rawURL in @@ -108,7 +112,8 @@ struct MobileInjectedAttachStartupTests { onCompletion: { completion in connectionFinished.continuation.yield(completion.result) } - )) + ) + #expect(consumedDuplicateAttach) allowConnectionToFinish.continuation.yield() var results: [MobilePairingURLConnectionResult] = [] diff --git a/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/TerminalPickerMenuValueTests.swift b/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/TerminalPickerMenuValueTests.swift index 851b247fe79f..518085bed429 100644 --- a/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/TerminalPickerMenuValueTests.swift +++ b/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/TerminalPickerMenuValueTests.swift @@ -75,91 +75,6 @@ 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 deleted file mode 100644 index a1a05d55d009..000000000000 --- a/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/TodoSurfaceModelTests.swift +++ /dev/null @@ -1,80 +0,0 @@ -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/VerifiedTerminalReplayStateMachineTests.swift b/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/VerifiedTerminalReplayStateMachineTests.swift index 57ce19300ece..72a49567935e 100644 --- a/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/VerifiedTerminalReplayStateMachineTests.swift +++ b/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/VerifiedTerminalReplayStateMachineTests.swift @@ -190,6 +190,113 @@ struct VerifiedTerminalReplayStateMachineTests { #expect(machine.visibleSnapshot == nil) } + @Test("recovery admits a full baseline at the acknowledged floor revision") + func recoveryAdmitsFullBaselineAtViewportFloor() throws { + let machine = VerifiedTerminalReplayStateMachine() + let steady = try frame( + renderEpoch: "epoch-recovery-floor", + renderRevision: 40, + stateSeq: 9, + columns: 72, + text: "steady state" + ) + commit(steady, to: machine) + + // Transport refused output (for example during a fast scroll storm), + // so the machine waits in recovery for an authoritative full baseline. + _ = machine.rejectUnverifiedOutput() + + // The phone re-reports an unchanged viewport while requesting that + // baseline. The Mac acknowledges with the same revision its concurrent + // replay response claimed, so the floor lands exactly on the recovery + // baseline's revision. + machine.acknowledgeViewport( + renderEpoch: "epoch-recovery-floor", + renderRevisionFloor: 41 + ) + + let recoveryBaseline = try frame( + renderEpoch: "epoch-recovery-floor", + renderRevision: 41, + stateSeq: 10, + columns: 72, + text: "authoritative recovery" + ) + guard case .apply(let transaction) = machine.begin(frame: recoveryBaseline) else { + Issue.record( + "a full recovery baseline must not be starved by the viewport floor" + ) + return + } + #expect( + machine.complete(transactionID: transaction.id, observedFrame: recoveryBaseline) + == .reveal + ) + #expect(machine.visibleSnapshot?.rows.first?.first?.text == "authoritative recovery") + #expect(!machine.isFrozen) + } + + @Test("fail-open drops stale ordering hints but still requires a full baseline") + func failOpenReadmitsNextFullBaseline() throws { + let machine = VerifiedTerminalReplayStateMachine() + let steady = try frame( + renderEpoch: "epoch-fail-open", + renderRevision: 40, + stateSeq: 9, + columns: 72, + text: "steady state" + ) + commit(steady, to: machine) + + // A replay is admitted but its completion never arrives; the delivery + // layer exhausts its retries and fails the barrier open. Poisoned + // floors recorded meanwhile must not outlive the abandoned barrier. + let abandoned = try frame( + renderEpoch: "epoch-fail-open", + renderRevision: 41, + stateSeq: 10, + columns: 72, + text: "abandoned replay" + ) + guard case .apply = machine.begin(frame: abandoned) else { + Issue.record("expected the abandoned replay to be admitted") + return + } + machine.acknowledgeViewport( + renderEpoch: "epoch-fail-open", + renderRevisionFloor: 45 + ) + machine.failOpen() + + let delta = try frame( + renderEpoch: "epoch-fail-open", + renderRevision: 46, + stateSeq: 11, + columns: 72, + text: "delta after fail-open", + full: false + ) + guard case .keepFrozenAndRequestReplay = machine.begin(frame: delta) else { + Issue.record("fail-open must still fail closed for deltas") + return + } + + let baseline = try frame( + renderEpoch: "epoch-fail-open", + renderRevision: 42, + stateSeq: 12, + columns: 72, + text: "post fail-open baseline" + ) + let transaction = try #require(extractTransaction(from: machine.begin(frame: baseline))) + #expect( + machine.complete(transactionID: transaction.id, observedFrame: baseline) + == .reveal + ) + #expect(machine.visibleSnapshot?.rows.first?.first?.text == "post fail-open baseline") + #expect(!machine.isFrozen) + } + @Test("a width change presents only the old or fully verified new grid") func widthChangeIsAtomic() throws { let machine = VerifiedTerminalReplayStateMachine() diff --git a/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/WorkspaceActiveSurfaceTests.swift b/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/WorkspaceActiveSurfaceTests.swift index 815f98b3521b..d53339eea2c6 100644 --- a/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/WorkspaceActiveSurfaceTests.swift +++ b/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/WorkspaceActiveSurfaceTests.swift @@ -1,5 +1,4 @@ import Testing -import CmuxMobileShellModel @testable import CmuxMobileShellUI @Suite struct WorkspaceActiveSurfaceTests { @@ -27,22 +26,6 @@ import CmuxMobileShellModel ) == .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, @@ -52,17 +35,6 @@ import CmuxMobileShellModel ) == .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, @@ -73,18 +45,6 @@ import CmuxMobileShellModel ) == .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 ae211959b93d..fd8b9bee471d 100644 --- a/Packages/iOS/CmuxMobileSupport/Sources/CmuxMobileSupport/UITestConfig.swift +++ b/Packages/iOS/CmuxMobileSupport/Sources/CmuxMobileSupport/UITestConfig.swift @@ -242,23 +242,6 @@ 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/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/GhosttySurfaceView+VerifiedReplay.swift b/Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/GhosttySurfaceView+VerifiedReplay.swift index 59161c4352d1..21c6717d7810 100644 --- a/Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/GhosttySurfaceView+VerifiedReplay.swift +++ b/Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/GhosttySurfaceView+VerifiedReplay.swift @@ -406,6 +406,22 @@ extension GhosttySurfaceView { CATransaction.commit() } + /// Discards the retained frozen presentation after the delivery layer + /// failed a replay barrier open. + /// + /// The replay that would have verified and revealed this transaction is + /// never coming; keeping the frozen pixels and render suppression would + /// freeze the terminal until the surface is remounted. Falling back to + /// the live renderer trades a possibly stale grid for continued + /// liveness, and the next verified baseline re-freezes normally. + public func failOpenVerifiedReplayPresentation() { + guard verifiedReplayFrozenPresentationLayer != nil + || verifiedReplayRenderSuppressed else { return } + clearVerifiedReplayPresentation() + needsDraw = true + drawForWakeup() + } + func clearVerifiedReplayPresentation() { CATransaction.begin() CATransaction.setDisableActions(true) diff --git a/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Wire/ControlCommandExecutionPolicy.swift b/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Wire/ControlCommandExecutionPolicy.swift index b9f3e46402ae..c6aeaae69bdb 100644 --- a/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Wire/ControlCommandExecutionPolicy.swift +++ b/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Wire/ControlCommandExecutionPolicy.swift @@ -104,13 +104,6 @@ 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 8f4c0de04eaa..4b094905fe65 100644 --- a/Packages/macOS/CmuxControlSocket/Tests/CmuxControlSocketTests/ControlCommandExecutionPolicyTests.swift +++ b/Packages/macOS/CmuxControlSocket/Tests/CmuxControlSocketTests/ControlCommandExecutionPolicyTests.swift @@ -35,8 +35,6 @@ 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", @@ -145,9 +143,6 @@ 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 671e8cdf2c4c..0e793712e8a3 100644 --- a/Resources/Localizable.xcstrings +++ b/Resources/Localizable.xcstrings @@ -137803,91 +137803,6 @@ } } }, - "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 72035d1ef95d..954a17476243 100644 --- a/Sources/Mobile/MobileHostService+Capabilities.swift +++ b/Sources/Mobile/MobileHostService+Capabilities.swift @@ -190,11 +190,7 @@ 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 375cef515e7f..671de5855650 100644 --- a/Sources/Mobile/MobileHostService+TicketAuthorization.swift +++ b/Sources/Mobile/MobileHostService+TicketAuthorization.swift @@ -86,12 +86,7 @@ extension MobileHostService { authorization: authorization, workspaceSelection: workspaceSelection.value ) - 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": + case "workspace.action", "workspace.close": 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 cff62c33ed6d..cdf9780b751e 100644 --- a/Sources/Mobile/MobileStateSync.swift +++ b/Sources/Mobile/MobileStateSync.swift @@ -238,7 +238,6 @@ 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 8c34aa81cf2a..5b0fe9ce4895 100644 --- a/Sources/Mobile/MobileWorkspaceListObserver.swift +++ b/Sources/Mobile/MobileWorkspaceListObserver.swift @@ -345,7 +345,6 @@ 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 () } @@ -520,7 +519,6 @@ 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 ecf410db02cc..949705ab227f 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, CaseIterable, Sendable { +public enum PanelType: String, Codable, Sendable { case terminal case browser case markdown diff --git a/Sources/TerminalController+MobileSurfaces.swift b/Sources/TerminalController+MobileSurfaces.swift deleted file mode 100644 index 468afc1e72e9..000000000000 --- a/Sources/TerminalController+MobileSurfaces.swift +++ /dev/null @@ -1,445 +0,0 @@ -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 deleted file mode 100644 index d30de807d80b..000000000000 --- a/Sources/TerminalController+MobileTodos.swift +++ /dev/null @@ -1,48 +0,0 @@ -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 762867f2f340..4945bf6331de 100644 --- a/Sources/TerminalController+MobileWorkspaceList.swift +++ b/Sources/TerminalController+MobileWorkspaceList.swift @@ -249,18 +249,6 @@ 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) @@ -300,7 +288,6 @@ 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/TerminalController.swift b/Sources/TerminalController.swift index 571fbfd747eb..78de225d1eae 100644 --- a/Sources/TerminalController.swift +++ b/Sources/TerminalController.swift @@ -15042,6 +15042,23 @@ class TerminalController { return .err(code: "not_found", message: "Terminal surface not found", data: nil) } + // Mint the acknowledgement floor BEFORE applying the report: the + // floor must exclude only captures taken before this acknowledgement + // could change the effective grid. Reading it after the apply races + // the replay response minted for this same recovery cycle, and the + // phone then refuses the exact baseline it asked for. + let preAckRenderIdentity = MobileTerminalByteTee.shared.currentRenderCaptureIdentity( + surfaceID: surfaceId + ) + func ackEffectiveGrid() -> (columns: Int, rows: Int)? { + guard let surface = terminalPanel.surface.liveSurfaceForGhosttyAccess( + reason: "mobileTerminalViewport.ackGrid" + ) else { return nil } + let size = ghostty_surface_size(surface) + return (columns: max(Int(size.columns), 1), rows: max(Int(size.rows), 1)) + } + let preAckGrid = ackEffectiveGrid() + let reportedGrid: (columns: Int, rows: Int)? let allowLiveSurfaceFallback: Bool if v2Bool(params, "clear") == true { @@ -15078,11 +15095,17 @@ class TerminalController { payload["columns"] = max(Int(size.columns), 1) payload["rows"] = max(Int(size.rows), 1) } - let renderFloor = MobileTerminalByteTee.shared.currentRenderCaptureIdentity( - surfaceID: surfaceId - ) - payload["render_epoch"] = renderFloor.epoch - payload["render_revision_floor"] = renderFloor.revision + // An acknowledgement that did not change the effective grid carries + // no floor: every prior capture still describes the correct grid, and + // flooring at the producer's current revision starves the + // concurrently minted recovery baseline (the phone refuses the exact + // frame it asked for and its render freezes until the workspace is + // re-entered). + let postAckGrid = ackEffectiveGrid() + if let preAckGrid, let postAckGrid, preAckGrid != postAckGrid { + payload["render_epoch"] = preAckRenderIdentity.epoch + payload["render_revision_floor"] = preAckRenderIdentity.revision + } return .ok(payload) } diff --git a/Sources/Workspace+PanelLifecycle.swift b/Sources/Workspace+PanelLifecycle.swift index 4e27b735abb5..fe00ff65404f 100644 --- a/Sources/Workspace+PanelLifecycle.swift +++ b/Sources/Workspace+PanelLifecycle.swift @@ -450,10 +450,7 @@ extension Workspace { discardBrowserPanelSubscription(panelId: panelId, panel: panel) removeBrowserOpenTabSuggestionIfNeeded(panel: panel, panelId: panelId) if cleanupControllerSurfaceState { - TerminalController.shared.cleanupSurfaceState( - surfaceIds: [panelId, tabId?.uuid].compactMap { $0 }, - workspaceID: id - ) + TerminalController.shared.cleanupSurfaceState(surfaceIds: [panelId, tabId?.uuid].compactMap { $0 }) } if !preservesTerminalForTransfer { terminalStartupRestoreCoordinator.discardPendingRestoreForPanelTeardown( diff --git a/Sources/Workspace+SurfaceNavigation.swift b/Sources/Workspace+SurfaceNavigation.swift index da8e76511662..14f8d06b79b4 100644 --- a/Sources/Workspace+SurfaceNavigation.swift +++ b/Sources/Workspace+SurfaceNavigation.swift @@ -177,12 +177,7 @@ extension Workspace { /// Surface-kind mapping used by workspace state snapshots. func surfaceKind(for panel: any Panel) -> String { - 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 { + switch panel.panelType { case .terminal: return SurfaceKind.terminal.rawValue case .browser: diff --git a/cmux.xcodeproj/project.pbxproj b/cmux.xcodeproj/project.pbxproj index 5c5901da7414..e1bd1e4574c4 100644 --- a/cmux.xcodeproj/project.pbxproj +++ b/cmux.xcodeproj/project.pbxproj @@ -1449,7 +1449,6 @@ 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 */; }; @@ -2320,11 +2319,9 @@ 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 */; }; @@ -4232,7 +4229,6 @@ 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 = ""; }; @@ -5089,11 +5085,9 @@ 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 = ""; }; @@ -6749,14 +6743,12 @@ 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 */, @@ -8208,7 +8200,6 @@ 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 */, @@ -10398,11 +10389,9 @@ 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 */, @@ -11338,7 +11327,6 @@ 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 bbc347bbf5e8..3e2e2e0df823 100644 --- a/cmuxTests/MobileHostConnectionLifecycleTests.swift +++ b/cmuxTests/MobileHostConnectionLifecycleTests.swift @@ -590,9 +590,6 @@ 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 fb0d03c6ecdb..c64630fb85b2 100644 --- a/cmuxTests/MobileHostWorkspaceTicketAuthorizationTests.swift +++ b/cmuxTests/MobileHostWorkspaceTicketAuthorizationTests.swift @@ -312,30 +312,6 @@ 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 deleted file mode 100644 index 605d9409686f..000000000000 --- a/cmuxTests/MobileSurfaceKindMappingTests.swift +++ /dev/null @@ -1,41 +0,0 @@ -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 ffd105a5203a..81fe9e9734eb 100644 --- a/cmuxTests/TerminalControllerSocketSecurityTests.swift +++ b/cmuxTests/TerminalControllerSocketSecurityTests.swift @@ -776,39 +776,6 @@ 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() @@ -976,9 +943,6 @@ 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 c1fdd4568c5f..78cc7f9f3059 100644 --- a/ios/cmux/Resources/Localizable.xcstrings +++ b/ios/cmux/Resources/Localizable.xcstrings @@ -19724,74 +19724,6 @@ }, "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から通知を転送"}}}}, diff --git a/ios/cmuxPackage/Tests/cmuxFeatureTests/TerminalInputScrollToBottomTests.swift b/ios/cmuxPackage/Tests/cmuxFeatureTests/TerminalInputScrollToBottomTests.swift index 49d774b041ea..99736784a0f1 100644 --- a/ios/cmuxPackage/Tests/cmuxFeatureTests/TerminalInputScrollToBottomTests.swift +++ b/ios/cmuxPackage/Tests/cmuxFeatureTests/TerminalInputScrollToBottomTests.swift @@ -83,7 +83,12 @@ struct TerminalInputScrollToBottomTests { #expect(await waitUntil { viewportText(view).contains(lastLineMarker) }, "seeded output should land with the viewport at the bottom") - view.applyLocalScrollbackScroll(lines: 120, col: 2, row: 2) + view.applyLocalScrollbackScroll( + lines: 120, + col: 2, + row: 2, + interactionGeneration: view.bumpUserViewportInteractionGeneration() + ) #expect(await waitUntil { !viewportText(view).contains(lastLineMarker) }, "scrolling up should move the last line out of the viewport") return lastLineMarker diff --git a/ios/cmuxUITests/cmuxUITests.swift b/ios/cmuxUITests/cmuxUITests.swift index 8422422ce1a8..b0dde422bf4d 100644 --- a/ios/cmuxUITests/cmuxUITests.swift +++ b/ios/cmuxUITests/cmuxUITests.swift @@ -1593,7 +1593,8 @@ final class cmuxUITests: XCTestCase { minimumCount: 2 ) XCTAssertTrue(didDisableCaffeine) - XCTAssertEqual(await server.caffeineSetValues(), [true, false]) + let caffeineSetValues = await server.caffeineSetValues() + XCTAssertEqual(caffeineSetValues, [true, false]) } @MainActor