Skip to content
Closed
Show file tree
Hide file tree
Changes from 2 commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
24a3c08
Improve Simulator discoverability
azooz2003-bit Aug 10, 2026
445870a
Merge remote-tracking branch 'origin/main' into task-improve-simulato…
azooz2003-bit Aug 10, 2026
901915e
Fix Simulator button accessibility label
azooz2003-bit Aug 10, 2026
290955b
Address simulator discoverability review findings
azooz2003-bit Aug 10, 2026
2cec093
Fix simulator picker getter return
azooz2003-bit Aug 10, 2026
7a84690
Unify simulator stream state ownership
azooz2003-bit Aug 10, 2026
9e77288
Stabilize simulator stream environment lifetime
azooz2003-bit Aug 10, 2026
0efe264
Own mobile terminal runtime before SwiftUI mounting
azooz2003-bit Aug 10, 2026
db6f334
Expose borrowed mobile session view
azooz2003-bit Aug 10, 2026
62fc0aa
Allow process-owned terminal runtime retry
azooz2003-bit Aug 10, 2026
4968ed8
Import terminal theme in renderer recovery view
azooz2003-bit Aug 10, 2026
282d6aa
Load iOS Ghostty config from owned path
azooz2003-bit Aug 10, 2026
e356c7f
Resolve renderer recovery strings from package
azooz2003-bit Aug 10, 2026
9b65abd
Add renderer recovery preview fixture
azooz2003-bit Aug 10, 2026
24c437f
Stabilize Simulator discoverability UI tests
azooz2003-bit Aug 10, 2026
7144595
Instrument Simulator selection lifecycle
azooz2003-bit Aug 10, 2026
ba45abe
Expose Simulator lifecycle diagnostic timeline
azooz2003-bit Aug 10, 2026
c2fc815
Fix Simulator discoverability UI assertions
azooz2003-bit Aug 10, 2026
3a60ad2
Avoid quadratic simulator picker snapshots
azooz2003-bit Aug 10, 2026
a4fb70f
Test macOS tab bar omits Simulator button
azooz2003-bit Aug 10, 2026
48c2c7c
Remove Simulator from macOS tab defaults
azooz2003-bit Aug 10, 2026
3d83481
Test crowded tab bar keeps default actions visible
azooz2003-bit Aug 11, 2026
f7097c9
Remove unrelated crowded tab bar test
azooz2003-bit Aug 11, 2026
ee2df25
Test Simulator toolbar returns from active panel
azooz2003-bit Aug 11, 2026
08cc2f7
Add Simulator tab navigation on iOS
azooz2003-bit Aug 11, 2026
9eedc37
Merge remote-tracking branch 'origin/main' into task-improve-simulato…
azooz2003-bit Aug 11, 2026
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
@@ -0,0 +1,8 @@
#if DEBUG
extension MobileShellComposite {
/// Enables the Simulator-stream capability for an isolated debug preview instance.
public func enableSimulatorStreamPreviewCapability() {
supportedHostCapabilities.insert(Self.simulatorStreamCapability)
}
}
#endif
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
import CMUXMobileCore
import CmuxMobileBrowser
import CmuxMobileBrowserStream
import CmuxMobileShell
import CmuxMobileShellModel
import SwiftUI

#if os(iOS) && DEBUG
/// Deterministic real-workspace fixture for Simulator chrome and stream-state verification.
public struct SimulatorDiscoverabilityPreviewView: View {
@State private var store: MobileShellComposite
@State private var browserStore = BrowserSurfaceStore()
@State private var browserStreamStore = BrowserStreamStore()
@State private var simulatorStreamStore: MobileSimulatorStreamStore

/// Creates a fixture with zero, one, two, or unsupported Simulator panels.
public init(mode: String, state: String) {
let workspaceID = "simulator-discoverability-workspace"
let descriptors = Self.descriptors(mode: mode, state: state, workspaceID: workspaceID)
let streamStore = MobileSimulatorStreamStore()
streamStore.replaceSimulatorPanels(in: workspaceID, with: descriptors)
streamStore.setSimulatorStreamConnectionStatus(
state == "disconnected" ? .disconnected : .connected
)

if state != "inactive", let first = descriptors.first {
_ = streamStore.activate(panelID: first.panelID, in: workspaceID)
if state == "locked" {
streamStore.state(for: first.panelID)?.markLockedByOtherConnection()
} else if state == "stalled" {
streamStore.state(for: first.panelID)?.markStreamStale()
}
}

let terminalID = MobileTerminalPreview.ID(rawValue: "simulator-fixture-terminal")
let workspace = MobileWorkspacePreview(
id: MobileWorkspacePreview.ID(rawValue: workspaceID),
name: "Simulator Fixture",
terminals: [MobileTerminalPreview(id: terminalID, name: "Terminal")],
simulators: descriptors
)
let shellStore = MobileShellComposite(
isSignedIn: true,
connectionState: .connected,
connectedHostName: "UI Test Mac",
workspaces: [workspace],
simulatorStreamStore: streamStore
)
if mode != "unsupported" {
shellStore.enableSimulatorStreamPreviewCapability()
}
shellStore.selectedWorkspaceID = workspace.id
shellStore.selectedTerminalID = terminalID

_store = State(initialValue: shellStore)
_simulatorStreamStore = State(initialValue: streamStore)
}

public var body: some View {
WorkspaceShellView(store: store, signOut: {}, showAddDevice: nil)
.environment(browserStore)
.environment(browserStreamStore)
.environment(simulatorStreamStore)
}

private static func descriptors(
mode: String,
state: String,
workspaceID: String
) -> [MobileSimulatorPanelDescriptor] {
let count: Int
switch mode {
case "one": count = 1
case "two": count = 2
case "unsupported": count = 1
default: count = 0
}
return (0..<count).map { index in
let suffix = index == 0 ? "A" : "B"
return MobileSimulatorPanelDescriptor(
panelID: "sim-\(suffix.lowercased())",
workspaceID: workspaceID,
title: "Simulator \(suffix)",
selectedDeviceName: "iPhone \(suffix)",
selectedDeviceState: "Booted",
status: "streaming",
isReady: true,
supportsTouch: true,
supportsKeyboard: true,
supportsHardwareButtons: true,
supportsRotation: true,
ownerConnectionID: state == "locked" ? "other-phone" : nil,
isOwnedByCurrentConnection: state == "locked" ? false : nil
)
}
}
}
#endif
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import CMUXMobileCore
import CmuxMobileSupport
import SwiftUI

/// One-tap workspace-chrome control for discovering an interactive Mac Simulator panel.
struct SimulatorPickerControl: View {
let value: SimulatorPickerMenuValue
let selectPanel: (String) -> Void
let terminalTheme: TerminalTheme

var body: some View {
Button {
if let targetPanelID = value.targetPanelID {
selectPanel(targetPanelID)
}
} label: {
Label(
L10n.string("mobile.simulatorStream.menuTitle", defaultValue: "Mac Simulators"),
systemImage: value.activePanelID == nil ? "iphone" : "iphone.circle.fill"
)
.labelStyle(.iconOnly)
}
.frame(minWidth: 44, minHeight: 44)
.contentShape(Rectangle())
.foregroundStyle(terminalTheme.terminalChromeForegroundColor)
.accessibilityLabel(
L10n.string("mobile.simulatorStream.menuTitle", defaultValue: "Mac Simulators")
)
.accessibilityIdentifier("MobileSimulatorPicker")
.accessibilityValue(
value.rows.first(where: { $0.id == value.activePanelID })?.label ?? ""
)
.disabled(value.targetPanelID == nil)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
/// Immutable state that controls whether the workspace chrome presents Simulator panels.
struct SimulatorPickerMenuValue: Equatable {
let rows: [SimulatorStreamPickerRow]
let activePanelID: String?

init(
supportsSimulatorStream: Bool,
rows: [SimulatorStreamPickerRow],
activePanelID: String?
) {
self.rows = supportsSimulatorStream ? rows : []
self.activePanelID = rows.contains(where: { $0.id == activePanelID })
? activePanelID
: nil
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
}

var isVisible: Bool {
!rows.isEmpty
}

/// The current panel wins; otherwise the first authoritative workspace row is deterministic.
var targetPanelID: String? {
activePanelID ?? rows.first?.id
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -116,7 +116,15 @@ extension WorkspaceDetailView {

@ViewBuilder
var toolbarTrailingCluster: some View {
let simulatorPicker = simulatorPickerValue
HStack(spacing: 8) {
if simulatorPicker.isVisible {
SimulatorPickerControl(
value: simulatorPicker,
selectPanel: selectSimulatorStreamFromToolbar,
terminalTheme: store.activeTerminalTheme
)
}
if shouldShowChatToggle {
chatToggleButton
.frame(width: 44, height: 44)
Expand All @@ -125,8 +133,15 @@ extension WorkspaceDetailView {
terminalPickerToolbarButton
.frame(width: 44, height: 44)
}
.frame(width: shouldShowChatToggle ? 96 : 44, height: 44, alignment: .trailing)
.frame(
width: 44
+ (shouldShowChatToggle ? 52 : 0)
+ (simulatorPicker.isVisible ? 52 : 0),
height: 44,
alignment: .trailing
)
.animation(.snappy(duration: 0.25), value: shouldShowChatToggle)
.animation(.snappy(duration: 0.25), value: simulatorPicker.isVisible)
}

var chatToggleButton: some View {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -654,6 +654,15 @@ struct WorkspaceDetailView: View {
.onChange(of: terminalPickerLiveMembership) { _, _ in syncTerminalPickerRows() }
}

var simulatorPickerValue: SimulatorPickerMenuValue {
SimulatorPickerMenuValue(
supportsSimulatorStream: store.supportsSimulatorStream,
rows: simulatorStreamStore.panels(in: workspace.rpcWorkspaceID.rawValue)
.map(SimulatorStreamPickerRow.init),
activePanelID: activeSimulatorStream?.id
)
}

#if canImport(UIKit)
#if DEBUG
private func copyDebugLogsFromMenu() {
Expand Down Expand Up @@ -929,7 +938,7 @@ struct WorkspaceDetailView: View {
Task { await store.startMobileBrowserStream(panelID: panelID) }
}

private func selectSimulatorStreamFromToolbar(_ panelID: String) {
func selectSimulatorStreamFromToolbar(_ panelID: String) {
dismissTerminalKeyboardForChrome()
browserStore.closeBrowser(for: workspace.id.rawValue)
stopActiveBrowserStream()
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import CMUXMobileCore
import Testing
@testable import CmuxMobileShellUI

@Suite struct SimulatorPickerMenuValueTests {
@Test func unsupportedOrEmptyWorkspacesHideTheControl() {
let row = SimulatorStreamPickerRow(descriptor(id: "sim-a"))

let unsupported = SimulatorPickerMenuValue(
supportsSimulatorStream: false,
rows: [row],
activePanelID: nil
)
let empty = SimulatorPickerMenuValue(
supportsSimulatorStream: true,
rows: [],
activePanelID: nil
)

#expect(!unsupported.isVisible)
#expect(unsupported.targetPanelID == nil)
#expect(!empty.isVisible)
#expect(empty.targetPanelID == nil)
}

@Test func onePanelIsTheOneTapTarget() {
let value = SimulatorPickerMenuValue(
supportsSimulatorStream: true,
rows: [SimulatorStreamPickerRow(descriptor(id: "sim-a"))],
activePanelID: nil
)

#expect(value.isVisible)
#expect(value.targetPanelID == "sim-a")
}

@Test func multiplePanelsPreferTheActivePanelOrDeterministicFirstFallback() {
let rows = [
SimulatorStreamPickerRow(descriptor(id: "sim-a")),
SimulatorStreamPickerRow(descriptor(id: "sim-b")),
]
let active = SimulatorPickerMenuValue(
supportsSimulatorStream: true,
rows: rows,
activePanelID: "sim-b"
)
let stale = SimulatorPickerMenuValue(
supportsSimulatorStream: true,
rows: rows,
activePanelID: "removed"
)

#expect(active.activePanelID == "sim-b")
#expect(active.targetPanelID == "sim-b")
#expect(stale.activePanelID == nil)
#expect(stale.targetPanelID == "sim-a")
}

private func descriptor(id: String) -> MobileSimulatorPanelDescriptor {
MobileSimulatorPanelDescriptor(
panelID: id,
workspaceID: "workspace-1",
title: "Simulator \(id)",
selectedDeviceName: "iPhone \(id)",
selectedDeviceState: "Booted",
status: "streaming",
isReady: true,
supportsTouch: true,
supportsKeyboard: true,
supportsHardwareButtons: true,
supportsRotation: true
)
}
}
2 changes: 2 additions & 0 deletions Sources/CmuxConfig.swift
Original file line number Diff line number Diff line change
Expand Up @@ -944,6 +944,7 @@ struct CmuxSurfaceTabBarButton: Codable, Sendable, Hashable, Identifiable {
static let defaults: [CmuxSurfaceTabBarButton] = [
.newTerminal,
.newBrowser,
.newSimulator,
.splitRight,
.splitDown
]
Expand Down Expand Up @@ -2067,6 +2068,7 @@ final class CmuxConfigStore: ObservableObject {
}) ?? [
.builtIn(.newTerminal),
.builtIn(.newBrowser),
.builtIn(.newSimulator),
.builtIn(.splitRight),
.builtIn(.splitDown)
]
Expand Down
53 changes: 53 additions & 0 deletions cmuxTests/CmuxConfigTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -723,6 +723,59 @@ final class CmuxConfigDecodingTests: XCTestCase {
cancellable?.cancel()
}

@MainActor
func testMissingSurfaceTabBarConfigurationUsesDiscoverableSimulatorDefault() throws {
let root = FileManager.default.temporaryDirectory.appendingPathComponent(
"cmux-config-store-\(UUID().uuidString)",
isDirectory: true
)
try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true)
defer { try? FileManager.default.removeItem(at: root) }

let store = CmuxConfigStore(
globalConfigPath: root.appendingPathComponent("missing-global.json").path,
localConfigPath: root.appendingPathComponent("missing-local.json").path,
startFileWatchers: false
)
store.loadAll()

XCTAssertEqual(
store.surfaceTabBarButtons.map(\.id),
[
CmuxSurfaceTabBarBuiltInAction.newTerminal.configID,
CmuxSurfaceTabBarBuiltInAction.newBrowser.configID,
CmuxSurfaceTabBarBuiltInAction.newSimulator.configID,
CmuxSurfaceTabBarBuiltInAction.splitRight.configID,
CmuxSurfaceTabBarBuiltInAction.splitDown.configID,
]
)
}

@MainActor
func testExplicitSurfaceTabBarConfigurationRemainsAuthoritative() throws {
let root = FileManager.default.temporaryDirectory.appendingPathComponent(
"cmux-config-store-\(UUID().uuidString)",
isDirectory: true
)
try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true)
defer { try? FileManager.default.removeItem(at: root) }
let configURL = root.appendingPathComponent("cmux.json")
try #"{"ui":{"surfaceTabBar":{"buttons":["cmux.newTerminal"]}}}"#
.write(to: configURL, atomically: true, encoding: .utf8)

let store = CmuxConfigStore(
globalConfigPath: configURL.path,
localConfigPath: root.appendingPathComponent("missing-local.json").path,
startFileWatchers: false
)
store.loadAll()

XCTAssertEqual(
store.surfaceTabBarButtons.map(\.id),
[CmuxSurfaceTabBarBuiltInAction.newTerminal.configID]
)
}

@MainActor
func testConfigStoreParsesGlobalCmuxJSONCSettingsAndActionSections() throws {
let root = FileManager.default.temporaryDirectory.appendingPathComponent(
Expand Down
9 changes: 9 additions & 0 deletions ios/cmuxPackage/Sources/cmuxFeature/CMUXMobileRootScene.swift
Original file line number Diff line number Diff line change
Expand Up @@ -323,6 +323,15 @@ public struct CMUXMobileRootScene: View {
#if DEBUG
if UITestConfig.taskComposerPreviewEnabled {
TaskComposerAccessibilityPreviewView()
} else if let simulatorMode = ProcessInfo.processInfo.environment[
"CMUX_UITEST_SIMULATOR_DISCOVERABILITY"
] {
SimulatorDiscoverabilityPreviewView(
mode: simulatorMode,
state: ProcessInfo.processInfo.environment[
"CMUX_UITEST_SIMULATOR_DISCOVERABILITY_STATE"
] ?? "inactive"
)
} else if UITestConfig.notificationFeedPreviewEnabled {
NotificationFeedPreviewView()
} else if UITestConfig.workspaceListLayoutPreviewEnabled {
Expand Down
Loading