mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-23 20:11:14 +00:00
* 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
909 lines
36 KiB
Swift
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"))
|
|
}
|
|
}
|
|
}
|