Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down
Original file line number Diff line number Diff line change
@@ -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)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

@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)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ public struct MacSurfaceGalleryPreviewView: View {
TodoSurfaceView(
surface: Self.todoSurface,
todo: Self.todoSnapshot,
allowsMutations: true,
mutate: { _ in }
)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,16 +10,23 @@ 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

init(
surface: MobileSurfacePreview,
todo: MobileTodoSnapshot,
allowsMutations: Bool,
mutate: @escaping @MainActor (MobileTodoMutation) async throws -> Void
Comment thread
coderabbitai[bot] marked this conversation as resolved.
) {
self.surface = surface
self.allowsMutations = allowsMutations
_model = State(initialValue: TodoSurfaceModel(snapshot: todo, mutate: mutate))
}

Expand All @@ -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)) }
)
}
Expand All @@ -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)) },
Expand Down Expand Up @@ -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
}
Expand All @@ -177,6 +185,7 @@ struct TodoSurfaceView: View {
}

private func run(_ mutation: MobileTodoMutation) {
guard allowsMutations else { return }
Task { await model.perform(mutation) }
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -595,7 +595,7 @@ struct WorkspaceDetailView: View {
#endif
}

private func reconnectToWorkspaceMac() {
func reconnectToWorkspaceMac() {
Task {
await store.reconnectToMac(
macDeviceID: workspace.macDeviceID,
Expand Down Expand Up @@ -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,
Expand Down
12 changes: 12 additions & 0 deletions Sources/TerminalController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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":
Expand Down