mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-13 05:16:07 +00:00
fix(ios): reset sidebar navigation stacks (#94991)
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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]
|
||||
}
|
||||
|
||||
@@ -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]
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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...]
|
||||
|
||||
@@ -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)"))
|
||||
|
||||
Reference in New Issue
Block a user