diff --git a/scarf/Scarf iOS/App/ScarfGoTabRoot.swift b/scarf/Scarf iOS/App/ScarfGoTabRoot.swift new file mode 100644 index 0000000..a0e7b5e --- /dev/null +++ b/scarf/Scarf iOS/App/ScarfGoTabRoot.swift @@ -0,0 +1,164 @@ +import SwiftUI +import ScarfCore +import ScarfIOS + +/// ScarfGo's primary navigation surface. Replaces the pre-M8 +/// "Dashboard is the hub" pattern where Chat/Memory/Cron/Skills/ +/// Settings lived as NavigationLink rows three-quarters of the way +/// down a scrolling List — pass-1 user-visible complaint: +/// +/// > "We should have the actions for the user in a permanent footer? +/// > I don't see any navigation." +/// +/// 4 primary tabs + a "More" bucket for the read-heavy / seldom-used +/// features. Uses iOS 18's `.sidebarAdaptable` tab style so the same +/// tree degrades to a bottom tab bar on iPhone and gets a native +/// sidebar on iPadOS / macCatalyst if we ever add those targets. +/// +/// Each tab wraps its feature view in its own `NavigationStack` so +/// push navigation (Cron editor, Memory detail, etc.) stays scoped +/// to the tab instead of bleeding across. +struct ScarfGoTabRoot: View { + let config: IOSServerConfig + let key: SSHKeyBundle + let onDisconnect: @MainActor () async -> Void + + /// Stable context UUID shared with DashboardView + ChatView. + /// Matches the prior convention so the CitadelServerTransport + /// connection pool reuses the same SSH client across tabs. + private static let sharedContextID: ServerID = ServerID( + uuidString: "00000000-0000-0000-0000-0000000000A1" + )! + + var body: some View { + let ctx = config.toServerContext(id: Self.sharedContextID) + TabView { + // 1 — Chat: the reason the app is on your phone. Primary + // tab; opens straight into the chat surface. + NavigationStack { + ChatView(config: config, key: key) + } + .tabItem { + Label("Chat", systemImage: "bubble.left.and.bubble.right.fill") + } + + // 2 — Dashboard: stats + recent sessions (no surfaces list + // anymore — those live in More). + NavigationStack { + DashboardView(config: config, key: key) + } + .tabItem { + Label("Dashboard", systemImage: "gauge.with.needle") + } + + // 3 — Memory: MEMORY.md + USER.md + SOUL.md. + NavigationStack { + MemoryListView(config: config) + } + .tabItem { + Label("Memory", systemImage: "brain.head.profile") + } + + // 4 — More: Cron, Skills, Settings, plus the destructive + // "Forget this server" action. Named "More" because on + // iOS 18 with .sidebarAdaptable the system collapses + // leftover tabs into a disclosure group with that exact + // label automatically; choosing the same word keeps our + // More tab visually consistent with the system default. + NavigationStack { + MoreTab(config: config, onDisconnect: onDisconnect) + } + .tabItem { + Label("More", systemImage: "ellipsis.circle") + } + } + // Pulls the sidebar-on-iPad affordance into the same code path + // as the bottom-bar-on-iPhone one. No-op on iPhone today. + .tabViewStyle(.sidebarAdaptable) + .environment(\.serverContext, ctx) + } +} + +/// Groups the features that don't deserve a primary tab on a phone: +/// Cron (infrequent edits), Skills (read-only), Settings (read-only +/// until M9 scoped editor), plus the destructive server-forget action. +/// +/// Kept private to this file because we don't expect it to be reused +/// elsewhere — if a feature graduates to a primary tab, that's a +/// deliberate design decision. +private struct MoreTab: View { + let config: IOSServerConfig + let onDisconnect: @MainActor () async -> Void + + @State private var showForgetConfirmation = false + @State private var isForgetting = false + + var body: some View { + List { + Section("Server") { + LabeledContent("Host", value: config.host) + if let user = config.user { + LabeledContent("User", value: user) + } + if let port = config.port { + LabeledContent("Port", value: String(port)) + } + } + + Section("Features") { + NavigationLink { + CronListView(config: config) + } label: { + Label("Cron jobs", systemImage: "clock.arrow.circlepath") + } + NavigationLink { + SkillsListView(config: config) + } label: { + Label("Skills", systemImage: "sparkles") + } + NavigationLink { + SettingsView(config: config) + } label: { + Label("Settings", systemImage: "gearshape.fill") + } + } + + Section { + Button(role: .destructive) { + showForgetConfirmation = true + } label: { + HStack { + Spacer() + if isForgetting { + ProgressView() + } else { + Text("Forget this server") + } + Spacer() + } + } + .disabled(isForgetting) + } footer: { + Text("Removes this server's SSH key and host info from the device. You'll need to add the public key back to `~/.ssh/authorized_keys` to reconnect.") + .font(.caption) + } + } + .navigationTitle("More") + .navigationBarTitleDisplayMode(.inline) + .confirmationDialog( + "Forget this server?", + isPresented: $showForgetConfirmation, + titleVisibility: .visible + ) { + Button("Forget \(config.displayName)", role: .destructive) { + Task { + isForgetting = true + await onDisconnect() + } + } + Button("Cancel", role: .cancel) {} + } message: { + Text("Your SSH key and host settings will be removed from this device. This cannot be undone.") + } + } +} diff --git a/scarf/Scarf iOS/App/ScarfIOSApp.swift b/scarf/Scarf iOS/App/ScarfIOSApp.swift index e9b456c..bfb335e 100644 --- a/scarf/Scarf iOS/App/ScarfIOSApp.swift +++ b/scarf/Scarf iOS/App/ScarfIOSApp.swift @@ -127,7 +127,7 @@ struct RootView: View { await model.onboardingFinished() }) case .connected(let config, let key): - DashboardView( + ScarfGoTabRoot( config: config, key: key, onDisconnect: { diff --git a/scarf/Scarf iOS/Dashboard/DashboardView.swift b/scarf/Scarf iOS/Dashboard/DashboardView.swift index 23202c1..6d62996 100644 --- a/scarf/Scarf iOS/Dashboard/DashboardView.swift +++ b/scarf/Scarf iOS/Dashboard/DashboardView.swift @@ -9,11 +9,8 @@ import ScarfIOS struct DashboardView: View { let config: IOSServerConfig let key: SSHKeyBundle - let onDisconnect: @MainActor () async -> Void @State private var vm: IOSDashboardViewModel - @State private var isDisconnecting = false - @State private var showForgetConfirmation = false /// Stable ID used when building the `ServerContext` — tied to the /// config's host+user tuple so re-launching the app without reset @@ -24,20 +21,19 @@ struct DashboardView: View { init( config: IOSServerConfig, - key: SSHKeyBundle, - onDisconnect: @escaping @MainActor () async -> Void + key: SSHKeyBundle ) { self.config = config self.key = key - self.onDisconnect = onDisconnect let ctx = config.toServerContext(id: Self.contextID) _vm = State(initialValue: IOSDashboardViewModel(context: ctx)) } var body: some View { - NavigationStack { - List { - if let err = vm.lastError { + // TabView root already wraps this in a NavigationStack; don't + // nest (causes duplicate nav bars + broken back swipes). + List { + if let err = vm.lastError { Section { VStack(alignment: .leading, spacing: 8) { Label("Connection issue", systemImage: "exclamationmark.triangle.fill") @@ -90,95 +86,21 @@ struct DashboardView: View { } } - Section("Surfaces") { - NavigationLink { - ChatView(config: config, key: key) - } label: { - Label("Chat", systemImage: "bubble.left.and.bubble.right.fill") - } - NavigationLink { - MemoryListView(config: config) - } label: { - Label("Memory", systemImage: "brain.head.profile") - } - NavigationLink { - CronListView(config: config) - } label: { - Label("Cron", systemImage: "clock.arrow.circlepath") - } - NavigationLink { - SkillsListView(config: config) - } label: { - Label("Skills", systemImage: "sparkles") - } - NavigationLink { - SettingsView(config: config) - } label: { - Label("Settings", systemImage: "gearshape.fill") - } - } - - Section("Connected to") { - LabeledContent("Host", value: config.host) - if let user = config.user { - LabeledContent("User", value: user) - } - if let port = config.port { - LabeledContent("Port", value: String(port)) - } - LabeledContent("Device key", value: key.displayFingerprint) - } - - Section { - Button(role: .destructive) { - showForgetConfirmation = true - } label: { - HStack { - Spacer() - if isDisconnecting { - ProgressView() - } else { - Text("Forget this server") - } - Spacer() - } - } - .disabled(isDisconnecting) - } footer: { - Text("Removes this server's SSH key and host info from the device. You'll need to add the public key back to `~/.ssh/authorized_keys` to reconnect.") - .font(.caption) - } - } - .confirmationDialog( - "Forget this server?", - isPresented: $showForgetConfirmation, - titleVisibility: .visible - ) { - Button("Forget \(config.displayName)", role: .destructive) { - Task { - isDisconnecting = true - await onDisconnect() - } - } - Button("Cancel", role: .cancel) {} - } message: { - Text("Your SSH key and host settings will be removed from this device. This cannot be undone.") - } - .navigationTitle(config.displayName) - .navigationBarTitleDisplayMode(.large) - .refreshable { - await vm.refresh() - } - .overlay { - if vm.isLoading, vm.recentSessions.isEmpty { - ProgressView("Loading dashboard…") - .padding() - .background(.regularMaterial) - .clipShape(RoundedRectangle(cornerRadius: 10)) - } - } - .task { await vm.load() } } + .navigationTitle(config.displayName) + .navigationBarTitleDisplayMode(.large) + .refreshable { + await vm.refresh() + } + .overlay { + if vm.isLoading, vm.recentSessions.isEmpty { + ProgressView("Loading dashboard…") + .padding() + .background(.regularMaterial) + .clipShape(RoundedRectangle(cornerRadius: 10)) + } + } + .task { await vm.load() } } @ViewBuilder