diff --git a/scarf/Scarf iOS/App/ScarfGoCoordinator.swift b/scarf/Scarf iOS/App/ScarfGoCoordinator.swift new file mode 100644 index 0000000..3c7cbe4 --- /dev/null +++ b/scarf/Scarf iOS/App/ScarfGoCoordinator.swift @@ -0,0 +1,51 @@ +import SwiftUI +import ScarfCore + +/// Cross-tab signalling for ScarfGo. Mirrors the Mac app's +/// `AppCoordinator` pattern: an `@Observable` carrier injected via +/// `.environment(_:)` that any view in the tab tree can reach. +/// +/// Single responsibility in M9 scope: route "user tapped a recent +/// session in Dashboard" → "open the Chat tab with a resume request." +/// Future uses (project-scoped chat handoff, notification deep-link +/// → specific session) compose naturally on the same primitive. +@Observable +@MainActor +final class ScarfGoCoordinator { + + /// Which tab ScarfGoTabRoot should present. Changing this from + /// anywhere in the tree re-selects the tab. Bound as `selection:` + /// on the root TabView. + var selectedTab: Tab = .chat + + /// If non-nil, ChatController should resume this session on next + /// appear instead of starting a fresh one. Consumed (cleared) by + /// ChatController after it honours the request. + var pendingResumeSessionID: String? + + enum Tab: Hashable { + case chat, dashboard, memory, more + } + + /// Convenience: route to Chat and queue a resume. Dashboard rows + /// call this on tap. Clearing `pendingResumeSessionID` is the + /// consumer's responsibility — in ChatController's case, right + /// after the resume flow wins (success or failure). + func resumeSession(_ id: String) { + pendingResumeSessionID = id + selectedTab = .chat + } +} + +/// Environment key so subviews can pull the coordinator without +/// explicit threading. +private struct ScarfGoCoordinatorKey: EnvironmentKey { + static let defaultValue: ScarfGoCoordinator? = nil +} + +extension EnvironmentValues { + var scarfGoCoordinator: ScarfGoCoordinator? { + get { self[ScarfGoCoordinatorKey.self] } + set { self[ScarfGoCoordinatorKey.self] = newValue } + } +} diff --git a/scarf/Scarf iOS/App/ScarfGoTabRoot.swift b/scarf/Scarf iOS/App/ScarfGoTabRoot.swift index 16df113..907c687 100644 --- a/scarf/Scarf iOS/App/ScarfGoTabRoot.swift +++ b/scarf/Scarf iOS/App/ScarfGoTabRoot.swift @@ -25,6 +25,11 @@ struct ScarfGoTabRoot: View { let onSoftDisconnect: @MainActor () async -> Void let onForget: @MainActor () async -> Void + /// One coordinator per server-connected session. Cross-tab + /// signalling (Dashboard row → Chat tab resume, eventually + /// notification deep-link → Chat) flows through here. + @State private var coordinator = ScarfGoCoordinator() + var body: some View { // The transport factory is keyed by ServerID, so the correct // Keychain slot + config is picked automatically. Reuses the @@ -33,7 +38,7 @@ struct ScarfGoTabRoot: View { // pre-M9). Two active servers → two connection holders, no // SSH channel contention. let ctx = config.toServerContext(id: serverID) - TabView { + TabView(selection: $coordinator.selectedTab) { // 1 — Chat: the reason the app is on your phone. Primary // tab; opens straight into the chat surface. NavigationStack { @@ -42,6 +47,7 @@ struct ScarfGoTabRoot: View { .tabItem { Label("Chat", systemImage: "bubble.left.and.bubble.right.fill") } + .tag(ScarfGoCoordinator.Tab.chat) // 2 — Dashboard: stats + recent sessions (no surfaces list // anymore — those live in More). @@ -51,6 +57,7 @@ struct ScarfGoTabRoot: View { .tabItem { Label("Dashboard", systemImage: "gauge.with.needle") } + .tag(ScarfGoCoordinator.Tab.dashboard) // 3 — Memory: MEMORY.md + USER.md + SOUL.md. NavigationStack { @@ -59,6 +66,7 @@ struct ScarfGoTabRoot: View { .tabItem { Label("Memory", systemImage: "brain.head.profile") } + .tag(ScarfGoCoordinator.Tab.memory) // 4 — More: Cron, Skills, Settings, plus the destructive // "Forget this server" action. Named "More" because on @@ -76,11 +84,13 @@ struct ScarfGoTabRoot: View { .tabItem { Label("More", systemImage: "ellipsis.circle") } + .tag(ScarfGoCoordinator.Tab.more) } // 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) + .environment(\.scarfGoCoordinator, coordinator) } } diff --git a/scarf/Scarf iOS/Chat/ChatView.swift b/scarf/Scarf iOS/Chat/ChatView.swift index 0731f5b..fb6b5f1 100644 --- a/scarf/Scarf iOS/Chat/ChatView.swift +++ b/scarf/Scarf iOS/Chat/ChatView.swift @@ -20,6 +20,8 @@ struct ChatView: View { let config: IOSServerConfig let key: SSHKeyBundle + @Environment(\.scarfGoCoordinator) private var coordinator + @Environment(\.serverContext) private var envContext @State private var controller: ChatController init(config: IOSServerConfig, key: SSHKeyBundle) { @@ -56,7 +58,28 @@ struct ChatView: View { } } .task { - await controller.start() + // Dashboard row taps set `pendingResumeSessionID` on the + // coordinator before switching to the Chat tab. Honor + // that if present, else open a fresh session. Clearing + // the coordinator value is the consumer's responsibility + // (us) — otherwise a later plain tap on the Chat tab + // would accidentally re-resume the old session. + if let sessionID = coordinator?.pendingResumeSessionID { + coordinator?.pendingResumeSessionID = nil + await controller.startResuming(sessionID: sessionID) + } else { + await controller.start() + } + } + // Also react to a coordinator change that happens while Chat + // is already mounted (e.g., user is in Chat, switches to + // Dashboard, taps a session row — coordinator flips the tab + // AND sets pendingResumeSessionID. The `.task` above only + // fires on first appear; this is the mid-session hook.) + .onChange(of: coordinator?.pendingResumeSessionID) { _, new in + guard let sessionID = new else { return } + coordinator?.pendingResumeSessionID = nil + Task { await controller.startResuming(sessionID: sessionID) } } .onDisappear { Task { await controller.stop() } @@ -430,6 +453,72 @@ final class ChatController { await start() } + /// Resume an existing ACP session. Called from ChatView when the + /// coordinator carries a `pendingResumeSessionID` (Dashboard row + /// tap). If we're currently on a different session, stop first + /// so there's no phantom ACP process hanging around. Falls back + /// to `session/load` if the remote doesn't support `session/resume` + /// (Hermes < 0.9.x). + func startResuming(sessionID: String) async { + await stop() + vm.reset() + state = .connecting + let client = ACPClient.forIOSApp( + context: context, + keyProvider: { + let store = KeychainSSHKeyStore() + guard let key = try await store.load() else { + throw SSHKeyStoreError.backendFailure( + message: "No SSH key in Keychain — re-run onboarding.", + osStatus: nil + ) + } + return key + } + ) + self.client = client + vm.acpStderrProvider = { [weak client] in + await client?.recentStderr ?? "" + } + + do { + try await client.start() + } catch { + state = .failed(error.localizedDescription) + await vm.recordACPFailure(error, client: client) + return + } + + let stream = await client.events + eventTask = Task { [weak self] in + for await event in stream { + guard let self else { break } + await MainActor.run { + self.vm.handleACPEvent(event) + } + } + } + + do { + let home = await context.resolvedUserHome() + // Prefer `session/resume` for true resume semantics + // (same session id preserved in state.db); fall back to + // `session/load` if the remote doesn't know resume. + let resolvedID: String + do { + resolvedID = try await client.resumeSession(cwd: home, sessionId: sessionID) + } catch { + resolvedID = try await client.loadSession(cwd: home, sessionId: sessionID) + } + vm.setSessionId(resolvedID) + state = .ready + } catch { + state = .failed(error.localizedDescription) + await vm.recordACPFailure(error, client: client) + await stop() + } + } + /// Dispatch the user's answer to a pending permission request. /// Called by `PermissionSheet`. func respondToPermission(requestId: Int, optionId: String) async { diff --git a/scarf/Scarf iOS/Dashboard/DashboardView.swift b/scarf/Scarf iOS/Dashboard/DashboardView.swift index 225a83d..764f799 100644 --- a/scarf/Scarf iOS/Dashboard/DashboardView.swift +++ b/scarf/Scarf iOS/Dashboard/DashboardView.swift @@ -10,6 +10,7 @@ struct DashboardView: View { let config: IOSServerConfig let key: SSHKeyBundle + @Environment(\.scarfGoCoordinator) private var coordinator @State private var vm: IOSDashboardViewModel /// Stable ID used when building the `ServerContext` — tied to the @@ -66,22 +67,34 @@ struct DashboardView: View { if !vm.recentSessions.isEmpty { Section("Recent sessions") { ForEach(vm.recentSessions) { session in - VStack(alignment: .leading, spacing: 4) { - Text(session.displayTitle) - .font(.body) - .lineLimit(2) - HStack(spacing: 12) { - Label(session.source, systemImage: session.sourceIcon) - .font(.caption) - .foregroundStyle(.secondary) - if let started = session.startedAt { - Text(started, format: .relative(presentation: .numeric)) + Button { + // Route to Chat tab with a resume + // request for this session id. Chat + // will pick it up from the coordinator + // on next appear (M9 #4.1). + coordinator?.resumeSession(session.id) + } label: { + VStack(alignment: .leading, spacing: 4) { + Text(session.displayTitle) + .font(.body) + .lineLimit(2) + .foregroundStyle(.primary) + HStack(spacing: 12) { + Label(session.source, systemImage: session.sourceIcon) .font(.caption) .foregroundStyle(.secondary) + if let started = session.startedAt { + Text(started, format: .relative(presentation: .numeric)) + .font(.caption) + .foregroundStyle(.secondary) + } } } + .padding(.vertical, 2) + .frame(maxWidth: .infinity, alignment: .leading) + .contentShape(Rectangle()) } - .padding(.vertical, 2) + .buttonStyle(.plain) } } }