Files
openclaw/apps/ios/Sources/RootSidebar.swift
Peter Steinberger 0fff74c800 feat(ios): replace the tab bar with a black overlay sidebar (web parity + dashboard) (#111339)
* feat(ios): unify navigation on a black overlay sidebar and drop the phone tab bar

* test(ios): rework navigation tests and snapshot UITests for the sidebar shell

* chore(i18n): sync derived native catalogs for sidebar strings

* feat(ios): reveal the sidebar beneath a push-away rounded content card

* feat(ios): web-parity sidebar — agent switcher, pinned pages, sessions vocabulary, uniform content card

* chore(i18n): translate sidebar and session strings across native catalogs

* fix(ios): sidebar footer shows connection dot only when degraded, like the web

* feat(ios): sidebar toggle top-left with edge-swipe open; card surface owns the drawer chrome

* fix(ios): single sidebar refresh owner, alias-aware Home badges, RM tap-close, user pin order, drop vestigial idiom shims

* feat(ios): align sidebar with the mobile prototype — agent roster, theme-following palette, web session subtitles, row badges

* chore(i18n): translate agent roster and Recent strings across native catalogs

* feat(ios): frosted-glass agent picker with uniform drawer insets

* fix(ios): address sidebar review findings — settings toggle edge, per-agent scoping, alias-aware selection, cron paging, refresh cadence

* fix(ios): clear landing CI blockers
2026-07-19 14:50:39 -07:00

909 lines
36 KiB
Swift

import OpenClawChatUI
import OpenClawProtocol
import SwiftUI
struct RootSidebar: View {
@Environment(NodeAppModel.self) private var appModel
@Environment(\.displayScale) private var displayScale
@Bindable var model: RootSidebarModel
@State private var searchText = ""
@State private var isSearchActive = false
@State private var showsPagesEditor = false
@FocusState private var isSearchFocused: Bool
@AppStorage("sidebar.pinnedPages") private var pinnedPagesStorage: String = ""
@AppStorage(RootSidebar.visibleAgentCountKey) private var visibleAgentCount: Int = 1
let selectedDestination: RootTabs.SidebarDestination
let isDrawerLayout: Bool
let selectDestination: (RootTabs.SidebarDestination) -> Void
let selectSettingsRoute: (SettingsRoute) -> Void
let hideSidebar: () -> Void
var body: some View {
VStack(spacing: 0) {
self.brandHeader
if self.isSearchActive {
self.searchField
}
ScrollView {
// One 10pt unit everywhere: side insets, section gaps, and
// the picker card's clearance all match.
LazyVStack(alignment: .leading, spacing: 10) {
self.agentsSection
self.pagesSection
self.sessionsSection
}
.padding(.horizontal, 10)
.padding(.vertical, 10)
}
self.footer
}
.foregroundStyle(OpenClawSidebarPalette.text)
.background(OpenClawSidebarPalette.background)
.sheet(isPresented: self.$showsPagesEditor) {
RootSidebarPagesEditor(
pinnedPages: self.pinnedPages,
onSelect: { destination in
self.showsPagesEditor = false
self.selectSidebarDestination(destination)
},
onTogglePin: self.togglePinnedPage)
}
}
private var pinnedPages: [RootTabs.SidebarDestination] {
RootTabs.pinnedSidebarPages(from: self.pinnedPagesStorage)
}
private func togglePinnedPage(_ destination: RootTabs.SidebarDestination) {
var pages = self.pinnedPages
if let index = pages.firstIndex(of: destination) {
pages.remove(at: index)
} else {
pages.append(destination)
}
self.pinnedPagesStorage = RootTabs.pinnedSidebarPagesStorage(pages)
}
/// Brand header with compact actions; the gear owns Settings entry
/// (prototype parity) so the footer stays connection-only.
private var brandHeader: some View {
HStack(spacing: 4) {
HStack(spacing: 8) {
OpenClawProMark(size: 26, shadowRadius: 2)
.accessibilityHidden(true)
Text(String(localized: "OpenClaw"))
.font(OpenClawType.headline)
.foregroundStyle(OpenClawSidebarPalette.textStrong)
.lineLimit(1)
}
.padding(.leading, 6)
Spacer(minLength: 4)
self.headerIconButton(
systemName: "magnifyingglass",
label: String(localized: "Search sessions"))
{
withAnimation(.easeInOut(duration: 0.15)) {
self.isSearchActive.toggle()
}
if self.isSearchActive {
self.isSearchFocused = true
} else {
self.searchText = ""
}
}
self.headerIconButton(
systemName: "gearshape",
label: String(localized: "Settings"))
{
self.selectSidebarDestination(.settings)
}
if self.isDrawerLayout {
Button(action: self.dismissSidebar) {
Image(systemName: "xmark")
.font(OpenClawType.subheadSemiBold)
.frame(width: 40, height: 44)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.foregroundStyle(OpenClawSidebarPalette.accent)
.accessibilityLabel(String(localized: "Hide Sidebar"))
.accessibilityIdentifier(RootTabs.sidebarHideButtonAccessibilityIdentifier)
}
}
.padding(.leading, 8)
.padding(.trailing, 8)
.padding(.vertical, 8)
.background(OpenClawSidebarPalette.background)
.overlay(alignment: .bottom) { self.separator }
}
/// Prototype-style agent roster: up to `visibleAgentCount` rows inline
/// (owner setting, default 1) and a switcher menu for the rest.
@ViewBuilder
private var agentsSection: some View {
let agents = self.orderedAgents
let shownCount = Self.shownAgentCount(configured: self.visibleAgentCount, total: agents.count)
if !agents.isEmpty {
VStack(alignment: .leading, spacing: 2) {
ForEach(agents.prefix(shownCount), id: \.id) { agent in
self.agentRow(agent)
}
if agents.count > shownCount {
self.moreAgentsRow(Array(agents.dropFirst(shownCount)))
}
}
.padding(4)
.background(.ultraThinMaterial, in: RoundedRectangle(
cornerRadius: OpenClawProMetric.cardRadius,
style: .continuous))
}
}
static let visibleAgentCountKey = "sidebar.visibleAgentCount"
static func shownAgentCount(configured: Int, total: Int) -> Int {
min(max(configured, 1), max(total, 1))
}
/// Selected agent leads; the rest keep the gateway roster order.
private var orderedAgents: [AgentSummary] {
let agents = self.appModel.gatewayAgents
guard let index = agents.firstIndex(where: { $0.id == self.currentAgentID }), index != 0 else {
return agents
}
var ordered = agents
let selected = ordered.remove(at: index)
ordered.insert(selected, at: 0)
return ordered
}
private func agentRow(_ agent: AgentSummary) -> some View {
let isSelected = agent.id == self.currentAgentID
return Button {
self.appModel.setSelectedAgentId(agent.id)
} label: {
HStack(spacing: 9) {
ZStack(alignment: .bottomTrailing) {
ZStack {
Circle()
.fill(OpenClawSidebarPalette.elevated)
Text(verbatim: Self.agentBadge(name: Self.agentDisplayName(agent), identity: agent.identity))
.font(OpenClawType.caption2Bold)
.foregroundStyle(OpenClawSidebarPalette.textStrong)
}
.frame(width: 28, height: 28)
if isSelected {
Circle()
.fill(OpenClawBrand.ok)
.frame(width: 8, height: 8)
.overlay(Circle().stroke(OpenClawSidebarPalette.background, lineWidth: 1.5))
}
}
.accessibilityHidden(true)
VStack(alignment: .leading, spacing: 1) {
Text(verbatim: Self.agentDisplayName(agent))
.font(OpenClawType.subheadSemiBold)
.foregroundStyle(OpenClawSidebarPalette.textStrong)
.lineLimit(1)
if let model = Self.agentModelLabel(agent) {
Text(verbatim: model)
.font(OpenClawType.caption2Medium)
.foregroundStyle(OpenClawSidebarPalette.muted)
.lineLimit(1)
}
}
Spacer(minLength: 4)
}
.frame(maxWidth: .infinity, minHeight: 44, alignment: .leading)
.padding(.horizontal, 10)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.background(isSelected ? OpenClawSidebarPalette.selection : Color.clear, in: RoundedRectangle(
cornerRadius: OpenClawProMetric.controlRadius,
style: .continuous))
.accessibilityValue(isSelected ? String(localized: "Selected") : "")
}
private func moreAgentsRow(_ agents: [AgentSummary]) -> some View {
Menu {
ForEach(agents, id: \.id) { agent in
Button {
self.appModel.setSelectedAgentId(agent.id)
} label: {
Text(verbatim: Self.agentDisplayName(agent))
.font(OpenClawType.subheadSemiBold)
}
}
} label: {
HStack(spacing: 9) {
Image(systemName: "chevron.up.chevron.down")
.font(OpenClawType.captionSemiBold)
.frame(width: 28)
Text(String(localized: "More Agents"))
.font(OpenClawType.subheadSemiBold)
.lineLimit(1)
Spacer(minLength: 4)
}
.frame(maxWidth: .infinity, minHeight: 40, alignment: .leading)
.padding(.horizontal, 10)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.foregroundStyle(OpenClawSidebarPalette.muted)
}
/// Same key order the Agents roster uses for its model subtitles.
static func agentModelLabel(_ agent: AgentSummary) -> String? {
guard let model = agent.model else { return nil }
for key in ["primary", "name", "id", "model"] {
if let value = (model[key]?.value as? String)?.trimmingCharacters(in: .whitespacesAndNewlines),
!value.isEmpty
{
return value
}
}
return nil
}
private func headerIconButton(
systemName: String,
label: String,
action: @escaping () -> Void) -> some View
{
Button(action: action) {
Image(systemName: systemName)
.font(OpenClawType.subheadSemiBold)
.frame(width: 40, height: 44)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.foregroundStyle(OpenClawSidebarPalette.text)
.accessibilityLabel(label)
}
private var currentAgentID: String {
let selected = self.appModel.selectedAgentId?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
if !selected.isEmpty { return selected }
return self.appModel.gatewayDefaultAgentId?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
}
static func agentDisplayName(_ agent: AgentSummary) -> String {
let name = agent.name?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
return name.isEmpty ? agent.id : name
}
static func agentBadge(name: String, identity: [String: AnyCodable]?) -> String {
if let emoji = (identity?["emoji"]?.value as? String)?.trimmingCharacters(in: .whitespacesAndNewlines),
!emoji.isEmpty
{
return emoji
}
let initials = name
.split(whereSeparator: { $0.isWhitespace || $0 == "-" || $0 == "_" })
.prefix(2)
.compactMap(\.first)
.map(String.init)
.joined()
return initials.isEmpty ? "OC" : initials.uppercased()
}
private var searchField: some View {
HStack(spacing: 8) {
Image(systemName: "magnifyingglass")
.font(OpenClawType.captionSemiBold)
.foregroundStyle(OpenClawSidebarPalette.muted)
.accessibilityHidden(true)
ZStack(alignment: .leading) {
if self.searchText.isEmpty {
Text(String(localized: "Search sessions"))
.font(OpenClawType.subhead)
.foregroundStyle(OpenClawSidebarPalette.muted)
.accessibilityHidden(true)
}
TextField("", text: self.$searchText)
.font(OpenClawType.subhead)
.foregroundStyle(OpenClawSidebarPalette.textStrong)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
.focused(self.$isSearchFocused)
.accessibilityLabel(String(localized: "Search sessions"))
}
if !self.searchText.isEmpty {
Button {
self.searchText = ""
} label: {
Image(systemName: "xmark.circle.fill")
.font(OpenClawType.subhead)
.foregroundStyle(OpenClawSidebarPalette.muted)
}
.buttonStyle(.plain)
.accessibilityLabel(String(localized: "Clear session search"))
}
}
.frame(minHeight: 44)
.padding(.horizontal, 12)
.background(OpenClawSidebarPalette.elevated, in: RoundedRectangle(
cornerRadius: OpenClawProMetric.controlRadius,
style: .continuous))
.padding(.horizontal, 10)
.padding(.bottom, 4)
}
@ViewBuilder
private var sessionsSection: some View {
let sections = self.visibleSessionSections
let selectedSessionKey = self.resolvedSelectedSessionKey
VStack(alignment: .leading, spacing: 6) {
if let sessionErrorText = self.model.sessionErrorText {
Text(verbatim: sessionErrorText)
.font(OpenClawType.captionMedium)
.foregroundStyle(OpenClawBrand.warn)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal, 10)
}
if self.model.isRefreshing, self.model.sessions.isEmpty {
self.sessionsHeader(String(localized: "Recent"))
HStack(spacing: 9) {
ProgressView().controlSize(.small)
Text(String(localized: "Loading sessions"))
.font(OpenClawType.captionMedium)
.foregroundStyle(OpenClawSidebarPalette.muted)
}
.frame(minHeight: 44)
.padding(.horizontal, 10)
} else if sections.isEmpty {
self.sessionsHeader(String(localized: "Recent"))
Text(String(localized: "No recent sessions"))
.font(OpenClawType.captionMedium)
.foregroundStyle(OpenClawSidebarPalette.muted)
.frame(maxWidth: .infinity, minHeight: 44, alignment: .leading)
.padding(.horizontal, 10)
} else {
ForEach(Array(sections.enumerated()), id: \.element.id) { index, section in
// Sole ungrouped section carries no model title; the web
// and apps agree on calling it Recent. New Chat rides the
// first header so it lives where sessions live.
let title = section.title ?? String(localized: "Recent")
if index == 0 {
self.sessionsHeader(title)
} else {
self.sectionTitle(title)
}
ForEach(self.sessionNodes(for: section)) { node in
self.sessionButton(node, selectedSessionKey: selectedSessionKey)
}
}
}
Button {
self.selectSidebarDestination(.sessions)
} label: {
Label {
Text(String(localized: "All Sessions…"))
.font(OpenClawType.subheadSemiBold)
} icon: {
Image(systemName: "rectangle.stack")
.font(OpenClawType.subheadSemiBold)
}
.frame(maxWidth: .infinity, minHeight: 44, alignment: .leading)
.padding(.horizontal, 10)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.foregroundStyle(OpenClawSidebarPalette.accent)
}
}
private var pagesSection: some View {
VStack(alignment: .leading, spacing: 2) {
HStack(spacing: 4) {
self.sectionTitle(String(localized: "Pages"))
Spacer(minLength: 4)
Button {
self.showsPagesEditor = true
} label: {
Image(systemName: "square.and.pencil")
.font(OpenClawType.captionSemiBold)
.frame(width: 40, height: 32)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.foregroundStyle(OpenClawSidebarPalette.muted)
.accessibilityLabel(String(localized: "Edit Pages"))
}
self.homeRow
ForEach(self.pinnedPages) { destination in
self.destinationButton(destination)
}
}
}
/// Fixed first Pages row like the web "Home": opens the agent's chat and
/// carries the main session's run/unread state.
private var homeRow: some View {
let mainKey = self.resolvedMainSessionKey
let isSelected = self.selectedDestination == .chat &&
self.resolvedSelectedSessionKey.caseInsensitiveCompare(mainKey) == .orderedSame
let mainSession = self.mainSessionEntry
return Button {
self.appModel.openChat(sessionKey: mainKey, unread: mainSession?.unread == true)
self.selectSidebarDestination(.chat)
} label: {
HStack(spacing: 9) {
Image(systemName: "house")
.font(OpenClawType.subheadSemiBold)
.frame(width: 18)
Text(String(localized: "Home"))
.font(OpenClawType.subheadSemiBold)
.lineLimit(1)
Spacer(minLength: 4)
if mainSession?.hasActiveRun == true {
ProgressView()
.controlSize(.mini)
.tint(OpenClawSidebarPalette.accent)
} else if mainSession?.unread == true {
Circle()
.fill(OpenClawSidebarPalette.accent)
.frame(width: 7, height: 7)
.accessibilityHidden(true)
}
}
.frame(maxWidth: .infinity, minHeight: 44, alignment: .leading)
.padding(.horizontal, 10)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.foregroundStyle(isSelected ? OpenClawSidebarPalette.accent : OpenClawSidebarPalette.text)
.background(isSelected ? OpenClawSidebarPalette.selection : Color.clear, in: RoundedRectangle(
cornerRadius: OpenClawProMetric.controlRadius,
style: .continuous))
.accessibilityValue(mainSession?.unread == true ? String(localized: "Unread") : "")
}
/// Web-parity compact footer: connection state left, Settings gear right.
private var footer: some View {
VStack(spacing: 0) {
self.separator
HStack(spacing: 4) {
Button {
self.selectSidebarDestination(.gateway)
} label: {
HStack(spacing: 9) {
// The agent card owns the healthy status; like the web
// footer, a dot appears here only when degraded.
if !self.isGatewayConnected {
Circle()
.fill(self.gatewayStatusColor)
.frame(width: 8, height: 8)
.accessibilityHidden(true)
}
Text(verbatim: self.gatewayName)
.font(OpenClawType.subheadSemiBold)
.lineLimit(1)
}
.frame(minHeight: 44, alignment: .leading)
.padding(.horizontal, 10)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.foregroundStyle(OpenClawSidebarPalette.text)
.accessibilityValue(self.gatewayStatusTitle)
Spacer(minLength: 4)
}
}
.padding(.horizontal, 10)
.padding(.bottom, 8)
.background(OpenClawSidebarPalette.background)
}
/// Alias-aware main lookup: rosters may return namespaced keys
/// ("agent:<id>:main"), so raw comparison would drop Home's badges.
private var mainSessionEntry: OpenClawChatSessionEntry? {
self.model.sessions.first { $0.key == self.resolvedMainSessionKey }
}
private var resolvedMainSessionKey: String {
ChatSessionSidebarModel.selectedSessionKey(
sessions: self.model.sessions,
currentSessionKey: "main",
mainSessionKey: self.appModel.defaultChatSessionKey,
activeAgentID: self.appModel.chatAgentId)
}
private var resolvedSelectedSessionKey: String {
ChatSessionSidebarModel.selectedSessionKey(
sessions: self.model.sessions,
currentSessionKey: self.appModel.chatSessionKey,
mainSessionKey: self.appModel.defaultChatSessionKey,
activeAgentID: self.appModel.chatAgentId)
}
private var visibleSessionSections: [ChatSessionSidebarModel.Section] {
self.model.sections(
query: self.searchText,
currentSessionKey: self.appModel.chatSessionKey,
mainSessionKey: self.appModel.defaultChatSessionKey,
activeAgentID: self.appModel.chatAgentId,
groups: self.sessionGroups)
}
private var sessionCategories: [String] {
CommandSessionGrouping.categories(from: self.model.sessions, knownGroups: SessionGroupStore.load())
}
private var sessionGroups: [OpenClawChatSessionGroup] {
self.sessionCategories.enumerated().map { offset, name in
OpenClawChatSessionGroup(name: name, position: offset)
}
}
private func flattened(_ nodes: [ChatSessionSidebarModel.Node]) -> [ChatSessionSidebarModel.Node] {
nodes.flatMap { [$0] + self.flattened($0.children) }
}
private func sessionNodes(for section: ChatSessionSidebarModel.Section) -> [ChatSessionSidebarModel.Node] {
let nodes = self.flattened(section.nodes)
guard section.id == "recent", let limit = Self.recentSessionCap(searchText: self.searchText) else {
return nodes
}
return Array(nodes.prefix(limit))
}
static func recentSessionCap(searchText: String) -> Int? {
searchText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? 20 : nil
}
private func sessionButton(
_ node: ChatSessionSidebarModel.Node,
selectedSessionKey: String) -> some View
{
let session = node.session
let isSelected = session.key == selectedSessionKey
return Button {
self.appModel.openChat(sessionKey: session.key, unread: session.unread == true)
self.selectSidebarDestination(.chat)
} label: {
HStack(spacing: 9) {
ZStack {
if node.badges.runningCount > 0 {
ProgressView()
.controlSize(.mini)
.tint(OpenClawSidebarPalette.accent)
} else {
Image(systemName: "bubble.left")
.font(OpenClawType.captionSemiBold)
.foregroundStyle(isSelected
? OpenClawSidebarPalette.accent
: OpenClawSidebarPalette.muted)
}
}
.frame(width: 18)
VStack(alignment: .leading, spacing: 2) {
Text(verbatim: CommandCenterTab.sessionTitle(session))
.font(OpenClawType.subheadSemiBold)
.foregroundStyle(isSelected
? OpenClawSidebarPalette.accent
: OpenClawSidebarPalette.textStrong)
.lineLimit(1)
// Web-parity subtitle: the work line (repo/branch) names the
// session; recency moves to the trailing metadata slot.
if let workSubtitle = ChatSessionSidebarModel.workSubtitle(for: session) {
Text(verbatim: workSubtitle)
.font(OpenClawType.caption2Medium)
.foregroundStyle(OpenClawSidebarPalette.muted)
.lineLimit(1)
}
}
Spacer(minLength: 4)
Text(verbatim: CommandCenterTab.sessionDetail(session))
.font(OpenClawType.caption2Medium)
.foregroundStyle(OpenClawSidebarPalette.muted)
.lineLimit(1)
if session.unread == true {
Circle()
.fill(OpenClawSidebarPalette.accent)
.frame(width: 7, height: 7)
.accessibilityHidden(true)
}
if session.pinned == true {
Image(systemName: "pin.fill")
.font(OpenClawType.caption2Medium)
.foregroundStyle(OpenClawSidebarPalette.accent)
.accessibilityHidden(true)
}
}
.frame(maxWidth: .infinity, minHeight: 44, alignment: .leading)
.padding(.horizontal, 10)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.background(isSelected ? OpenClawSidebarPalette.selection : Color.clear, in: RoundedRectangle(
cornerRadius: OpenClawProMetric.controlRadius,
style: .continuous))
.commandSessionActions(
session: session,
categories: self.sessionCategories,
isEnabled: self.appModel.isOperatorGatewayConnected,
canArchive: ChatSessionSidebarModel.canArchiveSession(
session,
mainSessionKey: self.resolvedMainSessionKey),
canDelete: ChatSessionSidebarModel.canDeleteSession(
key: session.key,
mainSessionKey: self.resolvedMainSessionKey),
actions: CommandSessionActions(
rename: { self.patchSession(session, label: .some($0)) },
moveToGroup: { self.patchSession(session, category: .some($0)) },
togglePinned: { self.patchSession(session, pinned: session.pinned != true) },
toggleUnread: { self.patchSession(session, unread: session.unread != true) },
fork: { self.forkSession(session) },
toggleArchived: { self.patchSession(session, archived: true) },
delete: { self.deleteSession(session) }))
.accessibilityValue(session.unread == true ? String(localized: "Unread") : "")
}
private func destinationButton(_ destination: RootTabs.SidebarDestination) -> some View {
let isSelected = destination == self.selectedDestination
let badgeCount = self.badgeCount(for: destination)
return Button {
self.selectSidebarDestination(destination)
} label: {
HStack(spacing: 0) {
Label {
Text(destination.sidebarTitle)
.font(OpenClawType.subheadSemiBold)
.lineLimit(1)
} icon: {
Image(systemName: destination.systemImage)
.font(OpenClawType.subheadSemiBold)
}
Spacer(minLength: 6)
if badgeCount > 0 {
Text(verbatim: badgeCount.formatted())
.font(OpenClawType.caption2Bold)
.foregroundStyle(Color.white)
.padding(.horizontal, 6)
.frame(minWidth: 18, minHeight: 18)
.background(OpenClawSidebarPalette.accent, in: Capsule())
.accessibilityLabel(String(localized: "Attention"))
}
}
.frame(maxWidth: .infinity, minHeight: 44, alignment: .leading)
.padding(.horizontal, 10)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.foregroundStyle(isSelected ? OpenClawSidebarPalette.accent : OpenClawSidebarPalette.text)
.background(isSelected ? OpenClawSidebarPalette.selection : Color.clear, in: RoundedRectangle(
cornerRadius: OpenClawProMetric.controlRadius,
style: .continuous))
}
/// Attention lives on the rows that act on it (prototype parity): pending
/// approvals surface on Overview, cron issues on Automations.
private func badgeCount(for destination: RootTabs.SidebarDestination) -> Int {
switch destination {
case .overview: self.appModel.pendingExecApprovalCount
case .cron: self.model.failedCronJobCount + self.model.overdueCronJobCount
default: 0
}
}
private func sessionsHeader(_ title: String) -> some View {
HStack(spacing: 4) {
self.sectionTitle(title)
Spacer(minLength: 4)
Button {
self.appModel.requestNewChat()
self.selectSidebarDestination(.chat)
} label: {
Image(systemName: "plus.bubble")
.font(OpenClawType.captionSemiBold)
.frame(width: 40, height: 32)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.foregroundStyle(OpenClawSidebarPalette.muted)
.disabled(!self.appModel.isOperatorGatewayConnected)
.accessibilityLabel(String(localized: "New Chat"))
}
}
private func sectionTitle(_ title: String) -> some View {
Text(verbatim: title.uppercased())
.font(OpenClawType.caption2Bold)
.foregroundStyle(OpenClawSidebarPalette.muted)
.tracking(0.5)
.padding(.horizontal, 10)
}
private var separator: some View {
Rectangle()
.fill(OpenClawSidebarPalette.hairline)
.frame(height: 1 / self.displayScale)
}
private var gatewayName: String {
Self.gatewayName(
serverName: self.appModel.gatewayServerName,
remoteAddress: self.appModel.gatewayRemoteAddress)
}
static func gatewayName(serverName: String?, remoteAddress: String?) -> String {
for candidate in [serverName, remoteAddress] {
let trimmed = candidate?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
if !trimmed.isEmpty { return trimmed }
}
return String(localized: "Connection")
}
private var gatewayStatusTitle: String {
switch GatewayStatusBuilder.build(appModel: self.appModel) {
case .connected: String(localized: "Online")
case .connecting: String(localized: "Connecting")
case .error: String(localized: "Needs attention")
case .disconnected: String(localized: "Offline")
}
}
private var isGatewayConnected: Bool {
GatewayStatusBuilder.build(appModel: self.appModel) == .connected
}
private var gatewayStatusColor: Color {
switch GatewayStatusBuilder.build(appModel: self.appModel) {
case .connected: OpenClawBrand.ok
case .connecting: OpenClawBrand.accent
case .error: OpenClawBrand.warn
case .disconnected: OpenClawSidebarPalette.muted
}
}
private func patchSession(
_ session: OpenClawChatSessionEntry,
label: String?? = nil,
category: String?? = nil,
pinned: Bool? = nil,
archived: Bool? = nil,
unread: Bool? = nil)
{
Task {
do {
try await self.appModel.makeChatTransport().patchSession(
key: session.key,
label: label,
category: category,
pinned: pinned,
archived: archived,
unread: unread)
if archived == true, session.key == self.appModel.chatSessionKey {
self.appModel.focusChatSession(nil)
}
await self.model.refreshSessions(appModel: self.appModel)
} catch {
self.model.reportSessionError(error)
}
}
}
private func deleteSession(_ session: OpenClawChatSessionEntry) {
Task {
do {
try await self.appModel.makeChatTransport().deleteSession(key: session.key)
if session.key == self.appModel.chatSessionKey {
self.appModel.focusChatSession(nil)
}
await self.model.refreshSessions(appModel: self.appModel)
} catch {
self.model.reportSessionError(error)
}
}
}
private func forkSession(_ session: OpenClawChatSessionEntry) {
Task {
do {
let key = try await self.appModel.makeChatTransport().forkSession(parentKey: session.key)
self.appModel.openChat(sessionKey: key)
self.selectSidebarDestination(.chat)
await self.model.refreshSessions(appModel: self.appModel)
} catch {
self.model.reportSessionError(error)
}
}
}
private func selectSidebarDestination(_ destination: RootTabs.SidebarDestination) {
self.isSearchFocused = false
self.selectDestination(destination)
}
private func dismissSidebar() {
self.isSearchFocused = false
self.hideSidebar()
}
}
/// Web-parity Pages editor (the pen menu): navigate to any page, pin/unpin
/// which ones stay in the sidebar. Home is fixed and not listed.
struct RootSidebarPagesEditor: View {
let pinnedPages: [RootTabs.SidebarDestination]
let onSelect: (RootTabs.SidebarDestination) -> Void
let onTogglePin: (RootTabs.SidebarDestination) -> Void
@Environment(\.dismiss) private var dismiss
var body: some View {
NavigationStack {
List {
Section {
ForEach(RootTabs.pinnableSidebarPages) { destination in
self.pageRow(destination)
}
} footer: {
Text("Pinned pages stay in the sidebar. Home is always shown.")
.font(OpenClawType.caption)
}
}
.navigationTitle(String(localized: "Pages"))
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .confirmationAction) {
Button {
self.dismiss()
} label: {
Text(String(localized: "Done"))
.font(OpenClawType.subheadSemiBold)
}
}
}
}
.openClawSheetChrome()
}
private func pageRow(_ destination: RootTabs.SidebarDestination) -> some View {
let isPinned = self.pinnedPages.contains(destination)
return HStack(spacing: 10) {
Button {
self.onSelect(destination)
} label: {
Label {
Text(destination.sidebarTitle)
.font(OpenClawType.subheadSemiBold)
} icon: {
Image(systemName: destination.systemImage)
}
.frame(maxWidth: .infinity, alignment: .leading)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
Button {
self.onTogglePin(destination)
} label: {
Image(systemName: isPinned ? "pin.fill" : "pin")
.font(OpenClawType.subheadSemiBold)
.foregroundStyle(isPinned ? OpenClawBrand.accent : Color.secondary)
.frame(width: 44, height: 44)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.accessibilityLabel(destination.sidebarTitle)
.accessibilityValue(
isPinned
? String(localized: "Pinned")
: String(localized: "Not pinned"))
}
}
}