mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-21 10:16:44 +00:00
fix(ios): sidebar drags jitter and toolbar control flickers (#111831)
* fix(ios): smooth sidebar gestures and toolbar controls * fix(ios): keep sidebar edge gesture below toolbar * chore(ios): refresh native localization inventory * fix(ios): preserve edge content interactions * fix(ios): restore smooth sidebar tracking * test(ios): align drawer source guards
This commit is contained in:
@@ -19379,7 +19379,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "ui-localized-call",
|
||||
"line": 414,
|
||||
"line": 452,
|
||||
"path": "apps/ios/Sources/Design/OpenClawProComponents.swift",
|
||||
"source": "Request ID: %@",
|
||||
"surface": "apple",
|
||||
@@ -19387,7 +19387,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "ui-modifier",
|
||||
"line": 604,
|
||||
"line": 642,
|
||||
"path": "apps/ios/Sources/Design/OpenClawProComponents.swift",
|
||||
"source": "OpenClaw",
|
||||
"surface": "apple",
|
||||
@@ -19395,7 +19395,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "ui-localized-call",
|
||||
"line": 634,
|
||||
"line": 672,
|
||||
"path": "apps/ios/Sources/Design/OpenClawProComponents.swift",
|
||||
"source": "Gateway %@",
|
||||
"surface": "apple",
|
||||
@@ -19403,7 +19403,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "ui-localized-call",
|
||||
"line": 641,
|
||||
"line": 679,
|
||||
"path": "apps/ios/Sources/Design/OpenClawProComponents.swift",
|
||||
"source": "Online",
|
||||
"surface": "apple",
|
||||
@@ -19411,7 +19411,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "ui-localized-call",
|
||||
"line": 643,
|
||||
"line": 681,
|
||||
"path": "apps/ios/Sources/Design/OpenClawProComponents.swift",
|
||||
"source": "Connecting",
|
||||
"surface": "apple",
|
||||
@@ -19419,7 +19419,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "ui-localized-call",
|
||||
"line": 645,
|
||||
"line": 683,
|
||||
"path": "apps/ios/Sources/Design/OpenClawProComponents.swift",
|
||||
"source": "Attention",
|
||||
"surface": "apple",
|
||||
@@ -19427,7 +19427,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "ui-localized-call",
|
||||
"line": 647,
|
||||
"line": 685,
|
||||
"path": "apps/ios/Sources/Design/OpenClawProComponents.swift",
|
||||
"source": "Offline",
|
||||
"surface": "apple",
|
||||
@@ -24971,7 +24971,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "ui-localized-call",
|
||||
"line": 75,
|
||||
"line": 76,
|
||||
"path": "apps/ios/Sources/RootSidebar.swift",
|
||||
"source": "OpenClaw",
|
||||
"surface": "apple",
|
||||
@@ -24979,7 +24979,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "ui-localized-call",
|
||||
"line": 100,
|
||||
"line": 101,
|
||||
"path": "apps/ios/Sources/RootSidebar.swift",
|
||||
"source": "Settings",
|
||||
"surface": "apple",
|
||||
@@ -24987,7 +24987,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "ui-localized-call",
|
||||
"line": 114,
|
||||
"line": 115,
|
||||
"path": "apps/ios/Sources/RootSidebar.swift",
|
||||
"source": "Hide Sidebar",
|
||||
"surface": "apple",
|
||||
@@ -24995,7 +24995,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "ui-localized-call",
|
||||
"line": 212,
|
||||
"line": 213,
|
||||
"path": "apps/ios/Sources/RootSidebar.swift",
|
||||
"source": "Selected",
|
||||
"surface": "apple",
|
||||
@@ -25003,7 +25003,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "ui-localized-call",
|
||||
"line": 230,
|
||||
"line": 231,
|
||||
"path": "apps/ios/Sources/RootSidebar.swift",
|
||||
"source": "More Agents",
|
||||
"surface": "apple",
|
||||
@@ -25011,7 +25011,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "ui-localized-call",
|
||||
"line": 317,
|
||||
"line": 318,
|
||||
"path": "apps/ios/Sources/RootSidebar.swift",
|
||||
"source": "Search sessions",
|
||||
"surface": "apple",
|
||||
@@ -25019,7 +25019,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "ui-localized-call",
|
||||
"line": 328,
|
||||
"line": 329,
|
||||
"path": "apps/ios/Sources/RootSidebar.swift",
|
||||
"source": "Clear session search",
|
||||
"surface": "apple",
|
||||
@@ -25027,7 +25027,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "ui-localized-call",
|
||||
"line": 356,
|
||||
"line": 357,
|
||||
"path": "apps/ios/Sources/RootSidebar.swift",
|
||||
"source": "Loading sessions",
|
||||
"surface": "apple",
|
||||
@@ -25035,7 +25035,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "ui-localized-call",
|
||||
"line": 364,
|
||||
"line": 365,
|
||||
"path": "apps/ios/Sources/RootSidebar.swift",
|
||||
"source": "No recent sessions",
|
||||
"surface": "apple",
|
||||
@@ -25043,7 +25043,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "ui-localized-call",
|
||||
"line": 374,
|
||||
"line": 375,
|
||||
"path": "apps/ios/Sources/RootSidebar.swift",
|
||||
"source": "Recent",
|
||||
"surface": "apple",
|
||||
@@ -25051,7 +25051,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "ui-localized-call",
|
||||
"line": 390,
|
||||
"line": 391,
|
||||
"path": "apps/ios/Sources/RootSidebar.swift",
|
||||
"source": "All Sessions…",
|
||||
"surface": "apple",
|
||||
@@ -25059,7 +25059,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "ui-localized-call",
|
||||
"line": 420,
|
||||
"line": 421,
|
||||
"path": "apps/ios/Sources/RootSidebar.swift",
|
||||
"source": "Edit Pages",
|
||||
"surface": "apple",
|
||||
@@ -25067,7 +25067,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "ui-localized-call",
|
||||
"line": 444,
|
||||
"line": 445,
|
||||
"path": "apps/ios/Sources/RootSidebar.swift",
|
||||
"source": "Home",
|
||||
"surface": "apple",
|
||||
@@ -25075,7 +25075,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "ui-localized-call",
|
||||
"line": 652,
|
||||
"line": 653,
|
||||
"path": "apps/ios/Sources/RootSidebar.swift",
|
||||
"source": "Unread",
|
||||
"surface": "apple",
|
||||
@@ -25083,7 +25083,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "ui-localized-call",
|
||||
"line": 678,
|
||||
"line": 679,
|
||||
"path": "apps/ios/Sources/RootSidebar.swift",
|
||||
"source": "Attention",
|
||||
"surface": "apple",
|
||||
@@ -25091,7 +25091,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "ui-localized-call",
|
||||
"line": 718,
|
||||
"line": 719,
|
||||
"path": "apps/ios/Sources/RootSidebar.swift",
|
||||
"source": "New Chat",
|
||||
"surface": "apple",
|
||||
@@ -25099,7 +25099,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "ui-localized-call",
|
||||
"line": 747,
|
||||
"line": 748,
|
||||
"path": "apps/ios/Sources/RootSidebar.swift",
|
||||
"source": "Connection",
|
||||
"surface": "apple",
|
||||
@@ -25107,7 +25107,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "ui-localized-call",
|
||||
"line": 752,
|
||||
"line": 753,
|
||||
"path": "apps/ios/Sources/RootSidebar.swift",
|
||||
"source": "Online",
|
||||
"surface": "apple",
|
||||
@@ -25115,7 +25115,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "ui-localized-call",
|
||||
"line": 753,
|
||||
"line": 754,
|
||||
"path": "apps/ios/Sources/RootSidebar.swift",
|
||||
"source": "Connecting",
|
||||
"surface": "apple",
|
||||
@@ -25123,7 +25123,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "ui-localized-call",
|
||||
"line": 754,
|
||||
"line": 755,
|
||||
"path": "apps/ios/Sources/RootSidebar.swift",
|
||||
"source": "Needs attention",
|
||||
"surface": "apple",
|
||||
@@ -25131,7 +25131,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "ui-localized-call",
|
||||
"line": 755,
|
||||
"line": 756,
|
||||
"path": "apps/ios/Sources/RootSidebar.swift",
|
||||
"source": "Offline",
|
||||
"surface": "apple",
|
||||
@@ -25139,7 +25139,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "ui-call",
|
||||
"line": 854,
|
||||
"line": 855,
|
||||
"path": "apps/ios/Sources/RootSidebar.swift",
|
||||
"source": "Pinned pages stay in the sidebar. Home is always shown.",
|
||||
"surface": "apple",
|
||||
@@ -25147,7 +25147,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "ui-localized-call",
|
||||
"line": 858,
|
||||
"line": 859,
|
||||
"path": "apps/ios/Sources/RootSidebar.swift",
|
||||
"source": "Pages",
|
||||
"surface": "apple",
|
||||
@@ -25155,7 +25155,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "ui-localized-call",
|
||||
"line": 865,
|
||||
"line": 866,
|
||||
"path": "apps/ios/Sources/RootSidebar.swift",
|
||||
"source": "Done",
|
||||
"surface": "apple",
|
||||
@@ -25163,7 +25163,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "ui-localized-call",
|
||||
"line": 904,
|
||||
"line": 905,
|
||||
"path": "apps/ios/Sources/RootSidebar.swift",
|
||||
"source": "Pinned",
|
||||
"surface": "apple",
|
||||
@@ -25171,7 +25171,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "ui-localized-call",
|
||||
"line": 905,
|
||||
"line": 906,
|
||||
"path": "apps/ios/Sources/RootSidebar.swift",
|
||||
"source": "Not pinned",
|
||||
"surface": "apple",
|
||||
@@ -25179,7 +25179,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "ui-named-argument",
|
||||
"line": 326,
|
||||
"line": 359,
|
||||
"path": "apps/ios/Sources/RootTabs.swift",
|
||||
"source": "Overview",
|
||||
"surface": "apple",
|
||||
@@ -25187,7 +25187,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "ui-named-argument",
|
||||
"line": 354,
|
||||
"line": 387,
|
||||
"path": "apps/ios/Sources/RootTabs.swift",
|
||||
"source": "Agents",
|
||||
"surface": "apple",
|
||||
@@ -25195,7 +25195,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "ui-named-argument",
|
||||
"line": 361,
|
||||
"line": 394,
|
||||
"path": "apps/ios/Sources/RootTabs.swift",
|
||||
"source": "Instances",
|
||||
"surface": "apple",
|
||||
@@ -25203,7 +25203,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "ui-named-argument",
|
||||
"line": 372,
|
||||
"line": 405,
|
||||
"path": "apps/ios/Sources/RootTabs.swift",
|
||||
"source": "Files",
|
||||
"surface": "apple",
|
||||
@@ -25211,7 +25211,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "ui-named-argument",
|
||||
"line": 379,
|
||||
"line": 412,
|
||||
"path": "apps/ios/Sources/RootTabs.swift",
|
||||
"source": "Dreaming",
|
||||
"surface": "apple",
|
||||
@@ -25219,7 +25219,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "ui-named-argument",
|
||||
"line": 386,
|
||||
"line": 419,
|
||||
"path": "apps/ios/Sources/RootTabs.swift",
|
||||
"source": "Usage",
|
||||
"surface": "apple",
|
||||
@@ -25227,7 +25227,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "ui-named-argument",
|
||||
"line": 393,
|
||||
"line": 426,
|
||||
"path": "apps/ios/Sources/RootTabs.swift",
|
||||
"source": "Automations",
|
||||
"surface": "apple",
|
||||
@@ -25235,7 +25235,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "ui-localized-call",
|
||||
"line": 484,
|
||||
"line": 517,
|
||||
"path": "apps/ios/Sources/RootTabs.swift",
|
||||
"source": "Hide Sidebar",
|
||||
"surface": "apple",
|
||||
@@ -25243,7 +25243,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "ui-localized-call",
|
||||
"line": 490,
|
||||
"line": 523,
|
||||
"path": "apps/ios/Sources/RootTabs.swift",
|
||||
"source": "Show Sidebar",
|
||||
"surface": "apple",
|
||||
@@ -25251,7 +25251,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "ui-modifier",
|
||||
"line": 662,
|
||||
"line": 717,
|
||||
"path": "apps/ios/Sources/RootTabs.swift",
|
||||
"source": "Close canvas",
|
||||
"surface": "apple",
|
||||
@@ -25259,7 +25259,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "conditional-branch",
|
||||
"line": 925,
|
||||
"line": 980,
|
||||
"path": "apps/ios/Sources/RootTabs.swift",
|
||||
"source": "Gateway needs attention",
|
||||
"surface": "apple",
|
||||
@@ -25267,7 +25267,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "conditional-branch",
|
||||
"line": 925,
|
||||
"line": 980,
|
||||
"path": "apps/ios/Sources/RootTabs.swift",
|
||||
"source": "OpenClaw iOS",
|
||||
"surface": "apple",
|
||||
@@ -25275,7 +25275,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "conditional-branch",
|
||||
"line": 961,
|
||||
"line": 1016,
|
||||
"path": "apps/ios/Sources/RootTabs.swift",
|
||||
"source": "Available",
|
||||
"surface": "apple",
|
||||
@@ -25283,7 +25283,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "conditional-branch",
|
||||
"line": 961,
|
||||
"line": 1016,
|
||||
"path": "apps/ios/Sources/RootTabs.swift",
|
||||
"source": "Gateway default",
|
||||
"surface": "apple",
|
||||
@@ -25291,7 +25291,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "conditional-branch",
|
||||
"line": 961,
|
||||
"line": 1016,
|
||||
"path": "apps/ios/Sources/RootTabs.swift",
|
||||
"source": "Routed on this phone",
|
||||
"surface": "apple",
|
||||
|
||||
@@ -55,9 +55,9 @@ extension AgentProTab {
|
||||
self.gatewayToolbarButton
|
||||
}
|
||||
if let headerSidebarAction {
|
||||
ToolbarItem(placement: .topBarLeading) {
|
||||
OpenClawSidebarHeaderLeadingSlot(action: headerSidebarAction)
|
||||
}
|
||||
OpenClawSidebarToolbarItem(
|
||||
action: headerSidebarAction,
|
||||
placement: .topBarLeading)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -138,9 +138,9 @@ struct ChatProTab: View {
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
if let headerSidebarAction {
|
||||
ToolbarItem(placement: .topBarLeading) {
|
||||
OpenClawSidebarRevealButton(action: headerSidebarAction)
|
||||
}
|
||||
OpenClawSidebarToolbarItem(
|
||||
action: headerSidebarAction,
|
||||
placement: .topBarLeading)
|
||||
}
|
||||
if self.showsAgentBadge {
|
||||
if #available(iOS 26.0, *) {
|
||||
|
||||
@@ -112,9 +112,9 @@ struct CommandCenterTab: View {
|
||||
}
|
||||
}
|
||||
if self.usesNativeNavigationChrome, let headerSidebarAction {
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
OpenClawSidebarRevealButton(action: headerSidebarAction)
|
||||
}
|
||||
OpenClawSidebarToolbarItem(
|
||||
action: headerSidebarAction,
|
||||
placement: .topBarTrailing)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -65,9 +65,9 @@ struct IPadSidebarScreenChrome<Content: View>: View {
|
||||
}
|
||||
}
|
||||
if self.usesNativeNavigationChrome, let headerSidebarAction {
|
||||
ToolbarItem(placement: .topBarLeading) {
|
||||
OpenClawSidebarRevealButton(action: headerSidebarAction)
|
||||
}
|
||||
OpenClawSidebarToolbarItem(
|
||||
action: headerSidebarAction,
|
||||
placement: .topBarLeading)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -46,9 +46,9 @@ struct OpenClawDocsScreen: View {
|
||||
}
|
||||
}
|
||||
if self.usesNativeNavigationChrome, let headerSidebarAction {
|
||||
ToolbarItem(placement: .topBarLeading) {
|
||||
OpenClawSidebarRevealButton(action: headerSidebarAction)
|
||||
}
|
||||
OpenClawSidebarToolbarItem(
|
||||
action: headerSidebarAction,
|
||||
placement: .topBarLeading)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -293,11 +293,28 @@ struct OpenClawSidebarRevealButton: View {
|
||||
height: OpenClawProMetric.compactControlSize)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.frame(width: 44, height: 44)
|
||||
.buttonBorderShape(.circle)
|
||||
.openClawGlassButton(tint: OpenClawBrand.accent)
|
||||
.accessibilityLabel(self.headerAction.accessibilityLabel.text)
|
||||
|
||||
if #available(iOS 26.0, *) {
|
||||
self.identified(
|
||||
button
|
||||
.buttonStyle(.plain)
|
||||
.foregroundStyle(OpenClawBrand.accent)
|
||||
.frame(width: 44, height: 44)
|
||||
.glassEffect(
|
||||
.regular.interactive(),
|
||||
in: Circle()))
|
||||
} else {
|
||||
self.identified(
|
||||
button
|
||||
.frame(width: 44, height: 44)
|
||||
.openClawGlassButton(tint: OpenClawBrand.accent))
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func identified(_ button: some View) -> some View {
|
||||
if let accessibilityIdentifier = headerAction.accessibilityIdentifier {
|
||||
button.accessibilityIdentifier(accessibilityIdentifier)
|
||||
} else {
|
||||
@@ -315,6 +332,27 @@ struct OpenClawSidebarHeaderLeadingSlot: View {
|
||||
}
|
||||
}
|
||||
|
||||
struct OpenClawSidebarToolbarItem: ToolbarContent {
|
||||
let action: OpenClawSidebarHeaderAction
|
||||
let placement: ToolbarItemPlacement
|
||||
|
||||
@ToolbarContentBuilder
|
||||
var body: some ToolbarContent {
|
||||
if #available(iOS 26.0, *) {
|
||||
ToolbarItem(placement: self.placement) {
|
||||
OpenClawSidebarRevealButton(action: self.action)
|
||||
}
|
||||
// The button owns an explicit circular glass shape; suppress the
|
||||
// toolbar's shared pill so it cannot stretch the leading control.
|
||||
.sharedBackgroundVisibility(.hidden)
|
||||
} else {
|
||||
ToolbarItem(placement: self.placement) {
|
||||
OpenClawSidebarRevealButton(action: self.action)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct OpenClawGlassControlGroup<Content: View>: View {
|
||||
@ViewBuilder let content: Content
|
||||
|
||||
|
||||
@@ -159,9 +159,9 @@ struct SettingsProTab: View {
|
||||
}
|
||||
.toolbar {
|
||||
if let headerSidebarAction {
|
||||
ToolbarItem(placement: .topBarLeading) {
|
||||
OpenClawSidebarRevealButton(action: headerSidebarAction)
|
||||
}
|
||||
OpenClawSidebarToolbarItem(
|
||||
action: headerSidebarAction,
|
||||
placement: .topBarLeading)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -308,9 +308,9 @@ extension SettingsProTab {
|
||||
}
|
||||
}
|
||||
if let headerSidebarAction {
|
||||
ToolbarItem(placement: .topBarLeading) {
|
||||
OpenClawSidebarHeaderLeadingSlot(action: headerSidebarAction)
|
||||
}
|
||||
OpenClawSidebarToolbarItem(
|
||||
action: headerSidebarAction,
|
||||
placement: .topBarLeading)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ struct RootSidebar: View {
|
||||
|
||||
let selectedDestination: RootTabs.SidebarDestination
|
||||
let isDrawerLayout: Bool
|
||||
let showsDismissButton: Bool
|
||||
let selectDestination: (RootTabs.SidebarDestination) -> Void
|
||||
let selectSettingsRoute: (SettingsRoute) -> Void
|
||||
let hideSidebar: () -> Void
|
||||
@@ -102,7 +103,7 @@ struct RootSidebar: View {
|
||||
self.selectSidebarDestination(.settings)
|
||||
}
|
||||
|
||||
if self.isDrawerLayout {
|
||||
if self.isDrawerLayout, self.showsDismissButton {
|
||||
Button(action: self.dismissSidebar) {
|
||||
Image(systemName: "xmark")
|
||||
.font(OpenClawType.subheadSemiBold)
|
||||
|
||||
+107
-52
@@ -4,6 +4,19 @@ import SwiftUI
|
||||
import UIKit
|
||||
|
||||
struct RootTabs: View {
|
||||
private enum SidebarEdgeDragDisposition: Equatable {
|
||||
case horizontal
|
||||
case rejected
|
||||
}
|
||||
|
||||
private struct SidebarEdgeDragState: Equatable {
|
||||
var disposition: SidebarEdgeDragDisposition?
|
||||
var translationWidth: CGFloat = 0
|
||||
}
|
||||
|
||||
private static let sidebarEdgeGestureWidth: CGFloat = 44
|
||||
private static let sidebarDrawerTopLeadingRadius: CGFloat = 8
|
||||
|
||||
@Environment(NodeAppModel.self) private var appModel
|
||||
@Environment(VoiceWakeManager.self) private var voiceWake
|
||||
@Environment(GatewayConnectionController.self) private var gatewayController
|
||||
@@ -27,11 +40,14 @@ struct RootTabs: View {
|
||||
// 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 isSidebarDetailRootVisible: Bool = true
|
||||
@State private var isSidebarVisible: Bool = Self.initialSidebarVisibility ?? false
|
||||
@State private var sidebarVisibilityUserOverridden: Bool = Self.initialSidebarVisibility != nil
|
||||
@State private var isSidebarDrawerLayout: Bool = false
|
||||
@State private var didResolveSidebarLayout: Bool = false
|
||||
@State private var sidebarContentDragOffset: CGFloat = 0
|
||||
@GestureState(resetTransaction: Transaction(animation: .spring(response: 0.35, dampingFraction: 0.86)))
|
||||
private var sidebarEdgeDragState = SidebarEdgeDragState()
|
||||
@State private var voiceWakeToastText: String?
|
||||
@State private var toastDismissTask: Task<Void, Never>?
|
||||
@State private var presentedSheet: PresentedSheet?
|
||||
@@ -197,7 +213,18 @@ struct RootTabs: View {
|
||||
.opacity(self.reduceMotion && self.isSidebarVisible ? 0 : 1)
|
||||
.accessibilityHidden(self.isSidebarVisible)
|
||||
.zIndex(1)
|
||||
|
||||
// Gesture ownership must stay on an unmoving shell. Attaching either
|
||||
// drag to the offset card makes a slow finger outrun its own recognizer.
|
||||
self.sidebarDrawerInteractionLayer(sidebarWidth: sidebarWidth)
|
||||
.zIndex(2)
|
||||
}
|
||||
.simultaneousGesture(
|
||||
self.sidebarEdgeOpenGesture(sidebarWidth: sidebarWidth),
|
||||
isEnabled: !self.isSidebarVisible &&
|
||||
!self.reduceMotion &&
|
||||
self.isSidebarDetailRootVisible &&
|
||||
self.sidebarNavigationPath.isEmpty)
|
||||
}
|
||||
|
||||
private func sidebarDrawerLayer(
|
||||
@@ -213,76 +240,81 @@ struct RootTabs: View {
|
||||
|
||||
private func sidebarDrawerContentSurface(sidebarWidth: CGFloat) -> some View {
|
||||
let progress = self.sidebarContentRevealProgress(sidebarWidth: sidebarWidth)
|
||||
return RoundedRectangle(
|
||||
cornerRadius: OpenClawProMetric.drawerRadius * progress,
|
||||
style: .continuous)
|
||||
let shape = self.sidebarDrawerContentShape(progress: progress)
|
||||
return shape
|
||||
.fill(Color(uiColor: .systemGroupedBackground))
|
||||
.overlay(
|
||||
RoundedRectangle(
|
||||
cornerRadius: OpenClawProMetric.drawerRadius * progress,
|
||||
style: .continuous)
|
||||
.strokeBorder(OpenClawSidebarPalette.hairline.opacity(Double(progress)), lineWidth: 1))
|
||||
.shadow(
|
||||
color: .black.opacity(0.28 * progress),
|
||||
radius: 20 * progress,
|
||||
x: -4 * progress,
|
||||
y: 0)
|
||||
shape.strokeBorder(
|
||||
OpenClawSidebarPalette.hairline.opacity(Double(progress)),
|
||||
lineWidth: 1))
|
||||
.ignoresSafeArea(.container, edges: .vertical)
|
||||
.offset(x: Self.sidebarContentOffset(
|
||||
sidebarWidth: sidebarWidth,
|
||||
isVisible: self.isSidebarVisible,
|
||||
dragOffset: self.sidebarContentDragOffset,
|
||||
dragOffset: self.sidebarResolvedDragOffset,
|
||||
reduceMotion: self.reduceMotion))
|
||||
}
|
||||
|
||||
private func sidebarDrawerContentCard(sidebarWidth: CGFloat) -> some View {
|
||||
let progress = self.sidebarContentRevealProgress(sidebarWidth: sidebarWidth)
|
||||
return ZStack {
|
||||
self.sidebarDetailNavigationShell
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.allowsHitTesting(!self.isSidebarVisible)
|
||||
return self.sidebarDetailNavigationShell
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.allowsHitTesting(!self.isSidebarVisible)
|
||||
.clipShape(self.sidebarDrawerContentShape(progress: progress))
|
||||
.offset(x: Self.sidebarContentOffset(
|
||||
sidebarWidth: sidebarWidth,
|
||||
isVisible: self.isSidebarVisible,
|
||||
dragOffset: self.sidebarResolvedDragOffset,
|
||||
reduceMotion: self.reduceMotion))
|
||||
}
|
||||
|
||||
// Tap-to-close stays available under Reduce Motion (the drags are
|
||||
// gated); otherwise the header X would be the only exit.
|
||||
if self.isSidebarVisible {
|
||||
/// Keep the Dynamic Island row nearly square while retaining the softer
|
||||
/// lower drawer edge. The surface and content must share this exact shape.
|
||||
private func sidebarDrawerContentShape(progress: CGFloat) -> UnevenRoundedRectangle {
|
||||
UnevenRoundedRectangle(
|
||||
topLeadingRadius: Self.sidebarDrawerTopLeadingRadius * progress,
|
||||
bottomLeadingRadius: OpenClawProMetric.drawerRadius * progress,
|
||||
bottomTrailingRadius: OpenClawProMetric.drawerRadius * progress,
|
||||
topTrailingRadius: OpenClawProMetric.drawerRadius * progress,
|
||||
style: .continuous)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func sidebarDrawerInteractionLayer(sidebarWidth: CGFloat) -> some View {
|
||||
if self.isSidebarVisible {
|
||||
HStack(spacing: 0) {
|
||||
Color.clear
|
||||
.frame(width: sidebarWidth)
|
||||
.allowsHitTesting(false)
|
||||
Color.clear
|
||||
.contentShape(Rectangle())
|
||||
.accessibilityHidden(true)
|
||||
.onTapGesture {
|
||||
self.hideSidebar()
|
||||
}
|
||||
}
|
||||
|
||||
// Edge-open is chat-root only: pushed screens own the system
|
||||
// back-swipe on this edge, and other destinations push internally.
|
||||
if !self.isSidebarVisible, !self.reduceMotion,
|
||||
self.selectedSidebarDestination == .chat, self.sidebarNavigationPath.isEmpty
|
||||
{
|
||||
HStack(spacing: 0) {
|
||||
Color.clear
|
||||
.frame(width: 24)
|
||||
.contentShape(Rectangle())
|
||||
.gesture(self.sidebarEdgeOpenGesture(sidebarWidth: sidebarWidth))
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
.gesture(
|
||||
self.sidebarContentDismissGesture(sidebarWidth: sidebarWidth),
|
||||
isEnabled: !self.reduceMotion)
|
||||
}
|
||||
}
|
||||
.contentShape(Rectangle())
|
||||
.gesture(
|
||||
self.sidebarContentDismissGesture(sidebarWidth: sidebarWidth),
|
||||
isEnabled: self.isSidebarVisible && !self.reduceMotion)
|
||||
.clipShape(RoundedRectangle(
|
||||
cornerRadius: OpenClawProMetric.drawerRadius * progress,
|
||||
style: .continuous))
|
||||
.offset(x: Self.sidebarContentOffset(
|
||||
sidebarWidth: sidebarWidth,
|
||||
isVisible: self.isSidebarVisible,
|
||||
dragOffset: self.sidebarContentDragOffset,
|
||||
reduceMotion: self.reduceMotion))
|
||||
}
|
||||
|
||||
private var sidebarDetailShell: some View {
|
||||
self.sidebarDetail
|
||||
.id(self.sidebarDetailShellID)
|
||||
let shellID = self.sidebarDetailShellID
|
||||
return self.sidebarDetail
|
||||
.id(shellID)
|
||||
// RootTabs disables destination-owned stacks at its call sites. A
|
||||
// destination-style NavigationLink therefore replaces this shared
|
||||
// root, so visibility guards its native back-swipe without relying
|
||||
// on the typed Settings path.
|
||||
.onAppear {
|
||||
guard self.sidebarDetailShellID == shellID else { return }
|
||||
self.isSidebarDetailRootVisible = true
|
||||
}
|
||||
.onDisappear {
|
||||
guard self.sidebarDetailShellID == shellID else { return }
|
||||
self.isSidebarDetailRootVisible = false
|
||||
}
|
||||
}
|
||||
|
||||
/// RootSidebar owns its dark surface; this wrapper only restores vertical
|
||||
@@ -293,6 +325,7 @@ struct RootTabs: View {
|
||||
model: self.sidebarModel,
|
||||
selectedDestination: self.selectedSidebarDestination,
|
||||
isDrawerLayout: self.isSidebarDrawerLayout,
|
||||
showsDismissButton: self.isSidebarVisible,
|
||||
selectDestination: self.selectSidebarDestination,
|
||||
selectSettingsRoute: self.selectSettingsRoute,
|
||||
hideSidebar: self.hideSidebar)
|
||||
@@ -505,10 +538,17 @@ struct RootTabs: View {
|
||||
return Self.sidebarContentOffset(
|
||||
sidebarWidth: sidebarWidth,
|
||||
isVisible: self.isSidebarVisible,
|
||||
dragOffset: self.sidebarContentDragOffset,
|
||||
dragOffset: self.sidebarResolvedDragOffset,
|
||||
reduceMotion: self.reduceMotion) / sidebarWidth
|
||||
}
|
||||
|
||||
private var sidebarResolvedDragOffset: CGFloat {
|
||||
guard !self.isSidebarVisible, self.sidebarEdgeDragState.disposition == .horizontal else {
|
||||
return self.sidebarContentDragOffset
|
||||
}
|
||||
return self.sidebarEdgeDragState.translationWidth
|
||||
}
|
||||
|
||||
private func sidebarContentDismissGesture(sidebarWidth: CGFloat) -> some Gesture {
|
||||
DragGesture(minimumDistance: 8)
|
||||
.onChanged { value in
|
||||
@@ -529,14 +569,29 @@ struct RootTabs: View {
|
||||
|
||||
private func sidebarEdgeOpenGesture(sidebarWidth: CGFloat) -> some Gesture {
|
||||
DragGesture(minimumDistance: 8)
|
||||
.onChanged { value in
|
||||
self.sidebarContentDragOffset = max(0, min(sidebarWidth, value.translation.width))
|
||||
.updating(self.$sidebarEdgeDragState) { value, state, _ in
|
||||
guard value.startLocation.x <= Self.sidebarEdgeGestureWidth,
|
||||
value.startLocation.y > Self.sidebarEdgeGestureWidth
|
||||
else {
|
||||
state.disposition = .rejected
|
||||
return
|
||||
}
|
||||
if state.disposition == nil {
|
||||
state.disposition = value.translation.width > 0 &&
|
||||
value.translation.width > abs(value.translation.height) ? .horizontal : .rejected
|
||||
}
|
||||
guard state.disposition == .horizontal else { return }
|
||||
state.translationWidth = max(0, min(sidebarWidth, value.translation.width))
|
||||
}
|
||||
.onEnded { value in
|
||||
guard value.startLocation.x <= Self.sidebarEdgeGestureWidth,
|
||||
value.startLocation.y > Self.sidebarEdgeGestureWidth,
|
||||
value.translation.width > 0,
|
||||
value.translation.width > abs(value.translation.height)
|
||||
else { return }
|
||||
let shouldOpen = value.translation.width > 80 ||
|
||||
value.predictedEndTranslation.width > 160
|
||||
withAnimation(self.sidebarAnimation) {
|
||||
self.sidebarContentDragOffset = 0
|
||||
if shouldOpen {
|
||||
self.sidebarVisibilityUserOverridden = true
|
||||
self.setSidebarVisible(true)
|
||||
|
||||
@@ -58,9 +58,9 @@ struct TerminalHubScreen: View {
|
||||
}
|
||||
}
|
||||
if let headerSidebarAction {
|
||||
ToolbarItem(placement: .topBarLeading) {
|
||||
OpenClawSidebarRevealButton(action: headerSidebarAction)
|
||||
}
|
||||
OpenClawSidebarToolbarItem(
|
||||
action: headerSidebarAction,
|
||||
placement: .topBarLeading)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,6 +42,24 @@ struct RootTabsSidebarRegressionTests {
|
||||
#expect(layoutUpdate.contains("guard force || !self.sidebarVisibilityUserOverridden else { return }"))
|
||||
}
|
||||
|
||||
@Test func `sidebar reveal uses one circular liquid glass background`() throws {
|
||||
let source = try String(contentsOf: Self.openClawProComponentsSourceURL(), encoding: .utf8)
|
||||
let revealButton = try Self.extract(
|
||||
source,
|
||||
from: "struct OpenClawSidebarRevealButton: View",
|
||||
to: "struct OpenClawSidebarHeaderLeadingSlot: View")
|
||||
let toolbarItem = try Self.extract(
|
||||
source,
|
||||
from: "struct OpenClawSidebarToolbarItem: ToolbarContent",
|
||||
to: "struct OpenClawGlassControlGroup")
|
||||
|
||||
#expect(revealButton.contains(".buttonStyle(.plain)"))
|
||||
#expect(revealButton.contains(".glassEffect("))
|
||||
#expect(revealButton.contains(".regular.interactive()"))
|
||||
#expect(revealButton.contains("in: Circle()"))
|
||||
#expect(toolbarItem.contains(".sharedBackgroundVisibility(.hidden)"))
|
||||
}
|
||||
|
||||
@Test func `push reveal keeps sidebar behind an interactive dismissal card`() throws {
|
||||
let source = try String(contentsOf: Self.rootTabsSourceURL(), encoding: .utf8)
|
||||
let drawerContent = try Self.extract(
|
||||
@@ -57,14 +75,17 @@ struct RootTabsSidebarRegressionTests {
|
||||
drawerContent,
|
||||
from: "private func sidebarDrawerContentSurface(",
|
||||
to: "private func sidebarDrawerContentCard(")
|
||||
let contentCardStart = try #require(
|
||||
drawerContent.range(of: "private func sidebarDrawerContentCard("))
|
||||
let contentCard = String(drawerContent[contentCardStart.lowerBound...])
|
||||
let contentCard = try Self.extract(
|
||||
drawerContent,
|
||||
from: "private func sidebarDrawerContentCard(",
|
||||
to: "private func sidebarDrawerInteractionLayer(")
|
||||
|
||||
#expect(drawerContent.contains("ZStack(alignment: .leading)"))
|
||||
#expect(drawerContent.contains("self.sidebarDrawerLayer"))
|
||||
#expect(drawerContent.contains("self.sidebarDrawerContentSurface"))
|
||||
#expect(drawerContent.contains("self.sidebarDrawerContentCard"))
|
||||
#expect(drawerContent.contains("self.sidebarDrawerInteractionLayer"))
|
||||
#expect(drawerContent.contains(".simultaneousGesture("))
|
||||
#expect(drawerContent.contains(".background(OpenClawSidebarPalette.background)"))
|
||||
#expect(!drawerContent.contains("Color.black.opacity(0.35)"))
|
||||
#expect(!sidebarLayer.contains(".clipShape"))
|
||||
@@ -73,16 +94,57 @@ struct RootTabsSidebarRegressionTests {
|
||||
#expect(sidebarLayer.contains(".ignoresSafeArea(.container, edges: .vertical)"))
|
||||
#expect(contentSurface.contains(".fill(Color(uiColor: .systemGroupedBackground))"))
|
||||
#expect(contentSurface.contains(".ignoresSafeArea(.container, edges: .vertical)"))
|
||||
#expect(contentSurface.contains(".shadow("))
|
||||
#expect(!contentSurface.contains(".shadow("))
|
||||
#expect(contentSurface.contains(".offset(x: Self.sidebarContentOffset("))
|
||||
#expect(contentCard.contains(".allowsHitTesting(!self.isSidebarVisible)"))
|
||||
#expect(contentCard.contains("if self.isSidebarVisible {"))
|
||||
#expect(contentCard.contains("self.hideSidebar()"))
|
||||
#expect(contentCard.contains("isEnabled: self.isSidebarVisible && !self.reduceMotion"))
|
||||
#expect(contentCard.contains("cornerRadius: OpenClawProMetric.drawerRadius * progress"))
|
||||
#expect(contentCard.contains("self.sidebarDrawerContentShape(progress: progress)"))
|
||||
#expect(contentCard.contains(".offset(x: Self.sidebarContentOffset("))
|
||||
#expect(!contentCard.contains(".gesture("))
|
||||
#expect(!contentCard.contains("OpenClawProBackground()"))
|
||||
#expect(!contentCard.contains(".shadow("))
|
||||
let contentShape = try Self.extract(
|
||||
drawerContent,
|
||||
from: "private func sidebarDrawerContentShape(progress: CGFloat)",
|
||||
to: "private func sidebarDrawerInteractionLayer(")
|
||||
#expect(source.contains("private static let sidebarDrawerTopLeadingRadius: CGFloat = 8"))
|
||||
#expect(contentShape.contains("UnevenRoundedRectangle("))
|
||||
#expect(contentShape.contains("topLeadingRadius: Self.sidebarDrawerTopLeadingRadius * progress"))
|
||||
#expect(contentShape.contains("bottomLeadingRadius: OpenClawProMetric.drawerRadius * progress"))
|
||||
#expect(contentShape.contains("bottomTrailingRadius: OpenClawProMetric.drawerRadius * progress"))
|
||||
#expect(contentShape.contains("topTrailingRadius: OpenClawProMetric.drawerRadius * progress"))
|
||||
let interactionLayer = try Self.extract(
|
||||
source,
|
||||
from: "private func sidebarDrawerInteractionLayer(",
|
||||
to: "private var sidebarDetailShell")
|
||||
#expect(interactionLayer.contains("self.sidebarContentDismissGesture(sidebarWidth: sidebarWidth)"))
|
||||
#expect(source.contains("private static let sidebarEdgeGestureWidth: CGFloat = 44"))
|
||||
#expect(interactionLayer.contains(".accessibilityHidden(true)"))
|
||||
#expect(!interactionLayer.contains("self.selectedSidebarDestination == .chat"))
|
||||
#expect(!interactionLayer.contains(".highPriorityGesture("))
|
||||
#expect(!interactionLayer.contains("self.sidebarEdgeOpenGesture(sidebarWidth: sidebarWidth)"))
|
||||
|
||||
let edgeGesture = try Self.extract(
|
||||
source,
|
||||
from: "private func sidebarEdgeOpenGesture(",
|
||||
to: "private func shouldUseSidebarDrawer(")
|
||||
#expect(edgeGesture.contains("value.startLocation.x <= Self.sidebarEdgeGestureWidth"))
|
||||
#expect(edgeGesture.contains("value.startLocation.y > Self.sidebarEdgeGestureWidth"))
|
||||
#expect(edgeGesture.contains(".updating(self.$sidebarEdgeDragState)"))
|
||||
#expect(edgeGesture.contains("state.disposition == .horizontal"))
|
||||
#expect(edgeGesture.contains("value.translation.width > abs(value.translation.height)"))
|
||||
#expect(source.contains("@GestureState(resetTransaction:"))
|
||||
#expect(source.contains("self.sidebarEdgeDragState.translationWidth"))
|
||||
#expect(!source.contains("UIScreenEdgePanGestureRecognizer"))
|
||||
|
||||
let detailShell = try Self.extract(
|
||||
source,
|
||||
from: "private var sidebarDetailShell: some View",
|
||||
to: "private func sidebarColumn(")
|
||||
#expect(detailShell.contains(".onAppear"))
|
||||
#expect(detailShell.contains("guard self.sidebarDetailShellID == shellID else { return }"))
|
||||
#expect(detailShell.contains("self.isSidebarDetailRootVisible = true"))
|
||||
#expect(detailShell.contains(".onDisappear"))
|
||||
#expect(detailShell.contains("self.isSidebarDetailRootVisible = false"))
|
||||
}
|
||||
|
||||
@Test func `sidebar selection resets embedded settings navigation path`() throws {
|
||||
@@ -149,6 +211,13 @@ struct RootTabsSidebarRegressionTests {
|
||||
.appendingPathComponent("Sources/Design/CommandCenterTab.swift")
|
||||
}
|
||||
|
||||
private static func openClawProComponentsSourceURL() -> URL {
|
||||
URL(fileURLWithPath: #filePath)
|
||||
.deletingLastPathComponent()
|
||||
.deletingLastPathComponent()
|
||||
.appendingPathComponent("Sources/Design/OpenClawProComponents.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...]
|
||||
|
||||
@@ -83,17 +83,28 @@ struct RootTabsSourceGuardTests {
|
||||
#expect(drawerContent.contains("self.sidebarDrawerLayer"))
|
||||
#expect(drawerContent.contains("self.sidebarDrawerContentSurface"))
|
||||
#expect(drawerContent.contains("self.sidebarDrawerContentCard"))
|
||||
#expect(drawerContent.contains("self.sidebarDrawerInteractionLayer"))
|
||||
#expect(drawerContent.contains("self.sidebarContentDismissGesture(sidebarWidth: sidebarWidth)"))
|
||||
#expect(drawerContent.contains("self.sidebarEdgeOpenGesture(sidebarWidth: sidebarWidth)"))
|
||||
#expect(drawerContent.contains("self.isSidebarDetailRootVisible"))
|
||||
#expect(drawerContent.contains("self.sidebarNavigationPath.isEmpty"))
|
||||
#expect(!drawerContent.contains("self.selectedSidebarDestination == .chat"))
|
||||
#expect(drawerContent.contains(".allowsHitTesting(!self.isSidebarVisible)"))
|
||||
#expect(drawerContent.contains(".clipShape(RoundedRectangle("))
|
||||
#expect(drawerContent.contains("cornerRadius: OpenClawProMetric.drawerRadius * progress"))
|
||||
#expect(source.contains("private static let sidebarDrawerTopLeadingRadius: CGFloat = 8"))
|
||||
#expect(drawerContent.contains("let shape = self.sidebarDrawerContentShape(progress: progress)"))
|
||||
#expect(drawerContent.contains("return shape\n .fill(Color(uiColor: .systemGroupedBackground))"))
|
||||
#expect(drawerContent.contains(".clipShape(self.sidebarDrawerContentShape(progress: progress))"))
|
||||
#expect(drawerContent.contains(".offset(x: Self.sidebarContentOffset("))
|
||||
#expect(drawerContent.contains(".shadow("))
|
||||
#expect(!drawerContent.contains(".shadow("))
|
||||
#expect(drawerContent.contains(".fill(Color(uiColor: .systemGroupedBackground))"))
|
||||
#expect(drawerContent.contains(".ignoresSafeArea(.container, edges: .vertical)"))
|
||||
#expect(!drawerContent.contains("Color.black.opacity(0.35)"))
|
||||
#expect(!drawerContent.contains("UnevenRoundedRectangle"))
|
||||
#expect(!drawerContent.contains("sidebarDrawerShape"))
|
||||
#expect(drawerContent.contains("private func sidebarDrawerContentShape(progress: CGFloat)"))
|
||||
#expect(drawerContent.contains("UnevenRoundedRectangle("))
|
||||
#expect(drawerContent.contains("topLeadingRadius: Self.sidebarDrawerTopLeadingRadius * progress"))
|
||||
#expect(drawerContent.contains("bottomLeadingRadius: OpenClawProMetric.drawerRadius * progress"))
|
||||
#expect(drawerContent.contains("bottomTrailingRadius: OpenClawProMetric.drawerRadius * progress"))
|
||||
#expect(drawerContent.contains("topTrailingRadius: OpenClawProMetric.drawerRadius * progress"))
|
||||
#expect(!drawerContent.contains("NavigationSplitView"))
|
||||
}
|
||||
|
||||
@@ -487,10 +498,12 @@ struct RootTabsSourceGuardTests {
|
||||
#expect(chatSource.contains("self.headerAgentIdentity"))
|
||||
#expect(!chatSource.contains("headerAgentModelPicker"))
|
||||
#expect(chatSource.contains(".sharedBackgroundVisibility(.hidden)"))
|
||||
#expect(chatSource.contains("OpenClawSidebarRevealButton(action: headerSidebarAction)"))
|
||||
#expect(chatSource.contains("OpenClawSidebarToolbarItem("))
|
||||
#expect(componentsSource.contains("struct OpenClawSidebarToolbarItem: ToolbarContent"))
|
||||
#expect(componentsSource.contains(".sharedBackgroundVisibility(.hidden)"))
|
||||
#expect(!chatSource.contains("OpenClawAdaptiveHeaderRow("))
|
||||
#expect(agentOverviewSource.contains("OpenClawAdaptiveHeaderRow("))
|
||||
#expect(settingsSource.contains("ToolbarItem(placement: .topBarLeading)"))
|
||||
#expect(settingsSource.contains("OpenClawSidebarToolbarItem("))
|
||||
#expect(!settingsSource.contains("ToolbarItem(placement: .topBarTrailing)"))
|
||||
}
|
||||
|
||||
|
||||
@@ -493,6 +493,7 @@ struct SwiftUIRenderSmokeTests {
|
||||
model: RootSidebarModel(),
|
||||
selectedDestination: .overview,
|
||||
isDrawerLayout: true,
|
||||
showsDismissButton: true,
|
||||
selectDestination: { _ in },
|
||||
selectSettingsRoute: { _ in },
|
||||
hideSidebar: {})
|
||||
@@ -508,6 +509,7 @@ struct SwiftUIRenderSmokeTests {
|
||||
model: RootSidebarModel(),
|
||||
selectedDestination: .chat,
|
||||
isDrawerLayout: true,
|
||||
showsDismissButton: true,
|
||||
selectDestination: { _ in },
|
||||
selectSettingsRoute: { _ in },
|
||||
hideSidebar: {})
|
||||
|
||||
@@ -92,6 +92,74 @@ final class OpenClawSnapshotUITests: XCTestCase {
|
||||
XCTAssertEqual(self.app?.state, .runningForeground)
|
||||
}
|
||||
|
||||
func testSidebarSlowEdgeDragOpensFromEveryRootDestination() throws {
|
||||
try XCTSkipIf(UIDevice.current.userInterfaceIdiom != .phone, "Phone sidebar only")
|
||||
let destinations = [
|
||||
"chat", "overview", "activity", "agents", "workboard", "skillWorkshop",
|
||||
"instances", "sessions", "files", "dreaming", "usage", "cron", "terminal",
|
||||
"docs", "settings", "gateway",
|
||||
]
|
||||
var testedDestinations: [String] = []
|
||||
|
||||
for destination in destinations {
|
||||
self.launchApp(for: ScreenshotTarget(
|
||||
initialTab: "chat",
|
||||
initialDestination: destination,
|
||||
name: "sidebar-slow-edge-drag-\(destination)"))
|
||||
|
||||
let showSidebar = try XCTUnwrap(self.app?.buttons["RootTabs.Sidebar.Show"])
|
||||
XCTAssertTrue(showSidebar.waitForExistence(timeout: 8), destination)
|
||||
XCTAssertTrue(showSidebar.isHittable, destination)
|
||||
if destination == "overview" {
|
||||
showSidebar.tap()
|
||||
let hideSidebar = try XCTUnwrap(self.app?.buttons["RootTabs.Sidebar.Hide"])
|
||||
self.waitForHittable(true, of: hideSidebar)
|
||||
hideSidebar.tap()
|
||||
self.waitForHittable(true, of: showSidebar)
|
||||
}
|
||||
try self.openSidebarWithSlowEdgeDrag()
|
||||
if destination == "overview" {
|
||||
self.attachScreenshot(named: "sidebar-slow-edge-drag-overview")
|
||||
}
|
||||
try self.closeSidebarWithSlowDrag()
|
||||
testedDestinations.append(destination)
|
||||
}
|
||||
XCTAssertEqual(testedDestinations, destinations)
|
||||
}
|
||||
|
||||
func testSidebarEdgeDragPreservesPushedScreenBackGesture() throws {
|
||||
try XCTSkipIf(UIDevice.current.userInterfaceIdiom != .phone, "Phone sidebar only")
|
||||
self.launchApp(for: ScreenshotTarget(
|
||||
initialTab: "settings",
|
||||
initialDestination: "settings",
|
||||
name: "sidebar-pushed-screen-back-gesture"), appearance: nil, screenshotMode: false)
|
||||
|
||||
if self.app?.buttons["Close"].waitForExistence(timeout: 2) == true {
|
||||
self.app?.buttons["Close"].tap()
|
||||
}
|
||||
let appearance = try XCTUnwrap(self.app?.buttons["settings-appearance-row"])
|
||||
XCTAssertTrue(appearance.waitForExistence(timeout: 8))
|
||||
self.waitForHittable(true, of: appearance)
|
||||
try self.verifyLeadingEdgeVerticalScrollPassesThrough(marker: appearance)
|
||||
// Appearance is a destination-style NavigationLink, so this exercises
|
||||
// the root-visibility guard rather than the typed Settings path guard.
|
||||
appearance.tap()
|
||||
XCTAssertTrue(self.app?.navigationBars["Appearance"].waitForExistence(timeout: 5) == true)
|
||||
|
||||
let app = try XCTUnwrap(self.app)
|
||||
let start = app.coordinate(withNormalizedOffset: CGVector(dx: 0.01, dy: 0.5))
|
||||
let end = app.coordinate(withNormalizedOffset: CGVector(dx: 0.78, dy: 0.5))
|
||||
start.press(
|
||||
forDuration: 0.1,
|
||||
thenDragTo: end,
|
||||
withVelocity: .slow,
|
||||
thenHoldForDuration: 0.1)
|
||||
|
||||
self.waitForHittable(false, of: app.buttons["RootTabs.Sidebar.Hide"])
|
||||
self.waitForHittable(true, of: appearance)
|
||||
XCTAssertFalse(app.navigationBars["Appearance"].exists)
|
||||
}
|
||||
|
||||
func testLocationAlwaysWaitsForSlowSystemPermissionResponse() throws {
|
||||
XCUIApplication().resetAuthorizationStatus(for: .location)
|
||||
self.launchApp(for: ScreenshotTarget(
|
||||
@@ -840,6 +908,67 @@ final class OpenClawSnapshotUITests: XCTestCase {
|
||||
XCTAssertEqual(XCTWaiter.wait(for: [expectation], timeout: 5), .completed)
|
||||
}
|
||||
|
||||
private func openSidebarWithSlowEdgeDrag(
|
||||
file: StaticString = #filePath,
|
||||
line: UInt = #line) throws
|
||||
{
|
||||
let app = try XCTUnwrap(self.app, file: file, line: line)
|
||||
let start = app.coordinate(withNormalizedOffset: CGVector(dx: 0.01, dy: 0.5))
|
||||
let end = app.coordinate(withNormalizedOffset: CGVector(dx: 0.78, dy: 0.5))
|
||||
start.press(
|
||||
forDuration: 0.1,
|
||||
thenDragTo: end,
|
||||
withVelocity: .slow,
|
||||
thenHoldForDuration: 0.1)
|
||||
|
||||
self.waitForHittable(true, of: app.buttons["RootTabs.Sidebar.Hide"])
|
||||
}
|
||||
|
||||
private func verifyLeadingEdgeVerticalScrollPassesThrough(
|
||||
marker: XCUIElement,
|
||||
file: StaticString = #filePath,
|
||||
line: UInt = #line) throws
|
||||
{
|
||||
let app = try XCTUnwrap(self.app, file: file, line: line)
|
||||
XCTAssertTrue(marker.waitForExistence(timeout: 5), file: file, line: line)
|
||||
let initialY = marker.frame.minY
|
||||
let start = app.coordinate(withNormalizedOffset: CGVector(dx: 0.01, dy: 0.78))
|
||||
let end = app.coordinate(withNormalizedOffset: CGVector(dx: 0.01, dy: 0.22))
|
||||
start.press(
|
||||
forDuration: 0.1,
|
||||
thenDragTo: end,
|
||||
withVelocity: .slow,
|
||||
thenHoldForDuration: 0.1)
|
||||
|
||||
XCTAssertLessThan(marker.frame.minY, initialY - 20, file: file, line: line)
|
||||
self.waitForHittable(true, of: app.buttons["RootTabs.Sidebar.Show"])
|
||||
|
||||
let restoreStart = app.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.22))
|
||||
let restoreEnd = app.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.78))
|
||||
restoreStart.press(
|
||||
forDuration: 0.1,
|
||||
thenDragTo: restoreEnd,
|
||||
withVelocity: .fast,
|
||||
thenHoldForDuration: 0.1)
|
||||
self.waitForHittable(true, of: marker)
|
||||
}
|
||||
|
||||
private func closeSidebarWithSlowDrag(
|
||||
file: StaticString = #filePath,
|
||||
line: UInt = #line) throws
|
||||
{
|
||||
let app = try XCTUnwrap(self.app, file: file, line: line)
|
||||
let start = app.coordinate(withNormalizedOffset: CGVector(dx: 0.92, dy: 0.5))
|
||||
let end = app.coordinate(withNormalizedOffset: CGVector(dx: 0.05, dy: 0.5))
|
||||
start.press(
|
||||
forDuration: 0.1,
|
||||
thenDragTo: end,
|
||||
withVelocity: .slow,
|
||||
thenHoldForDuration: 0.1)
|
||||
|
||||
self.waitForHittable(true, of: app.buttons["RootTabs.Sidebar.Show"])
|
||||
}
|
||||
|
||||
private func selectSidebarDestination(
|
||||
_ title: String,
|
||||
file: StaticString = #filePath,
|
||||
|
||||
Reference in New Issue
Block a user