From 98dd7c1ae0d4f76f5700514c20eb94d3b1810ff5 Mon Sep 17 00:00:00 2001 From: cmux reload-cloud Date: Tue, 21 Jul 2026 12:40:41 -0700 Subject: [PATCH 01/10] Add failing test: workspace table must drive nav/tab bar scroll edge effects The workspace list is a UIViewRepresentable UITableView, so SwiftUI never registers it as the bars' content scroll view and the .soft top edge style never renders: rows hard-clip at the search bar's bottom edge instead of soft-fading under the chrome like the App Store list. Co-Authored-By: Claude Fable 5 --- .../WorkspaceListScrollEdgeEffectTests.swift | 67 +++++++++++++++++++ 1 file changed, 67 insertions(+) create mode 100644 Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/WorkspaceListScrollEdgeEffectTests.swift diff --git a/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/WorkspaceListScrollEdgeEffectTests.swift b/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/WorkspaceListScrollEdgeEffectTests.swift new file mode 100644 index 000000000000..366f0779428b --- /dev/null +++ b/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/WorkspaceListScrollEdgeEffectTests.swift @@ -0,0 +1,67 @@ +#if os(iOS) +import Testing +import UIKit +@testable import CmuxMobileShellUI + +/// The workspace table is a `UIViewRepresentable`, so SwiftUI never registers +/// it with the enclosing bars. These tests pin the UIKit contract that makes +/// the soft scroll edge effect render: once hosted under a navigation and tab +/// bar controller, the table must be each container's content scroll view. +@MainActor +@Suite struct WorkspaceListScrollEdgeEffectTests { + @Test func hostedTableDrivesNavigationAndTabBarScrollEdgeEffects() throws { + guard #available(iOS 26.0, *) else { return } + let fixture = Fixture() + + #expect(fixture.content.contentScrollView(for: .top) === fixture.tableView) + #expect(fixture.navigation.contentScrollView(for: .bottom) === fixture.tableView) + } + + @Test func tableLeavingTheWindowReleasesBarRegistrations() throws { + guard #available(iOS 26.0, *) else { return } + let fixture = Fixture() + + fixture.tableView.removeFromSuperview() + + #expect(fixture.content.contentScrollView(for: .top) == nil) + #expect(fixture.navigation.contentScrollView(for: .bottom) == nil) + } + + @Test func departingTableDoesNotClobberReplacementRegistration() throws { + guard #available(iOS 26.0, *) else { return } + let fixture = Fixture() + let replacement = WorkspaceListUITableView(frame: .zero, style: .plain) + + fixture.content.view.addSubview(replacement) + replacement.layoutIfNeeded() + fixture.tableView.removeFromSuperview() + + #expect(fixture.content.contentScrollView(for: .top) === replacement) + } + + /// Table hosted under `UITabBarController > UINavigationController > + /// content`, mirroring the shell's TabView + NavigationStack chrome. + @MainActor + private struct Fixture { + let tableView: WorkspaceListUITableView + let content: UIViewController + let navigation: UINavigationController + let tabs: UITabBarController + let window: UIWindow + + init() { + tableView = WorkspaceListUITableView(frame: .zero, style: .plain) + content = UIViewController() + content.view.addSubview(tableView) + navigation = UINavigationController(rootViewController: content) + tabs = UITabBarController() + tabs.viewControllers = [navigation] + window = UIWindow(frame: CGRect(x: 0, y: 0, width: 390, height: 844)) + window.rootViewController = tabs + window.isHidden = false + window.layoutIfNeeded() + content.view.layoutIfNeeded() + } + } +} +#endif From bb3b122b932c0177a796e36ae1eebe155964ffad Mon Sep 17 00:00:00 2001 From: cmux reload-cloud Date: Tue, 21 Jul 2026 12:40:42 -0700 Subject: [PATCH 02/10] iOS: render the native soft scroll edge effect under the workspace list chrome Register the workspace UITableView as the content scroll view of its enclosing navigation and tab bar controllers (setContentScrollView top / bottom, iOS 26-gated) from didMoveToWindow, with a layoutSubviews retry until the controller parent chain is assembled. UIKit then renders the table's existing .soft top edge effect under the navigation bar + search drawer and drives the tab bar's bottom edge, matching the App Store's soft blur instead of hard-clipping rows at the search bar boundary. Unregistration on window removal only clears registrations this table still owns, so a replacement table's registration is never clobbered. Co-Authored-By: Claude Fable 5 --- .../WorkspaceListScrollEdgeCoordinator.swift | 86 +++++++++++++++++++ .../WorkspaceListUITableView.swift | 15 ++++ 2 files changed, 101 insertions(+) create mode 100644 Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceListScrollEdgeCoordinator.swift diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceListScrollEdgeCoordinator.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceListScrollEdgeCoordinator.swift new file mode 100644 index 000000000000..3d46be3907fd --- /dev/null +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceListScrollEdgeCoordinator.swift @@ -0,0 +1,86 @@ +#if os(iOS) +import UIKit + +/// Registers the workspace table as the content scroll view of its enclosing +/// navigation and tab bar controllers so UIKit renders the scroll edge effect +/// under the top chrome (navigation bar + search drawer) and behind the tab +/// bar, App Store-style. +/// +/// SwiftUI only drives bar scroll edge effects for its own scroll views. The +/// workspace list is a `UIViewRepresentable` `UITableView`, invisible to that +/// machinery, so without this registration the table's `.soft` top edge style +/// never renders and rows hard-clip at the search bar's bottom edge. +@MainActor +final class WorkspaceListScrollEdgeCoordinator { + private weak var registeredScrollView: UIScrollView? + private weak var navigationContentController: UIViewController? + private weak var tabContentController: UIViewController? + + /// Re-resolves the bar-owning controllers for `scrollView` and registers + /// it with them. Safe to call repeatedly; no-ops when nothing changed. + /// Returns false when no enclosing bar controller is reachable yet (the + /// view-controller parent chain may still be assembling), so the caller + /// can retry after the next layout pass. + @discardableResult + func registerIfNeeded(for scrollView: UIScrollView) -> Bool { + guard #available(iOS 26.0, *) else { return true } + guard scrollView.window != nil else { return false } + let navigationContent = Self.contentController( + hosting: scrollView, inParentOfKind: UINavigationController.self + ) + let tabContent = Self.contentController( + hosting: scrollView, inParentOfKind: UITabBarController.self + ) + guard navigationContent != nil || tabContent != nil else { return false } + if navigationContent !== navigationContentController + || tabContent !== tabContentController + || scrollView !== registeredScrollView { + unregister() + registeredScrollView = scrollView + navigationContentController = navigationContent + tabContentController = tabContent + navigationContent?.setContentScrollView(scrollView, for: .top) + tabContent?.setContentScrollView(scrollView, for: .bottom) + } + return true + } + + /// Clears this coordinator's registrations. A registration already taken + /// over by a replacement table (same controller, different scroll view) + /// is left intact. + func unregister() { + guard #available(iOS 26.0, *) else { return } + if let controller = navigationContentController, + let scrollView = registeredScrollView, + controller.contentScrollView(for: .top) === scrollView { + controller.setContentScrollView(nil, for: .top) + } + if let controller = tabContentController, + let scrollView = registeredScrollView, + controller.contentScrollView(for: .bottom) === scrollView { + controller.setContentScrollView(nil, for: .bottom) + } + navigationContentController = nil + tabContentController = nil + registeredScrollView = nil + } + + /// The last view controller on `view`'s parent chain before the first + /// container of `Kind`: the content controller whose bars that container + /// derives from `setContentScrollView(_:for:)` registrations. + private static func contentController( + hosting view: UIView, inParentOfKind kind: Kind.Type + ) -> UIViewController? { + var responder: UIResponder? = view.next + while let current = responder, !(current is UIViewController) { + responder = current.next + } + guard var content = responder as? UIViewController else { return nil } + while let parent = content.parent { + if parent is Kind { return content } + content = parent + } + return nil + } +} +#endif diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceListUITableView.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceListUITableView.swift index 4f02d6ce2dbb..269a74b699f2 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceListUITableView.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceListUITableView.swift @@ -7,6 +7,8 @@ final class WorkspaceListUITableView: UITableView { var layoutMetricsDidChange: (() -> Void)? private var measuredWidth: CGFloat = 0 + private let scrollEdgeCoordinator = WorkspaceListScrollEdgeCoordinator() + private var needsScrollEdgeRegistration = false override init(frame: CGRect, style: UITableView.Style) { super.init(frame: frame, style: style) @@ -18,6 +20,16 @@ final class WorkspaceListUITableView: UITableView { configureTopScrollEdgeEffect() } + override func didMoveToWindow() { + super.didMoveToWindow() + if window == nil { + needsScrollEdgeRegistration = false + scrollEdgeCoordinator.unregister() + } else { + needsScrollEdgeRegistration = !scrollEdgeCoordinator.registerIfNeeded(for: self) + } + } + override func layoutSubviews() { let previousWidth = measuredWidth super.layoutSubviews() @@ -25,6 +37,9 @@ final class WorkspaceListUITableView: UITableView { if previousWidth > 0, abs(previousWidth - measuredWidth) > 0.5 { layoutMetricsDidChange?() } + if needsScrollEdgeRegistration { + needsScrollEdgeRegistration = !scrollEdgeCoordinator.registerIfNeeded(for: self) + } } override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { From 33003793d2fcf37cde6f004f5fd5adfc16d83640 Mon Sep 17 00:00:00 2001 From: cmux reload-cloud Date: Tue, 21 Jul 2026 14:24:57 -0700 Subject: [PATCH 03/10] iOS: extend the workspace table under the vertical bars so the soft edge effect renders SwiftUI fits a UIViewRepresentable inside the safe area, so the table's frame started below the search drawer and ended above the tab bar (AX-verified frame {0,176,402,664} on a 402x874 screen): rows hard-clipped at the table's own bounds and the scroll edge effect had no covered region to render into. ignoresSafeArea(.container, edges: .vertical) restores the native underlap; the real UIKit bars still contribute safe area, so automatic content-inset adjustment keeps rows and indicators clear of the chrome, and the soft edge effect + bar registration now render the App Store-style progressive blur at both edges. Also adds CMUX_UITEST_WORKSPACE_LIST_PREVIEW_TABS=1 to the DEBUG preview fixture, wrapping the list in a TabView so the floating tab bar's bottom edge can be dogfooded without Mac pairing. Off by default to keep the App Store screenshot rig's chrome unchanged. Co-Authored-By: Claude Fable 5 --- .../WorkspaceListLayoutPreviewView.swift | 23 ++++++++++++++++++- .../CmuxMobileShellUI/WorkspaceListView.swift | 8 +++++++ 2 files changed, 30 insertions(+), 1 deletion(-) diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceListLayoutPreviewView.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceListLayoutPreviewView.swift index c120fb81add9..cc9bd0790fd6 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceListLayoutPreviewView.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceListLayoutPreviewView.swift @@ -209,6 +209,14 @@ public struct WorkspaceListLayoutPreviewView: View { ProcessInfo.processInfo.environment["CMUX_UITEST_NOTIFICATION_BANNER"] == "1" } + /// `CMUX_UITEST_WORKSPACE_LIST_PREVIEW_TABS=1` wraps the list in a tab + /// scaffold mirroring the shell's TabView, so scroll-edge behavior against + /// the real floating tab bar can be exercised without Mac pairing. Off by + /// default: the App Store screenshot rig expects the bare list chrome. + private var showsTabScaffold: Bool { + ProcessInfo.processInfo.environment["CMUX_UITEST_WORKSPACE_LIST_PREVIEW_TABS"] == "1" + } + public var body: some View { let workspacesBinding = $model.workspaces let refreshGenerationBinding = $refreshGeneration @@ -220,7 +228,7 @@ public struct WorkspaceListLayoutPreviewView: View { } else if UITestConfig.workspaceDetailDelayedTerminalPreviewEnabled { WorkspaceDetailDelayedTerminalPreviewView() } else { - NavigationStack { + let workspaceListStack = NavigationStack { WorkspaceListSearchHost { searchText in WorkspaceListView( workspaces: model.workspaces, @@ -302,6 +310,19 @@ public struct WorkspaceListLayoutPreviewView: View { .accessibilityHidden(true) } } + if showsTabScaffold { + TabView { + Tab("Workspaces", systemImage: "rectangle.stack") { + workspaceListStack + } + Tab("Notifications", systemImage: "bell") { + Text("Notification feed fixture") + .foregroundStyle(.secondary) + } + } + } else { + workspaceListStack + } } } .overlay(alignment: .topLeading) { diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceListView.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceListView.swift index ee27665d8f89..96e719d95a21 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceListView.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceListView.swift @@ -251,7 +251,15 @@ struct WorkspaceListView: View { visibleSelection: currentVisibleMacSelection ) #if os(iOS) + // SwiftUI fits a UIViewRepresentable inside the safe area, so without + // this the table's frame starts below the search drawer and ends above + // the tab bar: rows hard-clip at the chrome and no scroll edge effect + // can render. Extending under the vertical bars restores the native + // underlap; the real UIKit bars still contribute safe area, so the + // table's automatic content-inset adjustment keeps rows and indicators + // clear of the chrome. The keyboard region stays respected. let baseList = workspaceTable + .ignoresSafeArea(.container, edges: .vertical) #else let baseList = List { switch connectionChrome { From dfc43574214b0b4b16c054c2b5dcd7ee0c76caff Mon Sep 17 00:00:00 2001 From: cmux reload-cloud Date: Tue, 21 Jul 2026 14:51:37 -0700 Subject: [PATCH 04/10] Re-resolve scroll edge bar registration on every layout pass A one-shot registration flag stranded a partially assembled hierarchy: if the navigation controller hosted the table before joining the tab bar controller, the top-only registration cleared the retry flag and the bottom edge never registered (Greptile P1). Re-resolving each layout pass also covers reparenting that never changes the window (compact stack to split sidebar); the coordinator's identity guard makes the repeated call a no-op when nothing changed. Adds a regression test attaching the tab bar controller after the first registration. Co-Authored-By: Claude Fable 5 --- .../WorkspaceListScrollEdgeCoordinator.swift | 37 +++++++++---------- .../WorkspaceListUITableView.swift | 8 ++-- .../WorkspaceListScrollEdgeEffectTests.swift | 28 ++++++++++++++ 3 files changed, 49 insertions(+), 24 deletions(-) diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceListScrollEdgeCoordinator.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceListScrollEdgeCoordinator.swift index 3d46be3907fd..4f0d5aba51ae 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceListScrollEdgeCoordinator.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceListScrollEdgeCoordinator.swift @@ -17,32 +17,31 @@ final class WorkspaceListScrollEdgeCoordinator { private weak var tabContentController: UIViewController? /// Re-resolves the bar-owning controllers for `scrollView` and registers - /// it with them. Safe to call repeatedly; no-ops when nothing changed. - /// Returns false when no enclosing bar controller is reachable yet (the - /// view-controller parent chain may still be assembling), so the caller - /// can retry after the next layout pass. - @discardableResult - func registerIfNeeded(for scrollView: UIScrollView) -> Bool { - guard #available(iOS 26.0, *) else { return true } - guard scrollView.window != nil else { return false } + /// it with them. Called on every layout pass: the controller chain can + /// assemble incrementally (navigation controller before tab controller) + /// or reparent without the window changing, so a one-shot registration + /// would strand a partially registered edge. The identity guard makes the + /// repeated call a no-op (a few pointer hops) when nothing changed. + func registerIfNeeded(for scrollView: UIScrollView) { + guard #available(iOS 26.0, *) else { return } + guard scrollView.window != nil else { return } let navigationContent = Self.contentController( hosting: scrollView, inParentOfKind: UINavigationController.self ) let tabContent = Self.contentController( hosting: scrollView, inParentOfKind: UITabBarController.self ) - guard navigationContent != nil || tabContent != nil else { return false } - if navigationContent !== navigationContentController + guard navigationContent != nil || tabContent != nil else { return } + guard navigationContent !== navigationContentController || tabContent !== tabContentController - || scrollView !== registeredScrollView { - unregister() - registeredScrollView = scrollView - navigationContentController = navigationContent - tabContentController = tabContent - navigationContent?.setContentScrollView(scrollView, for: .top) - tabContent?.setContentScrollView(scrollView, for: .bottom) - } - return true + || scrollView !== registeredScrollView + else { return } + unregister() + registeredScrollView = scrollView + navigationContentController = navigationContent + tabContentController = tabContent + navigationContent?.setContentScrollView(scrollView, for: .top) + tabContent?.setContentScrollView(scrollView, for: .bottom) } /// Clears this coordinator's registrations. A registration already taken diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceListUITableView.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceListUITableView.swift index 269a74b699f2..6458f9d9a39a 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceListUITableView.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceListUITableView.swift @@ -8,7 +8,6 @@ final class WorkspaceListUITableView: UITableView { private var measuredWidth: CGFloat = 0 private let scrollEdgeCoordinator = WorkspaceListScrollEdgeCoordinator() - private var needsScrollEdgeRegistration = false override init(frame: CGRect, style: UITableView.Style) { super.init(frame: frame, style: style) @@ -23,10 +22,9 @@ final class WorkspaceListUITableView: UITableView { override func didMoveToWindow() { super.didMoveToWindow() if window == nil { - needsScrollEdgeRegistration = false scrollEdgeCoordinator.unregister() } else { - needsScrollEdgeRegistration = !scrollEdgeCoordinator.registerIfNeeded(for: self) + scrollEdgeCoordinator.registerIfNeeded(for: self) } } @@ -37,8 +35,8 @@ final class WorkspaceListUITableView: UITableView { if previousWidth > 0, abs(previousWidth - measuredWidth) > 0.5 { layoutMetricsDidChange?() } - if needsScrollEdgeRegistration { - needsScrollEdgeRegistration = !scrollEdgeCoordinator.registerIfNeeded(for: self) + if window != nil { + scrollEdgeCoordinator.registerIfNeeded(for: self) } } diff --git a/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/WorkspaceListScrollEdgeEffectTests.swift b/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/WorkspaceListScrollEdgeEffectTests.swift index 366f0779428b..838a4e15c7ec 100644 --- a/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/WorkspaceListScrollEdgeEffectTests.swift +++ b/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/WorkspaceListScrollEdgeEffectTests.swift @@ -27,6 +27,34 @@ import UIKit #expect(fixture.navigation.contentScrollView(for: .bottom) == nil) } + /// The controller chain can assemble incrementally: the navigation + /// controller can host the table before it joins a tab bar controller. + /// A one-shot registration would strand the bottom edge unregistered. + @Test func lateTabControllerAttachmentStillRegistersBottomEdge() throws { + guard #available(iOS 26.0, *) else { return } + let tableView = WorkspaceListUITableView(frame: .zero, style: .plain) + let content = UIViewController() + content.view.addSubview(tableView) + let navigation = UINavigationController(rootViewController: content) + let window = UIWindow(frame: CGRect(x: 0, y: 0, width: 390, height: 844)) + window.rootViewController = navigation + window.isHidden = false + window.layoutIfNeeded() + content.view.layoutIfNeeded() + #expect(content.contentScrollView(for: .top) === tableView) + #expect(navigation.contentScrollView(for: .bottom) == nil) + + let tabs = UITabBarController() + window.rootViewController = tabs + tabs.viewControllers = [navigation] + window.layoutIfNeeded() + tableView.setNeedsLayout() + tableView.layoutIfNeeded() + + #expect(content.contentScrollView(for: .top) === tableView) + #expect(navigation.contentScrollView(for: .bottom) === tableView) + } + @Test func departingTableDoesNotClobberReplacementRegistration() throws { guard #available(iOS 26.0, *) else { return } let fixture = Fixture() From 788334d069e75aee3d43fb5f326ff2de25ddaed3 Mon Sep 17 00:00:00 2001 From: cmux reload-cloud Date: Tue, 21 Jul 2026 15:19:06 -0700 Subject: [PATCH 05/10] Reclaim scroll edge registration after a transient replacement departs The identity guard trusted the coordinator's cache, so when a transient replacement table took the registration over and cleared it on departure, the surviving table saw unchanged targets and never re-registered, leaving both edges dead until the hierarchy changed. The guard now also compares the controllers' effective contentScrollView(for:) against this table, so the next layout pass self-heals the reverse handoff. Regression test covers replacement mount, takeover, departure, and reclaim. Co-Authored-By: Claude Fable 5 --- .../WorkspaceListScrollEdgeCoordinator.swift | 13 ++++++++++++ .../WorkspaceListScrollEdgeEffectTests.swift | 21 +++++++++++++++++++ 2 files changed, 34 insertions(+) diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceListScrollEdgeCoordinator.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceListScrollEdgeCoordinator.swift index 4f0d5aba51ae..7a96370c182a 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceListScrollEdgeCoordinator.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceListScrollEdgeCoordinator.swift @@ -32,9 +32,22 @@ final class WorkspaceListScrollEdgeCoordinator { hosting: scrollView, inParentOfKind: UITabBarController.self ) guard navigationContent != nil || tabContent != nil else { return } + // Compare the controllers' EFFECTIVE registration, not just cached + // identities: a transient replacement table can take a registration + // over and then clear it on departure, leaving this surviving table's + // cache claiming ownership it no longer holds. Re-registering on the + // next layout pass makes the handoff self-healing in both directions. + let topIsCurrent = navigationContent.map { + $0.contentScrollView(for: .top) === scrollView + } ?? true + let bottomIsCurrent = tabContent.map { + $0.contentScrollView(for: .bottom) === scrollView + } ?? true guard navigationContent !== navigationContentController || tabContent !== tabContentController || scrollView !== registeredScrollView + || !topIsCurrent + || !bottomIsCurrent else { return } unregister() registeredScrollView = scrollView diff --git a/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/WorkspaceListScrollEdgeEffectTests.swift b/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/WorkspaceListScrollEdgeEffectTests.swift index 838a4e15c7ec..e9286a743183 100644 --- a/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/WorkspaceListScrollEdgeEffectTests.swift +++ b/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/WorkspaceListScrollEdgeEffectTests.swift @@ -55,6 +55,27 @@ import UIKit #expect(navigation.contentScrollView(for: .bottom) === tableView) } + /// Reverse handoff: a transient replacement takes the registration over + /// and clears it when it departs. The surviving table must reclaim the + /// registration on its next layout pass instead of trusting its cache. + @Test func survivingTableRestoresRegistrationAfterReplacementDeparts() throws { + guard #available(iOS 26.0, *) else { return } + let fixture = Fixture() + let replacement = WorkspaceListUITableView(frame: .zero, style: .plain) + fixture.content.view.addSubview(replacement) + replacement.layoutIfNeeded() + #expect(fixture.content.contentScrollView(for: .top) === replacement) + + replacement.removeFromSuperview() + #expect(fixture.content.contentScrollView(for: .top) == nil) + + fixture.tableView.setNeedsLayout() + fixture.tableView.layoutIfNeeded() + + #expect(fixture.content.contentScrollView(for: .top) === fixture.tableView) + #expect(fixture.navigation.contentScrollView(for: .bottom) === fixture.tableView) + } + @Test func departingTableDoesNotClobberReplacementRegistration() throws { guard #available(iOS 26.0, *) else { return } let fixture = Fixture() From 237df1d1110da43bf7a8562619069782980ca211 Mon Sep 17 00:00:00 2001 From: cmux reload-cloud Date: Tue, 21 Jul 2026 15:58:21 -0700 Subject: [PATCH 06/10] Claim scroll edge registration only when vacant or stale The previous self-heal re-registered whenever the effective registration differed from this table, so two live coexisting tables (SwiftUI transition overlap) stole ownership from each other on every layout pass, making bar ownership layout-order dependent. An edge is now claimed only when its registration is nil or held by a detached scroll view; a different live table keeps ownership, departure clears the edge, and the survivor reclaims on its next layout pass. Tests pin both the no-steal coexistence and the reclaim-after-departure handoffs. Co-Authored-By: Claude Fable 5 --- .../WorkspaceListScrollEdgeCoordinator.swift | 92 +++++++++++-------- .../WorkspaceListScrollEdgeEffectTests.swift | 37 +++++--- 2 files changed, 75 insertions(+), 54 deletions(-) diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceListScrollEdgeCoordinator.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceListScrollEdgeCoordinator.swift index 7a96370c182a..d48f3137f7fb 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceListScrollEdgeCoordinator.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceListScrollEdgeCoordinator.swift @@ -16,12 +16,19 @@ final class WorkspaceListScrollEdgeCoordinator { private weak var navigationContentController: UIViewController? private weak var tabContentController: UIViewController? - /// Re-resolves the bar-owning controllers for `scrollView` and registers - /// it with them. Called on every layout pass: the controller chain can - /// assemble incrementally (navigation controller before tab controller) - /// or reparent without the window changing, so a one-shot registration - /// would strand a partially registered edge. The identity guard makes the - /// repeated call a no-op (a few pointer hops) when nothing changed. + /// Re-resolves the bar-owning controllers for `scrollView` and claims any + /// edge whose registration is vacant. Called on every layout pass: the + /// controller chain can assemble incrementally (navigation controller + /// before tab controller) or reparent without the window changing, so a + /// one-shot registration would strand a partially registered edge. + /// + /// Ownership arbitration: an edge is claimed only when its current + /// registration is nil or held by a detached scroll view. A different + /// LIVE table keeps its registration, so coexisting tables (SwiftUI + /// transition overlap) never steal from each other; when the owner + /// departs it clears the edge and the survivor reclaims on its next + /// layout pass. When nothing changed this is a no-op costing a short + /// parent walk and two getter comparisons. func registerIfNeeded(for scrollView: UIScrollView) { guard #available(iOS 26.0, *) else { return } guard scrollView.window != nil else { return } @@ -32,51 +39,58 @@ final class WorkspaceListScrollEdgeCoordinator { hosting: scrollView, inParentOfKind: UITabBarController.self ) guard navigationContent != nil || tabContent != nil else { return } - // Compare the controllers' EFFECTIVE registration, not just cached - // identities: a transient replacement table can take a registration - // over and then clear it on departure, leaving this surviving table's - // cache claiming ownership it no longer holds. Re-registering on the - // next layout pass makes the handoff self-healing in both directions. - let topIsCurrent = navigationContent.map { - $0.contentScrollView(for: .top) === scrollView - } ?? true - let bottomIsCurrent = tabContent.map { - $0.contentScrollView(for: .bottom) === scrollView - } ?? true - guard navigationContent !== navigationContentController - || tabContent !== tabContentController - || scrollView !== registeredScrollView - || !topIsCurrent - || !bottomIsCurrent - else { return } - unregister() + + if navigationContent !== navigationContentController + || scrollView !== registeredScrollView { + clearEdgeIfOwned(on: navigationContentController, edge: .top) + } + if tabContent !== tabContentController || scrollView !== registeredScrollView { + clearEdgeIfOwned(on: tabContentController, edge: .bottom) + } registeredScrollView = scrollView navigationContentController = navigationContent tabContentController = tabContent - navigationContent?.setContentScrollView(scrollView, for: .top) - tabContent?.setContentScrollView(scrollView, for: .bottom) + + if let navigationContent, + Self.canClaim(current: navigationContent.contentScrollView(for: .top), + claimant: scrollView) { + navigationContent.setContentScrollView(scrollView, for: .top) + } + if let tabContent, + Self.canClaim(current: tabContent.contentScrollView(for: .bottom), + claimant: scrollView) { + tabContent.setContentScrollView(scrollView, for: .bottom) + } } - /// Clears this coordinator's registrations. A registration already taken - /// over by a replacement table (same controller, different scroll view) - /// is left intact. + /// Clears this coordinator's registrations. A registration held by + /// another table (same controller, different scroll view) is left intact. func unregister() { guard #available(iOS 26.0, *) else { return } - if let controller = navigationContentController, - let scrollView = registeredScrollView, - controller.contentScrollView(for: .top) === scrollView { - controller.setContentScrollView(nil, for: .top) - } - if let controller = tabContentController, - let scrollView = registeredScrollView, - controller.contentScrollView(for: .bottom) === scrollView { - controller.setContentScrollView(nil, for: .bottom) - } + clearEdgeIfOwned(on: navigationContentController, edge: .top) + clearEdgeIfOwned(on: tabContentController, edge: .bottom) navigationContentController = nil tabContentController = nil registeredScrollView = nil } + private func clearEdgeIfOwned(on controller: UIViewController?, edge: NSDirectionalRectEdge) { + guard let controller, + let scrollView = registeredScrollView, + controller.contentScrollView(for: edge) === scrollView else { return } + controller.setContentScrollView(nil, for: edge) + } + + /// Vacant (nil) and stale (holder departed its window without clearing, + /// e.g. deallocated mid-transition) registrations are claimable. An edge + /// already held by the claimant needs no re-set, and a different live + /// scroll view keeps ownership. + private static func canClaim(current: UIScrollView?, claimant: UIScrollView) -> Bool { + guard let current else { return true } + if current === claimant { return false } + return current.window == nil + } + /// The last view controller on `view`'s parent chain before the first /// container of `Kind`: the content controller whose bars that container /// derives from `setContentScrollView(_:for:)` registrations. diff --git a/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/WorkspaceListScrollEdgeEffectTests.swift b/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/WorkspaceListScrollEdgeEffectTests.swift index e9286a743183..54cbffdc27df 100644 --- a/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/WorkspaceListScrollEdgeEffectTests.swift +++ b/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/WorkspaceListScrollEdgeEffectTests.swift @@ -55,20 +55,20 @@ import UIKit #expect(navigation.contentScrollView(for: .bottom) === tableView) } - /// Reverse handoff: a transient replacement takes the registration over - /// and clears it when it departs. The surviving table must reclaim the - /// registration on its next layout pass instead of trusting its cache. - @Test func survivingTableRestoresRegistrationAfterReplacementDeparts() throws { + /// While two live tables coexist (SwiftUI transition overlap), the later + /// one must not steal the registration: ownership would otherwise become + /// layout-order dependent and thrash on every pass. + @Test func coexistingTablesDoNotStealRegistrationFromEachOther() throws { guard #available(iOS 26.0, *) else { return } let fixture = Fixture() - let replacement = WorkspaceListUITableView(frame: .zero, style: .plain) - fixture.content.view.addSubview(replacement) - replacement.layoutIfNeeded() - #expect(fixture.content.contentScrollView(for: .top) === replacement) + let second = WorkspaceListUITableView(frame: .zero, style: .plain) - replacement.removeFromSuperview() - #expect(fixture.content.contentScrollView(for: .top) == nil) + fixture.content.view.addSubview(second) + second.layoutIfNeeded() + #expect(fixture.content.contentScrollView(for: .top) === fixture.tableView) + second.setNeedsLayout() + second.layoutIfNeeded() fixture.tableView.setNeedsLayout() fixture.tableView.layoutIfNeeded() @@ -76,16 +76,23 @@ import UIKit #expect(fixture.navigation.contentScrollView(for: .bottom) === fixture.tableView) } - @Test func departingTableDoesNotClobberReplacementRegistration() throws { + /// When the owning table departs it clears the edges, and the surviving + /// table reclaims them on its next layout pass. + @Test func survivingTableReclaimsRegistrationAfterOwnerDeparts() throws { guard #available(iOS 26.0, *) else { return } let fixture = Fixture() - let replacement = WorkspaceListUITableView(frame: .zero, style: .plain) + let second = WorkspaceListUITableView(frame: .zero, style: .plain) + fixture.content.view.addSubview(second) + second.layoutIfNeeded() - fixture.content.view.addSubview(replacement) - replacement.layoutIfNeeded() fixture.tableView.removeFromSuperview() + #expect(fixture.content.contentScrollView(for: .top) == nil) + + second.setNeedsLayout() + second.layoutIfNeeded() - #expect(fixture.content.contentScrollView(for: .top) === replacement) + #expect(fixture.content.contentScrollView(for: .top) === second) + #expect(fixture.navigation.contentScrollView(for: .bottom) === second) } /// Table hosted under `UITabBarController > UINavigationController > From 107b6dd6e7bba4e5d6365bc7d42835a325940788 Mon Sep 17 00:00:00 2001 From: cmux reload-cloud Date: Tue, 21 Jul 2026 17:21:39 -0700 Subject: [PATCH 07/10] Describe the late-tab-attachment test by the lifecycle it actually exercises UIKit's hierarchy-consistency check forbids re-parenting a controller whose view stays in a foreign hierarchy (attempting an addChild-based window-stable variant throws UIViewControllerHierarchyInconsistency), so a same-window assembly scenario is not constructible in a unit test; real container attachment always relocates views. The test now documents that it pins the end state through whichever lifecycle path fires, and points at survivingTableReclaimsRegistrationAfterOwnerDeparts as the window-stable layout-retry coverage. Co-Authored-By: Claude Fable 5 --- .../WorkspaceListScrollEdgeEffectTests.swift | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/WorkspaceListScrollEdgeEffectTests.swift b/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/WorkspaceListScrollEdgeEffectTests.swift index 54cbffdc27df..0a5f0bfa4ef6 100644 --- a/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/WorkspaceListScrollEdgeEffectTests.swift +++ b/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/WorkspaceListScrollEdgeEffectTests.swift @@ -29,7 +29,14 @@ import UIKit /// The controller chain can assemble incrementally: the navigation /// controller can host the table before it joins a tab bar controller. - /// A one-shot registration would strand the bottom edge unregistered. + /// Whichever lifecycle path fires for the move (UIKit container + /// attachment always relocates the child's view, so `didMoveToWindow` + /// and layout passes both run), the bottom edge must end registered. + /// UIKit's hierarchy-consistency check forbids re-parenting a controller + /// whose view stays in a foreign hierarchy, so a window-stable variant of + /// this scenario is not constructible; the window-stable layout-retry + /// path is covered by + /// ``survivingTableReclaimsRegistrationAfterOwnerDeparts()``. @Test func lateTabControllerAttachmentStillRegistersBottomEdge() throws { guard #available(iOS 26.0, *) else { return } let tableView = WorkspaceListUITableView(frame: .zero, style: .plain) From 08adcd41b98e52e1546529422e5afda5af8bb982 Mon Sep 17 00:00:00 2001 From: cmux reload-cloud Date: Tue, 21 Jul 2026 17:50:50 -0700 Subject: [PATCH 08/10] Gate the bar underlap to iOS 26 ignoresSafeArea applied on every supported release while the scroll edge registration is iOS 26-gated, so iOS 18-25 would scroll full-opacity rows beneath legacy bars with no edge effect. The underlap now lives in an availability-gated ViewModifier: iOS 26 gets the App Store treatment, earlier releases keep the fitted frame they have today. Co-Authored-By: Claude Fable 5 --- .../CmuxMobileShellUI/WorkspaceListView.swift | 33 ++++++++++++++----- 1 file changed, 25 insertions(+), 8 deletions(-) diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceListView.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceListView.swift index 96e719d95a21..07a3459ed40c 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceListView.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceListView.swift @@ -251,15 +251,8 @@ struct WorkspaceListView: View { visibleSelection: currentVisibleMacSelection ) #if os(iOS) - // SwiftUI fits a UIViewRepresentable inside the safe area, so without - // this the table's frame starts below the search drawer and ends above - // the tab bar: rows hard-clip at the chrome and no scroll edge effect - // can render. Extending under the vertical bars restores the native - // underlap; the real UIKit bars still contribute safe area, so the - // table's automatic content-inset adjustment keeps rows and indicators - // clear of the chrome. The keyboard region stays respected. let baseList = workspaceTable - .ignoresSafeArea(.container, edges: .vertical) + .modifier(WorkspaceListBarUnderlap()) #else let baseList = List { switch connectionChrome { @@ -746,3 +739,27 @@ struct MobileDisconnectedFailureCopy { return parts.isEmpty ? nil : parts.joined(separator: "\n\n") } } + +#if os(iOS) +/// Extends the workspace table under the vertical bars on iOS 26, where the +/// scroll edge effect and `WorkspaceListScrollEdgeCoordinator`'s bar +/// registration render the App Store-style soft blur over the underlap. +/// +/// SwiftUI fits a `UIViewRepresentable` inside the safe area, so without this +/// the table's frame starts below the search drawer and ends above the tab +/// bar: rows hard-clip at the chrome and no scroll edge effect can render. +/// The real UIKit bars still contribute safe area, so the table's automatic +/// content-inset adjustment keeps rows and indicators clear of the chrome, +/// and the keyboard region stays respected. Earlier releases keep the fitted +/// frame: the coordinator's registration is iOS 26-gated, and underlapping +/// without it would scroll full-opacity rows beneath legacy bars. +private struct WorkspaceListBarUnderlap: ViewModifier { + func body(content: Content) -> some View { + if #available(iOS 26.0, *) { + content.ignoresSafeArea(.container, edges: .vertical) + } else { + content + } + } +} +#endif From 140bffa97044769feb7687d74f649183e785c752 Mon Sep 17 00:00:00 2001 From: cmux reload-cloud Date: Tue, 21 Jul 2026 18:29:58 -0700 Subject: [PATCH 09/10] Nudge the waiting table when a departing owner clears a bar edge An overlapping successor stands down while the owner is live, and UIKit does not guarantee it another layout pass when the owner later departs, so both edge registrations could stay vacant until an unrelated scroll or resize. Clearing an edge now finds the remaining workspace table under the same controller and marks it needing layout; its next pass runs the normal claim arbitration. The reclaim test now only flushes pending window layout instead of dirtying the survivor by hand, so a missing wake fails it. Co-Authored-By: Claude Fable 5 --- .../WorkspaceListScrollEdgeCoordinator.swift | 24 +++++++++++++++++++ .../WorkspaceListScrollEdgeEffectTests.swift | 10 ++++---- 2 files changed, 30 insertions(+), 4 deletions(-) diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceListScrollEdgeCoordinator.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceListScrollEdgeCoordinator.swift index d48f3137f7fb..6388a667cfed 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceListScrollEdgeCoordinator.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceListScrollEdgeCoordinator.swift @@ -79,6 +79,30 @@ final class WorkspaceListScrollEdgeCoordinator { let scrollView = registeredScrollView, controller.contentScrollView(for: edge) === scrollView else { return } controller.setContentScrollView(nil, for: edge) + // Deterministic handoff: a waiting table stood down while this one + // owned the edge, and UIKit does not guarantee it another layout pass + // after this teardown. Nudge it so its next pass claims the vacancy; + // the woken table still runs its own claim arbitration. + if let waiting = Self.firstWorkspaceTable( + under: controller.viewIfLoaded, excluding: scrollView + ) { + waiting.setNeedsLayout() + } + } + + private static func firstWorkspaceTable( + under view: UIView?, excluding departing: UIScrollView + ) -> WorkspaceListUITableView? { + guard let view else { return nil } + if let table = view as? WorkspaceListUITableView, table !== departing { + return table + } + for subview in view.subviews { + if let found = firstWorkspaceTable(under: subview, excluding: departing) { + return found + } + } + return nil } /// Vacant (nil) and stale (holder departed its window without clearing, diff --git a/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/WorkspaceListScrollEdgeEffectTests.swift b/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/WorkspaceListScrollEdgeEffectTests.swift index 0a5f0bfa4ef6..a72ba469d72a 100644 --- a/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/WorkspaceListScrollEdgeEffectTests.swift +++ b/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/WorkspaceListScrollEdgeEffectTests.swift @@ -83,20 +83,22 @@ import UIKit #expect(fixture.navigation.contentScrollView(for: .bottom) === fixture.tableView) } - /// When the owning table departs it clears the edges, and the surviving - /// table reclaims them on its next layout pass. + /// When the owning table departs it clears the edges and nudges the + /// waiting table, which claims them on the next layout flush. The test + /// only flushes pending window layout; the survivor is never marked + /// dirty by hand, so a missing wake fails the assertions. @Test func survivingTableReclaimsRegistrationAfterOwnerDeparts() throws { guard #available(iOS 26.0, *) else { return } let fixture = Fixture() let second = WorkspaceListUITableView(frame: .zero, style: .plain) fixture.content.view.addSubview(second) second.layoutIfNeeded() + #expect(fixture.content.contentScrollView(for: .top) === fixture.tableView) fixture.tableView.removeFromSuperview() #expect(fixture.content.contentScrollView(for: .top) == nil) - second.setNeedsLayout() - second.layoutIfNeeded() + fixture.window.layoutIfNeeded() #expect(fixture.content.contentScrollView(for: .top) === second) #expect(fixture.navigation.contentScrollView(for: .bottom) === second) From 545c3ddb2c93f220bdf47372cfe88ecfb61b1b38 Mon Sep 17 00:00:00 2001 From: cmux reload-cloud Date: Tue, 21 Jul 2026 19:14:34 -0700 Subject: [PATCH 10/10] Apply review policy: instance helpers and a dedicated underlap file Converts the coordinator's pure static helpers to instance methods (the type holds state; statics on it read as namespace members) and moves WorkspaceListBarUnderlap into its own file per file-organization policy. Co-Authored-By: Claude Fable 5 --- .../WorkspaceListBarUnderlap.swift | 24 +++++++++++++++++++ .../WorkspaceListScrollEdgeCoordinator.swift | 20 ++++++++-------- .../CmuxMobileShellUI/WorkspaceListView.swift | 24 ------------------- 3 files changed, 34 insertions(+), 34 deletions(-) create mode 100644 Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceListBarUnderlap.swift diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceListBarUnderlap.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceListBarUnderlap.swift new file mode 100644 index 000000000000..c87be352ed26 --- /dev/null +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceListBarUnderlap.swift @@ -0,0 +1,24 @@ +import SwiftUI +#if os(iOS) +/// Extends the workspace table under the vertical bars on iOS 26, where the +/// scroll edge effect and `WorkspaceListScrollEdgeCoordinator`'s bar +/// registration render the App Store-style soft blur over the underlap. +/// +/// SwiftUI fits a `UIViewRepresentable` inside the safe area, so without this +/// the table's frame starts below the search drawer and ends above the tab +/// bar: rows hard-clip at the chrome and no scroll edge effect can render. +/// The real UIKit bars still contribute safe area, so the table's automatic +/// content-inset adjustment keeps rows and indicators clear of the chrome, +/// and the keyboard region stays respected. Earlier releases keep the fitted +/// frame: the coordinator's registration is iOS 26-gated, and underlapping +/// without it would scroll full-opacity rows beneath legacy bars. +struct WorkspaceListBarUnderlap: ViewModifier { + func body(content: Content) -> some View { + if #available(iOS 26.0, *) { + content.ignoresSafeArea(.container, edges: .vertical) + } else { + content + } + } +} +#endif diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceListScrollEdgeCoordinator.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceListScrollEdgeCoordinator.swift index 6388a667cfed..607be5d3ed7a 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceListScrollEdgeCoordinator.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceListScrollEdgeCoordinator.swift @@ -32,10 +32,10 @@ final class WorkspaceListScrollEdgeCoordinator { func registerIfNeeded(for scrollView: UIScrollView) { guard #available(iOS 26.0, *) else { return } guard scrollView.window != nil else { return } - let navigationContent = Self.contentController( + let navigationContent = contentController( hosting: scrollView, inParentOfKind: UINavigationController.self ) - let tabContent = Self.contentController( + let tabContent = contentController( hosting: scrollView, inParentOfKind: UITabBarController.self ) guard navigationContent != nil || tabContent != nil else { return } @@ -52,13 +52,13 @@ final class WorkspaceListScrollEdgeCoordinator { tabContentController = tabContent if let navigationContent, - Self.canClaim(current: navigationContent.contentScrollView(for: .top), - claimant: scrollView) { + canClaim(current: navigationContent.contentScrollView(for: .top), + claimant: scrollView) { navigationContent.setContentScrollView(scrollView, for: .top) } if let tabContent, - Self.canClaim(current: tabContent.contentScrollView(for: .bottom), - claimant: scrollView) { + canClaim(current: tabContent.contentScrollView(for: .bottom), + claimant: scrollView) { tabContent.setContentScrollView(scrollView, for: .bottom) } } @@ -83,14 +83,14 @@ final class WorkspaceListScrollEdgeCoordinator { // owned the edge, and UIKit does not guarantee it another layout pass // after this teardown. Nudge it so its next pass claims the vacancy; // the woken table still runs its own claim arbitration. - if let waiting = Self.firstWorkspaceTable( + if let waiting = firstWorkspaceTable( under: controller.viewIfLoaded, excluding: scrollView ) { waiting.setNeedsLayout() } } - private static func firstWorkspaceTable( + private func firstWorkspaceTable( under view: UIView?, excluding departing: UIScrollView ) -> WorkspaceListUITableView? { guard let view else { return nil } @@ -109,7 +109,7 @@ final class WorkspaceListScrollEdgeCoordinator { /// e.g. deallocated mid-transition) registrations are claimable. An edge /// already held by the claimant needs no re-set, and a different live /// scroll view keeps ownership. - private static func canClaim(current: UIScrollView?, claimant: UIScrollView) -> Bool { + private func canClaim(current: UIScrollView?, claimant: UIScrollView) -> Bool { guard let current else { return true } if current === claimant { return false } return current.window == nil @@ -118,7 +118,7 @@ final class WorkspaceListScrollEdgeCoordinator { /// The last view controller on `view`'s parent chain before the first /// container of `Kind`: the content controller whose bars that container /// derives from `setContentScrollView(_:for:)` registrations. - private static func contentController( + private func contentController( hosting view: UIView, inParentOfKind kind: Kind.Type ) -> UIViewController? { var responder: UIResponder? = view.next diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceListView.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceListView.swift index 07a3459ed40c..76403c21d1df 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceListView.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceListView.swift @@ -739,27 +739,3 @@ struct MobileDisconnectedFailureCopy { return parts.isEmpty ? nil : parts.joined(separator: "\n\n") } } - -#if os(iOS) -/// Extends the workspace table under the vertical bars on iOS 26, where the -/// scroll edge effect and `WorkspaceListScrollEdgeCoordinator`'s bar -/// registration render the App Store-style soft blur over the underlap. -/// -/// SwiftUI fits a `UIViewRepresentable` inside the safe area, so without this -/// the table's frame starts below the search drawer and ends above the tab -/// bar: rows hard-clip at the chrome and no scroll edge effect can render. -/// The real UIKit bars still contribute safe area, so the table's automatic -/// content-inset adjustment keeps rows and indicators clear of the chrome, -/// and the keyboard region stays respected. Earlier releases keep the fitted -/// frame: the coordinator's registration is iOS 26-gated, and underlapping -/// without it would scroll full-opacity rows beneath legacy bars. -private struct WorkspaceListBarUnderlap: ViewModifier { - func body(content: Content) -> some View { - if #available(iOS 26.0, *) { - content.ignoresSafeArea(.container, edges: .vertical) - } else { - content - } - } -} -#endif