From 5cac3836cfa620b5fc9c65fa7863436c83001718 Mon Sep 17 00:00:00 2001 From: Alan Wizemann Date: Fri, 24 Apr 2026 13:38:03 +0200 Subject: [PATCH] M8: TabView root navigation (Chat / Dashboard / Memory / More) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pass-1 loudest UX complaint — "I don't see any navigation" — was rooted in the Dashboard-as-hub pattern. Chat/Memory/Cron/Skills/ Settings lived as a NavigationLink section halfway down a scrolling List, below the stats + recent sessions. Users had to scroll to find any feature. That was the right shape for a very-early MVP but the wrong shape for a companion app whose primary tab should be Chat. New `ScarfGoTabRoot` renders a 4-tab TabView at the scene root: - **Chat** — primary tab. Tapping the app opens straight into it. - **Dashboard** — stats + recent sessions (stripped of Surfaces / Connected-to / Disconnect, which now live in More). - **Memory** — MEMORY.md + USER.md + SOUL.md, unchanged. - **More** — bucket for Cron / Skills / Settings plus the destructive Forget-this-server action. Also shows the host / user / port info as a read-only section. Uses iOS 18's `.tabViewStyle(.sidebarAdaptable)` so the same tree degrades to a bottom tab bar on iPhone and renders as a native sidebar on iPadOS / macCatalyst if we add those targets later — no UI code change required. Matches the M8 density research's sidebar recommendation. Each tab owns its own NavigationStack so push navigation (Cron editor, Memory detail, chat session list) stays scoped to that tab and doesn't bleed across. DashboardView is now simpler: just stats + recent sessions. The Forget confirmation + Disconnect button moved wholesale to MoreTab inside ScarfGoTabRoot. Co-Authored-By: Claude Opus 4.7 (1M context) --- scarf/Scarf iOS/App/ScarfGoTabRoot.swift | 164 ++++++++++++++++++ scarf/Scarf iOS/App/ScarfIOSApp.swift | 2 +- scarf/Scarf iOS/Dashboard/DashboardView.swift | 116 ++----------- 3 files changed, 184 insertions(+), 98 deletions(-) create mode 100644 scarf/Scarf iOS/App/ScarfGoTabRoot.swift 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