From f97965197518572ae0819bdd2a126f7ccf77422f Mon Sep 17 00:00:00 2001 From: Aziz Albahar Date: Sat, 13 Jun 2026 14:48:31 -0700 Subject: [PATCH 1/4] Reduce iOS workspace row swipe contention --- .../CmuxMobileShellUI/DeviceTreeView.swift | 6 ++- .../MobileWorkspacePreview+Display.swift | 50 ++++++------------- .../WorkspaceNavigationRow.swift | 18 ++++--- .../CmuxMobileShellUI/WorkspaceRow.swift | 14 ++---- 4 files changed, 34 insertions(+), 54 deletions(-) diff --git a/Packages/CmuxMobileShellUI/Sources/CmuxMobileShellUI/DeviceTreeView.swift b/Packages/CmuxMobileShellUI/Sources/CmuxMobileShellUI/DeviceTreeView.swift index 3e6bcd77a2c..5a7df02edb7 100644 --- a/Packages/CmuxMobileShellUI/Sources/CmuxMobileShellUI/DeviceTreeView.swift +++ b/Packages/CmuxMobileShellUI/Sources/CmuxMobileShellUI/DeviceTreeView.swift @@ -135,7 +135,11 @@ struct DeviceTreeView: View { if expansion.isExpanded(deviceExpansionID(device)) { ForEach(device.instances) { instance in - instanceRows(device: device, instance: instance, isConnectedDevice: isConnectedDevice) + instanceRows( + device: device, + instance: instance, + isConnectedDevice: isConnectedDevice + ) } } } diff --git a/Packages/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileWorkspacePreview+Display.swift b/Packages/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileWorkspacePreview+Display.swift index b3b8d48a6c7..f108d2f7452 100644 --- a/Packages/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileWorkspacePreview+Display.swift +++ b/Packages/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileWorkspacePreview+Display.swift @@ -1,5 +1,6 @@ import CmuxMobileShellModel import CmuxMobileSupport +import Foundation import SwiftUI /// Display-only derivations of ``MobileWorkspacePreview`` used by the workspace @@ -42,49 +43,28 @@ extension MobileWorkspacePreview { } /// The row's trailing slot: the connection problem when there is one, - /// otherwise the compact relative activity time. `now` is threaded from the - /// row's `TimelineView` so the label refreshes as time passes and stays - /// deterministic in tests. - func timestampOrStatus(connectionStatus: MobileMacConnectionStatus, now: Date) -> String { + /// otherwise a static activity timestamp. This intentionally avoids a live + /// relative clock in list rows so native swipe tracking is not invalidated by + /// timer-driven row updates. + func timestampOrStatus(connectionStatus: MobileMacConnectionStatus) -> String { if connectionStatus != .connected { return connectionStatus.label } - return relativeActivityLabel(now: now) + return activityTimestampLabel() } - /// Compact relative time for the row's trailing slot, like a messaging list: - /// "now" under a minute, then "2m", "1h", "3d", and a localized month/day - /// past a week. Empty when there is no real activity timestamp. The bucket - /// and its count come from ``MobileRelativeActivity``, computed purely from - /// the injected `now`, so the label is deterministic in tests (only the - /// `monthDay` case formats the date itself, which does not depend on `now`). - func relativeActivityLabel(now: Date) -> String { + /// Static timestamp for the row's trailing slot. Recent activity shows the + /// local time; older activity shows a compact month/day. Empty when there is + /// no real activity timestamp. + func activityTimestampLabel(referenceDate: Date = .now, calendar: Calendar = .current) -> String { let date = latestActivityDate - switch MobileRelativeActivity.bucket(for: date, now: now) { - case .none: - // The trailing slot stays empty rather than echoing the epoch. + guard date > Date(timeIntervalSince1970: 1) else { return "" - case .now: - return L10n.string("mobile.workspace.preview.justNow", defaultValue: "now") - case .minutes(let minutes): - return String( - format: L10n.string("mobile.workspace.preview.minutesCompactFormat", defaultValue: "%dm"), - minutes - ) - case .hours(let hours): - return String( - format: L10n.string("mobile.workspace.preview.hoursCompactFormat", defaultValue: "%dh"), - hours - ) - case .days(let days): - return String( - format: L10n.string("mobile.workspace.preview.daysCompactFormat", defaultValue: "%dd"), - days - ) - case .monthDay: - // Past a week, a month/day date is more useful than "5 weeks ago". - return date.formatted(.dateTime.month(.defaultDigits).day(.defaultDigits)) } + if calendar.isDate(date, inSameDayAs: referenceDate) { + return date.formatted(.dateTime.hour().minute()) + } + return date.formatted(.dateTime.month(.defaultDigits).day(.defaultDigits)) } func detailLine(connectionStatus: MobileMacConnectionStatus) -> String { diff --git a/Packages/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceNavigationRow.swift b/Packages/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceNavigationRow.swift index 67687cd7d01..151f75b65d3 100644 --- a/Packages/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceNavigationRow.swift +++ b/Packages/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceNavigationRow.swift @@ -35,16 +35,18 @@ struct WorkspaceNavigationRow: View { @State private var isRenaming = false var body: some View { - WorkspaceRow( - workspace: workspace, - connectionStatus: connectionStatus, - isSelected: navigationStyle == .sidebar && isSelected, - wrapWorkspaceTitles: wrapWorkspaceTitles, - previewLineLimit: previewLineLimit - ) - .onTapGesture { + Button { selectWorkspace(workspace.id) + } label: { + WorkspaceRow( + workspace: workspace, + connectionStatus: connectionStatus, + isSelected: navigationStyle == .sidebar && isSelected, + wrapWorkspaceTitles: wrapWorkspaceTitles, + previewLineLimit: previewLineLimit + ) } + .buttonStyle(.plain) .contentShape(Rectangle()) .contextMenu { contextMenu } .swipeActions(edge: .leading, allowsFullSwipe: true) { diff --git a/Packages/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceRow.swift b/Packages/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceRow.swift index c965a735313..3200fe2ec37 100644 --- a/Packages/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceRow.swift +++ b/Packages/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceRow.swift @@ -40,16 +40,10 @@ struct WorkspaceRow: View { Spacer(minLength: 8) - // TimelineView re-evaluates the label every minute so a - // quiet row's relative time ("now" -> "1m" -> ...) advances - // without waiting for an unrelated state change to - // invalidate the row. Minute granularity matches the label. - TimelineView(.everyMinute) { context in - Text(workspace.timestampOrStatus(connectionStatus: connectionStatus, now: context.date)) - .font(.subheadline) - .foregroundStyle(.secondary) - .lineLimit(1) - } + Text(workspace.timestampOrStatus(connectionStatus: connectionStatus)) + .font(.subheadline) + .foregroundStyle(.secondary) + .lineLimit(1) } Text(workspace.previewLine) From f4af2f38f49e48bdbb5125d3e360d8881362dc6b Mon Sep 17 00:00:00 2001 From: Aziz Albahar Date: Sat, 13 Jun 2026 16:04:45 -0700 Subject: [PATCH 2/4] Reduce mobile workspace avatar render cost --- .../MobileWorkspacePreview+Display.swift | 12 +++--------- .../Sources/CmuxMobileShellUI/WorkspaceRow.swift | 2 +- 2 files changed, 4 insertions(+), 10 deletions(-) diff --git a/Packages/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileWorkspacePreview+Display.swift b/Packages/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileWorkspacePreview+Display.swift index f108d2f7452..f690f603b28 100644 --- a/Packages/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileWorkspacePreview+Display.swift +++ b/Packages/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileWorkspacePreview+Display.swift @@ -31,15 +31,9 @@ extension MobileWorkspacePreview { terminals.count > 1 ? "rectangle.stack.fill" : "terminal.fill" } - var avatarGradient: LinearGradient { - let palettes: [[Color]] = [ - [Color.blue, Color.cyan], - [Color.green, Color.teal], - [Color.orange, Color.yellow], - [Color.gray, Color.blue], - ] - let colors = palettes[abs(stableAvatarSeed) % palettes.count] - return LinearGradient(colors: colors, startPoint: .topLeading, endPoint: .bottomTrailing) + var avatarColor: Color { + let colors: [Color] = [.blue, .green, .orange, .gray] + return colors[abs(stableAvatarSeed) % colors.count] } /// The row's trailing slot: the connection problem when there is one, diff --git a/Packages/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceRow.swift b/Packages/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceRow.swift index 3200fe2ec37..d03c1483a6d 100644 --- a/Packages/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceRow.swift +++ b/Packages/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceRow.swift @@ -82,7 +82,7 @@ struct WorkspaceAvatar: View { var body: some View { ZStack { Circle() - .fill(workspace.avatarGradient) + .fill(workspace.avatarColor) .frame(width: 48, height: 48) Image(systemName: workspace.avatarSymbolName) From 7093e1c34d8a855dafc98e722316d185d17d260e Mon Sep 17 00:00:00 2001 From: Aziz Albahar Date: Sat, 13 Jun 2026 21:13:14 -0700 Subject: [PATCH 3/4] Use navigation links for compact workspace rows --- .../MobileWorkspacePreview+Display.swift | 12 +++-- .../WorkspaceNavigationRow.swift | 44 +++++++++++++------ .../CmuxMobileShellUI/WorkspaceRow.swift | 2 +- 3 files changed, 41 insertions(+), 17 deletions(-) diff --git a/Packages/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileWorkspacePreview+Display.swift b/Packages/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileWorkspacePreview+Display.swift index f690f603b28..f108d2f7452 100644 --- a/Packages/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileWorkspacePreview+Display.swift +++ b/Packages/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileWorkspacePreview+Display.swift @@ -31,9 +31,15 @@ extension MobileWorkspacePreview { terminals.count > 1 ? "rectangle.stack.fill" : "terminal.fill" } - var avatarColor: Color { - let colors: [Color] = [.blue, .green, .orange, .gray] - return colors[abs(stableAvatarSeed) % colors.count] + var avatarGradient: LinearGradient { + let palettes: [[Color]] = [ + [Color.blue, Color.cyan], + [Color.green, Color.teal], + [Color.orange, Color.yellow], + [Color.gray, Color.blue], + ] + let colors = palettes[abs(stableAvatarSeed) % palettes.count] + return LinearGradient(colors: colors, startPoint: .topLeading, endPoint: .bottomTrailing) } /// The row's trailing slot: the connection problem when there is one, diff --git a/Packages/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceNavigationRow.swift b/Packages/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceNavigationRow.swift index 151f75b65d3..da7639c79db 100644 --- a/Packages/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceNavigationRow.swift +++ b/Packages/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceNavigationRow.swift @@ -35,19 +35,7 @@ struct WorkspaceNavigationRow: View { @State private var isRenaming = false var body: some View { - Button { - selectWorkspace(workspace.id) - } label: { - WorkspaceRow( - workspace: workspace, - connectionStatus: connectionStatus, - isSelected: navigationStyle == .sidebar && isSelected, - wrapWorkspaceTitles: wrapWorkspaceTitles, - previewLineLimit: previewLineLimit - ) - } - .buttonStyle(.plain) - .contentShape(Rectangle()) + rowTarget .contextMenu { contextMenu } .swipeActions(edge: .leading, allowsFullSwipe: true) { if let setUnread { @@ -100,6 +88,36 @@ struct WorkspaceNavigationRow: View { } } + @ViewBuilder + private var rowTarget: some View { + switch navigationStyle { + case .push: + NavigationLink(value: workspace.id) { + rowLabel + } + .simultaneousGesture(TapGesture().onEnded { + selectWorkspace(workspace.id) + }) + case .sidebar: + Button { + selectWorkspace(workspace.id) + } label: { + rowLabel + } + .buttonStyle(.plain) + } + } + + private var rowLabel: some View { + WorkspaceRow( + workspace: workspace, + connectionStatus: connectionStatus, + isSelected: navigationStyle == .sidebar && isSelected, + wrapWorkspaceTitles: wrapWorkspaceTitles, + previewLineLimit: previewLineLimit + ) + } + @ViewBuilder private var contextMenu: some View { if let setPinned { diff --git a/Packages/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceRow.swift b/Packages/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceRow.swift index d03c1483a6d..3200fe2ec37 100644 --- a/Packages/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceRow.swift +++ b/Packages/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceRow.swift @@ -82,7 +82,7 @@ struct WorkspaceAvatar: View { var body: some View { ZStack { Circle() - .fill(workspace.avatarColor) + .fill(workspace.avatarGradient) .frame(width: 48, height: 48) Image(systemName: workspace.avatarSymbolName) From 3dc767248fb7ed4b36e0143421cc133a7057f39e Mon Sep 17 00:00:00 2001 From: Aziz Albahar Date: Sat, 13 Jun 2026 21:27:15 -0700 Subject: [PATCH 4/4] Keep device tree workspace rows as buttons --- .../Sources/CmuxMobileShellUI/DeviceTreeView.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Packages/CmuxMobileShellUI/Sources/CmuxMobileShellUI/DeviceTreeView.swift b/Packages/CmuxMobileShellUI/Sources/CmuxMobileShellUI/DeviceTreeView.swift index 5a7df02edb7..6605e9b95dc 100644 --- a/Packages/CmuxMobileShellUI/Sources/CmuxMobileShellUI/DeviceTreeView.swift +++ b/Packages/CmuxMobileShellUI/Sources/CmuxMobileShellUI/DeviceTreeView.swift @@ -207,7 +207,7 @@ struct DeviceTreeView: View { workspace: workspace, connectionStatus: store.macConnectionStatus, isSelected: false, - navigationStyle: .push, + navigationStyle: .sidebar, wrapWorkspaceTitles: displaySettings.wrapWorkspaceTitles, previewLineLimit: displaySettings.workspacePreviewLineCount, selectWorkspace: { id in