M8: TabView root navigation (Chat / Dashboard / Memory / More)

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) <noreply@anthropic.com>
This commit is contained in:
Alan Wizemann
2026-04-24 13:38:03 +02:00
parent cecc1060c6
commit 5cac3836cf
3 changed files with 184 additions and 98 deletions
+164
View File
@@ -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.")
}
}
}
+1 -1
View File
@@ -127,7 +127,7 @@ struct RootView: View {
await model.onboardingFinished()
})
case .connected(let config, let key):
DashboardView(
ScarfGoTabRoot(
config: config,
key: key,
onDisconnect: {
+19 -97
View File
@@ -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