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:
Colin Johnson
2026-07-20 17:26:17 -04:00
committed by GitHub
parent 21bfc75648
commit 0d3cbae6a1
16 changed files with 449 additions and 142 deletions
+48 -48
View File
@@ -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)
}
}
}
+3 -3
View File
@@ -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
+3 -3
View File
@@ -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)
}
}
}
+2 -1
View File
@@ -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
View File
@@ -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...]
+20 -7
View File
@@ -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,