From 54b09580f61b5d3ad41c1483dfd9d65cd4b2274a Mon Sep 17 00:00:00 2001 From: Colin Johnson Date: Mon, 29 Jun 2026 20:56:22 -0400 Subject: [PATCH] fix(ios): reset sidebar navigation stacks (#94991) --- apps/ios/Sources/Design/ChatProTab.swift | 89 ++++++++++-------- .../ios/Sources/Design/CommandCenterTab.swift | 93 +++++++++++-------- .../Design/RootTabsPhoneControlHub.swift | 39 ++++---- apps/ios/Sources/Design/SettingsProTab.swift | 50 ++++++---- apps/ios/Sources/Design/TalkProTab.swift | 59 +++++++----- apps/ios/Sources/RootTabs.swift | 48 ++++++++-- .../ios/Tests/RootTabsPresentationTests.swift | 36 +++++++ .../RootTabsSidebarRegressionTests.swift | 77 +++++++++++++++ apps/ios/Tests/RootTabsSourceGuardTests.swift | 32 ++++++- 9 files changed, 380 insertions(+), 143 deletions(-) diff --git a/apps/ios/Sources/Design/ChatProTab.swift b/apps/ios/Sources/Design/ChatProTab.swift index 236d81a839c2..cfb20d4c3a43 100644 --- a/apps/ios/Sources/Design/ChatProTab.swift +++ b/apps/ios/Sources/Design/ChatProTab.swift @@ -11,6 +11,7 @@ struct ChatProTab: View { let headerTitle: String? let headerSubtitle: String? let showsAgentBadge: Bool + let ownsNavigationStack: Bool let openSettings: (() -> Void)? init( @@ -18,56 +19,26 @@ struct ChatProTab: View { headerTitle: String? = nil, headerSubtitle: String? = nil, showsAgentBadge: Bool = true, + ownsNavigationStack: Bool = true, openSettings: (() -> Void)? = nil) { self.headerLeadingAction = headerLeadingAction self.headerTitle = headerTitle self.headerSubtitle = headerSubtitle self.showsAgentBadge = showsAgentBadge + self.ownsNavigationStack = ownsNavigationStack self.openSettings = openSettings } var body: some View { - NavigationStack { - ZStack { - OpenClawProBackground() - VStack(spacing: 0) { - self.header - if let viewModel { - OpenClawChatView( - viewModel: viewModel, - drawsBackground: false, - showsSessionSwitcher: false, - userAccent: self.chatUserAccent, - assistantName: self.agentDisplayName, - assistantAvatarText: self.agentBadge, - assistantAvatarTint: OpenClawBrand.accent, - showsAssistantAvatars: false, - composerChrome: .clean, - isComposerEnabled: self.gatewayConnected, - messagePlaceholder: self.messagePlaceholder, - talkControl: self.talkControl) - .id(ObjectIdentifier(viewModel)) - .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) - } else { - ProCard { - VStack(alignment: .leading, spacing: 8) { - Text("Chat is preparing") - .font(.headline) - Text("The operator session will attach when the gateway is ready.") - .font(.subheadline) - .foregroundStyle(.secondary) - } - } - .padding() - Spacer() - } + Group { + if self.ownsNavigationStack { + NavigationStack { + self.content } - .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) - .safeAreaPadding(.top, 8) + } else { + self.content } - .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) - .navigationBarHidden(true) } .task { self.syncChatViewModel() @@ -90,6 +61,48 @@ struct ChatProTab: View { } } + private var content: some View { + ZStack { + OpenClawProBackground() + VStack(spacing: 0) { + self.header + if let viewModel { + OpenClawChatView( + viewModel: viewModel, + drawsBackground: false, + showsSessionSwitcher: false, + userAccent: self.chatUserAccent, + assistantName: self.agentDisplayName, + assistantAvatarText: self.agentBadge, + assistantAvatarTint: OpenClawBrand.accent, + showsAssistantAvatars: false, + composerChrome: .clean, + isComposerEnabled: self.gatewayConnected, + messagePlaceholder: self.messagePlaceholder, + talkControl: self.talkControl) + .id(ObjectIdentifier(viewModel)) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) + } else { + ProCard { + VStack(alignment: .leading, spacing: 8) { + Text("Chat is preparing") + .font(.headline) + Text("The operator session will attach when the gateway is ready.") + .font(.subheadline) + .foregroundStyle(.secondary) + } + } + .padding() + Spacer() + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) + .safeAreaPadding(.top, 8) + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) + .navigationBarHidden(true) + } + private var header: some View { OpenClawAdaptiveHeaderRow( title: self.headerDisplayTitle, diff --git a/apps/ios/Sources/Design/CommandCenterTab.swift b/apps/ios/Sources/Design/CommandCenterTab.swift index 96f122a807d9..4955301205f4 100644 --- a/apps/ios/Sources/Design/CommandCenterTab.swift +++ b/apps/ios/Sources/Design/CommandCenterTab.swift @@ -10,11 +10,13 @@ struct CommandCenterTab: View { @Environment(\.scenePhase) private var scenePhase @State private var defaultChatSessionEntry: OpenClawChatSessionEntry? @State private var recentChatSessions: [OpenClawChatSessionEntry] = [] + var ownsNavigationStack: Bool = true var headerTitle: String = "OpenClaw" var headerLeadingAction: OpenClawSidebarHeaderAction? var showsHeaderMark: Bool = true var openChat: () -> Void var openSettings: () -> Void + var openSessions: (() -> Void)? enum WorkRoute { case chat(String?) @@ -34,46 +36,56 @@ struct CommandCenterTab: View { } var body: some View { - NavigationStack { - GeometryReader { geometry in - ZStack { - CommandControlBackground() - self.commandAmbientOverlay - ScrollView { - VStack(alignment: .leading, spacing: 14) { - self.header - self.gatewayCard - if Self.usesSplitSectionsLayout( - horizontalSizeClass: self.horizontalSizeClass, - containerWidth: geometry.size.width) - { - HStack(alignment: .top, spacing: 12) { - self.defaultChatSessionSection - .frame(maxWidth: .infinity, alignment: .topLeading) - self.recentSessions - .frame(maxWidth: .infinity, alignment: .topLeading) - } - .padding(.horizontal, OpenClawProMetric.pagePadding) - } else { - self.defaultChatSessionSection - .padding(.horizontal, OpenClawProMetric.pagePadding) - self.recentSessions - .padding(.horizontal, OpenClawProMetric.pagePadding) - } - } - .padding(.top, 18) - .padding(.bottom, 18) - } - .safeAreaPadding(.bottom, OpenClawProMetric.bottomScrollInset) + Group { + if self.ownsNavigationStack { + NavigationStack { + self.content } + } else { + self.content } - .navigationBarHidden(true) } .task(id: self.recentSessionsRefreshID) { await self.refreshRecentSessionsIfNeeded() } } + private var content: some View { + GeometryReader { geometry in + ZStack { + CommandControlBackground() + self.commandAmbientOverlay + ScrollView { + VStack(alignment: .leading, spacing: 14) { + self.header + self.gatewayCard + if Self.usesSplitSectionsLayout( + horizontalSizeClass: self.horizontalSizeClass, + containerWidth: geometry.size.width) + { + HStack(alignment: .top, spacing: 12) { + self.defaultChatSessionSection + .frame(maxWidth: .infinity, alignment: .topLeading) + self.recentSessions + .frame(maxWidth: .infinity, alignment: .topLeading) + } + .padding(.horizontal, OpenClawProMetric.pagePadding) + } else { + self.defaultChatSessionSection + .padding(.horizontal, OpenClawProMetric.pagePadding) + self.recentSessions + .padding(.horizontal, OpenClawProMetric.pagePadding) + } + } + .padding(.top, 18) + .padding(.bottom, 18) + } + .safeAreaPadding(.bottom, OpenClawProMetric.bottomScrollInset) + } + } + .navigationBarHidden(true) + } + static func usesSplitSectionsLayout( horizontalSizeClass: UserInterfaceSizeClass?, containerWidth: CGFloat) -> Bool @@ -244,12 +256,19 @@ struct CommandCenterTab: View { } if self.hasMoreRecentSessions { - NavigationLink { - CommandSessionsScreen(openChat: self.openChat) - } label: { - CommandViewMoreRow() + if let openSessions { + Button(action: openSessions) { + CommandViewMoreRow() + } + .buttonStyle(.plain) + } else { + NavigationLink { + CommandSessionsScreen(openChat: self.openChat) + } label: { + CommandViewMoreRow() + } + .buttonStyle(.plain) } - .buttonStyle(.plain) } } } diff --git a/apps/ios/Sources/Design/RootTabsPhoneControlHub.swift b/apps/ios/Sources/Design/RootTabsPhoneControlHub.swift index 835cbcfc418d..933795181afb 100644 --- a/apps/ios/Sources/Design/RootTabsPhoneControlHub.swift +++ b/apps/ios/Sources/Design/RootTabsPhoneControlHub.swift @@ -89,7 +89,7 @@ struct RootTabsPhoneControlHub: View { private var gatewayActionRow: some View { Button { - self.openRootDestination(.gateway) + self.openPhoneRootDestination(.gateway) } label: { HStack(spacing: 10) { ProStatusDot(color: self.gatewayStateColor) @@ -140,7 +140,7 @@ struct RootTabsPhoneControlHub: View { private func destinationRow(_ destination: RootTabs.SidebarDestination) -> some View { if self.opensRootTab(destination) { Button { - self.openRootDestination(destination) + self.openPhoneRootDestination(destination) } label: { self.rowLabel(destination) } @@ -198,57 +198,59 @@ struct RootTabsPhoneControlHub: View { EmptyView() case .overview: CommandCenterTab( + ownsNavigationStack: false, headerTitle: "Overview", headerLeadingAction: self.phoneDetailBackAction, showsHeaderMark: false, - openChat: { self.openRootDestination(.chat) }, - openSettings: { self.openRootDestination(.gateway) }) + openChat: { self.openPhoneRootDestination(.chat) }, + openSettings: { self.openPhoneRootDestination(.gateway) }, + openSessions: { self.navigationPath.append(.sessions) }) case .activity: IPadActivityScreen( headerLeadingAction: self.phoneDetailBackAction, - openChat: { self.openRootDestination(.chat) }, - openSettings: { self.openRootDestination(.gateway) }) + openChat: { self.openPhoneRootDestination(.chat) }, + openSettings: { self.openPhoneRootDestination(.gateway) }) case .workboard: IPadWorkboardScreen( headerLeadingAction: self.phoneDetailBackAction, - openChat: { self.openRootDestination(.chat) }, - openSettings: { self.openRootDestination(.gateway) }) + openChat: { self.openPhoneRootDestination(.chat) }, + openSettings: { self.openPhoneRootDestination(.gateway) }) case .skillWorkshop: IPadSkillWorkshopScreen( headerLeadingAction: self.phoneDetailBackAction, - openSettings: { self.openRootDestination(.gateway) }) + openSettings: { self.openPhoneRootDestination(.gateway) }) case .instances: AgentProTab( directRoute: .instances, headerLeadingAction: self.phoneDetailBackAction, headerTitle: "Instances", - openSettings: { self.openRootDestination(.gateway) }) + openSettings: { self.openPhoneRootDestination(.gateway) }) case .sessions: CommandSessionsScreen( headerLeadingAction: self.phoneDetailBackAction, - openChat: { self.openRootDestination(.chat) }) + openChat: { self.openPhoneRootDestination(.chat) }) case .dreaming: AgentProTab( directRoute: .dreaming, headerLeadingAction: self.phoneDetailBackAction, headerTitle: "Dreaming", - openSettings: { self.openRootDestination(.gateway) }) + openSettings: { self.openPhoneRootDestination(.gateway) }) case .usage: AgentProTab( directRoute: .usage, headerLeadingAction: self.phoneDetailBackAction, headerTitle: "Usage", - openSettings: { self.openRootDestination(.gateway) }) + openSettings: { self.openPhoneRootDestination(.gateway) }) case .cron: AgentProTab( directRoute: .cron, headerLeadingAction: self.phoneDetailBackAction, headerTitle: "Cron Jobs", - openSettings: { self.openRootDestination(.gateway) }) + openSettings: { self.openPhoneRootDestination(.gateway) }) case .docs: OpenClawDocsScreen( headerLeadingAction: self.phoneDetailBackAction, - gatewayAction: { self.openRootDestination(.gateway) }) + gatewayAction: { self.openPhoneRootDestination(.gateway) }) case .settings: EmptyView() } @@ -267,6 +269,11 @@ struct RootTabsPhoneControlHub: View { self.navigationPath.removeLast() } + private func openPhoneRootDestination(_ destination: RootTabs.SidebarDestination) { + self.navigationPath.removeAll() + self.openRootDestination(destination) + } + private func opensRootTab(_ destination: RootTabs.SidebarDestination) -> Bool { RootTabs.shouldOpenRootTabFromPhoneHub(destination) } @@ -276,7 +283,7 @@ struct RootTabsPhoneControlHub: View { self.didApplyInitialDestination = true guard let initialDestination, initialDestination != .overview else { return } if self.opensRootTab(initialDestination) { - self.openRootDestination(initialDestination) + self.openPhoneRootDestination(initialDestination) } else { self.navigationPath = [initialDestination] } diff --git a/apps/ios/Sources/Design/SettingsProTab.swift b/apps/ios/Sources/Design/SettingsProTab.swift index c5146982f54d..f0f601adaaf4 100644 --- a/apps/ios/Sources/Design/SettingsProTab.swift +++ b/apps/ios/Sources/Design/SettingsProTab.swift @@ -63,17 +63,23 @@ struct SettingsProTab: View { let initialRoute: SettingsRoute? let directRoute: SettingsRoute? let headerLeadingAction: OpenClawSidebarHeaderAction? + let ownsNavigationStack: Bool + let navigateToRoute: ((SettingsRoute) -> Void)? let onRouteChange: ((SettingsRoute?) -> Void)? init( initialRoute: SettingsRoute? = nil, directRoute: SettingsRoute? = nil, headerLeadingAction: OpenClawSidebarHeaderAction? = nil, + ownsNavigationStack: Bool = true, + navigateToRoute: ((SettingsRoute) -> Void)? = nil, onRouteChange: ((SettingsRoute?) -> Void)? = nil) { self.initialRoute = initialRoute self.directRoute = directRoute self.headerLeadingAction = headerLeadingAction + self.ownsNavigationStack = ownsNavigationStack + self.navigateToRoute = navigateToRoute self.onRouteChange = onRouteChange } @@ -88,29 +94,37 @@ struct SettingsProTab: View { if let directRoute { self.destination(for: directRoute) } else { - self.settingsNavigationStack + if self.ownsNavigationStack { + self.settingsNavigationStack + } else { + self.settingsNavigationContent + } } } private var settingsNavigationStack: some View { NavigationStack(path: self.$navigationPath) { - ZStack { - OpenClawProBackground() - ScrollView { - VStack(alignment: .leading, spacing: 18) { - self.settingsHeader - self.appearanceSection - self.gatewaySection - self.settingsListSection - } - .padding(.top, 18) - .padding(.bottom, 18) + self.settingsNavigationContent + } + } + + private var settingsNavigationContent: some View { + ZStack { + OpenClawProBackground() + ScrollView { + VStack(alignment: .leading, spacing: 18) { + self.settingsHeader + self.appearanceSection + self.gatewaySection + self.settingsListSection } + .padding(.top, 18) + .padding(.bottom, 18) } - .navigationBarHidden(true) - .navigationDestination(for: SettingsRoute.self) { route in - self.destination(for: route) - } + } + .navigationBarHidden(true) + .navigationDestination(for: SettingsRoute.self) { route in + self.destination(for: route) } } @@ -238,6 +252,10 @@ struct SettingsProTab: View { func openNotificationsRouteFromApprovals() { guard self.directRoute == nil else { return } + if !self.ownsNavigationStack, let navigateToRoute { + navigateToRoute(.notifications) + return + } self.navigationPath = [.notifications] } diff --git a/apps/ios/Sources/Design/TalkProTab.swift b/apps/ios/Sources/Design/TalkProTab.swift index 0ed1c3fb00c6..bd8370787b62 100644 --- a/apps/ios/Sources/Design/TalkProTab.swift +++ b/apps/ios/Sources/Design/TalkProTab.swift @@ -10,13 +10,16 @@ struct TalkProTab: View { @State private var showPermissionPrompt = false @State private var showTalkIssueDetails = false let headerLeadingAction: OpenClawSidebarHeaderAction? + let ownsNavigationStack: Bool var openSettings: () -> Void init( headerLeadingAction: OpenClawSidebarHeaderAction? = nil, + ownsNavigationStack: Bool = true, openSettings: @escaping () -> Void) { self.headerLeadingAction = headerLeadingAction + self.ownsNavigationStack = ownsNavigationStack self.openSettings = openSettings } @@ -34,31 +37,14 @@ struct TalkProTab: View { } var body: some View { - NavigationStack { - ZStack { - CommandControlBackground() - ScrollView { - VStack(alignment: .leading, spacing: 10) { - self.header - if let fallbackIssue = self.fallbackIssue { - TalkRuntimeIssueBanner( - issue: fallbackIssue, - onOpenSettings: self.openSettings, - onShowDetails: { - self.showTalkIssueDetails = true - }) - .padding(.horizontal, OpenClawProMetric.pagePadding) - } - self.voiceHeroCard - self.conversationCard - self.voiceModeCard - self.controlsCard - } - .padding(.top, 16) - .padding(.bottom, 18) + Group { + if self.ownsNavigationStack { + NavigationStack { + self.content } + } else { + self.content } - .navigationBarHidden(true) } .sheet(isPresented: self.$showPermissionPrompt) { NavigationStack { @@ -92,6 +78,33 @@ struct TalkProTab: View { .onAppear { self.alignPersistedTalkState() } } + private var content: some View { + ZStack { + CommandControlBackground() + ScrollView { + VStack(alignment: .leading, spacing: 10) { + self.header + if let fallbackIssue = self.fallbackIssue { + TalkRuntimeIssueBanner( + issue: fallbackIssue, + onOpenSettings: self.openSettings, + onShowDetails: { + self.showTalkIssueDetails = true + }) + .padding(.horizontal, OpenClawProMetric.pagePadding) + } + self.voiceHeroCard + self.conversationCard + self.voiceModeCard + self.controlsCard + } + .padding(.top, 16) + .padding(.bottom, 18) + } + } + .navigationBarHidden(true) + } + private var header: some View { HStack(alignment: .center, spacing: 11) { if let headerLeadingAction { diff --git a/apps/ios/Sources/RootTabs.swift b/apps/ios/Sources/RootTabs.swift index e85776597615..7b9f09eccaa8 100644 --- a/apps/ios/Sources/RootTabs.swift +++ b/apps/ios/Sources/RootTabs.swift @@ -26,6 +26,9 @@ struct RootTabs: View { @State private var selectedSidebarDestination: SidebarDestination = Self.initialSidebarDestination @State private var selectedSettingsRoute: SettingsRoute? = Self.initialSidebarDestination.settingsRoute @State private var selectedSettingsRouteRequestID: Int = 0 + // Embedded Settings rows push onto the sidebar stack; clear it before + // changing sidebar roots so stale settings detail screens cannot survive. + @State private var sidebarNavigationPath: [SettingsRoute] = [] @State private var isSidebarVisible: Bool = Self.initialSidebarVisibility ?? false @State private var sidebarVisibilityUserOverridden: Bool = Self.initialSidebarVisibility != nil @State private var isSidebarDrawerLayout: Bool = false @@ -218,13 +221,19 @@ struct RootTabs: View { .frame(maxWidth: .infinity, maxHeight: .infinity) if self.isSidebarVisible { - Color.black.opacity(0.28) - .ignoresSafeArea() - .contentShape(Rectangle()) - .onTapGesture { - self.hideSidebar() - } - .transition(.opacity) + HStack(spacing: 0) { + Color.clear + .frame(width: sidebarWidth) + .allowsHitTesting(false) + Color.black.opacity(0.28) + .contentShape(Rectangle()) + .onTapGesture { + self.hideSidebar() + } + } + .ignoresSafeArea() + .transition(.opacity) + .zIndex(0) self.sidebarColumn .frame(width: sidebarWidth, alignment: .topLeading) @@ -234,6 +243,7 @@ struct RootTabs: View { } .shadow(color: .black.opacity(0.26), radius: 18, x: 8, y: 0) .transition(.move(edge: .leading).combined(with: .opacity)) + .zIndex(1) } } } @@ -378,7 +388,10 @@ struct RootTabs: View { .lineLimit(1) .minimumScaleFactor(0.82) .truncationMode(.tail) + .padding(.vertical, 8) + .padding(.horizontal, 10) .frame(maxWidth: .infinity, alignment: .leading) + .contentShape(Rectangle()) } .buttonStyle(.plain) .foregroundStyle(destination == self.selectedSidebarDestination ? OpenClawBrand.accent : .primary) @@ -398,18 +411,22 @@ struct RootTabs: View { headerTitle: "Chat", headerSubtitle: "Agent conversation", showsAgentBadge: false, + ownsNavigationStack: false, openSettings: { self.selectSidebarDestination(.gateway) }) case .talk: TalkProTab( headerLeadingAction: self.sidebarHeaderLeadingAction, + ownsNavigationStack: false, openSettings: { self.selectSidebarDestination(.gateway) }) case .overview: CommandCenterTab( + ownsNavigationStack: false, headerTitle: "Overview", headerLeadingAction: self.sidebarHeaderLeadingAction, showsHeaderMark: false, openChat: { self.selectSidebarDestination(.chat) }, - openSettings: { self.selectSidebarDestination(.gateway) }) + openSettings: { self.selectSidebarDestination(.gateway) }, + openSessions: { self.selectSidebarDestination(.sessions) }) case .activity: IPadActivityScreen( headerLeadingAction: self.sidebarHeaderLeadingAction, @@ -472,22 +489,28 @@ struct RootTabs: View { SettingsProTab( directRoute: selectedSettingsRoute, headerLeadingAction: self.sidebarHeaderLeadingAction, + ownsNavigationStack: false, + navigateToRoute: self.pushSidebarSettingsRoute, onRouteChange: self.handleSettingsRouteChange) } else { SettingsProTab( headerLeadingAction: self.sidebarHeaderLeadingAction, + ownsNavigationStack: false, + navigateToRoute: self.pushSidebarSettingsRoute, onRouteChange: self.handleSettingsRouteChange) } case .gateway: SettingsProTab( directRoute: self.selectedSettingsRoute ?? self.selectedSidebarDestination.settingsRoute ?? .gateway, headerLeadingAction: self.sidebarHeaderLeadingAction, + ownsNavigationStack: false, + navigateToRoute: self.pushSidebarSettingsRoute, onRouteChange: self.handleSettingsRouteChange) } } private var sidebarDetailNavigationShell: some View { - NavigationStack { + NavigationStack(path: self.$sidebarNavigationPath) { self.sidebarDetailShell } .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) @@ -926,6 +949,7 @@ struct RootTabs: View { extension RootTabs { private func selectSidebarDestination(_ destination: SidebarDestination) { + self.sidebarNavigationPath.removeAll() if destination.settingsRoute != .notifications { self.suppressedExecApprovalPromptIDForNotificationSettings = nil } @@ -939,6 +963,7 @@ extension RootTabs { } private func selectSettingsRoute(_ route: SettingsRoute) { + self.sidebarNavigationPath.removeAll() if route != .notifications { self.suppressedExecApprovalPromptIDForNotificationSettings = nil } @@ -952,6 +977,11 @@ extension RootTabs { } } + private func pushSidebarSettingsRoute(_ route: SettingsRoute) { + self.sidebarNavigationPath = [route] + self.handleSettingsRouteChange(route) + } + private func handleSettingsRouteChange(_ route: SettingsRoute?) { guard route != .notifications else { return } if route == nil { diff --git a/apps/ios/Tests/RootTabsPresentationTests.swift b/apps/ios/Tests/RootTabsPresentationTests.swift index 53ffd5c0e6fd..87321a24a97e 100644 --- a/apps/ios/Tests/RootTabsPresentationTests.swift +++ b/apps/ios/Tests/RootTabsPresentationTests.swift @@ -251,20 +251,42 @@ import UIKit #expect(!CommandCenterTab.shouldShowHeaderMark(hasLeadingAction: false, showsHeaderMark: false)) } + @Test func commandCenterCanUseParentNavigationStackForEmbeddedRoutes() { + let standalone = CommandCenterTab(openChat: {}, openSettings: {}) + let embedded = CommandCenterTab( + ownsNavigationStack: false, + openChat: {}, + openSettings: {}) + let shellRouted = CommandCenterTab( + ownsNavigationStack: false, + openChat: {}, + openSettings: {}, + openSessions: {}) + + #expect(standalone.ownsNavigationStack) + #expect(standalone.openSessions == nil) + #expect(!embedded.ownsNavigationStack) + #expect(embedded.openSessions == nil) + #expect(shellRouted.openSessions != nil) + } + @Test func chatSidebarDestinationCanUseRouteHeaderInsteadOfAgentBranding() { let standalone = ChatProTab() let routed = ChatProTab( headerTitle: "Chat", headerSubtitle: "Agent conversation", showsAgentBadge: false, + ownsNavigationStack: false, openSettings: {}) #expect(standalone.showsAgentBadge) + #expect(standalone.ownsNavigationStack) #expect(standalone.headerTitle == nil) #expect(standalone.openSettings == nil) #expect(routed.headerTitle == "Chat") #expect(routed.headerSubtitle == "Agent conversation") #expect(!routed.showsAgentBadge) + #expect(!routed.ownsNavigationStack) #expect(routed.openSettings != nil) #expect(ChatProTab.defaultHeaderTitle(showsAgentBadge: true, agentDisplayName: "OpenClaw") == "OpenClaw") #expect(ChatProTab.defaultHeaderTitle(showsAgentBadge: false, agentDisplayName: "OpenClaw") == "Chat") @@ -310,9 +332,23 @@ import UIKit accessibilityLabel: "Show Sidebar", action: {}) let routed = TalkProTab(headerLeadingAction: action, openSettings: {}) + let embedded = TalkProTab( + headerLeadingAction: action, + ownsNavigationStack: false, + openSettings: {}) #expect(routed.headerLeadingAction?.systemName == "sidebar.left") #expect(routed.headerLeadingAction?.accessibilityLabel == "Show Sidebar") + #expect(routed.ownsNavigationStack) + #expect(!embedded.ownsNavigationStack) + } + + @Test func settingsCanUseParentNavigationStackForSidebarRoutes() { + let standalone = SettingsProTab() + let embedded = SettingsProTab(ownsNavigationStack: false) + + #expect(standalone.ownsNavigationStack) + #expect(!embedded.ownsNavigationStack) } @Test func iPadPortraitUsesHiddenDrawerSidebar() { diff --git a/apps/ios/Tests/RootTabsSidebarRegressionTests.swift b/apps/ios/Tests/RootTabsSidebarRegressionTests.swift index fcfcbc7cc96b..b2972a1fb9ed 100644 --- a/apps/ios/Tests/RootTabsSidebarRegressionTests.swift +++ b/apps/ios/Tests/RootTabsSidebarRegressionTests.swift @@ -42,6 +42,69 @@ import Testing #expect(layoutUpdate.contains("guard force || !self.sidebarVisibilityUserOverridden else { return }")) } + @Test func drawerDimmingLayerDoesNotStealSidebarTouches() throws { + let source = try String(contentsOf: Self.rootTabsSourceURL(), encoding: .utf8) + let drawerContent = try Self.extract( + source, + from: "private func sidebarDrawerContent(sidebarWidth: CGFloat) -> some View", + to: "private var sidebarDetailShell: some View") + + #expect(drawerContent.contains("HStack(spacing: 0)")) + #expect(drawerContent.contains("Color.clear")) + #expect(drawerContent.contains(".frame(width: sidebarWidth)")) + #expect(drawerContent.contains(".allowsHitTesting(false)")) + #expect(drawerContent.contains("Color.black.opacity(0.28)")) + #expect(drawerContent.contains(".zIndex(0)")) + #expect(drawerContent.contains(".zIndex(1)")) + } + + @Test func sidebarSelectionResetsEmbeddedSettingsNavigationPath() throws { + let source = try String(contentsOf: Self.rootTabsSourceURL(), encoding: .utf8) + let sidebarDetail = try Self.extract( + source, + from: "private var sidebarDetail: some View", + to: "private var sidebarDetailNavigationShell: some View") + let navigationShell = try Self.extract( + source, + from: "private var sidebarDetailNavigationShell: some View", + to: "private var usesSidebarTabs: Bool") + let selection = try Self.extract( + source, + from: "private func selectSidebarDestination(_ destination: SidebarDestination)", + to: "private func showSidebar()") + let resetRange = try #require(selection.range(of: "self.sidebarNavigationPath.removeAll()")) + let destinationRange = try #require(selection.range(of: "self.selectedSidebarDestination = destination")) + + #expect(source.contains("@State private var sidebarNavigationPath: [SettingsRoute] = []")) + #expect(navigationShell.contains("NavigationStack(path: self.$sidebarNavigationPath)")) + #expect(sidebarDetail.contains("case .settings:")) + #expect(sidebarDetail.contains("ownsNavigationStack: false")) + #expect(resetRange.lowerBound < destinationRange.lowerBound) + } + + @Test func embeddedOverviewRoutesViewMoreThroughOwningNavigationStack() throws { + let rootTabsSource = try String(contentsOf: Self.rootTabsSourceURL(), encoding: .utf8) + let commandCenterSource = try String(contentsOf: Self.commandCenterSourceURL(), encoding: .utf8) + let phoneHubSource = try String(contentsOf: Self.phoneHubSourceURL(), encoding: .utf8) + let sidebarDetail = try Self.extract( + rootTabsSource, + from: "private var sidebarDetail: some View", + to: "private var sidebarDetailNavigationShell: some View") + let iPadOverview = try Self.extract(sidebarDetail, from: "case .overview:", to: "case .activity:") + let recentSessions = try Self.extract( + commandCenterSource, + from: "private var recentSessions: some View", + to: "private func cardHeader(") + let phoneOverview = try Self.extract(phoneHubSource, from: "case .overview:", to: "case .activity:") + + #expect(commandCenterSource.contains("var openSessions: (() -> Void)?")) + #expect(recentSessions.contains("if let openSessions")) + #expect(recentSessions.contains("Button(action: openSessions)")) + #expect(recentSessions.contains("NavigationLink")) + #expect(iPadOverview.contains("openSessions: { self.selectSidebarDestination(.sessions) }")) + #expect(phoneOverview.contains("openSessions: { self.navigationPath.append(.sessions) }")) + } + private static func rootTabsSourceURL() -> URL { URL(fileURLWithPath: #filePath) .deletingLastPathComponent() @@ -56,6 +119,20 @@ import Testing .appendingPathComponent("Sources/RootTabsNavigation.swift") } + private static func commandCenterSourceURL() -> URL { + URL(fileURLWithPath: #filePath) + .deletingLastPathComponent() + .deletingLastPathComponent() + .appendingPathComponent("Sources/Design/CommandCenterTab.swift") + } + + private static func phoneHubSourceURL() -> URL { + URL(fileURLWithPath: #filePath) + .deletingLastPathComponent() + .deletingLastPathComponent() + .appendingPathComponent("Sources/Design/RootTabsPhoneControlHub.swift") + } + private static func extract(_ source: String, from start: String, to end: String) throws -> String { let startRange = try #require(source.range(of: start)) let tail = source[startRange.lowerBound...] diff --git a/apps/ios/Tests/RootTabsSourceGuardTests.swift b/apps/ios/Tests/RootTabsSourceGuardTests.swift index c12d072b3c10..960eae6cf437 100644 --- a/apps/ios/Tests/RootTabsSourceGuardTests.swift +++ b/apps/ios/Tests/RootTabsSourceGuardTests.swift @@ -193,7 +193,7 @@ struct RootTabsSourceGuardTests { #expect(source.contains("case .docs:")) #expect(source.contains("OpenClawDocsScreen(")) #expect(source.contains("headerLeadingAction: self.phoneDetailBackAction")) - #expect(source.contains("gatewayAction: { self.openRootDestination(.gateway) }")) + #expect(source.contains("gatewayAction: { self.openPhoneRootDestination(.gateway) }")) #expect(!source.contains("Label(\"Docs\", systemImage: \"book\")")) #expect(!source.contains("https://docs.openclaw.ai")) } @@ -239,7 +239,7 @@ struct RootTabsSourceGuardTests { let source = try String(contentsOf: Self.phoneHubSourceURL(), encoding: .utf8) #expect(source.contains("private var gatewayActionRow: some View")) - #expect(source.contains("self.openRootDestination(.gateway)")) + #expect(source.contains("self.openPhoneRootDestination(.gateway)")) #expect(source.contains("private var phoneDetailBackAction: OpenClawSidebarHeaderAction")) #expect(source.contains("accessibilityLabel: \"Back to Control\"")) #expect(source.contains("accessibilityIdentifier: \"OpenClawPhoneDetailBackButton\"")) @@ -253,7 +253,22 @@ struct RootTabsSourceGuardTests { #expect(!source.contains("private func metric(label:")) } - @Test func `workboard uses real gateway methods`() throws { + @Test func phoneHubClearsDetailPathBeforeRootTabHandoff() throws { + let source = try String(contentsOf: Self.phoneHubSourceURL(), encoding: .utf8) + let handoff = try Self.extract( + source, + from: "private func openPhoneRootDestination(_ destination: RootTabs.SidebarDestination)", + to: "private func opensRootTab(_ destination: RootTabs.SidebarDestination)") + let clearRange = try #require(handoff.range(of: "self.navigationPath.removeAll()")) + let openRange = try #require(handoff.range(of: "self.openRootDestination(destination)")) + + #expect(source.contains("NavigationStack(path: self.$navigationPath)")) + #expect(!source.contains("self.openRootDestination(.gateway)")) + #expect(source.contains("self.openPhoneRootDestination(.gateway)")) + #expect(clearRange.lowerBound < openRange.lowerBound) + } + + @Test func workboardUsesRealGatewayMethods() throws { let source = try String(contentsOf: Self.iPadWorkboardScreenSourceURL(), encoding: .utf8) #expect(source.contains("workboard.cards.list")) @@ -519,11 +534,16 @@ struct RootTabsSourceGuardTests { #expect(settingsSource.contains("NavigationLink(value: SettingsRoute.gateway)")) #expect(rootSource.contains("case .settings:")) #expect(rootSource - .matches(of: /SettingsProTab\(\s*headerLeadingAction: self\.sidebarHeaderLeadingAction,/) + .matches( + of: /case \.settings:[\s\S]*?SettingsProTab\([\s\S]*?headerLeadingAction: self\.sidebarHeaderLeadingAction,[\s\S]*?ownsNavigationStack: false[\s\S]*?onRouteChange: self\.handleSettingsRouteChange/) .count >= 1) #expect(rootSource .contains( "directRoute: self.selectedSettingsRoute ?? self.selectedSidebarDestination.settingsRoute ?? .gateway")) + #expect(rootSource.contains("ownsNavigationStack: false")) + #expect(rootSource.contains("@State private var sidebarNavigationPath: [SettingsRoute] = []")) + #expect(rootSource.contains("NavigationStack(path: self.$sidebarNavigationPath)")) + #expect(rootSource.contains("self.sidebarNavigationPath.removeAll()")) #expect(rootSource.matches(of: /SettingsProTab\(\s*initialRoute: self\.selectedSettingsRoute,/).count == 1) #expect(rootSource.contains(".id(self.settingsTabViewID)")) #expect(rootSource.contains("@State private var selectedSettingsRouteRequestID: Int = 0")) @@ -538,6 +558,10 @@ struct RootTabsSourceGuardTests { #expect(rootSource.contains("self.selectedSidebarDestination = .settings")) #expect(rootSource.contains("self.suppressedExecApprovalPromptIDForNotificationSettings = approvalId")) #expect(rootSource.contains("onRouteChange: self.handleSettingsRouteChange")) + #expect(rootSource.contains("navigateToRoute: self.pushSidebarSettingsRoute")) + #expect(rootSource.contains("private func pushSidebarSettingsRoute(_ route: SettingsRoute)")) + #expect(settingsTabSource.contains("let navigateToRoute: ((SettingsRoute) -> Void)?")) + #expect(settingsTabSource.contains("navigateToRoute(.notifications)")) #expect(rootSource.contains("private func handleSettingsRouteChange(_ route: SettingsRoute?)")) #expect(settingsTabSource.contains("let onRouteChange: ((SettingsRoute?) -> Void)?")) #expect(settingsTabSource.contains("self.onRouteChange?(self.navigationPath.last)"))