mirror of
https://github.com/awizemann/scarf.git
synced 2026-05-08 02:14:37 +00:00
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:
@@ -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 }
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user