feat(ios): redesign Control tab to a flatter, card-based layout (#98582)

* feat(ios): refresh Control tab layout

Co-authored-by: Raghav Sethi <rsraghavsethi@gmail.com>

* fix(ios): align Control card edges

---------

Co-authored-by: Peter Steinberger <steipete@gmail.com>
This commit is contained in:
Raghav Sethi 2026-07-09 19:38:07 +05:30 committed by GitHub
parent 4c55d46254
commit 19f42a27e5
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 242 additions and 107 deletions

View file

@ -9035,31 +9035,15 @@
}, },
{ {
"kind": "ui-modifier", "kind": "ui-modifier",
"line": 43, "line": 41,
"path": "apps/ios/Sources/Design/RootTabsPhoneControlHub.swift", "path": "apps/ios/Sources/Design/RootTabsPhoneControlHub.swift",
"source": "Control", "source": "Control",
"surface": "apple", "surface": "apple",
"id": "native.apple.a2d8e7b4dba29764" "id": "native.apple.a2d8e7b4dba29764"
}, },
{
"kind": "ui-call",
"line": 64,
"path": "apps/ios/Sources/Design/RootTabsPhoneControlHub.swift",
"source": "Gateway",
"surface": "apple",
"id": "native.apple.e0e99a802f07cc68"
},
{ {
"kind": "ui-modifier", "kind": "ui-modifier",
"line": 86, "line": 91,
"path": "apps/ios/Sources/Design/RootTabsPhoneControlHub.swift",
"source": "Gateway \\(self.gatewayStateText), \\(self.sidebarActiveAgentTitle)",
"surface": "apple",
"id": "native.apple.fe4468ab02aa489d"
},
{
"kind": "ui-modifier",
"line": 87,
"path": "apps/ios/Sources/Design/RootTabsPhoneControlHub.swift", "path": "apps/ios/Sources/Design/RootTabsPhoneControlHub.swift",
"source": "Opens Settings / Gateway", "source": "Opens Settings / Gateway",
"surface": "apple", "surface": "apple",
@ -9067,7 +9051,23 @@
}, },
{ {
"kind": "ui-named-argument", "kind": "ui-named-argument",
"line": 134, "line": 100,
"path": "apps/ios/Sources/Design/RootTabsPhoneControlHub.swift",
"source": "Agent chat and recent work.",
"surface": "apple",
"id": "native.apple.ed16c9793f468dcd"
},
{
"kind": "ui-named-argument",
"line": 103,
"path": "apps/ios/Sources/Design/RootTabsPhoneControlHub.swift",
"source": "Realtime voice and controls.",
"surface": "apple",
"id": "native.apple.a30d0a950d0a77aa"
},
{
"kind": "ui-named-argument",
"line": 173,
"path": "apps/ios/Sources/Design/RootTabsPhoneControlHub.swift", "path": "apps/ios/Sources/Design/RootTabsPhoneControlHub.swift",
"source": "Overview", "source": "Overview",
"surface": "apple", "surface": "apple",
@ -9075,7 +9075,7 @@
}, },
{ {
"kind": "ui-named-argument", "kind": "ui-named-argument",
"line": 156, "line": 195,
"path": "apps/ios/Sources/Design/RootTabsPhoneControlHub.swift", "path": "apps/ios/Sources/Design/RootTabsPhoneControlHub.swift",
"source": "Instances", "source": "Instances",
"surface": "apple", "surface": "apple",
@ -9083,7 +9083,7 @@
}, },
{ {
"kind": "ui-named-argument", "kind": "ui-named-argument",
"line": 165, "line": 204,
"path": "apps/ios/Sources/Design/RootTabsPhoneControlHub.swift", "path": "apps/ios/Sources/Design/RootTabsPhoneControlHub.swift",
"source": "Files", "source": "Files",
"surface": "apple", "surface": "apple",
@ -9091,7 +9091,7 @@
}, },
{ {
"kind": "ui-named-argument", "kind": "ui-named-argument",
"line": 170, "line": 209,
"path": "apps/ios/Sources/Design/RootTabsPhoneControlHub.swift", "path": "apps/ios/Sources/Design/RootTabsPhoneControlHub.swift",
"source": "Dreaming", "source": "Dreaming",
"surface": "apple", "surface": "apple",
@ -9099,7 +9099,7 @@
}, },
{ {
"kind": "ui-named-argument", "kind": "ui-named-argument",
"line": 175, "line": 214,
"path": "apps/ios/Sources/Design/RootTabsPhoneControlHub.swift", "path": "apps/ios/Sources/Design/RootTabsPhoneControlHub.swift",
"source": "Usage", "source": "Usage",
"surface": "apple", "surface": "apple",
@ -9107,15 +9107,39 @@
}, },
{ {
"kind": "ui-named-argument", "kind": "ui-named-argument",
"line": 180, "line": 219,
"path": "apps/ios/Sources/Design/RootTabsPhoneControlHub.swift", "path": "apps/ios/Sources/Design/RootTabsPhoneControlHub.swift",
"source": "Cron Jobs", "source": "Cron Jobs",
"surface": "apple", "surface": "apple",
"id": "native.apple.9eb33a11fb81796b" "id": "native.apple.9eb33a11fb81796b"
}, },
{
"kind": "ui-call",
"line": 299,
"path": "apps/ios/Sources/Design/RootTabsPhoneControlHub.swift",
"source": "Gateway",
"surface": "apple",
"id": "native.apple.e0e99a802f07cc68"
},
{
"kind": "ui-call",
"line": 306,
"path": "apps/ios/Sources/Design/RootTabsPhoneControlHub.swift",
"source": "Gateway \\(self.gatewayStateText), \\(gatewayDisplayLabel), \\(self.sidebarActiveAgentTitle)",
"surface": "apple",
"id": "native.apple.e30d7d35682e8ffa"
},
{
"kind": "ui-call",
"line": 309,
"path": "apps/ios/Sources/Design/RootTabsPhoneControlHub.swift",
"source": "Gateway \\(self.gatewayStateText), \\(self.sidebarActiveAgentTitle)",
"surface": "apple",
"id": "native.apple.75b65c24f3607c35"
},
{ {
"kind": "conditional-branch", "kind": "conditional-branch",
"line": 254, "line": 316,
"path": "apps/ios/Sources/Design/RootTabsPhoneControlHub.swift", "path": "apps/ios/Sources/Design/RootTabsPhoneControlHub.swift",
"source": "Online", "source": "Online",
"surface": "apple", "surface": "apple",
@ -9123,7 +9147,7 @@
}, },
{ {
"kind": "conditional-branch", "kind": "conditional-branch",
"line": 255, "line": 317,
"path": "apps/ios/Sources/Design/RootTabsPhoneControlHub.swift", "path": "apps/ios/Sources/Design/RootTabsPhoneControlHub.swift",
"source": "Connecting", "source": "Connecting",
"surface": "apple", "surface": "apple",
@ -9131,7 +9155,7 @@
}, },
{ {
"kind": "conditional-branch", "kind": "conditional-branch",
"line": 256, "line": 318,
"path": "apps/ios/Sources/Design/RootTabsPhoneControlHub.swift", "path": "apps/ios/Sources/Design/RootTabsPhoneControlHub.swift",
"source": "Attention", "source": "Attention",
"surface": "apple", "surface": "apple",
@ -9139,7 +9163,7 @@
}, },
{ {
"kind": "conditional-branch", "kind": "conditional-branch",
"line": 257, "line": 319,
"path": "apps/ios/Sources/Design/RootTabsPhoneControlHub.swift", "path": "apps/ios/Sources/Design/RootTabsPhoneControlHub.swift",
"source": "Offline", "source": "Offline",
"surface": "apple", "surface": "apple",

View file

@ -3,9 +3,6 @@ import SwiftUI
struct RootTabsPhoneControlHub: View { struct RootTabsPhoneControlHub: View {
@Environment(NodeAppModel.self) private var appModel @Environment(NodeAppModel.self) private var appModel
@State private var navigationPath: [RootTabs.SidebarDestination] = []
@State private var didApplyInitialDestination = false
@State private var handledNavigationRequestID = 0
let groups: [RootTabs.SidebarGroup] let groups: [RootTabs.SidebarGroup]
let initialDestination: RootTabs.SidebarDestination? let initialDestination: RootTabs.SidebarDestination?
@ -13,29 +10,30 @@ struct RootTabsPhoneControlHub: View {
let openRootDestination: (RootTabs.SidebarDestination) -> Void let openRootDestination: (RootTabs.SidebarDestination) -> Void
let openChatFromControlDetail: (RootTabs.SidebarDestination) -> Void let openChatFromControlDetail: (RootTabs.SidebarDestination) -> Void
@State private var navigationPath: [RootTabs.SidebarDestination] = []
@State private var didApplyInitialDestination = false
@State private var handledNavigationRequestID = 0
var body: some View { var body: some View {
NavigationStack(path: self.$navigationPath) { NavigationStack(path: self.$navigationPath) {
List { List {
Section { Section {
Button { self.gatewayHeader
self.openGatewayDetail() .listRowInsets(EdgeInsets(top: 4, leading: 16, bottom: 4, trailing: 16))
} label: { .listRowBackground(Color.clear)
self.gatewayRow .listRowSeparator(.hidden)
}
.buttonStyle(.plain)
} }
ForEach(self.phoneGroups) { group in Section {
Section { self.chatTalkRow
ForEach(group.destinations) { destination in .listRowInsets(EdgeInsets(top: 4, leading: 0, bottom: 4, trailing: 0))
self.destinationRow(destination) .listRowBackground(Color.clear)
} .listRowSeparator(.hidden)
} header: { }
if let title = self.sectionTitle(for: group) {
Text(title) Section {
.font(OpenClawType.captionSemiBold) ForEach(self.phoneDestinations) { destination in
.foregroundStyle(.secondary) self.destinationRow(destination)
}
} }
} }
} }
@ -55,59 +53,97 @@ struct RootTabsPhoneControlHub: View {
} }
} }
private var gatewayRow: some View { private var gatewayHeader: some View {
HStack(spacing: 12) { Button {
ProIconBadge( self.openGatewayDetail()
systemName: "antenna.radiowaves.left.and.right", } label: {
color: self.gatewayStateColor) HStack(spacing: 12) {
VStack(alignment: .leading, spacing: 2) { OpenClawProMark(size: 44, shadowRadius: 5)
Text("Gateway") VStack(alignment: .leading, spacing: 3) {
.font(OpenClawType.subheadSemiBold) self.gatewayIdentityTitle
.foregroundStyle(.primary) .foregroundStyle(.primary)
Text(self.sidebarActiveAgentTitle) .lineLimit(1)
.font(OpenClawType.footnote) .truncationMode(.middle)
HStack(spacing: 4) {
Text(self.sidebarActiveAgentTitle)
Text("")
.accessibilityHidden(true)
Text(self.gatewayStateText)
.foregroundStyle(self.gatewayStateColor)
}
.font(OpenClawType.captionMedium)
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
.lineLimit(1) .lineLimit(1)
}
Spacer(minLength: 8)
Image(systemName: "slider.horizontal.3")
.font(.system(size: 16, weight: .semibold))
.foregroundStyle(.primary)
.frame(width: 44, height: 44)
.background(Color.primary.opacity(0.06), in: Circle())
} }
Spacer(minLength: 8) .padding(.vertical, 6)
HStack(spacing: 6) { .contentShape(Rectangle())
ProStatusDot(color: self.gatewayStateColor)
Text(self.gatewayStateText)
.font(OpenClawType.footnoteSemiBold)
.foregroundStyle(self.gatewayStateColor)
Image(systemName: "chevron.right")
.font(OpenClawType.captionSemiBold)
.foregroundStyle(.secondary)
}
} }
.padding(.vertical, 4) .buttonStyle(.plain)
.contentShape(Rectangle())
.accessibilityElement(children: .combine) .accessibilityElement(children: .combine)
.accessibilityLabel("Gateway \(self.gatewayStateText), \(self.sidebarActiveAgentTitle)") .accessibilityLabel(self.gatewayAccessibilityLabel)
.accessibilityHint("Opens Settings / Gateway") .accessibilityHint("Opens Settings / Gateway")
} }
@ViewBuilder private var chatTalkRow: some View {
private func destinationRow(_ destination: RootTabs.SidebarDestination) -> some View { // Chat and Talk intentionally stay as Control shortcuts even though they own root tabs.
if self.opensRootTab(destination) { // These are the hub's primary actions; the remaining destination list filters root tabs.
Button { HStack(alignment: .top, spacing: 12) {
self.openPhoneRootDestination(destination) self.prominentDestinationCard(
} label: { .chat,
HStack(spacing: 12) { subtitle: "Agent chat and recent work.")
self.rowLabel(destination) self.prominentDestinationCard(
Spacer(minLength: 8) .talk,
Image(systemName: "chevron.right") subtitle: "Realtime voice and controls.")
.font(OpenClawType.captionSemiBold) }
.foregroundStyle(.secondary) }
private func prominentDestinationCard(
_ destination: RootTabs.SidebarDestination,
subtitle: LocalizedStringKey) -> some View
{
Button {
self.openPhoneRootDestination(destination)
} label: {
ProCard(padding: 16, radius: OpenClawProMetric.cardRadius) {
VStack(alignment: .leading, spacing: 12) {
ControlCircleIcon(
systemName: destination.systemImage,
color: self.color(for: destination),
size: 46)
HStack(alignment: .top, spacing: 6) {
VStack(alignment: .leading, spacing: 2) {
Text(destination.title)
.font(OpenClawType.headline)
.foregroundStyle(.primary)
Text(subtitle)
.font(OpenClawType.caption2)
.foregroundStyle(.secondary)
.lineLimit(2)
}
Spacer(minLength: 4)
Image(systemName: "chevron.right")
.font(OpenClawType.caption2Bold)
.foregroundStyle(.secondary)
}
} }
.contentShape(Rectangle()) .frame(maxWidth: .infinity, minHeight: 128, alignment: .leading)
}
.buttonStyle(.plain)
} else {
NavigationLink(value: destination) {
self.rowLabel(destination)
} }
} }
.buttonStyle(.plain)
.frame(maxWidth: .infinity, alignment: .leading)
}
private func destinationRow(_ destination: RootTabs.SidebarDestination) -> some View {
NavigationLink(value: destination) {
self.rowLabel(destination)
}
} }
private func rowLabel(_ destination: RootTabs.SidebarDestination) -> some View { private func rowLabel(_ destination: RootTabs.SidebarDestination) -> some View {
@ -116,7 +152,10 @@ struct RootTabsPhoneControlHub: View {
.font(OpenClawType.subheadSemiBold) .font(OpenClawType.subheadSemiBold)
.foregroundStyle(.primary) .foregroundStyle(.primary)
} icon: { } icon: {
ProIconBadge(systemName: destination.systemImage, color: .secondary) ControlCircleIcon(
systemName: destination.systemImage,
color: self.color(for: destination),
size: 34)
} }
} }
@ -207,12 +246,8 @@ struct RootTabsPhoneControlHub: View {
RootTabs.shouldOpenRootTabFromPhoneHub(destination) RootTabs.shouldOpenRootTabFromPhoneHub(destination)
} }
private var phoneGroups: [RootTabs.SidebarGroup] { private var phoneDestinations: [RootTabs.SidebarDestination] {
self.groups.compactMap { group in self.groups.flatMap(\.destinations).filter { !self.opensRootTab($0) }
let destinations = group.destinations.filter { !self.opensRootTab($0) }
guard !destinations.isEmpty else { return nil }
return RootTabs.SidebarGroup(title: group.title, destinations: destinations)
}
} }
private func applyInitialDestinationIfNeeded() { private func applyInitialDestinationIfNeeded() {
@ -249,6 +284,33 @@ struct RootTabsPhoneControlHub: View {
return self.normalized(self.appModel.activeAgentName) ?? "Default Agent" return self.normalized(self.appModel.activeAgentName) ?? "Default Agent"
} }
private var gatewayDisplayLabel: String? {
self.normalized(self.appModel.gatewayServerName)
?? self.normalized(self.appModel.gatewayRemoteAddress)
}
@ViewBuilder
private var gatewayIdentityTitle: some View {
// Gateway names are server data; only the product fallback is localizable.
if let gatewayDisplayLabel {
Text(verbatim: gatewayDisplayLabel)
.font(OpenClawType.headlineBold)
} else {
Text("Gateway")
.font(OpenClawType.headlineBold)
}
}
private var gatewayAccessibilityLabel: Text {
if let gatewayDisplayLabel {
Text("Gateway \(self.gatewayStateText), \(gatewayDisplayLabel), \(self.sidebarActiveAgentTitle)")
.font(OpenClawType.captionMedium)
} else {
Text("Gateway \(self.gatewayStateText), \(self.sidebarActiveAgentTitle)")
.font(OpenClawType.captionMedium)
}
}
private var gatewayStateText: String { private var gatewayStateText: String {
switch GatewayStatusBuilder.build(appModel: self.appModel) { switch GatewayStatusBuilder.build(appModel: self.appModel) {
case .connected: "Online" case .connected: "Online"
@ -271,11 +333,24 @@ struct RootTabsPhoneControlHub: View {
} }
} }
private func sectionTitle(for group: RootTabs.SidebarGroup) -> String? { private func color(for destination: RootTabs.SidebarDestination) -> Color {
switch group.title.lowercased() { switch destination {
case "chat": "Communication" case .chat:
case "control": nil OpenClawBrand.ok
default: group.title.capitalized case .talk, .skillWorkshop, .files:
OpenClawBrand.info
case .overview:
OpenClawBrand.warn
case .activity:
OpenClawBrand.accent
case .workboard:
.purple
case .instances, .sessions, .dreaming, .terminal:
.secondary
case .usage, .docs:
OpenClawBrand.accentHot
case .agents, .cron, .settings, .gateway:
OpenClawBrand.ok
} }
} }
@ -295,6 +370,31 @@ struct RootTabsPhoneControlHub: View {
} }
} }
private struct ControlCircleIcon: View {
@Environment(\.colorScheme) private var colorScheme
let systemName: String
let color: Color
let size: CGFloat
var body: some View {
Image(systemName: self.systemName)
.font(.system(size: self.size * 0.42, weight: .semibold))
.foregroundStyle(self.iconForegroundStyle)
.frame(width: self.size, height: self.size)
.background(
LinearGradient(
colors: [self.color.opacity(0.72), self.color],
startPoint: .topLeading,
endPoint: .bottomTrailing),
in: Circle())
}
private var iconForegroundStyle: Color {
self.colorScheme == .dark ? .black.opacity(0.82) : .white
}
}
#if DEBUG #if DEBUG
#Preview("Phone control hub offline") { #Preview("Phone control hub offline") {
RootTabsPhoneControlHub.preview(appModel: NodeAppModel()) RootTabsPhoneControlHub.preview(appModel: NodeAppModel())

View file

@ -382,22 +382,33 @@ struct RootTabsSourceGuardTests {
#expect(!source.contains("safeAreaPadding(.bottom")) #expect(!source.contains("safeAreaPadding(.bottom"))
} }
@Test func `phone hub stays task first without duplicating root tabs`() throws { @Test func `phone hub promotes chat and talk while filtering root tabs from its destination list`() throws {
let source = try String(contentsOf: Self.phoneHubSourceURL(), encoding: .utf8) let source = try String(contentsOf: Self.phoneHubSourceURL(), encoding: .utf8)
#expect(source.contains("private var gatewayRow: some View")) #expect(source.contains("private var gatewayHeader: some View"))
#expect(source.contains(".accessibilityLabel(\"Gateway \\(self.gatewayStateText),")) #expect(source.contains("private var gatewayIdentityTitle: some View"))
#expect(source.contains("Text(verbatim: gatewayDisplayLabel)"))
#expect(source.contains("Text(\"Gateway\")"))
#expect(source.contains(".accessibilityLabel(self.gatewayAccessibilityLabel)"))
#expect(source.contains("private var chatTalkRow: some View"))
#expect(source.contains(
".listRowInsets(EdgeInsets(top: 4, leading: 0, bottom: 4, trailing: 0))"))
#expect(source.contains("self.prominentDestinationCard(\n .chat,"))
#expect(source.contains("self.prominentDestinationCard(\n .talk,"))
#expect(source.contains("private var phoneDestinations: [RootTabs.SidebarDestination]"))
#expect(source.contains("self.groups.flatMap(\\.destinations).filter { !self.opensRootTab($0) }"))
#expect(source.contains("private struct ControlCircleIcon: View"))
#expect(source.contains(".foregroundStyle(self.iconForegroundStyle)"))
#expect(!source.contains("ProValuePill(value: self.gatewayStateText")) #expect(!source.contains("ProValuePill(value: self.gatewayStateText"))
#expect(!source.contains("destination.subtitle")) #expect(!source.contains("destination.subtitle"))
#expect(source.contains("self.openGatewayDetail()")) #expect(source.contains("self.openGatewayDetail()"))
#expect(!source.contains("self.openPhoneRootDestination(.gateway)")) #expect(!source.contains("self.openPhoneRootDestination(.gateway)"))
#expect(source.contains("group.destinations.filter { !self.opensRootTab($0) }"))
#expect(!source.contains("phoneDetailBackAction")) #expect(!source.contains("phoneDetailBackAction"))
#expect(!source.contains(".navigationBarBackButtonHidden(true)")) #expect(!source.contains(".navigationBarBackButtonHidden(true)"))
#expect(!source.contains(".toolbar(.hidden, for: .navigationBar)")) #expect(!source.contains(".toolbar(.hidden, for: .navigationBar)"))
#expect(source.matches(of: /usesNativeNavigationChrome: true/).count == 7) #expect(source.matches(of: /usesNativeNavigationChrome: true/).count == 7)
#expect(!source.contains("directRoute: .agents")) #expect(!source.contains("directRoute: .agents"))
#expect(!source.contains("Image(systemName: \"gearshape\")")) #expect(source.contains("Image(systemName: \"slider.horizontal.3\")"))
#expect(!source.contains("self.metric(label:")) #expect(!source.contains("self.metric(label:"))
#expect(!source.contains("private func metric(label:")) #expect(!source.contains("private func metric(label:"))
} }