Files
openclaw/apps/ios/Sources/RootTabsNavigation.swift
Colin Johnson 1ca6b6ff67 feat(ios): unify chat and voice experience (#107879)
* feat(ios): unify chat and voice experience

* refactor(ios): finish unified chat ownership

* fix(ios): unify chat and voice capture ownership

* fix(ios): serialize Talk permission hydration

* fix(ios): clear voice CI regressions

* fix(ios): clear remaining unified chat CI failures

* chore(i18n): sync native source inventory

* refactor(ios): defer provider-only realtime voice

* fix(ios): clean unified chat voice PR

* fix(ios): localize unified voice controls

* fix(ios): serialize composer audio controls

* fix(ios): isolate dictation preparation lifecycle

* fix(ios): key chat tab icons by appearance

* fix(ios): preserve talk upgrade handshakes

* fix(ios): refresh native i18n inventory

* fix(ios): reconcile shared locale artifacts

* fix(ios): satisfy chat view lint limit

* fix(ios): reconcile shared composer implementation

* test(apple): avoid XCTest actor deinit crash

* fix(ios): satisfy SwiftFormat scope spacing

* fix(ios): remove duplicate merged helper declarations
2026-07-18 14:30:10 -04:00

317 lines
9.6 KiB
Swift

import CoreGraphics
import Foundation
import SwiftUI
extension RootTabs {
struct PhoneChatReturn: Equatable {
let destination: SidebarDestination
let openChatRequestID: Int
}
struct PhoneControlNavigationRequest: Equatable {
enum Target: Equatable {
case root
case detail(SidebarDestination)
}
let id: Int
let target: Target
}
private static var sidebarPersistentWidthThreshold: CGFloat {
980
}
static let sidebarSplitIdealWidth: CGFloat = 316
static let sidebarSplitMaximumWidth: CGFloat = 340
static let sidebarDrawerMaximumWidth: CGFloat = 340
static let sidebarShowButtonAccessibilityIdentifier = "RootTabs.Sidebar.Show"
static let sidebarHideButtonAccessibilityIdentifier = "RootTabs.Sidebar.Hide"
enum AppTab: Hashable {
case control
case chat
case agent
case settings
}
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 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 .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 .terminal: "terminal"
case .docs: "book"
case .settings: "gearshape"
case .gateway: "gearshape"
}
}
var appTab: AppTab {
switch self {
case .chat:
.chat
case .agents:
.agent
case .settings, .gateway:
.settings
case .overview, .activity, .workboard, .skillWorkshop, .instances, .sessions, .files,
.dreaming,
.usage,
.cron, .terminal, .docs:
.control
}
}
var settingsRoute: SettingsRoute? {
switch self {
case .gateway:
.gateway
case .chat, .overview, .activity, .agents, .workboard, .skillWorkshop, .instances, .sessions,
.files,
.dreaming,
.usage, .cron, .terminal, .settings, .docs:
nil
}
}
}
enum SidebarLayoutMode: Equatable {
case drawer
case split
}
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, max(280, containerWidth * 0.86))
}
return min(self.sidebarSplitMaximumWidth, max(self.sidebarSplitIdealWidth, containerWidth * 0.25))
}
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
}
}
static func shouldOpenRootTabFromPhoneHub(_ destination: SidebarDestination) -> Bool {
switch destination {
case .chat, .agents, .gateway, .settings:
true
case .overview, .activity, .workboard, .skillWorkshop, .instances, .sessions, .files,
.dreaming,
.usage,
.cron, .terminal, .docs:
false
}
}
static func defaultSidebarDestination(for tab: AppTab) -> SidebarDestination {
switch tab {
case .control:
.overview
case .chat:
.chat
case .agent:
.agents
case .settings:
.settings
}
}
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
}
struct SidebarGroup: Identifiable {
let title: String
let destinations: [SidebarDestination]
var id: String {
self.title
}
}
static let sidebarGroups: [SidebarGroup] = [
SidebarGroup(title: "CHAT", destinations: [.chat]),
SidebarGroup(
title: "CONTROL",
destinations: [
.overview,
.activity,
.agents,
.workboard,
.skillWorkshop,
.instances,
.sessions,
.files,
.dreaming,
.usage,
.cron,
.terminal,
]),
SidebarGroup(
title: "SETTINGS",
destinations: [.settings]),
SidebarGroup(title: "REFERENCE", destinations: [.docs]),
]
static var phoneControlGroups: [SidebarGroup] {
// Agents owns a bottom tab and its hub entry duplicated the same destination.
let tabOwned: Set<SidebarDestination> = [.agents]
return self.sidebarGroups
.map { group in
SidebarGroup(
title: group.title,
destinations: group.destinations.filter { !tabOwned.contains($0) })
}
.filter { !$0.destinations.isEmpty }
}
}