From 5e8564af2e689531c1d9349b8fda25cbccd5c07f Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Thu, 2 Jul 2026 01:04:59 -0700 Subject: [PATCH 1/5] Show iOS terminal loading diagnostics --- .../TerminalLoadingDiagnosticsOverlay.swift | 305 ++++++++++++++++ .../WorkspaceDetailView.swift | 16 + ...TerminalLoadingDiagnosticsModelTests.swift | 62 ++++ Resources/Localizable.xcstrings | 340 ++++++++++++++++++ 4 files changed, 723 insertions(+) create mode 100644 Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TerminalLoadingDiagnosticsOverlay.swift create mode 100644 Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/TerminalLoadingDiagnosticsModelTests.swift diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TerminalLoadingDiagnosticsOverlay.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TerminalLoadingDiagnosticsOverlay.swift new file mode 100644 index 00000000000..4946dc445d8 --- /dev/null +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TerminalLoadingDiagnosticsOverlay.swift @@ -0,0 +1,305 @@ +#if os(iOS) +import CMUXMobileCore +import CmuxMobileShellModel +import CmuxMobileSupport +import SwiftUI + +struct TerminalLoadingDiagnosticsRow: Equatable, Identifiable { + enum Tone: Equatable { + case good + case pending + case warning + case neutral + } + + let id: String + let label: String + let value: String + let tone: Tone +} + +struct TerminalLoadingDiagnosticsModel: Equatable { + let title: String + let message: String + let rows: [TerminalLoadingDiagnosticsRow] + + static func snapshot( + workspaceName: String, + terminalCount: Int, + macName: String?, + connectionStatus: MobileMacConnectionStatus, + tailnetStatus: TailnetStatus?, + activeRoute: CmxAttachRoute?, + storedRouteDescription: String?, + connectionError: String?, + connectionErrorGuidance: String? + ) -> Self { + let resolvedMacName = Self.nonEmpty(macName) ?? Self.nonEmpty(workspaceName) ?? L10n.string( + "mobile.terminal.loading.macFallback", + defaultValue: "Mac" + ) + var rows: [TerminalLoadingDiagnosticsRow] = [ + TerminalLoadingDiagnosticsRow( + id: "mac", + label: L10n.string("mobile.terminal.loading.mac", defaultValue: "Mac"), + value: "\(resolvedMacName) · \(connectionStatus.label)", + tone: tone(for: connectionStatus) + ), + TerminalLoadingDiagnosticsRow( + id: "terminals", + label: L10n.string("mobile.terminal.loading.terminals", defaultValue: "Terminals"), + value: terminalStatusText(count: terminalCount), + tone: terminalCount > 0 ? .good : .pending + ), + TerminalLoadingDiagnosticsRow( + id: "tailscale", + label: L10n.string("mobile.terminal.loading.tailscale", defaultValue: "Tailscale"), + value: tailnetStatusText(tailnetStatus), + tone: tone(for: tailnetStatus) + ), + TerminalLoadingDiagnosticsRow( + id: "route", + label: L10n.string("mobile.terminal.loading.route", defaultValue: "Route"), + value: routeText(activeRoute: activeRoute, storedRouteDescription: storedRouteDescription), + tone: activeRoute == nil && Self.nonEmpty(storedRouteDescription) == nil ? .warning : .neutral + ), + ] + + if let detail = Self.nonEmpty(connectionErrorGuidance) ?? Self.nonEmpty(connectionError) { + rows.append(TerminalLoadingDiagnosticsRow( + id: "network", + label: L10n.string("mobile.terminal.loading.network", defaultValue: "Network"), + value: detail, + tone: .warning + )) + } + + return Self( + title: L10n.string("mobile.terminal.loading.title", defaultValue: "Loading terminals"), + message: String( + format: L10n.string( + "mobile.terminal.loading.messageFormat", + defaultValue: "Waiting for %@ to send terminal metadata for this workspace." + ), + resolvedMacName + ), + rows: rows + ) + } + + private static func nonEmpty(_ value: String?) -> String? { + let trimmed = value?.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed?.isEmpty == false ? trimmed : nil + } + + private static func terminalStatusText(count: Int) -> String { + guard count > 0 else { + return L10n.string("mobile.terminal.loading.terminalsWaiting", defaultValue: "No terminal list yet") + } + return L10n.terminalCount(count) + } + + private static func tailnetStatusText(_ status: TailnetStatus?) -> String { + switch status { + case .active: + return L10n.string("mobile.terminal.loading.tailscale.active", defaultValue: "Active") + case .inactiveOrNotInstalled: + return L10n.string("mobile.terminal.loading.tailscale.inactive", defaultValue: "Off or not installed") + case .unknown: + return L10n.string("mobile.terminal.loading.tailscale.unknown", defaultValue: "Unknown") + case nil: + return L10n.string("mobile.terminal.loading.tailscale.notChecked", defaultValue: "Not checked") + } + } + + private static func routeText(activeRoute: CmxAttachRoute?, storedRouteDescription: String?) -> String { + if let activeRoute { + return "\(routeKindText(activeRoute.kind)) · \(endpointText(activeRoute.endpoint))" + } + if let storedRouteDescription = nonEmpty(storedRouteDescription) { + return String( + format: L10n.string( + "mobile.terminal.loading.routeStoredFormat", + defaultValue: "Saved route · %@" + ), + storedRouteDescription + ) + } + return L10n.string("mobile.terminal.loading.routeMissing", defaultValue: "No saved route") + } + + private static func routeKindText(_ kind: CmxAttachTransportKind) -> String { + switch kind { + case .tailscale: + return L10n.string("mobile.terminal.loading.route.tailscale", defaultValue: "Tailscale") + case .debugLoopback: + return L10n.string("mobile.terminal.loading.route.debugLoopback", defaultValue: "Debug loopback") + case .iroh: + return L10n.string("mobile.terminal.loading.route.iroh", defaultValue: "Iroh") + case .websocket: + return L10n.string("mobile.terminal.loading.route.websocket", defaultValue: "WebSocket") + } + } + + private static func endpointText(_ endpoint: CmxAttachEndpoint) -> String { + switch endpoint { + case let .hostPort(host, port): + return "\(host):\(port)" + case let .peer(id, _, directAddrs, _): + return directAddrs.first ?? id + case let .url(url): + return url + } + } + + private static func tone(for status: MobileMacConnectionStatus) -> TerminalLoadingDiagnosticsRow.Tone { + switch status { + case .connected: + return .good + case .reconnecting: + return .pending + case .unavailable: + return .warning + } + } + + private static func tone(for status: TailnetStatus?) -> TerminalLoadingDiagnosticsRow.Tone { + switch status { + case .active: + return .good + case .inactiveOrNotInstalled: + return .warning + case .unknown, nil: + return .neutral + } + } +} + +struct TerminalLoadingDiagnosticsOverlay: View { + let workspace: MobileWorkspacePreview + let host: String + let connectionStatus: MobileMacConnectionStatus + let tailnetStatus: TailnetStatus? + let activeRoute: CmxAttachRoute? + let storedRouteDescription: String? + let connectionError: String? + let connectionErrorGuidance: String? + let createTerminal: () -> Void + let canCreateTerminal: Bool + + private var model: TerminalLoadingDiagnosticsModel { + TerminalLoadingDiagnosticsModel.snapshot( + workspaceName: workspace.name, + terminalCount: workspace.terminals.count, + macName: workspace.macDisplayName ?? host, + connectionStatus: workspace.macConnectionStatus ?? connectionStatus, + tailnetStatus: tailnetStatus, + activeRoute: activeRoute, + storedRouteDescription: storedRouteDescription, + connectionError: connectionError, + connectionErrorGuidance: connectionErrorGuidance + ) + } + + var body: some View { + VStack(spacing: 16) { + ProgressView() + .controlSize(.large) + .tint(.white) + .accessibilityHidden(true) + + VStack(spacing: 6) { + Text(model.title) + .font(.headline) + .foregroundStyle(.white) + Text(model.message) + .font(.subheadline) + .foregroundStyle(.white.opacity(0.72)) + .multilineTextAlignment(.center) + .fixedSize(horizontal: false, vertical: true) + } + + VStack(spacing: 8) { + ForEach(model.rows) { row in + HStack(spacing: 10) { + Circle() + .fill(color(for: row.tone)) + .frame(width: 8, height: 8) + .accessibilityHidden(true) + Text(row.label) + .font(.caption.weight(.semibold)) + .foregroundStyle(.white.opacity(0.62)) + Spacer(minLength: 8) + Text(row.value) + .font(.caption.monospacedDigit()) + .foregroundStyle(.white.opacity(0.88)) + .lineLimit(1) + .truncationMode(.middle) + } + } + } + .padding(14) + .background(.white.opacity(0.08), in: RoundedRectangle(cornerRadius: 8, style: .continuous)) + + if canCreateTerminal { + Button(action: createTerminal) { + Label( + L10n.string("mobile.terminal.loading.createTerminal", defaultValue: "Create Terminal"), + systemImage: "plus" + ) + .font(.subheadline.weight(.semibold)) + .padding(.horizontal, 18) + .padding(.vertical, 9) + .background(Color.accentColor, in: Capsule()) + .foregroundStyle(.white) + } + .buttonStyle(.plain) + .accessibilityIdentifier("MobileLoadingCreateTerminalButton") + } + } + .padding(24) + .frame(maxWidth: 420) + .accessibilityIdentifier("MobileTerminalLoadingDiagnostics") + } + + private func color(for tone: TerminalLoadingDiagnosticsRow.Tone) -> Color { + switch tone { + case .good: + return .green + case .pending: + return .orange + case .warning: + return .red + case .neutral: + return .white.opacity(0.45) + } + } +} + +extension WorkspaceDetailView { + var loadingDiagnosticsMacSnapshot: MacComputerSnapshot? { + guard let macDeviceID = workspace.macDeviceID, + !macDeviceID.isEmpty else { + return nil + } + return MacComputerSnapshot.snapshots(from: store).first { snapshot in + snapshot.deviceId == macDeviceID || snapshot.aliasIDs.contains(macDeviceID) + } + } + + var activeLoadingDiagnosticsRoute: CmxAttachRoute? { + guard let macDeviceID = workspace.macDeviceID, + !macDeviceID.isEmpty else { + return nil + } + if macDeviceID == store.connectedMacDeviceID { + return store.activeRoute + } + guard let connectedMacDeviceID = store.connectedMacDeviceID, + store.pairedMacAliasIDs(for: macDeviceID).contains(connectedMacDeviceID) else { + return nil + } + return store.activeRoute + } +} +#endif diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceDetailView.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceDetailView.swift index 86aaaa8b78d..2a264585b5f 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceDetailView.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceDetailView.swift @@ -1,5 +1,6 @@ import CmuxAgentChat import CmuxAgentChatUI +import CMUXMobileCore import CmuxMobileBrowser import CmuxMobileDiagnostics import CmuxMobileShell @@ -33,6 +34,7 @@ struct WorkspaceDetailView: View { let signOut: (() -> Void)? /// Phone-local browser surfaces, injected from the app root. @Environment(BrowserSurfaceStore.self) private var browserStore + @Environment(\.tailscaleStatusMonitor) private var tailscaleStatusMonitor /// Drives the destructive close-workspace confirmation dialog. @State var isConfirmingClose = false #if canImport(UIKit) @@ -270,6 +272,20 @@ struct WorkspaceDetailView: View { } else { TerminalPalette.background .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + .overlay { + TerminalLoadingDiagnosticsOverlay( + workspace: workspace, + host: host, + connectionStatus: connectionStatus, + tailnetStatus: tailscaleStatusMonitor?.status, + activeRoute: activeLoadingDiagnosticsRoute, + storedRouteDescription: loadingDiagnosticsMacSnapshot?.routeDescription, + connectionError: store.connectionError, + connectionErrorGuidance: store.connectionErrorGuidance, + createTerminal: createTerminal, + canCreateTerminal: canCreateWorkspace + ) + } } #else TerminalPalette.background diff --git a/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/TerminalLoadingDiagnosticsModelTests.swift b/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/TerminalLoadingDiagnosticsModelTests.swift new file mode 100644 index 00000000000..ae5675c7934 --- /dev/null +++ b/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/TerminalLoadingDiagnosticsModelTests.swift @@ -0,0 +1,62 @@ +#if os(iOS) +import CMUXMobileCore +import CmuxMobileShellModel +@testable import CmuxMobileShellUI +import Testing + +@Suite struct TerminalLoadingDiagnosticsModelTests { + @Test func connectedTailscaleRouteExplainsLoadingTerminalState() throws { + let route = try CmxAttachRoute( + id: "tailscale", + kind: .tailscale, + endpoint: .hostPort(host: "100.64.0.10", port: 58465) + ) + + let model = TerminalLoadingDiagnosticsModel.snapshot( + workspaceName: "cmux", + terminalCount: 0, + macName: "Studio", + connectionStatus: .connected, + tailnetStatus: .active, + activeRoute: route, + storedRouteDescription: nil, + connectionError: nil, + connectionErrorGuidance: nil + ) + + #expect(model.title == "Loading terminals") + #expect(model.message == "Waiting for Studio to send terminal metadata for this workspace.") + #expect(model.rows[id: "mac"]?.value == "Studio · Connected") + #expect(model.rows[id: "tailscale"]?.value == "Active") + #expect(model.rows[id: "route"]?.value == "Tailscale · 100.64.0.10:58465") + #expect(model.rows[id: "terminals"]?.value == "No terminal list yet") + } + + @Test func inactiveTailscaleAndSavedRouteSurfaceNetworkGuidance() { + let model = TerminalLoadingDiagnosticsModel.snapshot( + workspaceName: "cmux", + terminalCount: 0, + macName: nil, + connectionStatus: .unavailable, + tailnetStatus: .inactiveOrNotInstalled, + activeRoute: nil, + storedRouteDescription: "100.64.0.20:58465", + connectionError: "Timed out", + connectionErrorGuidance: "Check that both devices are on the same Tailscale." + ) + + #expect(model.rows[id: "mac"]?.value == "cmux · Disconnected") + #expect(model.rows[id: "mac"]?.tone == .warning) + #expect(model.rows[id: "tailscale"]?.value == "Off or not installed") + #expect(model.rows[id: "tailscale"]?.tone == .warning) + #expect(model.rows[id: "route"]?.value == "Saved route · 100.64.0.20:58465") + #expect(model.rows[id: "network"]?.value == "Check that both devices are on the same Tailscale.") + } +} + +private extension [TerminalLoadingDiagnosticsRow] { + subscript(id id: String) -> TerminalLoadingDiagnosticsRow? { + first { $0.id == id } + } +} +#endif diff --git a/Resources/Localizable.xcstrings b/Resources/Localizable.xcstrings index ce2f0c72231..4785535b2f7 100644 --- a/Resources/Localizable.xcstrings +++ b/Resources/Localizable.xcstrings @@ -117469,6 +117469,346 @@ } } }, + "mobile.terminal.loading.createTerminal": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Create Terminal" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ターミナルを作成" + } + } + } + }, + "mobile.terminal.loading.mac": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Mac" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "Mac" + } + } + } + }, + "mobile.terminal.loading.macFallback": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Mac" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "Mac" + } + } + } + }, + "mobile.terminal.loading.messageFormat": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Waiting for %@ to send terminal metadata for this workspace." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "%@ からこのワークスペースのターミナル情報が送信されるのを待っています。" + } + } + } + }, + "mobile.terminal.loading.network": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Network" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ネットワーク" + } + } + } + }, + "mobile.terminal.loading.route": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Route" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ルート" + } + } + } + }, + "mobile.terminal.loading.route.debugLoopback": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Debug loopback" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "デバッグループバック" + } + } + } + }, + "mobile.terminal.loading.route.iroh": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Iroh" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "Iroh" + } + } + } + }, + "mobile.terminal.loading.route.tailscale": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Tailscale" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "Tailscale" + } + } + } + }, + "mobile.terminal.loading.route.websocket": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "WebSocket" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "WebSocket" + } + } + } + }, + "mobile.terminal.loading.routeMissing": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "No saved route" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "保存済みルートなし" + } + } + } + }, + "mobile.terminal.loading.routeStoredFormat": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Saved route · %@" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "保存済みルート · %@" + } + } + } + }, + "mobile.terminal.loading.tailscale": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Tailscale" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "Tailscale" + } + } + } + }, + "mobile.terminal.loading.tailscale.active": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Active" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "有効" + } + } + } + }, + "mobile.terminal.loading.tailscale.inactive": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Off or not installed" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "オフまたは未インストール" + } + } + } + }, + "mobile.terminal.loading.tailscale.notChecked": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Not checked" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "未確認" + } + } + } + }, + "mobile.terminal.loading.tailscale.unknown": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Unknown" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "不明" + } + } + } + }, + "mobile.terminal.loading.terminals": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Terminals" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ターミナル" + } + } + } + }, + "mobile.terminal.loading.terminalsWaiting": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "No terminal list yet" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ターミナル一覧はまだありません" + } + } + } + }, + "mobile.terminal.loading.title": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Loading terminals" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ターミナルを読み込み中" + } + } + } + }, "mobile.pairing.checking": { "extractionState": "manual", "localizations": { From 6c1f8454fc4ca8d3d6d72cb760cf0d4e19f021c4 Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Thu, 2 Jul 2026 20:01:43 -0700 Subject: [PATCH 2/5] Address loading diagnostics review feedback --- .../TerminalLoadingDiagnosticsOverlay.swift | 21 +- .../WorkspaceDetailView.swift | 2 +- ...TerminalLoadingDiagnosticsModelTests.swift | 23 ++ Resources/Localizable.xcstrings | 34 ++ ios/cmux/Resources/Localizable.xcstrings | 374 ++++++++++++++++++ 5 files changed, 451 insertions(+), 3 deletions(-) diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TerminalLoadingDiagnosticsOverlay.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TerminalLoadingDiagnosticsOverlay.swift index 4946dc445d8..7d55e111828 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TerminalLoadingDiagnosticsOverlay.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TerminalLoadingDiagnosticsOverlay.swift @@ -42,7 +42,14 @@ struct TerminalLoadingDiagnosticsModel: Equatable { TerminalLoadingDiagnosticsRow( id: "mac", label: L10n.string("mobile.terminal.loading.mac", defaultValue: "Mac"), - value: "\(resolvedMacName) · \(connectionStatus.label)", + value: String( + format: L10n.string( + "mobile.terminal.loading.macStatusFormat", + defaultValue: "%@ · %@" + ), + resolvedMacName, + connectionStatus.label + ), tone: tone(for: connectionStatus) ), TerminalLoadingDiagnosticsRow( @@ -114,7 +121,14 @@ struct TerminalLoadingDiagnosticsModel: Equatable { private static func routeText(activeRoute: CmxAttachRoute?, storedRouteDescription: String?) -> String { if let activeRoute { - return "\(routeKindText(activeRoute.kind)) · \(endpointText(activeRoute.endpoint))" + return String( + format: L10n.string( + "mobile.terminal.loading.routeActiveFormat", + defaultValue: "%@ · %@" + ), + routeKindText(activeRoute.kind), + endpointText(activeRoute.endpoint) + ) } if let storedRouteDescription = nonEmpty(storedRouteDescription) { return String( @@ -295,6 +309,9 @@ extension WorkspaceDetailView { if macDeviceID == store.connectedMacDeviceID { return store.activeRoute } + if macDeviceID == store.activeTicket?.macDeviceID { + return store.activeRoute + } guard let connectedMacDeviceID = store.connectedMacDeviceID, store.pairedMacAliasIDs(for: macDeviceID).contains(connectedMacDeviceID) else { return nil diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceDetailView.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceDetailView.swift index 1dbe167fa30..3339f05be70 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceDetailView.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceDetailView.swift @@ -253,7 +253,7 @@ struct WorkspaceDetailView: View { connectionError: store.connectionError, connectionErrorGuidance: store.connectionErrorGuidance, createTerminal: createTerminal, - canCreateTerminal: canCreateWorkspace + canCreateTerminal: true ) } } diff --git a/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/TerminalLoadingDiagnosticsModelTests.swift b/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/TerminalLoadingDiagnosticsModelTests.swift index ae5675c7934..f68d4397cb4 100644 --- a/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/TerminalLoadingDiagnosticsModelTests.swift +++ b/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/TerminalLoadingDiagnosticsModelTests.swift @@ -52,6 +52,29 @@ import Testing #expect(model.rows[id: "route"]?.value == "Saved route · 100.64.0.20:58465") #expect(model.rows[id: "network"]?.value == "Check that both devices are on the same Tailscale.") } + + @Test func routeAndMacStatusUseLocalizedFormatKeys() throws { + let route = try CmxAttachRoute( + id: "debug", + kind: .debugLoopback, + endpoint: .hostPort(host: "127.0.0.1", port: 3942) + ) + + let model = TerminalLoadingDiagnosticsModel.snapshot( + workspaceName: "cmux", + terminalCount: 1, + macName: "Mini", + connectionStatus: .reconnecting, + tailnetStatus: .unknown, + activeRoute: route, + storedRouteDescription: nil, + connectionError: nil, + connectionErrorGuidance: nil + ) + + #expect(model.rows[id: "mac"]?.value == "Mini · Reconnecting") + #expect(model.rows[id: "route"]?.value == "Debug loopback · 127.0.0.1:3942") + } } private extension [TerminalLoadingDiagnosticsRow] { diff --git a/Resources/Localizable.xcstrings b/Resources/Localizable.xcstrings index 550df75e8b6..95c36009763 100644 --- a/Resources/Localizable.xcstrings +++ b/Resources/Localizable.xcstrings @@ -117628,6 +117628,23 @@ } } }, + "mobile.terminal.loading.macStatusFormat": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "%@ · %@" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "%@ · %@" + } + } + } + }, "mobile.terminal.loading.messageFormat": { "extractionState": "manual", "localizations": { @@ -117679,6 +117696,23 @@ } } }, + "mobile.terminal.loading.routeActiveFormat": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "%@ · %@" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "%@ · %@" + } + } + } + }, "mobile.terminal.loading.route.debugLoopback": { "extractionState": "manual", "localizations": { diff --git a/ios/cmux/Resources/Localizable.xcstrings b/ios/cmux/Resources/Localizable.xcstrings index ef80c144603..9b8831d03f1 100644 --- a/ios/cmux/Resources/Localizable.xcstrings +++ b/ios/cmux/Resources/Localizable.xcstrings @@ -8195,6 +8195,380 @@ } } }, + "mobile.terminal.loading.createTerminal": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Create Terminal" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ターミナルを作成" + } + } + } + }, + "mobile.terminal.loading.mac": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Mac" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "Mac" + } + } + } + }, + "mobile.terminal.loading.macFallback": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Mac" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "Mac" + } + } + } + }, + "mobile.terminal.loading.macStatusFormat": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "%@ · %@" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "%@ · %@" + } + } + } + }, + "mobile.terminal.loading.messageFormat": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Waiting for %@ to send terminal metadata for this workspace." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "%@ からこのワークスペースのターミナル情報が送信されるのを待っています。" + } + } + } + }, + "mobile.terminal.loading.network": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Network" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ネットワーク" + } + } + } + }, + "mobile.terminal.loading.route": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Route" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ルート" + } + } + } + }, + "mobile.terminal.loading.routeActiveFormat": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "%@ · %@" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "%@ · %@" + } + } + } + }, + "mobile.terminal.loading.route.debugLoopback": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Debug loopback" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "デバッグループバック" + } + } + } + }, + "mobile.terminal.loading.route.iroh": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Iroh" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "Iroh" + } + } + } + }, + "mobile.terminal.loading.route.tailscale": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Tailscale" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "Tailscale" + } + } + } + }, + "mobile.terminal.loading.route.websocket": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "WebSocket" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "WebSocket" + } + } + } + }, + "mobile.terminal.loading.routeMissing": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "No saved route" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "保存済みルートなし" + } + } + } + }, + "mobile.terminal.loading.routeStoredFormat": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Saved route · %@" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "保存済みルート · %@" + } + } + } + }, + "mobile.terminal.loading.tailscale": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Tailscale" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "Tailscale" + } + } + } + }, + "mobile.terminal.loading.tailscale.active": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Active" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "有効" + } + } + } + }, + "mobile.terminal.loading.tailscale.inactive": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Off or not installed" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "オフまたは未インストール" + } + } + } + }, + "mobile.terminal.loading.tailscale.notChecked": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Not checked" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "未確認" + } + } + } + }, + "mobile.terminal.loading.tailscale.unknown": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Unknown" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "不明" + } + } + } + }, + "mobile.terminal.loading.terminals": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Terminals" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ターミナル" + } + } + } + }, + "mobile.terminal.loading.terminalsWaiting": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "No terminal list yet" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ターミナル一覧はまだありません" + } + } + } + }, + "mobile.terminal.loading.title": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Loading terminals" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ターミナルを読み込み中" + } + } + } + }, "terminal.zoom.restore_built_in": { "extractionState": "manual", "localizations": { From 337c98e2fa0e6150c7547f8b7f1319117e33c919 Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Thu, 2 Jul 2026 20:17:19 -0700 Subject: [PATCH 3/5] Scope terminal loading network diagnostics --- .../TerminalLoadingDiagnosticsOverlay.swift | 43 +++++++++++++------ .../WorkspaceDetailView.swift | 4 +- 2 files changed, 33 insertions(+), 14 deletions(-) diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TerminalLoadingDiagnosticsOverlay.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TerminalLoadingDiagnosticsOverlay.swift index 7d55e111828..5664aa0d85d 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TerminalLoadingDiagnosticsOverlay.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TerminalLoadingDiagnosticsOverlay.swift @@ -42,8 +42,8 @@ struct TerminalLoadingDiagnosticsModel: Equatable { TerminalLoadingDiagnosticsRow( id: "mac", label: L10n.string("mobile.terminal.loading.mac", defaultValue: "Mac"), - value: String( - format: L10n.string( + value: String.localizedStringWithFormat( + L10n.string( "mobile.terminal.loading.macStatusFormat", defaultValue: "%@ · %@" ), @@ -83,8 +83,8 @@ struct TerminalLoadingDiagnosticsModel: Equatable { return Self( title: L10n.string("mobile.terminal.loading.title", defaultValue: "Loading terminals"), - message: String( - format: L10n.string( + message: String.localizedStringWithFormat( + L10n.string( "mobile.terminal.loading.messageFormat", defaultValue: "Waiting for %@ to send terminal metadata for this workspace." ), @@ -121,8 +121,8 @@ struct TerminalLoadingDiagnosticsModel: Equatable { private static func routeText(activeRoute: CmxAttachRoute?, storedRouteDescription: String?) -> String { if let activeRoute { - return String( - format: L10n.string( + return String.localizedStringWithFormat( + L10n.string( "mobile.terminal.loading.routeActiveFormat", defaultValue: "%@ · %@" ), @@ -131,8 +131,8 @@ struct TerminalLoadingDiagnosticsModel: Equatable { ) } if let storedRouteDescription = nonEmpty(storedRouteDescription) { - return String( - format: L10n.string( + return String.localizedStringWithFormat( + L10n.string( "mobile.terminal.loading.routeStoredFormat", defaultValue: "Saved route · %@" ), @@ -306,17 +306,36 @@ extension WorkspaceDetailView { !macDeviceID.isEmpty else { return nil } - if macDeviceID == store.connectedMacDeviceID { + if loadingDiagnosticsMatchesForegroundMac(macDeviceID) { return store.activeRoute } + return nil + } + + var loadingDiagnosticsConnectionError: String? { + loadingDiagnosticsMatchesForegroundMac(workspace.macDeviceID) ? store.connectionError : nil + } + + var loadingDiagnosticsConnectionErrorGuidance: String? { + loadingDiagnosticsMatchesForegroundMac(workspace.macDeviceID) ? store.connectionErrorGuidance : nil + } + + private func loadingDiagnosticsMatchesForegroundMac(_ macDeviceID: String?) -> Bool { + guard let macDeviceID, + !macDeviceID.isEmpty else { + return false + } + if macDeviceID == store.connectedMacDeviceID { + return true + } if macDeviceID == store.activeTicket?.macDeviceID { - return store.activeRoute + return true } guard let connectedMacDeviceID = store.connectedMacDeviceID, store.pairedMacAliasIDs(for: macDeviceID).contains(connectedMacDeviceID) else { - return nil + return false } - return store.activeRoute + return true } } #endif diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceDetailView.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceDetailView.swift index 3339f05be70..0c2187317f2 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceDetailView.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceDetailView.swift @@ -250,8 +250,8 @@ struct WorkspaceDetailView: View { tailnetStatus: tailscaleStatusMonitor?.status, activeRoute: activeLoadingDiagnosticsRoute, storedRouteDescription: loadingDiagnosticsMacSnapshot?.routeDescription, - connectionError: store.connectionError, - connectionErrorGuidance: store.connectionErrorGuidance, + connectionError: loadingDiagnosticsConnectionError, + connectionErrorGuidance: loadingDiagnosticsConnectionErrorGuidance, createTerminal: createTerminal, canCreateTerminal: true ) From 11a5c7f3e72c12f379cb8a47087dbbfd89da4416 Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Thu, 2 Jul 2026 20:32:46 -0700 Subject: [PATCH 4/5] Clarify empty terminal loading state --- .../TerminalLoadingDiagnosticsOverlay.swift | 48 +++++++++++++++---- .../WorkspaceDetailView.swift | 30 ++++++------ ...TerminalLoadingDiagnosticsModelTests.swift | 4 +- Resources/Localizable.xcstrings | 34 +++++++++++++ ios/cmux/Resources/Localizable.xcstrings | 34 +++++++++++++ 5 files changed, 126 insertions(+), 24 deletions(-) diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TerminalLoadingDiagnosticsOverlay.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TerminalLoadingDiagnosticsOverlay.swift index 5664aa0d85d..7db12d9359a 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TerminalLoadingDiagnosticsOverlay.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TerminalLoadingDiagnosticsOverlay.swift @@ -81,15 +81,14 @@ struct TerminalLoadingDiagnosticsModel: Equatable { )) } + let text = headerText( + terminalCount: terminalCount, + connectionStatus: connectionStatus, + resolvedMacName: resolvedMacName + ) return Self( - title: L10n.string("mobile.terminal.loading.title", defaultValue: "Loading terminals"), - message: String.localizedStringWithFormat( - L10n.string( - "mobile.terminal.loading.messageFormat", - defaultValue: "Waiting for %@ to send terminal metadata for this workspace." - ), - resolvedMacName - ), + title: text.title, + message: text.message, rows: rows ) } @@ -106,6 +105,35 @@ struct TerminalLoadingDiagnosticsModel: Equatable { return L10n.terminalCount(count) } + private static func headerText( + terminalCount: Int, + connectionStatus: MobileMacConnectionStatus, + resolvedMacName: String + ) -> (title: String, message: String) { + if connectionStatus == .connected && terminalCount == 0 { + return ( + L10n.string("mobile.terminal.loading.emptyTitle", defaultValue: "No terminals yet"), + String.localizedStringWithFormat( + L10n.string( + "mobile.terminal.loading.emptyMessageFormat", + defaultValue: "%@ is connected. Create a terminal, or wait for terminal metadata to arrive." + ), + resolvedMacName + ) + ) + } + return ( + L10n.string("mobile.terminal.loading.title", defaultValue: "Loading terminals"), + String.localizedStringWithFormat( + L10n.string( + "mobile.terminal.loading.messageFormat", + defaultValue: "Waiting for %@ to send terminal metadata for this workspace." + ), + resolvedMacName + ) + ) + } + private static func tailnetStatusText(_ status: TailnetStatus?) -> String { switch status { case .active: @@ -291,6 +319,10 @@ struct TerminalLoadingDiagnosticsOverlay: View { } extension WorkspaceDetailView { + var loadingDiagnosticsConnectionStatus: MobileMacConnectionStatus { + workspace.macConnectionStatus ?? connectionStatus + } + var loadingDiagnosticsMacSnapshot: MacComputerSnapshot? { guard let macDeviceID = workspace.macDeviceID, !macDeviceID.isEmpty else { diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceDetailView.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceDetailView.swift index 0c2187317f2..317b5f5f2d5 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceDetailView.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceDetailView.swift @@ -243,18 +243,20 @@ struct WorkspaceDetailView: View { TerminalPalette.background .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) .overlay { - TerminalLoadingDiagnosticsOverlay( - workspace: workspace, - host: host, - connectionStatus: connectionStatus, - tailnetStatus: tailscaleStatusMonitor?.status, - activeRoute: activeLoadingDiagnosticsRoute, - storedRouteDescription: loadingDiagnosticsMacSnapshot?.routeDescription, - connectionError: loadingDiagnosticsConnectionError, - connectionErrorGuidance: loadingDiagnosticsConnectionErrorGuidance, - createTerminal: createTerminal, - canCreateTerminal: true - ) + if loadingDiagnosticsConnectionStatus == .connected { + TerminalLoadingDiagnosticsOverlay( + workspace: workspace, + host: host, + connectionStatus: loadingDiagnosticsConnectionStatus, + tailnetStatus: tailscaleStatusMonitor?.status, + activeRoute: activeLoadingDiagnosticsRoute, + storedRouteDescription: loadingDiagnosticsMacSnapshot?.routeDescription, + connectionError: loadingDiagnosticsConnectionError, + connectionErrorGuidance: loadingDiagnosticsConnectionErrorGuidance, + createTerminal: createTerminal, + canCreateTerminal: true + ) + } } } #else @@ -270,8 +272,8 @@ struct WorkspaceDetailView: View { } .overlay { // Show a reconnecting/offline state instead of a black terminal. - if connectionStatus != .connected { - TerminalDisconnectedOverlay(status: connectionStatus, host: host) { + if loadingDiagnosticsConnectionStatus != .connected { + TerminalDisconnectedOverlay(status: loadingDiagnosticsConnectionStatus, host: host) { Task { if let macDeviceID = workspace.macDeviceID, !macDeviceID.isEmpty, diff --git a/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/TerminalLoadingDiagnosticsModelTests.swift b/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/TerminalLoadingDiagnosticsModelTests.swift index f68d4397cb4..273ba35ea2b 100644 --- a/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/TerminalLoadingDiagnosticsModelTests.swift +++ b/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/TerminalLoadingDiagnosticsModelTests.swift @@ -24,8 +24,8 @@ import Testing connectionErrorGuidance: nil ) - #expect(model.title == "Loading terminals") - #expect(model.message == "Waiting for Studio to send terminal metadata for this workspace.") + #expect(model.title == "No terminals yet") + #expect(model.message == "Studio is connected. Create a terminal, or wait for terminal metadata to arrive.") #expect(model.rows[id: "mac"]?.value == "Studio · Connected") #expect(model.rows[id: "tailscale"]?.value == "Active") #expect(model.rows[id: "route"]?.value == "Tailscale · 100.64.0.10:58465") diff --git a/Resources/Localizable.xcstrings b/Resources/Localizable.xcstrings index 95c36009763..24afa5e51c1 100644 --- a/Resources/Localizable.xcstrings +++ b/Resources/Localizable.xcstrings @@ -117594,6 +117594,40 @@ } } }, + "mobile.terminal.loading.emptyMessageFormat": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "%@ is connected. Create a terminal, or wait for terminal metadata to arrive." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "%@ に接続済みです。ターミナルを作成するか、ターミナル情報の到着を待ってください。" + } + } + } + }, + "mobile.terminal.loading.emptyTitle": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "No terminals yet" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ターミナルはまだありません" + } + } + } + }, "mobile.terminal.loading.mac": { "extractionState": "manual", "localizations": { diff --git a/ios/cmux/Resources/Localizable.xcstrings b/ios/cmux/Resources/Localizable.xcstrings index 9b8831d03f1..6982dafb14d 100644 --- a/ios/cmux/Resources/Localizable.xcstrings +++ b/ios/cmux/Resources/Localizable.xcstrings @@ -8212,6 +8212,40 @@ } } }, + "mobile.terminal.loading.emptyMessageFormat": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "%@ is connected. Create a terminal, or wait for terminal metadata to arrive." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "%@ に接続済みです。ターミナルを作成するか、ターミナル情報の到着を待ってください。" + } + } + } + }, + "mobile.terminal.loading.emptyTitle": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "No terminals yet" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ターミナルはまだありません" + } + } + } + }, "mobile.terminal.loading.mac": { "extractionState": "manual", "localizations": { From 285867430f089fff26e4bcf684dd9c9dc991e8ce Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Thu, 2 Jul 2026 20:53:30 -0700 Subject: [PATCH 5/5] Add terminal loading retry state --- .../TerminalLoadingDiagnosticsModel.swift | 44 +++ ...rminalLoadingDiagnosticsModelBuilder.swift | 198 ++++++++++ .../TerminalLoadingDiagnosticsOverlay.swift | 347 ++++-------------- .../TerminalLoadingDiagnosticsRow.swift | 8 + .../TerminalLoadingDiagnosticsTone.swift | 8 + ...etailView+TerminalLoadingDiagnostics.swift | 74 ++++ .../WorkspaceDetailView.swift | 8 +- ...TerminalLoadingDiagnosticsModelTests.swift | 30 +- Resources/Localizable.xcstrings | 21 +- ios/cmux/Resources/Localizable.xcstrings | 21 +- 10 files changed, 482 insertions(+), 277 deletions(-) create mode 100644 Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TerminalLoadingDiagnosticsModel.swift create mode 100644 Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TerminalLoadingDiagnosticsModelBuilder.swift create mode 100644 Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TerminalLoadingDiagnosticsRow.swift create mode 100644 Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TerminalLoadingDiagnosticsTone.swift create mode 100644 Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceDetailView+TerminalLoadingDiagnostics.swift diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TerminalLoadingDiagnosticsModel.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TerminalLoadingDiagnosticsModel.swift new file mode 100644 index 00000000000..0e1669787fa --- /dev/null +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TerminalLoadingDiagnosticsModel.swift @@ -0,0 +1,44 @@ +#if os(iOS) +import CMUXMobileCore +import CmuxMobileShellModel + +struct TerminalLoadingDiagnosticsModel: Equatable { + let title: String + let message: String + let rows: [TerminalLoadingDiagnosticsRow] + let isLoading: Bool + + init( + workspaceName: String, + terminalCount: Int, + macName: String?, + connectionStatus: MobileMacConnectionStatus, + tailnetStatus: TailnetStatus?, + activeRoute: CmxAttachRoute?, + storedRouteDescription: String?, + connectionError: String?, + connectionErrorGuidance: String?, + loadingTimedOut: Bool = false + ) { + self = TerminalLoadingDiagnosticsModelBuilder( + workspaceName: workspaceName, + terminalCount: terminalCount, + macName: macName, + connectionStatus: connectionStatus, + tailnetStatus: tailnetStatus, + activeRoute: activeRoute, + storedRouteDescription: storedRouteDescription, + connectionError: connectionError, + connectionErrorGuidance: connectionErrorGuidance, + loadingTimedOut: loadingTimedOut + ).makeModel() + } + + init(title: String, message: String, rows: [TerminalLoadingDiagnosticsRow], isLoading: Bool) { + self.title = title + self.message = message + self.rows = rows + self.isLoading = isLoading + } +} +#endif diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TerminalLoadingDiagnosticsModelBuilder.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TerminalLoadingDiagnosticsModelBuilder.swift new file mode 100644 index 00000000000..d23f01cb35c --- /dev/null +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TerminalLoadingDiagnosticsModelBuilder.swift @@ -0,0 +1,198 @@ +#if os(iOS) +import CMUXMobileCore +import CmuxMobileShellModel +import CmuxMobileSupport +import Foundation + +struct TerminalLoadingDiagnosticsModelBuilder { + let workspaceName: String + let terminalCount: Int + let macName: String? + let connectionStatus: MobileMacConnectionStatus + let tailnetStatus: TailnetStatus? + let activeRoute: CmxAttachRoute? + let storedRouteDescription: String? + let connectionError: String? + let connectionErrorGuidance: String? + let loadingTimedOut: Bool + + func makeModel() -> TerminalLoadingDiagnosticsModel { + let resolvedMacName = nonEmpty(macName) ?? nonEmpty(workspaceName) ?? L10n.string( + "mobile.terminal.loading.macFallback", + defaultValue: "Mac" + ) + var rows: [TerminalLoadingDiagnosticsRow] = [ + TerminalLoadingDiagnosticsRow( + id: "mac", + label: L10n.string("mobile.terminal.loading.mac", defaultValue: "Mac"), + value: String.localizedStringWithFormat( + L10n.string( + "mobile.terminal.loading.macStatusFormat", + defaultValue: "%@ · %@" + ), + resolvedMacName, + connectionStatus.label + ), + tone: tone(for: connectionStatus) + ), + TerminalLoadingDiagnosticsRow( + id: "terminals", + label: L10n.string("mobile.terminal.loading.terminals", defaultValue: "Terminals"), + value: terminalStatusText(count: terminalCount), + tone: terminalCount > 0 ? .good : .pending + ), + TerminalLoadingDiagnosticsRow( + id: "tailscale", + label: L10n.string("mobile.terminal.loading.tailscale", defaultValue: "Tailscale"), + value: tailnetStatusText(tailnetStatus), + tone: tone(for: tailnetStatus) + ), + TerminalLoadingDiagnosticsRow( + id: "route", + label: L10n.string("mobile.terminal.loading.route", defaultValue: "Route"), + value: routeText( + activeRoute: activeRoute, + storedRouteDescription: storedRouteDescription + ), + tone: activeRoute == nil && nonEmpty(storedRouteDescription) == nil ? .warning : .neutral + ), + ] + + if let detail = nonEmpty(connectionErrorGuidance) ?? nonEmpty(connectionError) { + rows.append(TerminalLoadingDiagnosticsRow( + id: "network", + label: L10n.string("mobile.terminal.loading.network", defaultValue: "Network"), + value: detail, + tone: .warning + )) + } + + let text = headerText(resolvedMacName: resolvedMacName) + return TerminalLoadingDiagnosticsModel( + title: text.title, + message: text.message, + rows: rows, + isLoading: connectionStatus == .connected && !loadingTimedOut + ) + } + + private func nonEmpty(_ value: String?) -> String? { + let trimmed = value?.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed?.isEmpty == false ? trimmed : nil + } + + private func terminalStatusText(count: Int) -> String { + guard count > 0 else { + return L10n.string("mobile.terminal.loading.terminalsWaiting", defaultValue: "No terminal list yet") + } + return L10n.terminalCount(count) + } + + private func headerText(resolvedMacName: String) -> (title: String, message: String) { + if connectionStatus == .connected && terminalCount == 0 && loadingTimedOut { + return ( + L10n.string("mobile.terminal.loading.emptyTitle", defaultValue: "No terminals yet"), + String.localizedStringWithFormat( + L10n.string( + "mobile.terminal.loading.emptyMessageFormat", + defaultValue: "%@ is connected. Create a terminal, or tap Refresh to check again." + ), + resolvedMacName + ) + ) + } + return ( + L10n.string("mobile.terminal.loading.title", defaultValue: "Loading terminals"), + String.localizedStringWithFormat( + L10n.string( + "mobile.terminal.loading.messageFormat", + defaultValue: "Waiting for %@ to send terminal metadata for this workspace." + ), + resolvedMacName + ) + ) + } + + private func tailnetStatusText(_ status: TailnetStatus?) -> String { + switch status { + case .active: + return L10n.string("mobile.terminal.loading.tailscale.active", defaultValue: "Active") + case .inactiveOrNotInstalled: + return L10n.string("mobile.terminal.loading.tailscale.inactive", defaultValue: "Off or not installed") + case .unknown: + return L10n.string("mobile.terminal.loading.tailscale.unknown", defaultValue: "Unknown") + case nil: + return L10n.string("mobile.terminal.loading.tailscale.notChecked", defaultValue: "Not checked") + } + } + + private func routeText(activeRoute: CmxAttachRoute?, storedRouteDescription: String?) -> String { + if let activeRoute { + return String.localizedStringWithFormat( + L10n.string( + "mobile.terminal.loading.routeActiveFormat", + defaultValue: "%@ · %@" + ), + routeKindText(activeRoute.kind), + endpointText(activeRoute.endpoint) + ) + } + if let storedRouteDescription = nonEmpty(storedRouteDescription) { + return String.localizedStringWithFormat( + L10n.string( + "mobile.terminal.loading.routeStoredFormat", + defaultValue: "Saved route · %@" + ), + storedRouteDescription + ) + } + return L10n.string("mobile.terminal.loading.routeMissing", defaultValue: "No saved route") + } + + private func routeKindText(_ kind: CmxAttachTransportKind) -> String { + switch kind { + case .tailscale: + return L10n.string("mobile.terminal.loading.route.tailscale", defaultValue: "Tailscale") + case .debugLoopback: + return L10n.string("mobile.terminal.loading.route.debugLoopback", defaultValue: "Debug loopback") + case .iroh: + return L10n.string("mobile.terminal.loading.route.iroh", defaultValue: "Iroh") + case .websocket: + return L10n.string("mobile.terminal.loading.route.websocket", defaultValue: "WebSocket") + } + } + + private func endpointText(_ endpoint: CmxAttachEndpoint) -> String { + switch endpoint { + case let .hostPort(host, port): + return "\(host):\(port)" + case let .peer(id, _, directAddrs, _): + return directAddrs.first ?? id + case let .url(url): + return url + } + } + + private func tone(for status: MobileMacConnectionStatus) -> TerminalLoadingDiagnosticsTone { + switch status { + case .connected: + return .good + case .reconnecting: + return .pending + case .unavailable: + return .warning + } + } + + private func tone(for status: TailnetStatus?) -> TerminalLoadingDiagnosticsTone { + switch status { + case .active: + return .good + case .inactiveOrNotInstalled: + return .warning + case .unknown, nil: + return .neutral + } + } +} +#endif diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TerminalLoadingDiagnosticsOverlay.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TerminalLoadingDiagnosticsOverlay.swift index 7db12d9359a..36ade96fac7 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TerminalLoadingDiagnosticsOverlay.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TerminalLoadingDiagnosticsOverlay.swift @@ -4,219 +4,6 @@ import CmuxMobileShellModel import CmuxMobileSupport import SwiftUI -struct TerminalLoadingDiagnosticsRow: Equatable, Identifiable { - enum Tone: Equatable { - case good - case pending - case warning - case neutral - } - - let id: String - let label: String - let value: String - let tone: Tone -} - -struct TerminalLoadingDiagnosticsModel: Equatable { - let title: String - let message: String - let rows: [TerminalLoadingDiagnosticsRow] - - static func snapshot( - workspaceName: String, - terminalCount: Int, - macName: String?, - connectionStatus: MobileMacConnectionStatus, - tailnetStatus: TailnetStatus?, - activeRoute: CmxAttachRoute?, - storedRouteDescription: String?, - connectionError: String?, - connectionErrorGuidance: String? - ) -> Self { - let resolvedMacName = Self.nonEmpty(macName) ?? Self.nonEmpty(workspaceName) ?? L10n.string( - "mobile.terminal.loading.macFallback", - defaultValue: "Mac" - ) - var rows: [TerminalLoadingDiagnosticsRow] = [ - TerminalLoadingDiagnosticsRow( - id: "mac", - label: L10n.string("mobile.terminal.loading.mac", defaultValue: "Mac"), - value: String.localizedStringWithFormat( - L10n.string( - "mobile.terminal.loading.macStatusFormat", - defaultValue: "%@ · %@" - ), - resolvedMacName, - connectionStatus.label - ), - tone: tone(for: connectionStatus) - ), - TerminalLoadingDiagnosticsRow( - id: "terminals", - label: L10n.string("mobile.terminal.loading.terminals", defaultValue: "Terminals"), - value: terminalStatusText(count: terminalCount), - tone: terminalCount > 0 ? .good : .pending - ), - TerminalLoadingDiagnosticsRow( - id: "tailscale", - label: L10n.string("mobile.terminal.loading.tailscale", defaultValue: "Tailscale"), - value: tailnetStatusText(tailnetStatus), - tone: tone(for: tailnetStatus) - ), - TerminalLoadingDiagnosticsRow( - id: "route", - label: L10n.string("mobile.terminal.loading.route", defaultValue: "Route"), - value: routeText(activeRoute: activeRoute, storedRouteDescription: storedRouteDescription), - tone: activeRoute == nil && Self.nonEmpty(storedRouteDescription) == nil ? .warning : .neutral - ), - ] - - if let detail = Self.nonEmpty(connectionErrorGuidance) ?? Self.nonEmpty(connectionError) { - rows.append(TerminalLoadingDiagnosticsRow( - id: "network", - label: L10n.string("mobile.terminal.loading.network", defaultValue: "Network"), - value: detail, - tone: .warning - )) - } - - let text = headerText( - terminalCount: terminalCount, - connectionStatus: connectionStatus, - resolvedMacName: resolvedMacName - ) - return Self( - title: text.title, - message: text.message, - rows: rows - ) - } - - private static func nonEmpty(_ value: String?) -> String? { - let trimmed = value?.trimmingCharacters(in: .whitespacesAndNewlines) - return trimmed?.isEmpty == false ? trimmed : nil - } - - private static func terminalStatusText(count: Int) -> String { - guard count > 0 else { - return L10n.string("mobile.terminal.loading.terminalsWaiting", defaultValue: "No terminal list yet") - } - return L10n.terminalCount(count) - } - - private static func headerText( - terminalCount: Int, - connectionStatus: MobileMacConnectionStatus, - resolvedMacName: String - ) -> (title: String, message: String) { - if connectionStatus == .connected && terminalCount == 0 { - return ( - L10n.string("mobile.terminal.loading.emptyTitle", defaultValue: "No terminals yet"), - String.localizedStringWithFormat( - L10n.string( - "mobile.terminal.loading.emptyMessageFormat", - defaultValue: "%@ is connected. Create a terminal, or wait for terminal metadata to arrive." - ), - resolvedMacName - ) - ) - } - return ( - L10n.string("mobile.terminal.loading.title", defaultValue: "Loading terminals"), - String.localizedStringWithFormat( - L10n.string( - "mobile.terminal.loading.messageFormat", - defaultValue: "Waiting for %@ to send terminal metadata for this workspace." - ), - resolvedMacName - ) - ) - } - - private static func tailnetStatusText(_ status: TailnetStatus?) -> String { - switch status { - case .active: - return L10n.string("mobile.terminal.loading.tailscale.active", defaultValue: "Active") - case .inactiveOrNotInstalled: - return L10n.string("mobile.terminal.loading.tailscale.inactive", defaultValue: "Off or not installed") - case .unknown: - return L10n.string("mobile.terminal.loading.tailscale.unknown", defaultValue: "Unknown") - case nil: - return L10n.string("mobile.terminal.loading.tailscale.notChecked", defaultValue: "Not checked") - } - } - - private static func routeText(activeRoute: CmxAttachRoute?, storedRouteDescription: String?) -> String { - if let activeRoute { - return String.localizedStringWithFormat( - L10n.string( - "mobile.terminal.loading.routeActiveFormat", - defaultValue: "%@ · %@" - ), - routeKindText(activeRoute.kind), - endpointText(activeRoute.endpoint) - ) - } - if let storedRouteDescription = nonEmpty(storedRouteDescription) { - return String.localizedStringWithFormat( - L10n.string( - "mobile.terminal.loading.routeStoredFormat", - defaultValue: "Saved route · %@" - ), - storedRouteDescription - ) - } - return L10n.string("mobile.terminal.loading.routeMissing", defaultValue: "No saved route") - } - - private static func routeKindText(_ kind: CmxAttachTransportKind) -> String { - switch kind { - case .tailscale: - return L10n.string("mobile.terminal.loading.route.tailscale", defaultValue: "Tailscale") - case .debugLoopback: - return L10n.string("mobile.terminal.loading.route.debugLoopback", defaultValue: "Debug loopback") - case .iroh: - return L10n.string("mobile.terminal.loading.route.iroh", defaultValue: "Iroh") - case .websocket: - return L10n.string("mobile.terminal.loading.route.websocket", defaultValue: "WebSocket") - } - } - - private static func endpointText(_ endpoint: CmxAttachEndpoint) -> String { - switch endpoint { - case let .hostPort(host, port): - return "\(host):\(port)" - case let .peer(id, _, directAddrs, _): - return directAddrs.first ?? id - case let .url(url): - return url - } - } - - private static func tone(for status: MobileMacConnectionStatus) -> TerminalLoadingDiagnosticsRow.Tone { - switch status { - case .connected: - return .good - case .reconnecting: - return .pending - case .unavailable: - return .warning - } - } - - private static func tone(for status: TailnetStatus?) -> TerminalLoadingDiagnosticsRow.Tone { - switch status { - case .active: - return .good - case .inactiveOrNotInstalled: - return .warning - case .unknown, nil: - return .neutral - } - } -} - struct TerminalLoadingDiagnosticsOverlay: View { let workspace: MobileWorkspacePreview let host: String @@ -227,10 +14,16 @@ struct TerminalLoadingDiagnosticsOverlay: View { let connectionError: String? let connectionErrorGuidance: String? let createTerminal: () -> Void + let refreshConnection: () -> Void let canCreateTerminal: Bool + private static let terminalMetadataTimeout: Duration = .seconds(10) + + @State private var terminalMetadataTimedOut = false + @State private var refreshGeneration = 0 + private var model: TerminalLoadingDiagnosticsModel { - TerminalLoadingDiagnosticsModel.snapshot( + TerminalLoadingDiagnosticsModel( workspaceName: workspace.name, terminalCount: workspace.terminals.count, macName: workspace.macDisplayName ?? host, @@ -239,16 +32,24 @@ struct TerminalLoadingDiagnosticsOverlay: View { activeRoute: activeRoute, storedRouteDescription: storedRouteDescription, connectionError: connectionError, - connectionErrorGuidance: connectionErrorGuidance + connectionErrorGuidance: connectionErrorGuidance, + loadingTimedOut: terminalMetadataTimedOut ) } var body: some View { VStack(spacing: 16) { - ProgressView() - .controlSize(.large) - .tint(.white) - .accessibilityHidden(true) + if model.isLoading { + ProgressView() + .controlSize(.large) + .tint(.white) + .accessibilityHidden(true) + } else { + Image(systemName: "terminal") + .font(.system(size: 38, weight: .semibold)) + .foregroundStyle(.white.opacity(0.9)) + .accessibilityHidden(true) + } VStack(spacing: 6) { Text(model.title) @@ -283,28 +84,47 @@ struct TerminalLoadingDiagnosticsOverlay: View { .padding(14) .background(.white.opacity(0.08), in: RoundedRectangle(cornerRadius: 8, style: .continuous)) - if canCreateTerminal { - Button(action: createTerminal) { + HStack(spacing: 10) { + Button(action: retryLoading) { Label( - L10n.string("mobile.terminal.loading.createTerminal", defaultValue: "Create Terminal"), - systemImage: "plus" + L10n.string("mobile.terminal.loading.refresh", defaultValue: "Refresh"), + systemImage: "arrow.clockwise" ) .font(.subheadline.weight(.semibold)) - .padding(.horizontal, 18) + .padding(.horizontal, 16) .padding(.vertical, 9) - .background(Color.accentColor, in: Capsule()) + .background(.white.opacity(0.12), in: Capsule()) .foregroundStyle(.white) } .buttonStyle(.plain) - .accessibilityIdentifier("MobileLoadingCreateTerminalButton") + .accessibilityIdentifier("MobileLoadingRefreshButton") + + if canCreateTerminal { + Button(action: createTerminal) { + Label( + L10n.string("mobile.terminal.loading.createTerminal", defaultValue: "Create Terminal"), + systemImage: "plus" + ) + .font(.subheadline.weight(.semibold)) + .padding(.horizontal, 18) + .padding(.vertical, 9) + .background(Color.accentColor, in: Capsule()) + .foregroundStyle(.white) + } + .buttonStyle(.plain) + .accessibilityIdentifier("MobileLoadingCreateTerminalButton") + } } } .padding(24) .frame(maxWidth: 420) .accessibilityIdentifier("MobileTerminalLoadingDiagnostics") + .task(id: deadlineTaskID) { + await updateTerminalMetadataDeadline() + } } - private func color(for tone: TerminalLoadingDiagnosticsRow.Tone) -> Color { + private func color(for tone: TerminalLoadingDiagnosticsTone) -> Color { switch tone { case .good: return .green @@ -316,58 +136,53 @@ struct TerminalLoadingDiagnosticsOverlay: View { return .white.opacity(0.45) } } -} -extension WorkspaceDetailView { - var loadingDiagnosticsConnectionStatus: MobileMacConnectionStatus { + private var effectiveConnectionStatus: MobileMacConnectionStatus { workspace.macConnectionStatus ?? connectionStatus } - var loadingDiagnosticsMacSnapshot: MacComputerSnapshot? { - guard let macDeviceID = workspace.macDeviceID, - !macDeviceID.isEmpty else { - return nil - } - return MacComputerSnapshot.snapshots(from: store).first { snapshot in - snapshot.deviceId == macDeviceID || snapshot.aliasIDs.contains(macDeviceID) - } + private var deadlineTaskID: String { + [ + workspace.id.rawValue, + String(workspace.terminals.count), + connectionStatusKey(effectiveConnectionStatus), + String(refreshGeneration), + ].joined(separator: ":") } - var activeLoadingDiagnosticsRoute: CmxAttachRoute? { - guard let macDeviceID = workspace.macDeviceID, - !macDeviceID.isEmpty else { - return nil + private func updateTerminalMetadataDeadline() async { + terminalMetadataTimedOut = false + guard effectiveConnectionStatus == .connected, + workspace.terminals.isEmpty else { + return } - if loadingDiagnosticsMatchesForegroundMac(macDeviceID) { - return store.activeRoute + do { + try await ContinuousClock().sleep(for: Self.terminalMetadataTimeout) + } catch { + return } - return nil - } - - var loadingDiagnosticsConnectionError: String? { - loadingDiagnosticsMatchesForegroundMac(workspace.macDeviceID) ? store.connectionError : nil + guard effectiveConnectionStatus == .connected, + workspace.terminals.isEmpty else { + return + } + terminalMetadataTimedOut = true } - var loadingDiagnosticsConnectionErrorGuidance: String? { - loadingDiagnosticsMatchesForegroundMac(workspace.macDeviceID) ? store.connectionErrorGuidance : nil + private func retryLoading() { + terminalMetadataTimedOut = false + refreshGeneration &+= 1 + refreshConnection() } - private func loadingDiagnosticsMatchesForegroundMac(_ macDeviceID: String?) -> Bool { - guard let macDeviceID, - !macDeviceID.isEmpty else { - return false - } - if macDeviceID == store.connectedMacDeviceID { - return true - } - if macDeviceID == store.activeTicket?.macDeviceID { - return true - } - guard let connectedMacDeviceID = store.connectedMacDeviceID, - store.pairedMacAliasIDs(for: macDeviceID).contains(connectedMacDeviceID) else { - return false + private func connectionStatusKey(_ status: MobileMacConnectionStatus) -> String { + switch status { + case .connected: + return "connected" + case .reconnecting: + return "reconnecting" + case .unavailable: + return "unavailable" } - return true } } #endif diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TerminalLoadingDiagnosticsRow.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TerminalLoadingDiagnosticsRow.swift new file mode 100644 index 00000000000..28afdce9450 --- /dev/null +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TerminalLoadingDiagnosticsRow.swift @@ -0,0 +1,8 @@ +#if os(iOS) +struct TerminalLoadingDiagnosticsRow: Equatable, Identifiable { + let id: String + let label: String + let value: String + let tone: TerminalLoadingDiagnosticsTone +} +#endif diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TerminalLoadingDiagnosticsTone.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TerminalLoadingDiagnosticsTone.swift new file mode 100644 index 00000000000..296be604b99 --- /dev/null +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TerminalLoadingDiagnosticsTone.swift @@ -0,0 +1,8 @@ +#if os(iOS) +enum TerminalLoadingDiagnosticsTone: Equatable { + case good + case pending + case warning + case neutral +} +#endif diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceDetailView+TerminalLoadingDiagnostics.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceDetailView+TerminalLoadingDiagnostics.swift new file mode 100644 index 00000000000..4e41f4dda3e --- /dev/null +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceDetailView+TerminalLoadingDiagnostics.swift @@ -0,0 +1,74 @@ +#if os(iOS) +import CMUXMobileCore +import CmuxMobileShellModel + +extension WorkspaceDetailView { + var loadingDiagnosticsConnectionStatus: MobileMacConnectionStatus { + if let macDeviceID = workspace.macDeviceID, + !macDeviceID.isEmpty, + !loadingDiagnosticsMatchesForegroundMac(macDeviceID) { + return .unavailable + } + return workspace.macConnectionStatus ?? connectionStatus + } + + var loadingDiagnosticsStoredRouteDescription: String? { + guard let macDeviceID = workspace.macDeviceID, + !macDeviceID.isEmpty else { + return nil + } + return store.displayPairedMacs + .first { mac in + mac.macDeviceID == macDeviceID || store.pairedMacAliasIDs(for: mac.macDeviceID).contains(macDeviceID) + } + .flatMap { CmxAttachRoute.deviceTreeRouteDescription(for: $0.routes) } + } + + var activeLoadingDiagnosticsRoute: CmxAttachRoute? { + guard let macDeviceID = workspace.macDeviceID, + !macDeviceID.isEmpty else { + return loadingDiagnosticsConnectionStatus == .connected ? store.activeRoute : nil + } + if loadingDiagnosticsMatchesForegroundMac(macDeviceID) { + return store.activeRoute + } + return nil + } + + var loadingDiagnosticsConnectionError: String? { + loadingDiagnosticsMatchesForegroundMac(workspace.macDeviceID) ? store.connectionError : nil + } + + var loadingDiagnosticsConnectionErrorGuidance: String? { + loadingDiagnosticsMatchesForegroundMac(workspace.macDeviceID) ? store.connectionErrorGuidance : nil + } + + func refreshLoadingDiagnosticsConnection() { + Task { + if let macDeviceID = workspace.macDeviceID, + !macDeviceID.isEmpty { + _ = await store.switchToMac(macDeviceID: macDeviceID) + } + await store.reconnectOrRefresh() + } + } + + private func loadingDiagnosticsMatchesForegroundMac(_ macDeviceID: String?) -> Bool { + guard let macDeviceID, + !macDeviceID.isEmpty else { + return false + } + if macDeviceID == store.connectedMacDeviceID { + return true + } + if macDeviceID == store.activeTicket?.macDeviceID { + return true + } + guard let connectedMacDeviceID = store.connectedMacDeviceID, + store.pairedMacAliasIDs(for: macDeviceID).contains(connectedMacDeviceID) else { + return false + } + return true + } +} +#endif diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceDetailView.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceDetailView.swift index 317b5f5f2d5..f583d1943ee 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceDetailView.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceDetailView.swift @@ -250,10 +250,11 @@ struct WorkspaceDetailView: View { connectionStatus: loadingDiagnosticsConnectionStatus, tailnetStatus: tailscaleStatusMonitor?.status, activeRoute: activeLoadingDiagnosticsRoute, - storedRouteDescription: loadingDiagnosticsMacSnapshot?.routeDescription, + storedRouteDescription: loadingDiagnosticsStoredRouteDescription, connectionError: loadingDiagnosticsConnectionError, connectionErrorGuidance: loadingDiagnosticsConnectionErrorGuidance, createTerminal: createTerminal, + refreshConnection: refreshLoadingDiagnosticsConnection, canCreateTerminal: true ) } @@ -273,7 +274,10 @@ struct WorkspaceDetailView: View { .overlay { // Show a reconnecting/offline state instead of a black terminal. if loadingDiagnosticsConnectionStatus != .connected { - TerminalDisconnectedOverlay(status: loadingDiagnosticsConnectionStatus, host: host) { + TerminalDisconnectedOverlay( + status: loadingDiagnosticsConnectionStatus, + host: workspace.macDisplayName ?? host + ) { Task { if let macDeviceID = workspace.macDeviceID, !macDeviceID.isEmpty, diff --git a/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/TerminalLoadingDiagnosticsModelTests.swift b/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/TerminalLoadingDiagnosticsModelTests.swift index 273ba35ea2b..6d5e80db756 100644 --- a/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/TerminalLoadingDiagnosticsModelTests.swift +++ b/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/TerminalLoadingDiagnosticsModelTests.swift @@ -12,7 +12,7 @@ import Testing endpoint: .hostPort(host: "100.64.0.10", port: 58465) ) - let model = TerminalLoadingDiagnosticsModel.snapshot( + let model = TerminalLoadingDiagnosticsModel( workspaceName: "cmux", terminalCount: 0, macName: "Studio", @@ -24,16 +24,36 @@ import Testing connectionErrorGuidance: nil ) - #expect(model.title == "No terminals yet") - #expect(model.message == "Studio is connected. Create a terminal, or wait for terminal metadata to arrive.") + #expect(model.title == "Loading terminals") + #expect(model.message == "Waiting for Studio to send terminal metadata for this workspace.") + #expect(model.isLoading) #expect(model.rows[id: "mac"]?.value == "Studio · Connected") #expect(model.rows[id: "tailscale"]?.value == "Active") #expect(model.rows[id: "route"]?.value == "Tailscale · 100.64.0.10:58465") #expect(model.rows[id: "terminals"]?.value == "No terminal list yet") } + @Test func connectedEmptyWorkspaceTimesOutToExplicitRecoveryCopy() { + let model = TerminalLoadingDiagnosticsModel( + workspaceName: "cmux", + terminalCount: 0, + macName: "Studio", + connectionStatus: .connected, + tailnetStatus: .active, + activeRoute: nil, + storedRouteDescription: nil, + connectionError: nil, + connectionErrorGuidance: nil, + loadingTimedOut: true + ) + + #expect(model.title == "No terminals yet") + #expect(model.message == "Studio is connected. Create a terminal, or tap Refresh to check again.") + #expect(!model.isLoading) + } + @Test func inactiveTailscaleAndSavedRouteSurfaceNetworkGuidance() { - let model = TerminalLoadingDiagnosticsModel.snapshot( + let model = TerminalLoadingDiagnosticsModel( workspaceName: "cmux", terminalCount: 0, macName: nil, @@ -60,7 +80,7 @@ import Testing endpoint: .hostPort(host: "127.0.0.1", port: 3942) ) - let model = TerminalLoadingDiagnosticsModel.snapshot( + let model = TerminalLoadingDiagnosticsModel( workspaceName: "cmux", terminalCount: 1, macName: "Mini", diff --git a/Resources/Localizable.xcstrings b/Resources/Localizable.xcstrings index 24afa5e51c1..1a92dbc2cdb 100644 --- a/Resources/Localizable.xcstrings +++ b/Resources/Localizable.xcstrings @@ -117600,13 +117600,13 @@ "en": { "stringUnit": { "state": "translated", - "value": "%@ is connected. Create a terminal, or wait for terminal metadata to arrive." + "value": "%@ is connected. Create a terminal, or tap Refresh to check again." } }, "ja": { "stringUnit": { "state": "translated", - "value": "%@ に接続済みです。ターミナルを作成するか、ターミナル情報の到着を待ってください。" + "value": "%@ に接続済みです。ターミナルを作成するか、「更新」をタップして再確認してください。" } } } @@ -117713,6 +117713,23 @@ } } }, + "mobile.terminal.loading.refresh": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Refresh" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "更新" + } + } + } + }, "mobile.terminal.loading.route": { "extractionState": "manual", "localizations": { diff --git a/ios/cmux/Resources/Localizable.xcstrings b/ios/cmux/Resources/Localizable.xcstrings index 6982dafb14d..5034b44d487 100644 --- a/ios/cmux/Resources/Localizable.xcstrings +++ b/ios/cmux/Resources/Localizable.xcstrings @@ -8218,13 +8218,13 @@ "en": { "stringUnit": { "state": "translated", - "value": "%@ is connected. Create a terminal, or wait for terminal metadata to arrive." + "value": "%@ is connected. Create a terminal, or tap Refresh to check again." } }, "ja": { "stringUnit": { "state": "translated", - "value": "%@ に接続済みです。ターミナルを作成するか、ターミナル情報の到着を待ってください。" + "value": "%@ に接続済みです。ターミナルを作成するか、「更新」をタップして再確認してください。" } } } @@ -8331,6 +8331,23 @@ } } }, + "mobile.terminal.loading.refresh": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Refresh" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "更新" + } + } + } + }, "mobile.terminal.loading.route": { "extractionState": "manual", "localizations": {