mirror of
https://github.com/awizemann/scarf.git
synced 2026-05-08 02:14:37 +00:00
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:
@@ -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.")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -127,7 +127,7 @@ struct RootView: View {
|
||||
await model.onboardingFinished()
|
||||
})
|
||||
case .connected(let config, let key):
|
||||
DashboardView(
|
||||
ScarfGoTabRoot(
|
||||
config: config,
|
||||
key: key,
|
||||
onDisconnect: {
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user