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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -165,7 +165,8 @@ public struct WorkspaceListLayoutPreviewView: View {
}

@State private var fixtureRoute: FixtureWorkspaceRoute?
@State private var pendingSearchFixtureRoute: FixtureWorkspaceRoute?
@State private var searchFixtureRoute: FixtureWorkspaceRoute?
@State private var restoresSearchPresentationOnReturn = false

private var scrollMetricsEnabled: Bool {
ProcessInfo.processInfo.environment["CMUX_UITEST_SCROLL_METRICS"] == "1"
Expand Down Expand Up @@ -496,33 +497,11 @@ public struct WorkspaceListLayoutPreviewView: View {
workspaceListFixture(searchText: searchText)
}
.navigationDestination(item: $fixtureRoute) { route in
VStack(spacing: 12) {
Text(
model.workspaces.first(where: { $0.id == route.id })?.name
?? route.id.rawValue
)
.font(.title2)
Text("Fixture workspace detail")
.foregroundStyle(.secondary)
}
.accessibilityIdentifier("FixtureWorkspaceDetail")
.toolbarVisibility(.hidden, for: .tabBar, .bottomBar)
.navigationBarBackButtonHidden(true)
.toolbar {
ToolbarItem(placement: .topBarLeading) {
WorkspaceBackButton(unreadCount: 0) {
fixtureRoute = nil
}
}
fixtureWorkspaceDetail(route: route) {
fixtureRoute = nil
}
}
}
.onAppear {
consumePendingSearchFixtureNavigation()
}
.onChange(of: pendingSearchFixtureRoute) { _, _ in
consumePendingSearchFixtureNavigation()
}
.overlay(alignment: .bottomTrailing) {
if scrollMetricsEnabled {
WorkspaceListScrollMetricsProbe(runsSweep: scrollSweepEnabled)
Expand All @@ -549,6 +528,15 @@ public struct WorkspaceListLayoutPreviewView: View {
) { searchText in
workspaceListFixture(searchText: searchText)
}
.navigationDestination(item: $searchFixtureRoute) { route in
fixtureWorkspaceDetail(route: route) {
searchFixtureRoute = nil
}
}
}
.onChange(of: searchFixtureRoute) { oldRoute, newRoute in
guard oldRoute != nil, newRoute == nil else { return }
restoreSearchPresentationIfNeeded()
}
} notificationSearch: {
Text("Notification feed fixture")
Expand All @@ -559,10 +547,6 @@ public struct WorkspaceListLayoutPreviewView: View {
}
}
}
.onChange(of: primarySearchCoordinator.isPresented) { _, isPresented in
guard !isPresented else { return }
consumePendingSearchFixtureNavigation()
}
.overlay(alignment: .topLeading) {
ZStack(alignment: .topLeading) {
Color.clear
Expand Down Expand Up @@ -601,19 +585,40 @@ public struct WorkspaceListLayoutPreviewView: View {
let route = FixtureWorkspaceRoute(id: id)
if showsTabScaffold,
selectedPrimaryTab == .search || primarySearchCoordinator.isPresented {
pendingSearchFixtureRoute = route
transitionPrimaryTab(to: .workspaces)
restoresSearchPresentationOnReturn = primarySearchCoordinator.isPresented
searchFixtureRoute = route
} else {
fixtureRoute = route
}
}

private func consumePendingSearchFixtureNavigation() {
guard !primarySearchCoordinator.isPresented,
selectedPrimaryTab == .workspaces,
let route = pendingSearchFixtureRoute else { return }
pendingSearchFixtureRoute = nil
fixtureRoute = route
private func fixtureWorkspaceDetail(
route: FixtureWorkspaceRoute,
onBack: @escaping () -> Void
) -> some View {
VStack(spacing: 12) {
Text(
model.workspaces.first(where: { $0.id == route.id })?.name
?? route.id.rawValue
)
.font(.title2)
Text("Fixture workspace detail")
.foregroundStyle(.secondary)
}
.accessibilityIdentifier("FixtureWorkspaceDetail")
.toolbarVisibility(.hidden, for: .tabBar, .bottomBar)
.navigationBarBackButtonHidden(true)
.toolbar {
ToolbarItem(placement: .topBarLeading) {
WorkspaceBackButton(unreadCount: 0, action: onBack)
}
}
}

private func restoreSearchPresentationIfNeeded() {
guard restoresSearchPresentationOnReturn else { return }
restoresSearchPresentationOnReturn = false
primarySearchCoordinator.setPresentation(true)
}

@discardableResult
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -167,7 +167,9 @@ struct WorkspaceShellView: View {
#if os(iOS)
@State private var selectedPrimaryTab: MobilePrimaryTab = .workspaces
@State private var notificationNavigationPath: [MobileWorkspacePreview.ID] = []
@State private var workspaceSearchNavigationPath: [MobileWorkspacePreview.ID] = []
@State private var notificationSearchNavigationPath: [MobileWorkspacePreview.ID] = []
@State private var restoreSearchOnDetailReturn = false
@State private var pendingPrimarySearchWorkspaceNavigationID: MobileWorkspacePreview.ID?
@State private var pendingPrimarySearchNotificationNavigationID: MobileWorkspacePreview.ID?
@State private var showingRootSettings = false
Expand Down Expand Up @@ -344,7 +346,7 @@ struct WorkspaceShellView: View {

private func workspaceSearchTabContent(canCreateWorkspaceForSelection: Bool) -> some View {
workspaceActionToastOverlay {
NavigationStack {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Stale search stack after tab leave

Medium Severity

Leaving the Search tab clears notificationSearchNavigationPath but not the new workspaceSearchNavigationPath or restoreSearchOnDetailReturn. A deeplink or other tab transition while a search-owned detail is open leaves that stack mounted, so returning to Search can show a stale detail, compact-stack selection sync stays blocked by the empty-path guard, and a later pop can incorrectly re-present Search.

Additional Locations (2)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit c4704c0. Configure here.

NavigationStack(path: $workspaceSearchNavigationPath) {
MobilePrimaryWorkspaceSearchContentHost(
searchCoordinator: primarySearchCoordinator
) { searchText in
Expand All @@ -360,9 +362,30 @@ struct WorkspaceShellView: View {
)
}
.toolbar {
rootToolbarContent
if workspaceSearchNavigationPath.isEmpty {
rootToolbarContent
}
}
.navigationDestination(for: MobileWorkspacePreview.ID.self) { workspaceID in
workspaceDestination(
for: workspaceID,
createWorkspace: createWorkspaceInCompactStack,
canCreateWorkspaceForSelection: canCreateWorkspaceForSelection,
backButtonConfiguration: WorkspaceBackButtonConfiguration(
unreadCount: unreadWorkspaceCount(excluding: workspaceID),
badgeContrast: .darkBackground,
action: popWorkspaceSearchStack
)
)
.toolbarVisibility(.hidden, for: .tabBar, .bottomBar)
.navigationBarBackButtonHidden(true)
.background(InteractiveSwipeBackEnabler())
}
}
.onChange(of: workspaceSearchNavigationPath) { oldPath, newPath in
guard !oldPath.isEmpty, newPath.isEmpty else { return }
restoreWorkspaceSearchPresentationIfNeeded()
}
}
}

Expand Down Expand Up @@ -482,6 +505,7 @@ struct WorkspaceShellView: View {
}
}
.onChange(of: store.selectedWorkspaceID) { _, selectedWorkspaceID in
guard workspaceSearchNavigationPath.isEmpty else { return }
if let createdPath = compactNavigationPolicy.pathForCreatedWorkspaceSelection(
currentPath: compactNavigationPath,
selectedWorkspaceID: selectedWorkspaceID,
Expand Down Expand Up @@ -900,8 +924,21 @@ struct WorkspaceShellView: View {
}

private func selectWorkspaceFromSearch(_ id: MobileWorkspacePreview.ID) {
pendingPrimarySearchWorkspaceNavigationID = id
transitionPrimaryTab(to: .workspaces)
restoreSearchOnDetailReturn = primarySearchCoordinator.isPresented
workspaceSearchNavigationPath = [id]
pendingCompactCreateNavigationWorkspaceIDs = nil
store.selectedWorkspaceID = id
}

private func popWorkspaceSearchStack() {
guard !workspaceSearchNavigationPath.isEmpty else { return }
workspaceSearchNavigationPath.removeLast()
}

private func restoreWorkspaceSearchPresentationIfNeeded() {
guard restoreSearchOnDetailReturn else { return }
restoreSearchOnDetailReturn = false
primarySearchCoordinator.setPresentation(true)
}

private func createWorkspaceFromSearch() {
Expand Down
18 changes: 15 additions & 3 deletions ios/cmuxUITests/cmuxUITests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1146,7 +1146,7 @@ final class cmuxUITests: XCTestCase {
}

@MainActor
func testWorkspaceSearchIsMinimizedAndPreservesQueryAcrossRefresh() throws {
func testWorkspaceSearchRestoresPresentationQueryAndResultsAfterWorkspaceDetailAndRefresh() throws {
guard #available(iOS 26.0, *) else {
throw XCTSkip("The detached workspace search control requires iOS 26.")
}
Expand Down Expand Up @@ -1220,15 +1220,27 @@ final class cmuxUITests: XCTestCase {
tap(backButton, in: app)
XCTAssertNotNil(waitForVisibleElement(in: workspaceListTables, app: app, timeout: 3))
XCTAssertTrue(minimizedSearch.waitForExistence(timeout: 3))
XCTAssertTrue(waitForKeyboardDismissal(in: app))
XCTAssertTrue(
searchField.waitForExistence(timeout: 3),
"Returning from workspace detail must restore the expanded Search presentation."
)
XCTAssertEqual(
searchField.value as? String,
"Docs",
"Returning from workspace detail must restore the exact search query."
)
XCTAssertTrue(
app.keyboards.firstMatch.waitForExistence(timeout: 3),
"Returning from workspace detail must restore Search focus and its keyboard."
)
XCTAssertTrue(waitForHittable(docsRow, timeout: 3))
XCTAssertTrue(waitForNotHittable(mainRow, timeout: 3))

let previewRefreshButtons = app.buttons.matching(
NSPredicate(format: "identifier == %@", "MobileWorkspaceListPreviewRefresh")
)
guard let previewRefresh = waitForVisibleElement(in: previewRefreshButtons, app: app, timeout: 3) else {
XCTFail("Visible preview refresh trigger disappeared after leaving Search")
XCTFail("Visible preview refresh trigger disappeared after returning to Search")
return
}
tap(previewRefresh, in: app)
Expand Down