mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-23 10:55:31 -06:00
2a8b322ebf
* 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.
280 lines
9.4 KiB
Swift
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: ",")
|
|
}
|
|
}
|