Files
openclaw/apps/ios/Sources/RootTabsNavigation.swift
T
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

280 lines
9.4 KiB
Swift

import CoreGraphics
import Foundation
import SwiftUI
extension RootTabs {
private static var sidebarPersistentWidthThreshold: CGFloat {
980
}
static let sidebarSplitIdealWidth: CGFloat = 316
static let sidebarSplitMaximumWidth: CGFloat = 340
// Keep the web drawer's 86% reveal while using more of current iPhone widths.
static let sidebarDrawerMaximumWidth: CGFloat = 340
static let sidebarShowButtonAccessibilityIdentifier = "RootTabs.Sidebar.Show"
static let sidebarHideButtonAccessibilityIdentifier = "RootTabs.Sidebar.Hide"
enum SidebarDestination: String, CaseIterable, Hashable, Identifiable {
case chat
case overview
case activity
case agents
case workboard
case skillWorkshop
case instances
case sessions
case files
case dreaming
case usage
case cron
case desktop
case terminal
case docs
case settings
case gateway
var id: String {
rawValue
}
var title: String {
switch self {
case .chat: String(localized: "Chat")
case .overview: String(localized: "Overview")
case .activity: String(localized: "Activity")
case .agents: String(localized: "Agents")
case .workboard: String(localized: "Workboard")
case .skillWorkshop: String(localized: "Skill Workshop")
case .instances: String(localized: "Instances")
case .sessions: String(localized: "Sessions")
case .files: String(localized: "Files")
case .dreaming: String(localized: "Dreaming")
case .usage: String(localized: "Usage")
case .cron: String(localized: "Automations")
case .desktop: String(localized: "Desktop")
case .terminal: String(localized: "Terminal")
case .docs: String(localized: "Docs")
case .settings: String(localized: "Settings")
case .gateway: String(localized: "Settings / Gateway")
}
}
var sidebarTitle: String {
switch self {
case .gateway: String(localized: "Connection")
default: self.title
}
}
var systemImage: String {
switch self {
case .chat: "bubble.left"
case .overview: "chart.bar"
case .activity: "waveform.path.ecg"
case .agents: "person.2"
case .workboard: "folder"
case .skillWorkshop: "hammer"
case .instances: "dot.radiowaves.left.and.right"
case .sessions: "doc.text"
case .files: "folder.fill"
case .dreaming: "moon.stars"
case .usage: "chart.bar.xaxis"
case .cron: "timer"
case .desktop: "display"
case .terminal: "terminal"
case .docs: "book"
case .settings: "gearshape"
case .gateway: "gearshape"
}
}
var settingsRoute: SettingsRoute? {
switch self {
case .gateway:
.gateway
case .chat, .overview, .activity, .agents, .workboard, .skillWorkshop, .instances, .sessions,
.files,
.dreaming,
.usage, .cron, .desktop, .terminal, .settings, .docs:
nil
}
}
}
enum SidebarLayoutMode: Equatable {
case drawer
case split
}
static func sidebarLayoutContainerSize(contentSize: CGSize, windowSize: CGSize?) -> CGSize {
windowSize ?? contentSize
}
static func sidebarLayoutMode(containerSize: CGSize) -> SidebarLayoutMode {
containerSize.width < self.sidebarPersistentWidthThreshold || containerSize.height > containerSize.width
? .drawer
: .split
}
static func preferredSidebarVisibility(layoutMode: SidebarLayoutMode) -> Bool {
layoutMode == .split
}
static func shouldCollapseSidebarAfterSelection(layoutMode: SidebarLayoutMode) -> Bool {
layoutMode == .drawer
}
static func sidebarWidth(containerWidth: CGFloat, isDrawerLayout: Bool) -> CGFloat {
if isDrawerLayout {
return min(self.sidebarDrawerMaximumWidth, containerWidth * 0.86)
}
return min(self.sidebarSplitMaximumWidth, max(self.sidebarSplitIdealWidth, containerWidth * 0.25))
}
static func sidebarContentOffset(
sidebarWidth: CGFloat,
isVisible: Bool,
dragOffset: CGFloat,
reduceMotion: Bool) -> CGFloat
{
guard !reduceMotion else { return 0 }
if isVisible {
return max(0, sidebarWidth + min(0, dragOffset))
}
// Closed: a positive drag is the interactive edge-open follow.
return max(0, min(sidebarWidth, dragOffset))
}
static func shouldShowSidebarRevealControl(isSidebarVisible: Bool) -> Bool {
!isSidebarVisible
}
static func visibleSettingsRoute(
navigationPath: [SettingsRoute],
baseRoute: SettingsRoute?) -> SettingsRoute?
{
navigationPath.last ?? baseRoute
}
static func shouldShowSidebarRevealInDestinationHeader(
isSidebarVisible: Bool,
layoutMode: SidebarLayoutMode) -> Bool
{
switch layoutMode {
case .split:
true
case .drawer:
self.shouldShowSidebarRevealControl(isSidebarVisible: isSidebarVisible)
}
}
static func requestedInitialSidebarVisibility(arguments: [String]) -> Bool? {
guard let flagIndex = arguments.firstIndex(of: "--openclaw-sidebar-visibility") else {
return nil
}
let valueIndex = arguments.index(after: flagIndex)
guard arguments.indices.contains(valueIndex) else { return nil }
switch arguments[valueIndex].trimmingCharacters(in: .whitespacesAndNewlines).lowercased() {
case "visible", "show", "shown", "open", "true", "1":
return true
case "hidden", "hide", "closed", "false", "0":
return false
default:
return nil
}
}
enum StartupPresentationRoute: Equatable {
case none
case onboarding
case settings
}
static func startupPresentationRoute(
gatewayConnected: Bool,
hasConnectedOnce: Bool,
onboardingComplete: Bool,
hasExistingGatewayConfig: Bool,
shouldPresentOnLaunch: Bool) -> StartupPresentationRoute
{
if gatewayConnected {
return .none
}
// Saved gateway state survives independently of the onboarding markers.
// Explicit resets bypass this route through evaluateOnboardingPresentation(force:).
if hasExistingGatewayConfig {
return .none
}
if shouldPresentOnLaunch || !hasConnectedOnce || !onboardingComplete {
return .onboarding
}
return .settings
}
static func shouldPresentQuickSetup(
quickSetupDismissed: Bool,
showOnboarding: Bool,
hasPresentedSheet: Bool,
gatewayConnected: Bool,
hasExistingGatewayConfig: Bool,
discoveredGatewayCount: Int) -> Bool
{
guard !quickSetupDismissed else { return false }
guard !showOnboarding else { return false }
guard !hasPresentedSheet else { return false }
guard !gatewayConnected else { return false }
guard !hasExistingGatewayConfig else { return false }
return discoveredGatewayCount > 0
}
static let sidebarDestinations: [SidebarDestination] = [
.chat,
.overview,
.workboard,
.usage,
.cron,
.sessions,
.activity,
.skillWorkshop,
.agents,
.instances,
.files,
.dreaming,
.desktop,
.terminal,
.docs,
]
/// Home (chat) is a fixed first row like the web sidebar; only these can be
/// pinned/unpinned by the user.
static let pinnableSidebarPages: [SidebarDestination] = sidebarDestinations.filter { $0 != .chat }
/// Echoes the web first-run Pages zone (Home, Usage, Automations, …):
/// compact by default so sessions stay above the fold. The Sessions page is
/// intentionally unpinned — the sessions section + "All Sessions…" own it.
static let defaultPinnedSidebarPages: [SidebarDestination] = [.overview, .usage, .cron]
/// "" = never customized (defaults); "none" = user unpinned everything.
/// Storage order is the user's pin order (web parity); unknown or
/// unpinnable raw values are dropped.
static func pinnedSidebarPages(from storage: String) -> [SidebarDestination] {
let trimmed = storage.trimmingCharacters(in: .whitespacesAndNewlines)
if trimmed.isEmpty { return self.defaultPinnedSidebarPages }
if trimmed == "none" { return [] }
var seen = Set<String>()
return trimmed.split(separator: ",").compactMap { raw in
let value = String(raw)
guard seen.insert(value).inserted,
let destination = SidebarDestination(rawValue: value),
self.pinnableSidebarPages.contains(destination)
else { return nil }
return destination
}
}
static func pinnedSidebarPagesStorage(_ pages: [SidebarDestination]) -> String {
pages.isEmpty ? "none" : pages.map(\.rawValue).joined(separator: ",")
}
}