fix(ios): reset sidebar navigation stacks (#94991)

This commit is contained in:
Colin Johnson
2026-06-29 20:56:22 -04:00
committed by GitHub
parent 587eefe5ad
commit 54b09580f6
9 changed files with 380 additions and 143 deletions

View File

@@ -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,

View File

@@ -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)
}
}
}

View File

@@ -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]
}

View File

@@ -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]
}

View File

@@ -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 {

View File

@@ -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 {

View File

@@ -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() {

View File

@@ -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...]

View File

@@ -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)"))