M9 #4.1: session resume — Dashboard row tap opens Chat in resume mode

Pass-1 showed Dashboard's Recent Sessions list as a read-only
marquee — tapping a row did nothing. The natural user expectation
is "take me back to that conversation." Users were opening a new
chat every time, defeating the point of having a phone client for
an already-running agent.

Added a tiny cross-tab coordinator (ScarfGoCoordinator) modeled on
the Mac app's AppCoordinator pattern:

- `@Observable` carrier, injected via `.environment` at ScarfGoTabRoot.
- `selectedTab` drives TabView selection (bound with `.tag` on each
  tab).
- `pendingResumeSessionID` is set by Dashboard row taps; consumed
  by ChatView in `.task` / `.onChange` and cleared immediately so
  later neutral tab switches don't accidentally re-resume.

ChatController gets a new `startResuming(sessionID:)` entry point
that mirrors `start()` but calls `session/resume` (falling back to
`session/load` if the remote Hermes is < 0.9.x). The rest of the
session lifecycle is identical so the event stream + error banner +
PATH wrap all stay in force.

Dashboard Recent Sessions rows now wrap in Button with `.buttonStyle(.plain)`
and fire `coordinator?.resumeSession(session.id)` on tap.

First usable on-the-go workflow: tap app icon → pick server → tap
Dashboard → see recent sessions → tap one → land directly back in
that conversation, full transcript loaded. No new-chat ceremony.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Alan Wizemann
2026-04-24 14:00:40 +02:00
parent 9c2e9279cc
commit ff6ea4f6dc
4 changed files with 176 additions and 13 deletions
@@ -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 }
}
}
+11 -1
View File
@@ -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)
}
}
+90 -1
View File
@@ -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 {
+24 -11
View File
@@ -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)
}
}
}