diff --git a/Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MacSurfaceRenderer.swift b/Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MacSurfaceRenderer.swift index b7c64bec0fb6..089a54d420c2 100644 --- a/Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MacSurfaceRenderer.swift +++ b/Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MacSurfaceRenderer.swift @@ -19,7 +19,11 @@ public enum MacSurfaceRenderer: Equatable, Sendable { /// /// - Parameters: /// - surface: The synced surface snapshot. - /// - supportsTodo: Whether the owning Mac advertises `todo.v1`. + /// - supportsTodo: Whether the owning Mac advertises `todo.v1`. Unused + /// for rendering: the todo snapshot is synced data that stays valid + /// while the connection recovers, so the checklist keeps rendering + /// (like the terminal's last frame) and the capability only gates + /// mutations at the view layer. /// - supportsPanelArtifacts: Whether the connected Mac advertises /// `panel.artifact.v1` panel file reads. /// - Returns: The renderer to mount; `.fallbackCard` whenever a required @@ -31,7 +35,7 @@ public enum MacSurfaceRenderer: Equatable, Sendable { ) -> MacSurfaceRenderer { switch surface.kind { case .todo: - guard supportsTodo, let todo = surface.todo else { return .fallbackCard } + guard let todo = surface.todo else { return .fallbackCard } return .todo(todo) case .filePreview: guard supportsPanelArtifacts, let path = normalizedFilePath(surface) else { diff --git a/Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileWorkspacePreview.swift b/Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileWorkspacePreview.swift index c441acebc6bf..c2fc6e69ab20 100644 --- a/Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileWorkspacePreview.swift +++ b/Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileWorkspacePreview.swift @@ -180,13 +180,24 @@ public struct MobileWorkspacePreview: Identifiable, Equatable, Sendable { } extension MobileWorkspacePreview { - /// The picker-selected non-terminal Mac surface, if it still exists. + /// The non-terminal Mac surface to present, honoring the picker selection. /// /// 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. + /// + /// With no explicit selection (or a stale one whose surface no longer + /// exists) and no terminals to stream (for example a workspace whose only + /// pane is a todo panel), this falls back to the first non-terminal + /// surface: the detail view would otherwise render an empty terminal + /// background. public func selectedMacSurface(id: MobileSurfacePreview.ID?) -> MobileSurfacePreview? { - guard let id else { return nil } - return surfaces.first { $0.id == id && !$0.kind.isTerminal } + guard let id else { return defaultMacSurface } + return surfaces.first { $0.id == id && !$0.kind.isTerminal } ?? defaultMacSurface + } + + private var defaultMacSurface: MobileSurfacePreview? { + guard terminals.isEmpty else { return nil } + return surfaces.first { !$0.kind.isTerminal } } } diff --git a/Packages/iOS/CmuxMobileShellModel/Tests/CmuxMobileShellModelTests/MacSurfaceRendererTests.swift b/Packages/iOS/CmuxMobileShellModel/Tests/CmuxMobileShellModelTests/MacSurfaceRendererTests.swift index 33cf82d85c0a..127eee524a35 100644 --- a/Packages/iOS/CmuxMobileShellModel/Tests/CmuxMobileShellModelTests/MacSurfaceRendererTests.swift +++ b/Packages/iOS/CmuxMobileShellModel/Tests/CmuxMobileShellModelTests/MacSurfaceRendererTests.swift @@ -30,13 +30,15 @@ struct MacSurfaceRendererTests { #expect(renderer == .todo(todoSnapshot)) } - @Test func todoWithoutCapabilityFallsBackToCard() { + @Test func todoWithoutCapabilityStillRendersSnapshot() { + // The capability set empties while a connection recovers; the synced + // snapshot must keep rendering (mutations are gated at the view layer). let renderer = MacSurfaceRenderer.resolve( surface: surface(kind: .todo, todo: todoSnapshot), supportsTodo: false, supportsPanelArtifacts: true ) - #expect(renderer == .fallbackCard) + #expect(renderer == .todo(todoSnapshot)) } @Test func todoWithoutSnapshotFallsBackToCard() { diff --git a/Packages/iOS/CmuxMobileShellModel/Tests/CmuxMobileShellModelTests/MobileWorkspacePreviewDefaultSurfaceTests.swift b/Packages/iOS/CmuxMobileShellModel/Tests/CmuxMobileShellModelTests/MobileWorkspacePreviewDefaultSurfaceTests.swift new file mode 100644 index 000000000000..3bdd4da3b959 --- /dev/null +++ b/Packages/iOS/CmuxMobileShellModel/Tests/CmuxMobileShellModelTests/MobileWorkspacePreviewDefaultSurfaceTests.swift @@ -0,0 +1,83 @@ +import CMUXMobileCore +import Testing +@testable import CmuxMobileShellModel + +/// Covers the surface shown when the picker has no explicit Mac-surface +/// selection. A workspace whose panes are all non-terminal (for example a +/// lone todo panel) has no terminal to stream, so without a fallback the +/// detail view renders an empty terminal background. +struct MobileWorkspacePreviewDefaultSurfaceTests { + private func surface( + id: MobileSurfacePreview.ID, + kind: MobileSurfacePreview.Kind, + todo: MobileTodoSnapshot? = nil + ) -> MobileSurfacePreview { + MobileSurfacePreview(id: id, kind: kind, title: "Surface", todo: todo) + } + + private func workspace( + terminals: [MobileTerminalPreview], + surfaces: [MobileSurfacePreview] + ) -> MobileWorkspacePreview { + MobileWorkspacePreview( + id: "workspace-1", + name: "TODO - iOS", + terminals: terminals, + surfaces: surfaces + ) + } + + private var todoSurface: MobileSurfacePreview { + surface( + id: "surface-todo", + kind: .todo, + todo: MobileTodoSnapshot(status: .todo, statusHidden: false, items: []) + ) + } + + @Test func noTerminalsFallsBackToFirstNonTerminalSurface() { + let workspace = workspace(terminals: [], surfaces: [todoSurface]) + #expect(workspace.selectedMacSurface(id: nil) == todoSurface) + } + + @Test func fallbackSkipsTerminalKindedSurfaces() { + let terminalSurface = surface(id: "surface-term", kind: .terminal) + let workspace = workspace(terminals: [], surfaces: [terminalSurface, todoSurface]) + #expect(workspace.selectedMacSurface(id: nil) == todoSurface) + } + + @Test func fallbackPicksFirstNonTerminalSurfaceInSpatialOrder() { + let browserSurface = surface(id: "surface-browser", kind: .browser) + let workspace = workspace(terminals: [], surfaces: [browserSurface, todoSurface]) + #expect(workspace.selectedMacSurface(id: nil) == browserSurface) + } + + @Test func staleSelectionFallsBackWhenTerminalless() { + let workspace = workspace(terminals: [], surfaces: [todoSurface]) + #expect(workspace.selectedMacSurface(id: "surface-closed") == todoSurface) + } + + @Test func staleSelectionKeepsTerminalWhenTerminalsExist() { + let terminal = MobileTerminalPreview(id: "terminal-1", name: "zsh") + let workspace = workspace(terminals: [terminal], surfaces: [todoSurface]) + #expect(workspace.selectedMacSurface(id: "surface-closed") == nil) + } + + @Test func explicitSelectionStillWins() { + let browserSurface = surface(id: "surface-browser", kind: .browser) + let workspace = workspace(terminals: [], surfaces: [todoSurface, browserSurface]) + #expect(workspace.selectedMacSurface(id: browserSurface.id) == browserSurface) + } + + @Test func terminalsPresentKeepTerminalAsDefault() { + let terminal = MobileTerminalPreview(id: "terminal-1", name: "zsh") + let workspace = workspace(terminals: [terminal], surfaces: [todoSurface]) + #expect(workspace.selectedMacSurface(id: nil) == nil) + } + + @Test func noNonTerminalSurfacesReturnsNil() { + let terminalSurface = surface(id: "surface-term", kind: .terminal) + let workspace = workspace(terminals: [], surfaces: [terminalSurface]) + #expect(workspace.selectedMacSurface(id: nil) == nil) + } +} diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MacSurfaceGalleryPreviewView.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MacSurfaceGalleryPreviewView.swift index 7406a84fa8ce..e80050c746a7 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MacSurfaceGalleryPreviewView.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MacSurfaceGalleryPreviewView.swift @@ -52,6 +52,7 @@ public struct MacSurfaceGalleryPreviewView: View { TodoSurfaceView( surface: Self.todoSurface, todo: Self.todoSnapshot, + allowsMutations: true, mutate: { _ in } ) } diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TodoSurfaceView.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TodoSurfaceView.swift index 115177a7ab19..f422f3c9db57 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TodoSurfaceView.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TodoSurfaceView.swift @@ -10,6 +10,11 @@ import UIKit /// Native iOS renderer for a Mac workspace todo surface. struct TodoSurfaceView: View { let surface: MobileSurfacePreview + /// False while the owning Mac can't take todo mutations (reconnecting, or + /// a Mac without `todo.v1`): the last synced checklist stays readable and + /// every mutating control is disabled, mirroring the terminal's blocked + /// input during recovery. + let allowsMutations: Bool @State private var model: TodoSurfaceModel @State private var pendingItemText = "" @FocusState private var composerFocused: Bool @@ -17,9 +22,11 @@ struct TodoSurfaceView: View { init( surface: MobileSurfacePreview, todo: MobileTodoSnapshot, + allowsMutations: Bool, mutate: @escaping @MainActor (MobileTodoMutation) async throws -> Void ) { self.surface = surface + self.allowsMutations = allowsMutations _model = State(initialValue: TodoSurfaceModel(snapshot: todo, mutate: mutate)) } @@ -34,7 +41,7 @@ struct TodoSurfaceView: View { TodoStatusMenu( status: snapshot.status, statusHidden: snapshot.statusHidden, - isEnabled: !model.isMutationPending, + isEnabled: allowsMutations && !model.isMutationPending, setStatus: { run(.setStatus($0)) } ) } @@ -60,7 +67,7 @@ struct TodoSurfaceView: View { TodoSurfaceRowView( item: item, displayIndex: index, - isEnabled: !model.isMutationPending, + isEnabled: allowsMutations && !model.isMutationPending, actions: TodoSurfaceRowActions( cycleState: { run(.setState(itemID: item.id, state: item.state.next)) }, edit: { run(.edit(itemID: item.id, text: $0)) }, @@ -161,7 +168,8 @@ struct TodoSurfaceView: View { } private var canAddPendingItem: Bool { - !model.isMutationPending + allowsMutations + && !model.isMutationPending && model.snapshot.items.count < MobileTodoSnapshot.maxItems && !pendingItemText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty } @@ -177,6 +185,7 @@ struct TodoSurfaceView: View { } private func run(_ mutation: MobileTodoMutation) { + guard allowsMutations else { return } Task { await model.perform(mutation) } } } diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceDetailView+Surfaces.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceDetailView+Surfaces.swift index b4be98fc61e0..0dd7f4c6868c 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceDetailView+Surfaces.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceDetailView+Surfaces.swift @@ -46,6 +46,21 @@ extension WorkspaceDetailView { // not the device appearance, or rows flash white over a // dark theme (and vice versa). .environment(\.colorScheme, store.activeTerminalTheme.terminalColorScheme) + // Same recovery chrome as the terminal: the last synced + // surface stays visible underneath while the pill shows + // reconnect progress (it renders nothing when connected). + .overlay(alignment: .topLeading) { + MobileMacConnectionStatusPill( + host: host, + status: effectiveConnectionStatus, + reconnect: Self.reconnectAction( + connectionRequiresReauth: store.connectionRequiresReauth, + reconnect: { reconnectToWorkspaceMac() } + ) + ) + .padding(.top, 10) + .padding(.leading, 10) + } } } .safeAreaInset(edge: .top, spacing: 0) { @@ -93,9 +108,13 @@ extension WorkspaceDetailView { let canOpenOnMac = store.supportsSurfaceFocus(in: workspace.id) switch renderer { case .todo(let todo): + // The capability set empties while the connection recovers, so it + // doubles as the "Mac can take mutations right now" signal; the + // snapshot itself stays rendered either way. TodoSurfaceView( surface: macSurface, - todo: todo + todo: todo, + allowsMutations: store.supportsTodo(in: workspace.id) ) { mutation in try await store.performTodoMutation(mutation, workspaceID: workspace.id) } diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceDetailView.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceDetailView.swift index 86e508222297..c18b76afaf71 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceDetailView.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceDetailView.swift @@ -595,7 +595,7 @@ struct WorkspaceDetailView: View { #endif } - private func reconnectToWorkspaceMac() { + func reconnectToWorkspaceMac() { Task { await store.reconnectToMac( macDeviceID: workspace.macDeviceID, @@ -784,7 +784,10 @@ struct WorkspaceDetailView: View { liveSurfaces: workspace.surfaces, snapshotRows: terminalPickerRows, selectedID: store.selectedTerminalID, - selectedMacSurfaceID: store.selectedMacSurfaceID, + // Resolved through the workspace so the auto-presented + // fallback surface (no terminals, no explicit selection) + // carries the picker checkmark like any picked surface. + selectedMacSurfaceID: workspace.selectedMacSurface(id: store.selectedMacSurfaceID)?.id, canCreateWorkspace: canCreateWorkspace, hasActiveBrowser: activeBrowser != nil, isChatMode: isChatMode, diff --git a/Sources/TerminalController.swift b/Sources/TerminalController.swift index 2f7d62354b8c..c7edd6e6176a 100644 --- a/Sources/TerminalController.swift +++ b/Sources/TerminalController.swift @@ -14511,6 +14511,18 @@ class TerminalController { params: request.params, connectionID: executionContext?.connectionID ) + case let method where method.hasPrefix("mobile.todo."): + result = v2MobileTodoDispatch(method: method, params: request.params) + case "mobile.status.set", "mobile.status.cycle": + result = v2MobileTodoDispatch(method: request.method, params: request.params) + case "mobile.surface.focus": + result = v2MobileSurfaceFocus(params: request.params) + case let method where method.hasPrefix("mobile.panel.artifact."): + result = await v2MobilePanelArtifactDispatch( + method: method, + params: request.params, + executionContext: executionContext + ) case "workspace.close": result = v2MobileWorkspaceClose(params: request.params) case "workspace.group.collapse":