Files
Peter Steinberger 2a8b322ebf feat: view your machine's screen from the iOS and Android apps (#123097)
* feat(ui): add mobile desktop document mode

Add a shell-free mobile desktop route that reuses the dock panel controller and lazy noVNC client, with source preselection, touch controls, keyboard input, and retryable inventory failures.

* feat(ios): add desktop viewer entry points

* feat(android): add desktop viewer

* fix(android): keep System Back inside the desktop viewer

The per-session viewer replaces SessionDashboardScreen in place instead of
pushing a shell tab, so System Back fell through to the shell-level handler
and popped the whole Dashboard tab, stranding the operator on Chat. Claim
Back while the viewer is showing.

Also carry over TerminalSettingsScreen's imePadding: the viewer's own touch
toolbar hosts the keyboard affordance, so the soft keyboard would cover it.

Proof (emulator, Medium_Phone_API_36.0, stub control UI on 18789):
pre-fix Back from the viewer lands on Chat; post-fix it returns to Dashboard.
No Robolectric regression test — no existing screen test constructs
MainViewModel, and BackHandler under Robolectric would need new scaffolding
for weaker evidence than the live repro.

* test(ui): stop the pairing views leaking dialogs into the shared document

`ui/vitest.config.ts` runs the unit project with `isolate: false`, so test files
share one jsdom document inside a worker. `view.pairing.test.ts` appends a
container to `document.body` for every case and never tears down, unlike its
sibling `channels-page.test.ts`, so whichever suite the worker scheduled next
inherited a mounted pairing dialog.

That surfaced on this PR's first CI run as ten failures in the untouched
`input-dialog.test.ts`, which found "Approve DM access" where it expected
"Rename session". A rerun went green, so the ordering is scheduler-dependent
rather than deterministic; this removes the contamination source rather than
leaving the next suite to lose the race.

Not a proven fix for that specific run — the leak reproduces only under CI's
file scheduling, and the full suite passes locally either way — but the missing
teardown is a real violation of the shared-environment contract.

* test(ui): stop the background-tasks rail asserting on a ticking clock

The rail e2e captured the main transcript's text before opening a task detail
and required it to be byte-identical afterwards. A running task renders a live
elapsed label, so the assertion failed whenever a second ticked over between
the two reads — twice while landing this PR, both times "12s" against "13s"
with no other difference.

Normalize elapsed labels on both sides instead of weakening the assertion. The
invariant it protects, that opening a detail leaves the main transcript alone,
still holds: a real content change is still caught, and only complete duration
tokens collapse, so diffstat figures like +14/-3 and phrases like "5 messages"
are untouched.
2026-08-13 04:10:48 -07:00

946 lines
38 KiB
Swift

import OpenClawChatUI
import OpenClawProtocol
import SwiftUI
struct RootSidebar: View {
@Environment(NodeAppModel.self) private var appModel
@Environment(\.colorScheme) private var colorScheme
@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 isDismissButtonEnabled: Bool
let selectDestination: (RootTabs.SidebarDestination) -> 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(
destinations: RootTabs.pinnableSidebarPages.filter(self.isDestinationAvailable),
pinnedPages: self.storedPinnedPages,
onSelect: { destination in
self.showsPagesEditor = false
self.selectSidebarDestination(destination)
},
onTogglePin: self.togglePinnedPage)
}
}
private var storedPinnedPages: [RootTabs.SidebarDestination] {
RootTabs.pinnedSidebarPages(from: self.pinnedPagesStorage)
}
private var pinnedPages: [RootTabs.SidebarDestination] {
self.storedPinnedPages.filter(self.isDestinationAvailable)
}
private func isDestinationAvailable(_ destination: RootTabs.SidebarDestination) -> Bool {
destination != .desktop || self.appModel.isDesktopObserveAvailable
}
private func togglePinnedPage(_ destination: RootTabs.SidebarDestination) {
var pages = self.storedPinnedPages
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)
}
.accessibilityIdentifier("RootTabs.Sidebar.Destination.settings")
if self.isDrawerLayout {
OpenClawSidebarControlButton(action: self.dismissAction)
.allowsHitTesting(self.isDismissButtonEnabled)
.accessibilityHidden(!self.isDismissButtonEnabled)
}
}
.padding(.leading, 8)
.padding(.trailing, 8)
.padding(.vertical, 8)
.background(OpenClawSidebarPalette.background)
.overlay(alignment: .bottom) { self.separator }
}
private var dismissAction: OpenClawSidebarHeaderAction {
OpenClawSidebarHeaderAction(
systemName: "xmark",
accessibilityLabel: .localized("Hide Sidebar"),
accessibilityIdentifier: self.isDismissButtonEnabled
? RootTabs.sidebarHideButtonAccessibilityIdentifier
: nil,
action: self.dismissSidebar)
}
/// 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.filter(\.isSelectableAgent)
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) {
self.agentAvatarBadge(agent, size: 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: {
Label {
Text(verbatim: Self.agentDisplayName(agent))
.font(OpenClawType.subheadSemiBold)
} icon: {
self.agentMenuAvatarImage(agent)
.renderingMode(.original)
}
}
.accessibilityLabel(Self.agentDisplayName(agent))
}
} 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 {
AgentIdentityPresentation.badge(
avatarText: identity?["emoji"]?.value as? String,
displayName: name)
}
private func agentAvatarBadge(_ agent: AgentSummary, size: CGFloat) -> some View {
ZStack {
Circle()
.fill(OpenClawSidebarPalette.elevated)
Text(verbatim: Self.agentBadge(name: Self.agentDisplayName(agent), identity: agent.identity))
.font(OpenClawType.caption2Bold)
.foregroundStyle(OpenClawSidebarPalette.textStrong)
.minimumScaleFactor(0.65)
.lineLimit(1)
}
.frame(width: size, height: size)
.overlay(Circle().strokeBorder(OpenClawSidebarPalette.hairline, lineWidth: 1))
.accessibilityHidden(true)
}
private func agentMenuAvatarImage(_ agent: AgentSummary) -> Image {
let renderer = ImageRenderer(content: self.agentAvatarBadge(agent, size: 24)
.environment(\.colorScheme, self.colorScheme))
renderer.scale = self.displayScale
guard let image = renderer.uiImage else {
return Image(systemName: "person.crop.circle")
}
return Image(uiImage: image)
}
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))
.accessibilityIdentifier("RootTabs.Sidebar.Destination.chat")
.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)
.fixedSize(horizontal: false, vertical: true)
.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 subtitle = ChatSessionSidebarModel.subtitle(
for: session,
workSubtitle: ChatSessionSidebarModel.workSubtitle(for: session))
{
Text(verbatim: subtitle)
.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))
.accessibilityIdentifier("RootTabs.Sidebar.Destination.\(destination.rawValue)")
}
/// 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,
expectedSessionID: archived == nil ? nil : session.sessionId,
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 destinations: [RootTabs.SidebarDestination]
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(self.destinations) { 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"))
}
}
}