mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-24 11:25:50 -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.
1378 lines
58 KiB
Swift
1378 lines
58 KiB
Swift
import OpenClawKit
|
|
import OpenClawProtocol
|
|
import SwiftUI
|
|
import UIKit
|
|
|
|
struct RootTabs: View {
|
|
struct SessionObserverTaskIdentity: Equatable {
|
|
let sidebarRefreshID: String
|
|
let isSceneActive: Bool
|
|
let isSidebarVisible: Bool
|
|
|
|
var isObserverVisible: Bool {
|
|
self.isSceneActive && self.isSidebarVisible
|
|
}
|
|
}
|
|
|
|
@Environment(NodeAppModel.self) private var appModel
|
|
@Environment(VoiceWakeManager.self) private var voiceWake
|
|
@Environment(GatewayConnectionController.self) private var gatewayController
|
|
@Environment(\.accessibilityReduceMotion) private var reduceMotion
|
|
@Environment(\.displayScale) private var displayScale
|
|
@Environment(\.scenePhase) private var scenePhase
|
|
@AppStorage("screen.preventSleep") private var preventSleep: Bool = true
|
|
@AppStorage("onboarding.requestID") private var onboardingRequestID: Int = 0
|
|
@AppStorage("gateway.onboardingComplete") private var onboardingComplete: Bool = false
|
|
@AppStorage("gateway.hasConnectedOnce") private var hasConnectedOnce: Bool = false
|
|
@AppStorage("gateway.preferredStableID") private var preferredGatewayStableID: String = ""
|
|
@AppStorage("gateway.manual.enabled") private var manualGatewayEnabled: Bool = false
|
|
@AppStorage("gateway.manual.host") private var manualGatewayHost: String = ""
|
|
@AppStorage("onboarding.quickSetupDismissed") private var quickSetupDismissed: Bool = false
|
|
@AppStorage("canvas.debugStatusEnabled") private var canvasDebugStatusEnabled: Bool = false
|
|
@State private var selectedSidebarDestination: SidebarDestination = Self.initialSidebarDestination
|
|
@State private var selectedSettingsRoute: SettingsRoute? =
|
|
Self.initialSettingsRoute ?? Self.initialSidebarDestination.settingsRoute
|
|
@State private var activeSettingsRoute: SettingsRoute? =
|
|
Self.initialSettingsRoute ?? Self.initialSidebarDestination.settingsRoute
|
|
@State private var selectedSettingsRouteRequestID: Int = 0
|
|
@State private var sidebarModel = RootSidebarModel()
|
|
// Embedded Settings rows push onto the sidebar stack; clear it before
|
|
// changing sidebar roots so stale settings detail screens cannot survive.
|
|
@State private var sidebarNavigationPath: [SettingsRoute] = []
|
|
@State private var isSidebarDetailRootVisible: Bool = true
|
|
@State private var isSidebarVisible: Bool = Self.initialSidebarVisibility ?? false
|
|
@State private var sidebarVisibilityUserOverridden: Bool = Self.initialSidebarVisibility != nil
|
|
@State private var isSidebarDrawerLayout: Bool = false
|
|
@State private var didResolveSidebarLayout: Bool = false
|
|
@State private var voiceWakeToastText: String?
|
|
@State private var toastDismissGate = DelayedActionGate()
|
|
@State private var presentedSheet: PresentedSheet?
|
|
@State private var showGatewayProblemDetails: Bool = false
|
|
@State private var gatewayToastDragOffset: CGFloat = 0
|
|
// Swipe-up hides the toast only until the next problem report.
|
|
@State private var isGatewayToastSwipeDismissed: Bool = false
|
|
@State private var showOnboarding: Bool = false
|
|
@State private var onboardingAllowSkip: Bool = true
|
|
@State private var didEvaluateOnboarding: Bool = false
|
|
@State private var didAutoOpenSettings: Bool = false
|
|
@State private var didApplyInitialChatSession: Bool = false
|
|
@State private var gatewaySetupRequest: GatewaySetupRequest?
|
|
@State private var suppressedExecApprovalForNotificationSettings: NodeAppModel.ExecApprovalInboxKey?
|
|
|
|
init(initialSidebarVisibility: Bool? = nil) {
|
|
let resolvedVisibility = initialSidebarVisibility ?? Self.initialSidebarVisibility
|
|
_isSidebarVisible = State(initialValue: resolvedVisibility ?? false)
|
|
_sidebarVisibilityUserOverridden = State(initialValue: resolvedVisibility != nil)
|
|
}
|
|
|
|
private static var initialSidebarDestination: SidebarDestination {
|
|
initialDestination(arguments: ProcessInfo.processInfo.arguments)
|
|
}
|
|
|
|
private static var initialSettingsRoute: SettingsRoute? {
|
|
requestedInitialSettingsRoute(arguments: ProcessInfo.processInfo.arguments)
|
|
}
|
|
|
|
static func initialDestination(arguments: [String]) -> SidebarDestination {
|
|
if self.requestedInitialSettingsRoute(arguments: arguments) != nil {
|
|
return .settings
|
|
}
|
|
if let requested = self.requestedInitialSidebarDestination(arguments: arguments) {
|
|
return requested
|
|
}
|
|
guard let flagIndex = arguments.firstIndex(of: "--openclaw-initial-tab") else { return .chat }
|
|
let valueIndex = arguments.index(after: flagIndex)
|
|
guard arguments.indices.contains(valueIndex) else { return .chat }
|
|
return switch arguments[valueIndex].trimmingCharacters(in: .whitespacesAndNewlines).lowercased() {
|
|
case "control", "overview": .overview
|
|
case "chat", "talk", "voice": .chat
|
|
case "agent", "agents": .agents
|
|
case "settings": .settings
|
|
default: .chat
|
|
}
|
|
}
|
|
|
|
static func requestedInitialSettingsRoute(arguments: [String]) -> SettingsRoute? {
|
|
guard let flagIndex = arguments.firstIndex(of: "--openclaw-settings-route") else {
|
|
return nil
|
|
}
|
|
let valueIndex = arguments.index(after: flagIndex)
|
|
guard arguments.indices.contains(valueIndex) else { return nil }
|
|
return switch arguments[valueIndex].trimmingCharacters(in: .whitespacesAndNewlines).lowercased() {
|
|
case "openclaw", "system-agent": .systemAgent
|
|
default: nil
|
|
}
|
|
}
|
|
|
|
static func requestedInitialSidebarDestination(arguments: [String]) -> SidebarDestination? {
|
|
guard let flagIndex = arguments.firstIndex(of: "--openclaw-initial-destination") else {
|
|
return nil
|
|
}
|
|
let valueIndex = arguments.index(after: flagIndex)
|
|
guard arguments.indices.contains(valueIndex) else { return nil }
|
|
let requested = arguments[valueIndex].trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
|
return SidebarDestination.allCases.first { $0.rawValue.lowercased() == requested }
|
|
}
|
|
|
|
private static var initialSidebarVisibility: Bool? {
|
|
requestedInitialSidebarVisibility(arguments: ProcessInfo.processInfo.arguments)
|
|
}
|
|
|
|
private static var initialChatSessionKey: String? {
|
|
let arguments = ProcessInfo.processInfo.arguments
|
|
guard let flagIndex = arguments.firstIndex(of: "--openclaw-chat-session") else {
|
|
return nil
|
|
}
|
|
let valueIndex = arguments.index(after: flagIndex)
|
|
guard arguments.indices.contains(valueIndex) else { return nil }
|
|
let trimmed = arguments[valueIndex].trimmingCharacters(in: .whitespacesAndNewlines)
|
|
return trimmed.isEmpty ? nil : trimmed
|
|
}
|
|
|
|
private enum PresentedSheet: Identifiable {
|
|
case quickSetup
|
|
|
|
var id: Int {
|
|
switch self {
|
|
case .quickSetup: 0
|
|
}
|
|
}
|
|
}
|
|
|
|
var body: some View {
|
|
self.rootPresentation(
|
|
self.rootLifecycle(
|
|
self.rootOverlays(
|
|
self.sidebarSplitContent
|
|
.tint(OpenClawBrand.accent))))
|
|
.overlay(alignment: .topLeading) {
|
|
self.uiTestReadinessMarker
|
|
}
|
|
}
|
|
|
|
@ViewBuilder
|
|
private var uiTestReadinessMarker: some View {
|
|
#if DEBUG
|
|
if ProcessInfo.processInfo.arguments.contains("--openclaw-ui-test-readiness") {
|
|
Color.clear
|
|
.frame(width: 1, height: 1)
|
|
.allowsHitTesting(false)
|
|
.accessibilityElement(children: .ignore)
|
|
.accessibilityIdentifier("RootTabs.Ready")
|
|
.accessibilityLabel(Text(verbatim: "OpenClaw test readiness"))
|
|
.accessibilityValue(
|
|
"\(self.scenePhase == .active ? "ready" : "inactive"):\(self.selectedSidebarDestination.rawValue)")
|
|
}
|
|
#endif
|
|
}
|
|
|
|
private var sidebarSplitContent: some View {
|
|
GeometryReader { proxy in
|
|
// Keyboard safe-area changes must not masquerade as window/orientation changes;
|
|
// switching layouts destroys the focused detail subtree.
|
|
let layoutContainerSize = Self.sidebarLayoutContainerSize(
|
|
contentSize: proxy.size,
|
|
windowSize: self.foregroundKeyWindowSize())
|
|
let isDrawerLayout = self.shouldUseSidebarDrawer(containerSize: layoutContainerSize)
|
|
let sidebarWidth = self.sidebarWidth(
|
|
containerWidth: layoutContainerSize.width,
|
|
isDrawerLayout: isDrawerLayout)
|
|
Group {
|
|
if isDrawerLayout {
|
|
self.sidebarDrawerContent(
|
|
sidebarWidth: sidebarWidth,
|
|
safeAreaInsets: proxy.safeAreaInsets)
|
|
} else {
|
|
self.sidebarNavigationSplitContent(sidebarWidth: sidebarWidth)
|
|
}
|
|
}
|
|
.onAppear {
|
|
self.updateSidebarLayout(containerSize: layoutContainerSize, force: false)
|
|
}
|
|
.onChange(of: proxy.size) { _, size in
|
|
let layoutContainerSize = Self.sidebarLayoutContainerSize(
|
|
contentSize: size,
|
|
windowSize: self.foregroundKeyWindowSize())
|
|
self.updateSidebarLayout(containerSize: layoutContainerSize, force: false)
|
|
}
|
|
// Single refresh owner: identity/session changes, scene activation,
|
|
// and the periodic attention refresh all land here.
|
|
.task(id: self.sidebarRefreshID) {
|
|
guard self.scenePhase == .active else { return }
|
|
await self.sidebarModel.refresh(appModel: self.appModel)
|
|
while !Task.isCancelled {
|
|
try? await Task.sleep(for: .seconds(600))
|
|
guard !Task.isCancelled else { return }
|
|
await self.sidebarModel.refresh(appModel: self.appModel)
|
|
}
|
|
}
|
|
.task(id: "\(self.sidebarRefreshID):events") {
|
|
guard self.scenePhase == .active else { return }
|
|
await self.sidebarModel.observeSessionEvents(appModel: self.appModel)
|
|
}
|
|
.task(id: self.sessionObserverTaskIdentity) {
|
|
await self.sidebarModel.setSessionObserverVisibility(
|
|
appModel: self.appModel,
|
|
visible: self.sessionObserverTaskIdentity.isObserverVisible)
|
|
}
|
|
}
|
|
}
|
|
|
|
private var sessionObserverTaskIdentity: SessionObserverTaskIdentity {
|
|
SessionObserverTaskIdentity(
|
|
sidebarRefreshID: self.sidebarRefreshID,
|
|
isSceneActive: self.scenePhase == .active,
|
|
isSidebarVisible: self.isSidebarVisible)
|
|
}
|
|
|
|
private var sidebarRefreshID: String {
|
|
[
|
|
self.appModel.chatViewModelIdentityID,
|
|
self.appModel.chatSessionKey,
|
|
self.scenePhase == .active ? "active" : "inactive",
|
|
].joined(separator: ":")
|
|
}
|
|
|
|
private func sidebarNavigationSplitContent(sidebarWidth: CGFloat) -> some View {
|
|
HStack(spacing: 0) {
|
|
if self.isSidebarVisible {
|
|
self.sidebarColumn()
|
|
.frame(width: sidebarWidth, alignment: .topLeading)
|
|
.frame(maxHeight: .infinity, alignment: .topLeading)
|
|
.overlay(alignment: .trailing) {
|
|
self.sidebarVerticalSeparator
|
|
}
|
|
.transition(self.sidebarTransition)
|
|
}
|
|
|
|
self.sidebarDetailNavigationShell
|
|
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
|
}
|
|
.background(OpenClawProBackground())
|
|
.animation(self.sidebarAnimation, value: self.isSidebarVisible)
|
|
}
|
|
|
|
private func sidebarDrawerContent(
|
|
sidebarWidth: CGFloat,
|
|
safeAreaInsets: EdgeInsets) -> some View
|
|
{
|
|
RootSidebarDrawer(
|
|
sidebarWidth: sidebarWidth,
|
|
isPresented: self.isSidebarVisible,
|
|
canOpenFromEdge: self.isSidebarDetailRootVisible && self.sidebarNavigationPath.isEmpty,
|
|
reduceMotion: self.reduceMotion,
|
|
animation: self.sidebarAnimation,
|
|
onShow: self.showSidebar,
|
|
onHide: self.hideSidebar,
|
|
sidebar: self.sidebarColumn(drawerSafeAreaInsets: safeAreaInsets),
|
|
detail: self.sidebarDetailNavigationShell)
|
|
}
|
|
|
|
private var sidebarDetailShell: some View {
|
|
let shellID = self.sidebarDetailShellID
|
|
return self.sidebarDetail
|
|
.id(shellID)
|
|
// RootTabs disables destination-owned stacks at its call sites. A
|
|
// destination-style NavigationLink therefore replaces this shared
|
|
// root, so visibility guards its native back-swipe without relying
|
|
// on the typed Settings path.
|
|
.onAppear {
|
|
guard self.sidebarDetailShellID == shellID else { return }
|
|
self.isSidebarDetailRootVisible = true
|
|
}
|
|
.onDisappear {
|
|
guard self.sidebarDetailShellID == shellID else { return }
|
|
self.isSidebarDetailRootVisible = false
|
|
}
|
|
}
|
|
|
|
/// RootSidebar owns its dark surface; this wrapper only restores vertical
|
|
/// insets. Drawer mode goes full-bleed (ignoresSafeArea) so the captured
|
|
/// insets are re-applied manually; split mode keeps system safe areas.
|
|
private func sidebarColumn(drawerSafeAreaInsets: EdgeInsets? = nil) -> some View {
|
|
RootSidebar(
|
|
model: self.sidebarModel,
|
|
selectedDestination: self.selectedSidebarDestination,
|
|
isDrawerLayout: self.isSidebarDrawerLayout,
|
|
isDismissButtonEnabled: self.isSidebarVisible,
|
|
selectDestination: self.selectSidebarDestination,
|
|
hideSidebar: self.hideSidebar)
|
|
.padding(.top, drawerSafeAreaInsets.map { $0.top + 8 } ?? 0)
|
|
.padding(.bottom, drawerSafeAreaInsets.map { $0.bottom + 8 } ?? 0)
|
|
.safeAreaPadding(.top, drawerSafeAreaInsets == nil ? 8 : 0)
|
|
.safeAreaPadding(.bottom, drawerSafeAreaInsets == nil ? 8 : 0)
|
|
// Paints the wrapper's inset strips; RootSidebar's own background
|
|
// stops at its bounds.
|
|
.background(OpenClawSidebarPalette.background)
|
|
}
|
|
|
|
private var sidebarVerticalSeparator: some View {
|
|
Rectangle()
|
|
.fill(OpenClawSidebarPalette.hairline)
|
|
.frame(width: 1 / self.displayScale)
|
|
}
|
|
|
|
@ViewBuilder
|
|
private var sidebarDetail: some View {
|
|
switch self.selectedSidebarDestination {
|
|
case .chat:
|
|
// Agent identity pill owns the chat header (prototype parity).
|
|
ChatProTab(
|
|
headerSidebarAction: self.sidebarHeaderAction,
|
|
ownsNavigationStack: false,
|
|
openSettings: { self.selectSidebarDestination(.gateway) })
|
|
case .overview:
|
|
CommandCenterTab(
|
|
ownsNavigationStack: false,
|
|
headerTitle: "Overview",
|
|
headerSidebarAction: self.sidebarHeaderAction,
|
|
dashboardModel: self.sidebarModel,
|
|
showsHeaderMark: false,
|
|
openChat: { self.selectSidebarDestination(.chat) },
|
|
openSettings: { self.selectSidebarDestination(.gateway) },
|
|
openSessions: { self.selectSidebarDestination(.sessions) },
|
|
openApprovals: { self.selectSettingsRoute(.approvals) },
|
|
openAutomations: { self.selectSidebarDestination(.cron) },
|
|
openUsage: { self.selectSidebarDestination(.usage) })
|
|
case .activity:
|
|
IPadActivityScreen(
|
|
headerSidebarAction: self.sidebarHeaderAction,
|
|
openChat: { self.selectSidebarDestination(.chat) },
|
|
openSettings: { self.selectSidebarDestination(.gateway) })
|
|
case .workboard:
|
|
IPadWorkboardScreen(
|
|
headerSidebarAction: self.sidebarHeaderAction,
|
|
openChat: { self.selectSidebarDestination(.chat) },
|
|
openSettings: { self.selectSidebarDestination(.gateway) })
|
|
case .skillWorkshop:
|
|
IPadSkillWorkshopScreen(
|
|
headerSidebarAction: self.sidebarHeaderAction,
|
|
openSettings: { self.selectSidebarDestination(.gateway) })
|
|
case .agents:
|
|
AgentProTab(
|
|
directRoute: .agents,
|
|
headerSidebarAction: self.sidebarHeaderAction,
|
|
headerTitle: "Agents",
|
|
openSettings: { self.selectSidebarDestination(.gateway) })
|
|
.id(self.selectedSidebarDestination.id)
|
|
case .instances:
|
|
AgentProTab(
|
|
directRoute: .instances,
|
|
headerSidebarAction: self.sidebarHeaderAction,
|
|
headerTitle: "Instances",
|
|
openSettings: { self.selectSidebarDestination(.gateway) })
|
|
.id(self.selectedSidebarDestination.id)
|
|
case .sessions:
|
|
CommandSessionsScreen(
|
|
headerSidebarAction: self.sidebarHeaderAction,
|
|
openChat: { self.selectSidebarDestination(.chat) })
|
|
case .files:
|
|
AgentProTab(
|
|
directRoute: .files,
|
|
headerSidebarAction: self.sidebarHeaderAction,
|
|
headerTitle: "Files",
|
|
openSettings: { self.selectSidebarDestination(.gateway) })
|
|
.id(self.selectedSidebarDestination.id)
|
|
case .dreaming:
|
|
AgentProTab(
|
|
directRoute: .dreaming,
|
|
headerSidebarAction: self.sidebarHeaderAction,
|
|
headerTitle: "Dreaming",
|
|
openSettings: { self.selectSidebarDestination(.gateway) })
|
|
.id(self.selectedSidebarDestination.id)
|
|
case .usage:
|
|
AgentProTab(
|
|
directRoute: .usage,
|
|
headerSidebarAction: self.sidebarHeaderAction,
|
|
headerTitle: "Usage",
|
|
openSettings: { self.selectSidebarDestination(.gateway) })
|
|
.id(self.selectedSidebarDestination.id)
|
|
case .cron:
|
|
AgentProTab(
|
|
directRoute: .cron,
|
|
headerSidebarAction: self.sidebarHeaderAction,
|
|
headerTitle: "Automations",
|
|
openSettings: { self.selectSidebarDestination(.gateway) })
|
|
.id(self.selectedSidebarDestination.id)
|
|
case .desktop:
|
|
DesktopHubScreen(
|
|
headerSidebarAction: self.sidebarHeaderAction,
|
|
gatewayAction: { self.selectSidebarDestination(.gateway) })
|
|
case .terminal:
|
|
TerminalHubScreen(
|
|
headerSidebarAction: self.sidebarHeaderAction,
|
|
gatewayAction: { self.selectSidebarDestination(.gateway) })
|
|
case .docs:
|
|
OpenClawDocsScreen(
|
|
headerSidebarAction: self.sidebarHeaderAction,
|
|
gatewayAction: { self.selectSidebarDestination(.gateway) })
|
|
case .settings:
|
|
if let selectedSettingsRoute {
|
|
SettingsProTab(
|
|
directRoute: selectedSettingsRoute,
|
|
headerSidebarAction: self.sidebarHeaderAction,
|
|
ownsNavigationStack: false,
|
|
navigateToRoute: pushSidebarSettingsRoute,
|
|
onRouteChange: handleSettingsRouteChange,
|
|
onApprovalNotificationsRoute: suppressExecApprovalPromptForNotificationSettings,
|
|
gatewaySetupRequest: self.gatewaySetupRequest,
|
|
onGatewaySetupRequestHandled: handleGatewaySetupRequest)
|
|
} else {
|
|
SettingsProTab(
|
|
headerSidebarAction: self.sidebarHeaderAction,
|
|
ownsNavigationStack: false,
|
|
navigateToRoute: pushSidebarSettingsRoute,
|
|
onRouteChange: handleSettingsRouteChange,
|
|
onApprovalNotificationsRoute: suppressExecApprovalPromptForNotificationSettings,
|
|
gatewaySetupRequest: self.gatewaySetupRequest,
|
|
onGatewaySetupRequestHandled: handleGatewaySetupRequest)
|
|
}
|
|
case .gateway:
|
|
SettingsProTab(
|
|
directRoute: self.selectedSettingsRoute ?? self.selectedSidebarDestination.settingsRoute ?? .gateway,
|
|
acceptsGatewaySetupRequests: !self.showOnboarding,
|
|
headerSidebarAction: self.sidebarHeaderAction,
|
|
ownsNavigationStack: false,
|
|
navigateToRoute: pushSidebarSettingsRoute,
|
|
onRouteChange: handleSettingsRouteChange,
|
|
onApprovalNotificationsRoute: suppressExecApprovalPromptForNotificationSettings,
|
|
gatewaySetupRequest: self.gatewaySetupRequest,
|
|
onGatewaySetupRequestHandled: handleGatewaySetupRequest)
|
|
}
|
|
}
|
|
|
|
private var sidebarDetailNavigationShell: some View {
|
|
NavigationStack(path: self.$sidebarNavigationPath) {
|
|
self.sidebarDetailShell
|
|
}
|
|
.onChange(of: self.sidebarNavigationPath) { _, navigationPath in
|
|
self.handleSidebarSettingsNavigationPathChange(navigationPath)
|
|
}
|
|
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
|
}
|
|
|
|
private var sidebarDetailShellID: String {
|
|
let routeID = self.selectedSettingsRoute.map { "\($0)" } ?? "root"
|
|
return "\(self.selectedSidebarDestination.id):\(routeID):\(self.selectedSettingsRouteRequestID)"
|
|
}
|
|
|
|
private var activeExecApprovalPromptSuppression: NodeAppModel.ExecApprovalInboxKey? {
|
|
guard self.selectedSidebarDestination == .settings || self.selectedSidebarDestination == .gateway else {
|
|
return nil
|
|
}
|
|
switch self.activeSettingsRoute {
|
|
case .approvals:
|
|
return NodeAppModel.execApprovalInboxKey(self.appModel.pendingExecApprovalPrompt)
|
|
case .notifications:
|
|
return self.suppressedExecApprovalForNotificationSettings
|
|
default:
|
|
return nil
|
|
}
|
|
}
|
|
|
|
private var shouldCollapseSidebarAfterSelection: Bool {
|
|
Self.shouldCollapseSidebarAfterSelection(
|
|
layoutMode: self.isSidebarDrawerLayout ? .drawer : .split)
|
|
}
|
|
|
|
private var sidebarHeaderAction: OpenClawSidebarHeaderAction? {
|
|
guard Self.shouldShowSidebarRevealInDestinationHeader(
|
|
isSidebarVisible: self.isSidebarVisible,
|
|
layoutMode: self.isSidebarDrawerLayout ? .drawer : .split)
|
|
else {
|
|
return nil
|
|
}
|
|
if self.isSidebarVisible {
|
|
return OpenClawSidebarHeaderAction(
|
|
systemName: "line.3.horizontal",
|
|
accessibilityLabel: .localized("Hide Sidebar"),
|
|
accessibilityIdentifier: Self.sidebarHideButtonAccessibilityIdentifier,
|
|
action: { self.hideSidebar() })
|
|
}
|
|
return OpenClawSidebarHeaderAction(
|
|
systemName: "line.3.horizontal",
|
|
accessibilityLabel: .localized("Show Sidebar"),
|
|
accessibilityIdentifier: Self.sidebarShowButtonAccessibilityIdentifier,
|
|
action: { self.showSidebar() })
|
|
}
|
|
|
|
private var sidebarAnimation: Animation? {
|
|
self.reduceMotion ? .easeOut(duration: 0.16) : .spring(response: 0.35, dampingFraction: 0.86)
|
|
}
|
|
|
|
private var sidebarTransition: AnyTransition {
|
|
self.reduceMotion ? .opacity : .move(edge: .leading).combined(with: .opacity)
|
|
}
|
|
|
|
private func shouldUseSidebarDrawer(containerSize: CGSize) -> Bool {
|
|
Self.sidebarLayoutMode(containerSize: containerSize) == .drawer
|
|
}
|
|
|
|
private func sidebarWidth(containerWidth: CGFloat, isDrawerLayout: Bool) -> CGFloat {
|
|
Self.sidebarWidth(containerWidth: containerWidth, isDrawerLayout: isDrawerLayout)
|
|
}
|
|
|
|
private func foregroundKeyWindowSize() -> CGSize? {
|
|
UIApplication.shared.connectedScenes
|
|
.compactMap { $0 as? UIWindowScene }
|
|
.first(where: { $0.activationState == .foregroundActive })?
|
|
.windows
|
|
.first(where: \.isKeyWindow)?
|
|
.bounds.size
|
|
}
|
|
|
|
private func rootOverlays(_ content: some View) -> some View {
|
|
content
|
|
.overlay(alignment: .top) {
|
|
// Stable container so the toast's move/opacity transition animates
|
|
// when the gateway problem appears or clears outside withAnimation.
|
|
ZStack(alignment: .top) {
|
|
if let gatewayProblem = self.activeGatewayProblemToast {
|
|
self.gatewayProblemToast(gatewayProblem)
|
|
}
|
|
}
|
|
.animation(self.gatewayToastAnimation, value: self.activeGatewayProblemToast)
|
|
}
|
|
.overlay(alignment: .topLeading) {
|
|
if let voiceWakeToastText, !voiceWakeToastText.isEmpty {
|
|
VoiceWakeToast(command: voiceWakeToastText)
|
|
.padding(.leading, 10)
|
|
.safeAreaPadding(.top, self.activeGatewayProblemToast == nil ? 58 : 132)
|
|
.transition(.move(edge: .top).combined(with: .opacity))
|
|
}
|
|
}
|
|
|
|
.overlay {
|
|
// Keep the observer mounted so the first 0 -> 1 capture transition
|
|
// flashes without treating a later remount as a new capture.
|
|
RootCameraFlashOverlay(nonce: self.appModel.cameraFlashNonce)
|
|
}
|
|
.overlay {
|
|
if self.appModel.screen.isCanvasPresented {
|
|
self.canvasPresentationOverlay
|
|
.transition(.opacity)
|
|
.zIndex(20)
|
|
}
|
|
}
|
|
}
|
|
|
|
private var activeGatewayProblemToast: GatewayConnectionProblem? {
|
|
// Operator-scope auth/pairing failures can coexist with a connected node.
|
|
// The problem itself, not aggregate gateway status, owns toast visibility.
|
|
guard let problem = appModel.lastGatewayProblem,
|
|
!self.isGatewayToastSwipeDismissed
|
|
else { return nil }
|
|
return problem
|
|
}
|
|
|
|
private var gatewayToastAnimation: Animation? {
|
|
self.reduceMotion ? nil : .spring(response: 0.35, dampingFraction: 0.85)
|
|
}
|
|
|
|
private func gatewayProblemToast(_ problem: GatewayConnectionProblem) -> some View {
|
|
GatewayProblemBanner(
|
|
problem: problem,
|
|
primaryActionTitle: gatewayProblemPrimaryActionTitle(problem),
|
|
onPrimaryAction: {
|
|
self.handleGatewayProblemPrimaryAction(problem)
|
|
},
|
|
onShowDetails: {
|
|
self.showGatewayProblemDetails = true
|
|
})
|
|
.padding(.horizontal, 12)
|
|
.safeAreaPadding(.top, 10)
|
|
.offset(y: min(self.gatewayToastDragOffset, 0))
|
|
.gesture(self.gatewayToastSwipeGesture)
|
|
// A drag cancelled by toast removal never fires onEnded; clear the
|
|
// offset so the next toast doesn't render shifted up.
|
|
.onDisappear { self.gatewayToastDragOffset = 0 }
|
|
.transition(.move(edge: .top).combined(with: .opacity))
|
|
}
|
|
|
|
private var gatewayToastSwipeGesture: some Gesture {
|
|
DragGesture(minimumDistance: 12)
|
|
.onChanged { value in
|
|
self.gatewayToastDragOffset = value.translation.height
|
|
}
|
|
.onEnded { value in
|
|
let swipedUp = value.translation.height < -32 || value.predictedEndTranslation.height < -80
|
|
withAnimation(self.gatewayToastAnimation) {
|
|
if swipedUp {
|
|
self.isGatewayToastSwipeDismissed = true
|
|
}
|
|
self.gatewayToastDragOffset = 0
|
|
}
|
|
}
|
|
}
|
|
|
|
private func handleGatewayProblemReport() {
|
|
guard self.isGatewayToastSwipeDismissed else { return }
|
|
self.isGatewayToastSwipeDismissed = false
|
|
}
|
|
|
|
private var canvasPresentationOverlay: some View {
|
|
ZStack(alignment: .topTrailing) {
|
|
Color.black.ignoresSafeArea()
|
|
ScreenWebView(controller: self.appModel.screen)
|
|
.ignoresSafeArea()
|
|
Button {
|
|
self.appModel.screen.hideCanvas()
|
|
} label: {
|
|
Image(systemName: "xmark.circle.fill")
|
|
.font(.system(size: 30, weight: .semibold))
|
|
.symbolRenderingMode(.hierarchical)
|
|
.foregroundStyle(.white)
|
|
.shadow(color: .black.opacity(0.32), radius: 8, y: 2)
|
|
.frame(width: 48, height: 48)
|
|
.contentShape(Rectangle())
|
|
}
|
|
.buttonStyle(.plain)
|
|
.accessibilityLabel("Close canvas")
|
|
.safeAreaPadding(.top, 8)
|
|
.padding(.trailing, 12)
|
|
}
|
|
}
|
|
|
|
private func rootLifecycle(_ content: some View) -> some View {
|
|
self.rootRequestLifecycle(
|
|
self.rootGatewayLifecycle(
|
|
self.rootAppearLifecycle(
|
|
self.rootVoiceWakeLifecycle(content))))
|
|
}
|
|
|
|
private func rootVoiceWakeLifecycle(_ content: some View) -> some View {
|
|
content
|
|
.onChange(of: self.voiceWake.lastTriggeredCommand) { _, newValue in
|
|
guard let newValue else { return }
|
|
let trimmed = newValue.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
guard !trimmed.isEmpty else { return }
|
|
|
|
withAnimation(self.reduceMotion ? .none : .spring(response: 0.25, dampingFraction: 0.85)) {
|
|
self.voiceWakeToastText = trimmed
|
|
}
|
|
|
|
self.toastDismissGate.schedule(after: .milliseconds(2300)) {
|
|
withAnimation(self.reduceMotion ? .none : .easeOut(duration: 0.25)) {
|
|
self.voiceWakeToastText = nil
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private func rootAppearLifecycle(_ content: some View) -> some View {
|
|
content
|
|
.onAppear { self.updateIdleTimer() }
|
|
.onAppear { self.updateCanvasState() }
|
|
.onAppear { self.evaluateOnboardingPresentation(force: false) }
|
|
.onAppear { self.maybeAutoOpenSettings() }
|
|
.onAppear { self.maybeOpenSettingsForGatewaySetup() }
|
|
.onAppear { self.maybeShowQuickSetup() }
|
|
.onAppear { self.applyInitialChatSessionIfNeeded() }
|
|
.onChange(of: self.preventSleep) { _, _ in self.updateIdleTimer() }
|
|
.onChange(of: self.appModel.talkMode.isEnabled) { _, _ in self.updateIdleTimer() }
|
|
.onChange(of: self.scenePhase) { _, newValue in
|
|
self.updateIdleTimer()
|
|
self.updateHomeCanvasState()
|
|
guard newValue == .active else {
|
|
self.clearVoiceWakeToast()
|
|
return
|
|
}
|
|
self.maybeRequestLocalNetworkAccess(reason: "scene_active")
|
|
Task {
|
|
await self.appModel.refreshGatewayOverviewIfConnected()
|
|
await MainActor.run {
|
|
self.updateHomeCanvasState()
|
|
}
|
|
}
|
|
}
|
|
.onDisappear {
|
|
UIApplication.shared.isIdleTimerDisabled = false
|
|
self.clearVoiceWakeToast()
|
|
}
|
|
}
|
|
|
|
private func clearVoiceWakeToast() {
|
|
self.voiceWakeToastText = nil
|
|
self.toastDismissGate.cancel()
|
|
}
|
|
|
|
private func rootGatewayProblemLifecycle(_ content: some View) -> some View {
|
|
content
|
|
.onChange(of: self.appModel.lastGatewayProblem) { _, newValue in
|
|
if newValue == nil {
|
|
self.isGatewayToastSwipeDismissed = false
|
|
}
|
|
}
|
|
.onChange(of: self.appModel.gatewayProblemReportCount) { _, _ in
|
|
self.handleGatewayProblemReport()
|
|
}
|
|
}
|
|
|
|
private func rootGatewayLifecycle(_ content: some View) -> some View {
|
|
self.rootGatewayProblemLifecycle(content)
|
|
.onChange(of: self.canvasDebugStatusEnabled) { _, _ in self.updateCanvasDebugStatus() }
|
|
.onChange(of: self.gatewayController.gateways.count) { _, _ in self.maybeShowQuickSetup() }
|
|
.onChange(of: self.appModel.gatewayServerName) { _, newValue in
|
|
if newValue != nil {
|
|
self.onboardingComplete = true
|
|
self.hasConnectedOnce = true
|
|
OnboardingStateStore.markCompleted(mode: nil)
|
|
}
|
|
self.maybeAutoOpenSettings()
|
|
self.maybeShowQuickSetup()
|
|
self.updateCanvasState()
|
|
}
|
|
.onChange(of: self.appModel.gatewayStatusText) { _, _ in self.updateCanvasState() }
|
|
.onChange(of: self.appModel.gatewayRemoteAddress) { _, _ in self.updateCanvasState() }
|
|
.onChange(of: self.appModel.gatewayDisplayStatusText) { _, _ in self.updateCanvasState() }
|
|
.onChange(of: self.appModel.homeCanvasRevision) { _, _ in self.updateHomeCanvasState() }
|
|
.onChange(of: self.appModel.gatewayAgents.count) { _, _ in self.updateHomeCanvasState() }
|
|
.onChange(of: self.appModel.selectedAgentId) { _, _ in self.updateHomeCanvasState() }
|
|
.onChange(of: self.appModel.gatewayDefaultAgentId) { _, _ in self.updateHomeCanvasState() }
|
|
.onChange(of: self.appModel.activeAgentName) { _, _ in self.updateHomeCanvasState() }
|
|
.onChange(of: self.appModel.connectedGatewayID) { _, _ in
|
|
self.updateCanvasState()
|
|
}
|
|
}
|
|
|
|
private func rootRequestLifecycle(_ content: some View) -> some View {
|
|
content
|
|
.onAppear {
|
|
self.handleDashboardNavigationRequest(self.appModel.dashboardNavigationRequestID)
|
|
}
|
|
.onChange(of: self.onboardingRequestID) { _, _ in
|
|
self.evaluateOnboardingPresentation(force: true)
|
|
}
|
|
.onChange(of: self.showOnboarding) { _, newValue in
|
|
guard !newValue else { return }
|
|
self.maybeRequestLocalNetworkAccess(reason: "onboarding_dismissed")
|
|
}
|
|
.onChange(of: self.appModel.openChatRequestID) { _, newValue in
|
|
self.handleOpenChatRequest(newValue)
|
|
}
|
|
.onChange(of: self.appModel.dashboardNavigationRequestID) { _, requestID in
|
|
self.handleDashboardNavigationRequest(requestID)
|
|
}
|
|
.onChange(of: self.appModel.gatewaySetupRequestID) { _, _ in
|
|
self.maybeOpenSettingsForGatewaySetup()
|
|
}
|
|
.onChange(of: NodeAppModel.execApprovalInboxKey(self.appModel.pendingExecApprovalPrompt)) { _, newValue in
|
|
if newValue != self.suppressedExecApprovalForNotificationSettings {
|
|
self.suppressedExecApprovalForNotificationSettings = nil
|
|
}
|
|
}
|
|
}
|
|
|
|
private func handleDashboardNavigationRequest(_ requestID: Int) {
|
|
guard self.appModel.consumeDashboardNavigationRequest(requestID) else { return }
|
|
self.selectSidebarDestination(.overview)
|
|
}
|
|
|
|
private func rootPresentation(_ content: some View) -> some View {
|
|
content
|
|
.sheet(isPresented: self.$showGatewayProblemDetails) {
|
|
if let gatewayProblem = self.appModel.lastGatewayProblem {
|
|
GatewayProblemDetailsSheet(
|
|
problem: gatewayProblem,
|
|
primaryActionTitle: self.gatewayProblemPrimaryActionTitle(gatewayProblem),
|
|
onPrimaryAction: {
|
|
self.handleGatewayProblemPrimaryAction(gatewayProblem)
|
|
})
|
|
}
|
|
}
|
|
.sheet(item: self.$presentedSheet) { sheet in
|
|
switch sheet {
|
|
case .quickSetup:
|
|
GatewayQuickSetupSheet(onUseManualSetup: {
|
|
self.presentedSheet = nil
|
|
self.selectSettingsRoute(.gateway)
|
|
})
|
|
.environment(self.appModel)
|
|
.environment(self.gatewayController)
|
|
.openClawSheetChrome()
|
|
}
|
|
}
|
|
.fullScreenCover(isPresented: self.$showOnboarding) {
|
|
OnboardingWizardView(
|
|
allowSkip: self.onboardingAllowSkip,
|
|
onRequestLocalNetworkAccess: { reason in
|
|
self.requestLocalNetworkAccess(reason: reason)
|
|
},
|
|
onClose: {
|
|
self.showOnboarding = false
|
|
},
|
|
onComplete: {
|
|
self.showOnboarding = false
|
|
self.selectSidebarDestination(.chat)
|
|
})
|
|
.environment(self.appModel)
|
|
.environment(self.voiceWake)
|
|
.environment(self.gatewayController)
|
|
}
|
|
.gatewayTrustPromptAlert(isEnabled: !self.showOnboarding)
|
|
.deepLinkAgentPromptAlert()
|
|
.execApprovalPromptDialog(
|
|
suppressedApproval: self.activeExecApprovalPromptSuppression)
|
|
.notificationPermissionGuidanceDialog(openNotifications: { approvalId in
|
|
self.suppressExecApprovalPromptForNotificationSettings(approvalId)
|
|
self.selectSettingsRoute(.notifications)
|
|
})
|
|
}
|
|
|
|
private var gatewayStatus: GatewayDisplayState {
|
|
GatewayStatusBuilder.build(appModel: self.appModel)
|
|
}
|
|
|
|
private func updateIdleTimer() {
|
|
UIApplication.shared.isIdleTimerDisabled =
|
|
self.scenePhase == .active && (self.preventSleep || self.appModel.talkMode.isEnabled)
|
|
}
|
|
}
|
|
|
|
extension RootTabs {
|
|
private func updateCanvasState() {
|
|
self.updateHomeCanvasState()
|
|
self.updateCanvasDebugStatus()
|
|
}
|
|
|
|
private func updateCanvasDebugStatus() {
|
|
self.appModel.screen.setDebugStatusEnabled(self.canvasDebugStatusEnabled)
|
|
guard self.canvasDebugStatusEnabled else { return }
|
|
let title = self.appModel.gatewayDisplayStatusText.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
let subtitle = self.appModel.gatewayServerName ?? self.appModel.gatewayRemoteAddress
|
|
self.appModel.screen.updateDebugStatus(title: title, subtitle: subtitle)
|
|
}
|
|
|
|
private func updateHomeCanvasState() {
|
|
let payload = self.makeHomeCanvasPayload()
|
|
guard let data = try? JSONEncoder().encode(payload),
|
|
let json = String(data: data, encoding: .utf8)
|
|
else {
|
|
self.appModel.screen.updateHomeCanvasState(json: nil)
|
|
return
|
|
}
|
|
self.appModel.screen.updateHomeCanvasState(json: json)
|
|
}
|
|
|
|
private func makeHomeCanvasPayload() -> RootTabsHomeCanvasPayload {
|
|
let gatewayName = normalized(appModel.gatewayServerName)
|
|
let gatewayAddress = normalized(appModel.gatewayRemoteAddress)
|
|
let gatewayLabel = gatewayName ?? gatewayAddress ?? "Gateway"
|
|
let activeAgentID = self.resolveActiveAgentID()
|
|
let agents = self.homeCanvasAgents(activeAgentID: activeAgentID)
|
|
|
|
switch self.gatewayStatus {
|
|
case .connected:
|
|
return RootTabsHomeCanvasPayload(
|
|
gatewayState: "connected",
|
|
eyebrow: "\(gatewayLabel) online",
|
|
title: "Command center",
|
|
subtitle:
|
|
"Use Chat for code work or realtime voice, plus gateway tools for approved device actions.",
|
|
gatewayLabel: gatewayLabel,
|
|
activeAgentName: self.appModel.activeAgentName,
|
|
activeAgentBadge: agents.first(where: { $0.isActive })?.badge ?? "OC",
|
|
activeAgentCaption: "Routes chat and voice",
|
|
agentCount: agents.count,
|
|
agents: Array(agents.prefix(6)),
|
|
footer: "OpenClaw only runs phone-side capabilities while the app is connected and permitted.")
|
|
case .connecting:
|
|
return RootTabsHomeCanvasPayload(
|
|
gatewayState: "connecting",
|
|
eyebrow: "Gateway handshake",
|
|
title: "Reconnecting",
|
|
subtitle:
|
|
"Restoring the local node session, agent list, voice config, and device capability state.",
|
|
gatewayLabel: gatewayLabel,
|
|
activeAgentName: self.appModel.activeAgentName,
|
|
activeAgentBadge: "OC",
|
|
activeAgentCaption: "Session in progress",
|
|
agentCount: agents.count,
|
|
agents: Array(agents.prefix(4)),
|
|
footer: "If the gateway is reachable, the local node should recover without re-pairing.")
|
|
case .error, .disconnected:
|
|
return RootTabsHomeCanvasPayload(
|
|
gatewayState: self.gatewayStatus == .error ? "error" : "offline",
|
|
eyebrow: self.gatewayStatus == .error ? "Gateway needs attention" : "OpenClaw iOS",
|
|
title: "Pair a gateway",
|
|
subtitle:
|
|
"Connect this phone as a local node for chat, realtime voice, share intake, and approved device tools.",
|
|
gatewayLabel: gatewayLabel,
|
|
activeAgentName: "Main",
|
|
activeAgentBadge: "OC",
|
|
activeAgentCaption: "Connect to load your agents",
|
|
agentCount: agents.count,
|
|
agents: Array(agents.prefix(4)),
|
|
footer:
|
|
"Use Settings to scan a pairing QR code or paste a setup code from your OpenClaw gateway.")
|
|
}
|
|
}
|
|
|
|
private func resolveActiveAgentID() -> String {
|
|
let selected = normalized(appModel.selectedAgentId) ?? ""
|
|
if !selected.isEmpty {
|
|
return selected
|
|
}
|
|
return self.resolveDefaultAgentID()
|
|
}
|
|
|
|
private func resolveDefaultAgentID() -> String {
|
|
normalized(self.appModel.gatewayDefaultAgentId) ?? ""
|
|
}
|
|
|
|
private func homeCanvasAgents(activeAgentID: String) -> [RootTabsHomeCanvasAgentCard] {
|
|
let defaultAgentID = self.resolveDefaultAgentID()
|
|
let cards = self.appModel.gatewayAgents.map { agent -> RootTabsHomeCanvasAgentCard in
|
|
let isActive = !activeAgentID.isEmpty && agent.id == activeAgentID
|
|
let isDefault = !defaultAgentID.isEmpty && agent.id == defaultAgentID
|
|
return RootTabsHomeCanvasAgentCard(
|
|
id: agent.id,
|
|
name: self.homeCanvasName(for: agent),
|
|
badge: self.homeCanvasBadge(for: agent),
|
|
caption: isActive ? "Routed on this phone" : (isDefault ? "Gateway default" : "Available"),
|
|
isActive: isActive)
|
|
}
|
|
|
|
return cards.sorted { lhs, rhs in
|
|
if lhs.isActive != rhs.isActive {
|
|
return lhs.isActive
|
|
}
|
|
return lhs.name.localizedCaseInsensitiveCompare(rhs.name) == .orderedAscending
|
|
}
|
|
}
|
|
|
|
private func homeCanvasName(for agent: AgentSummary) -> String {
|
|
normalized(agent.name) ?? agent.id
|
|
}
|
|
}
|
|
|
|
extension RootTabs {
|
|
private func selectSidebarDestination(_ destination: SidebarDestination) {
|
|
self.sidebarNavigationPath.removeAll()
|
|
if destination.settingsRoute != .notifications {
|
|
self.suppressedExecApprovalForNotificationSettings = nil
|
|
}
|
|
self.selectedSidebarDestination = destination
|
|
self.selectedSettingsRoute = destination.settingsRoute
|
|
self.activeSettingsRoute = destination.settingsRoute
|
|
guard self.shouldCollapseSidebarAfterSelection else { return }
|
|
withAnimation(self.sidebarAnimation) {
|
|
self.setSidebarVisible(false)
|
|
}
|
|
}
|
|
|
|
private func handleOpenChatRequest(_: Int) {
|
|
self.selectSidebarDestination(.chat)
|
|
}
|
|
|
|
private func selectSettingsRoute(_ route: SettingsRoute) {
|
|
self.sidebarNavigationPath.removeAll()
|
|
if route != .notifications {
|
|
self.suppressedExecApprovalForNotificationSettings = nil
|
|
}
|
|
self.selectedSettingsRoute = route
|
|
self.activeSettingsRoute = route
|
|
self.selectedSettingsRouteRequestID &+= 1
|
|
self.selectedSidebarDestination = .settings
|
|
guard self.shouldCollapseSidebarAfterSelection else { return }
|
|
withAnimation(self.sidebarAnimation) {
|
|
self.setSidebarVisible(false)
|
|
}
|
|
}
|
|
|
|
private func pushSidebarSettingsRoute(_ route: SettingsRoute) {
|
|
// Push, don't replace: Back must return to the settings screen the
|
|
// user came from (e.g. Approvals -> Notifications -> back -> Approvals).
|
|
self.sidebarNavigationPath.append(route)
|
|
self.handleSettingsRouteChange(route)
|
|
}
|
|
|
|
private func suppressExecApprovalPromptForNotificationSettings(_ approvalID: String) {
|
|
guard let approvalID = ExecApprovalIdentifier.key(approvalID),
|
|
let prompt = self.appModel.pendingExecApprovalPrompt,
|
|
ExecApprovalIdentifier.key(prompt.id) == approvalID
|
|
else { return }
|
|
self.suppressedExecApprovalForNotificationSettings = NodeAppModel.execApprovalInboxKey(prompt)
|
|
}
|
|
|
|
private func handleSettingsRouteChange(_ route: SettingsRoute?) {
|
|
self.activeSettingsRoute = route
|
|
guard route != .notifications else { return }
|
|
if route == nil {
|
|
self.selectedSettingsRoute = nil
|
|
if self.selectedSidebarDestination == .settings {
|
|
self.selectedSidebarDestination = .settings
|
|
}
|
|
}
|
|
self.suppressedExecApprovalForNotificationSettings = nil
|
|
}
|
|
|
|
private func handleSidebarSettingsNavigationPathChange(_ navigationPath: [SettingsRoute]) {
|
|
guard self.selectedSidebarDestination == .settings || self.selectedSidebarDestination == .gateway else {
|
|
return
|
|
}
|
|
let baseRoute = self.selectedSettingsRoute ?? self.selectedSidebarDestination.settingsRoute
|
|
let route = Self.visibleSettingsRoute(
|
|
navigationPath: navigationPath,
|
|
baseRoute: baseRoute)
|
|
self.handleSettingsRouteChange(route)
|
|
}
|
|
|
|
private func showSidebar() {
|
|
self.sidebarVisibilityUserOverridden = true
|
|
withAnimation(self.sidebarAnimation) {
|
|
self.setSidebarVisible(true)
|
|
}
|
|
}
|
|
|
|
private func hideSidebar() {
|
|
self.sidebarVisibilityUserOverridden = true
|
|
withAnimation(self.sidebarAnimation) {
|
|
self.setSidebarVisible(false)
|
|
}
|
|
}
|
|
|
|
private func updateSidebarLayout(containerSize: CGSize, force: Bool) {
|
|
let layoutMode = Self.sidebarLayoutMode(containerSize: containerSize)
|
|
let previousLayoutMode: SidebarLayoutMode = self.isSidebarDrawerLayout ? .drawer : .split
|
|
let didResolvePreviousLayout = self.didResolveSidebarLayout
|
|
let layoutModeDidChange = layoutMode != previousLayoutMode
|
|
self.didResolveSidebarLayout = true
|
|
self.isSidebarDrawerLayout = layoutMode == .drawer
|
|
if layoutModeDidChange && didResolvePreviousLayout {
|
|
self.sidebarVisibilityUserOverridden = false
|
|
}
|
|
guard force || !self.sidebarVisibilityUserOverridden else { return }
|
|
|
|
let preferredVisibility = Self.preferredSidebarVisibility(layoutMode: layoutMode)
|
|
guard self.isSidebarVisible != preferredVisibility else { return }
|
|
self.setSidebarVisible(preferredVisibility)
|
|
}
|
|
|
|
private func setSidebarVisible(_ isVisible: Bool) {
|
|
self.isSidebarVisible = isVisible
|
|
}
|
|
|
|
private func homeCanvasBadge(for agent: AgentSummary) -> String {
|
|
if let identity = agent.identity,
|
|
let emoji = identity["emoji"]?.value as? String,
|
|
let normalizedEmoji = normalized(emoji)
|
|
{
|
|
return normalizedEmoji
|
|
}
|
|
let words = self.homeCanvasName(for: agent)
|
|
.split(whereSeparator: { $0.isWhitespace || $0 == "-" || $0 == "_" })
|
|
.prefix(2)
|
|
let initials = words.compactMap(\.first).map(String.init).joined()
|
|
if !initials.isEmpty {
|
|
return initials.uppercased()
|
|
}
|
|
return "OC"
|
|
}
|
|
|
|
private func normalized(_ value: String?) -> String? {
|
|
guard let value else { return nil }
|
|
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
return trimmed.isEmpty ? nil : trimmed
|
|
}
|
|
|
|
private func gatewayProblemPrimaryActionTitle(_ problem: GatewayConnectionProblem) -> String? {
|
|
GatewayProblemPrimaryAction.title(
|
|
for: problem,
|
|
retryTitle: "Retry",
|
|
resetTitle: "Reset onboarding",
|
|
nonRetryableTitle: "Open Settings")
|
|
}
|
|
|
|
private func handleGatewayProblemPrimaryAction(_ problem: GatewayConnectionProblem) {
|
|
if problem.suggestsOnboardingReset {
|
|
// Reset bumps onboarding.requestID, which re-presents the wizard.
|
|
let instanceId = UserDefaults.standard.string(forKey: "node.instanceId") ?? ""
|
|
Task {
|
|
await GatewayOnboardingReset.reset(appModel: self.appModel, instanceId: instanceId)
|
|
}
|
|
} else if problem.canTrustRotatedCertificate {
|
|
Task { await self.gatewayController.trustRotatedGatewayCertificate(from: problem) }
|
|
} else if GatewayProblemPrimaryAction.handleProtocolMismatchIfNeeded(problem) {
|
|
return
|
|
} else if problem.retryable {
|
|
Task { await self.gatewayController.connectActiveGateway() }
|
|
} else {
|
|
self.selectSidebarDestination(.gateway)
|
|
}
|
|
}
|
|
|
|
private func evaluateOnboardingPresentation(force: Bool) {
|
|
if force {
|
|
self.onboardingAllowSkip = true
|
|
self.showOnboarding = true
|
|
return
|
|
}
|
|
|
|
guard !self.didEvaluateOnboarding else { return }
|
|
self.didEvaluateOnboarding = true
|
|
let route = Self.startupPresentationRoute(
|
|
gatewayConnected: self.appModel.gatewayServerName != nil,
|
|
hasConnectedOnce: self.hasConnectedOnce,
|
|
onboardingComplete: self.onboardingComplete,
|
|
hasExistingGatewayConfig: self.hasExistingGatewayConfig(),
|
|
shouldPresentOnLaunch: OnboardingStateStore.shouldPresentOnLaunch(appModel: self.appModel))
|
|
switch route {
|
|
case .none:
|
|
self.maybeRequestLocalNetworkAccess(reason: "root_appear")
|
|
case .onboarding:
|
|
self.onboardingAllowSkip = true
|
|
self.showOnboarding = true
|
|
case .settings:
|
|
self.didAutoOpenSettings = true
|
|
self.selectSidebarDestination(.gateway)
|
|
self.maybeRequestLocalNetworkAccess(reason: "root_appear")
|
|
}
|
|
}
|
|
|
|
private func hasExistingGatewayConfig() -> Bool {
|
|
if self.appModel.activeGatewayConnectConfig != nil { return true }
|
|
if GatewaySettingsStore.activeGatewayEntry() != nil { return true }
|
|
|
|
let preferredStableID = self.preferredGatewayStableID.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
if !preferredStableID.isEmpty { return true }
|
|
|
|
let manualHost = self.manualGatewayHost.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
return self.manualGatewayEnabled && !manualHost.isEmpty
|
|
}
|
|
|
|
private func maybeAutoOpenSettings() {
|
|
guard !self.didAutoOpenSettings else { return }
|
|
guard !self.showOnboarding else { return }
|
|
let route = Self.startupPresentationRoute(
|
|
gatewayConnected: self.appModel.gatewayServerName != nil,
|
|
hasConnectedOnce: self.hasConnectedOnce,
|
|
onboardingComplete: self.onboardingComplete,
|
|
hasExistingGatewayConfig: self.hasExistingGatewayConfig(),
|
|
shouldPresentOnLaunch: false)
|
|
guard route == .settings else { return }
|
|
self.didAutoOpenSettings = true
|
|
self.selectSidebarDestination(.gateway)
|
|
self.maybeRequestLocalNetworkAccess(reason: "auto_open_settings")
|
|
}
|
|
|
|
private func maybeOpenSettingsForGatewaySetup() {
|
|
let requestID = self.appModel.gatewaySetupRequestID
|
|
guard requestID != 0, requestID != self.gatewaySetupRequest?.id else { return }
|
|
// The presented onboarding flow owns setup-link staging until it dismisses.
|
|
guard !self.showOnboarding else { return }
|
|
guard let link = appModel.consumePendingGatewaySetupLink() else { return }
|
|
self.showOnboarding = false
|
|
self.presentedSheet = nil
|
|
self.didAutoOpenSettings = true
|
|
self.selectSidebarDestination(.gateway)
|
|
// Root owns delivery so embedded Settings views cannot consume the one-shot link.
|
|
self.gatewaySetupRequest = GatewaySetupRequest(id: requestID, link: link)
|
|
self.requestLocalNetworkAccess(reason: "gateway_setup_deeplink")
|
|
}
|
|
|
|
private func handleGatewaySetupRequest(_ requestID: Int) {
|
|
guard self.gatewaySetupRequest?.id == requestID else { return }
|
|
self.gatewaySetupRequest = nil
|
|
}
|
|
|
|
private func maybeRequestLocalNetworkAccess(reason: String) {
|
|
guard self.didEvaluateOnboarding else { return }
|
|
guard self.scenePhase == .active else { return }
|
|
guard !self.showOnboarding else { return }
|
|
self.requestLocalNetworkAccess(reason: reason)
|
|
}
|
|
|
|
private func requestLocalNetworkAccess(reason: String) {
|
|
guard !self.appModel.isAppleReviewDemoModeEnabled else { return }
|
|
self.gatewayController.requestLocalNetworkAccess(reason: reason)
|
|
}
|
|
|
|
private func applyInitialChatSessionIfNeeded() {
|
|
guard !self.didApplyInitialChatSession else { return }
|
|
self.didApplyInitialChatSession = true
|
|
self.appModel.focusChatSession(Self.initialChatSessionKey)
|
|
}
|
|
|
|
private func maybeShowQuickSetup() {
|
|
let shouldPresent = Self.shouldPresentQuickSetup(
|
|
quickSetupDismissed: self.quickSetupDismissed,
|
|
showOnboarding: self.showOnboarding,
|
|
hasPresentedSheet: self.presentedSheet != nil,
|
|
gatewayConnected: self.appModel.gatewayServerName != nil,
|
|
hasExistingGatewayConfig: self.hasExistingGatewayConfig(),
|
|
discoveredGatewayCount: self.gatewayController.gateways.count)
|
|
guard shouldPresent else { return }
|
|
self.presentedSheet = .quickSetup
|
|
}
|
|
}
|
|
|
|
private struct RootCameraFlashOverlay: View {
|
|
@Environment(\.scenePhase) private var scenePhase
|
|
|
|
var nonce: Int
|
|
|
|
@State private var opacity: CGFloat = 0
|
|
@State private var dismissGate = DelayedActionGate()
|
|
|
|
var body: some View {
|
|
Color.white
|
|
.opacity(self.opacity)
|
|
.ignoresSafeArea()
|
|
.allowsHitTesting(false)
|
|
.onChange(of: self.nonce) { _, _ in
|
|
guard self.scenePhase == .active else {
|
|
self.clearFlash()
|
|
return
|
|
}
|
|
self.showFlash()
|
|
}
|
|
.onChange(of: self.scenePhase) { _, newValue in
|
|
guard newValue != .active else { return }
|
|
self.clearFlash()
|
|
}
|
|
.onDisappear { self.clearFlash() }
|
|
}
|
|
|
|
private func showFlash() {
|
|
withAnimation(.easeOut(duration: 0.08)) {
|
|
self.opacity = 0.85
|
|
}
|
|
self.dismissGate.schedule(after: .milliseconds(110)) {
|
|
withAnimation(.easeOut(duration: 0.32)) {
|
|
self.opacity = 0
|
|
}
|
|
}
|
|
}
|
|
|
|
private func clearFlash() {
|
|
self.opacity = 0
|
|
self.dismissGate.cancel()
|
|
}
|
|
}
|
|
|
|
#if DEBUG
|
|
#Preview(
|
|
"Shell iPhone portrait",
|
|
traits: .fixedLayout(width: 393, height: 852),
|
|
.portrait)
|
|
{
|
|
RootTabsPreviewHost()
|
|
}
|
|
|
|
#Preview(
|
|
"Shell iPhone drawer open",
|
|
traits: .fixedLayout(width: 393, height: 852),
|
|
.portrait)
|
|
{
|
|
RootTabsPreviewHost(sidebarVisible: true)
|
|
}
|
|
|
|
#Preview(
|
|
"Shell iPhone connected",
|
|
traits: .fixedLayout(width: 393, height: 852),
|
|
.portrait)
|
|
{
|
|
RootTabsPreviewHost(gatewayState: .connected)
|
|
}
|
|
|
|
#Preview(
|
|
"Shell iPhone gateway error",
|
|
traits: .fixedLayout(width: 393, height: 852),
|
|
.portrait)
|
|
{
|
|
RootTabsPreviewHost(gatewayState: .error)
|
|
}
|
|
|
|
#Preview(
|
|
"Shell iPhone landscape",
|
|
traits: .fixedLayout(width: 852, height: 393),
|
|
.landscapeLeft)
|
|
{
|
|
RootTabsPreviewHost()
|
|
.environment(\.horizontalSizeClass, .regular)
|
|
.environment(\.verticalSizeClass, .compact)
|
|
}
|
|
|
|
#Preview(
|
|
"Shell iPad portrait drawer",
|
|
traits: .fixedLayout(width: 1024, height: 1366),
|
|
.portrait)
|
|
{
|
|
RootTabsPreviewHost()
|
|
}
|
|
|
|
#Preview(
|
|
"Shell iPad landscape split",
|
|
traits: .fixedLayout(width: 1366, height: 1024),
|
|
.landscapeLeft)
|
|
{
|
|
RootTabsPreviewHost(gatewayState: .connected)
|
|
}
|
|
|
|
#Preview(
|
|
"Shell iPad connecting",
|
|
traits: .fixedLayout(width: 1366, height: 1024),
|
|
.landscapeLeft)
|
|
{
|
|
RootTabsPreviewHost(gatewayState: .connecting)
|
|
}
|
|
|
|
#Preview(
|
|
"Shell iPad gateway error",
|
|
traits: .fixedLayout(width: 1366, height: 1024),
|
|
.landscapeLeft)
|
|
{
|
|
RootTabsPreviewHost(gatewayState: .error)
|
|
}
|
|
|
|
private struct RootTabsPreviewHost: View {
|
|
@State private var appearanceModel = AppAppearanceModel()
|
|
@State private var appModel: NodeAppModel
|
|
@State private var gatewayController: GatewayConnectionController
|
|
private let sidebarVisible: Bool?
|
|
|
|
init(
|
|
gatewayState: RootTabsPreviewGatewayState = .offline,
|
|
sidebarVisible: Bool? = nil)
|
|
{
|
|
let appModel = NodeAppModel()
|
|
gatewayState.apply(to: appModel)
|
|
self.sidebarVisible = sidebarVisible
|
|
_appModel = State(initialValue: appModel)
|
|
_gatewayController = State(
|
|
initialValue: GatewayConnectionController(appModel: appModel, startDiscovery: false))
|
|
}
|
|
|
|
var body: some View {
|
|
RootTabs(initialSidebarVisibility: self.sidebarVisible)
|
|
.environment(self.appearanceModel)
|
|
.environment(self.appModel)
|
|
.environment(self.appModel.voiceWake)
|
|
.environment(self.gatewayController)
|
|
}
|
|
}
|
|
|
|
private enum RootTabsPreviewGatewayState {
|
|
case offline
|
|
case connecting
|
|
case connected
|
|
case error
|
|
|
|
@MainActor
|
|
func apply(to appModel: NodeAppModel) {
|
|
switch self {
|
|
case .offline:
|
|
break
|
|
case .connecting:
|
|
appModel.gatewayStatusText = "Connecting..."
|
|
case .connected:
|
|
appModel.enterAppleReviewDemoMode()
|
|
case .error:
|
|
appModel.gatewayStatusText = "Gateway error: connection refused"
|
|
}
|
|
}
|
|
}
|
|
|
|
#endif
|