mirror of
https://github.com/awizemann/scarf.git
synced 2026-05-10 02:26: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 onSoftDisconnect: @MainActor () async -> Void
|
||||||
let onForget: @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 {
|
var body: some View {
|
||||||
// The transport factory is keyed by ServerID, so the correct
|
// The transport factory is keyed by ServerID, so the correct
|
||||||
// Keychain slot + config is picked automatically. Reuses the
|
// Keychain slot + config is picked automatically. Reuses the
|
||||||
@@ -33,7 +38,7 @@ struct ScarfGoTabRoot: View {
|
|||||||
// pre-M9). Two active servers → two connection holders, no
|
// pre-M9). Two active servers → two connection holders, no
|
||||||
// SSH channel contention.
|
// SSH channel contention.
|
||||||
let ctx = config.toServerContext(id: serverID)
|
let ctx = config.toServerContext(id: serverID)
|
||||||
TabView {
|
TabView(selection: $coordinator.selectedTab) {
|
||||||
// 1 — Chat: the reason the app is on your phone. Primary
|
// 1 — Chat: the reason the app is on your phone. Primary
|
||||||
// tab; opens straight into the chat surface.
|
// tab; opens straight into the chat surface.
|
||||||
NavigationStack {
|
NavigationStack {
|
||||||
@@ -42,6 +47,7 @@ struct ScarfGoTabRoot: View {
|
|||||||
.tabItem {
|
.tabItem {
|
||||||
Label("Chat", systemImage: "bubble.left.and.bubble.right.fill")
|
Label("Chat", systemImage: "bubble.left.and.bubble.right.fill")
|
||||||
}
|
}
|
||||||
|
.tag(ScarfGoCoordinator.Tab.chat)
|
||||||
|
|
||||||
// 2 — Dashboard: stats + recent sessions (no surfaces list
|
// 2 — Dashboard: stats + recent sessions (no surfaces list
|
||||||
// anymore — those live in More).
|
// anymore — those live in More).
|
||||||
@@ -51,6 +57,7 @@ struct ScarfGoTabRoot: View {
|
|||||||
.tabItem {
|
.tabItem {
|
||||||
Label("Dashboard", systemImage: "gauge.with.needle")
|
Label("Dashboard", systemImage: "gauge.with.needle")
|
||||||
}
|
}
|
||||||
|
.tag(ScarfGoCoordinator.Tab.dashboard)
|
||||||
|
|
||||||
// 3 — Memory: MEMORY.md + USER.md + SOUL.md.
|
// 3 — Memory: MEMORY.md + USER.md + SOUL.md.
|
||||||
NavigationStack {
|
NavigationStack {
|
||||||
@@ -59,6 +66,7 @@ struct ScarfGoTabRoot: View {
|
|||||||
.tabItem {
|
.tabItem {
|
||||||
Label("Memory", systemImage: "brain.head.profile")
|
Label("Memory", systemImage: "brain.head.profile")
|
||||||
}
|
}
|
||||||
|
.tag(ScarfGoCoordinator.Tab.memory)
|
||||||
|
|
||||||
// 4 — More: Cron, Skills, Settings, plus the destructive
|
// 4 — More: Cron, Skills, Settings, plus the destructive
|
||||||
// "Forget this server" action. Named "More" because on
|
// "Forget this server" action. Named "More" because on
|
||||||
@@ -76,11 +84,13 @@ struct ScarfGoTabRoot: View {
|
|||||||
.tabItem {
|
.tabItem {
|
||||||
Label("More", systemImage: "ellipsis.circle")
|
Label("More", systemImage: "ellipsis.circle")
|
||||||
}
|
}
|
||||||
|
.tag(ScarfGoCoordinator.Tab.more)
|
||||||
}
|
}
|
||||||
// Pulls the sidebar-on-iPad affordance into the same code path
|
// Pulls the sidebar-on-iPad affordance into the same code path
|
||||||
// as the bottom-bar-on-iPhone one. No-op on iPhone today.
|
// as the bottom-bar-on-iPhone one. No-op on iPhone today.
|
||||||
.tabViewStyle(.sidebarAdaptable)
|
.tabViewStyle(.sidebarAdaptable)
|
||||||
.environment(\.serverContext, ctx)
|
.environment(\.serverContext, ctx)
|
||||||
|
.environment(\.scarfGoCoordinator, coordinator)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -20,6 +20,8 @@ struct ChatView: View {
|
|||||||
let config: IOSServerConfig
|
let config: IOSServerConfig
|
||||||
let key: SSHKeyBundle
|
let key: SSHKeyBundle
|
||||||
|
|
||||||
|
@Environment(\.scarfGoCoordinator) private var coordinator
|
||||||
|
@Environment(\.serverContext) private var envContext
|
||||||
@State private var controller: ChatController
|
@State private var controller: ChatController
|
||||||
|
|
||||||
init(config: IOSServerConfig, key: SSHKeyBundle) {
|
init(config: IOSServerConfig, key: SSHKeyBundle) {
|
||||||
@@ -56,7 +58,28 @@ struct ChatView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.task {
|
.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 {
|
.onDisappear {
|
||||||
Task { await controller.stop() }
|
Task { await controller.stop() }
|
||||||
@@ -430,6 +453,72 @@ final class ChatController {
|
|||||||
await start()
|
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.
|
/// Dispatch the user's answer to a pending permission request.
|
||||||
/// Called by `PermissionSheet`.
|
/// Called by `PermissionSheet`.
|
||||||
func respondToPermission(requestId: Int, optionId: String) async {
|
func respondToPermission(requestId: Int, optionId: String) async {
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ struct DashboardView: View {
|
|||||||
let config: IOSServerConfig
|
let config: IOSServerConfig
|
||||||
let key: SSHKeyBundle
|
let key: SSHKeyBundle
|
||||||
|
|
||||||
|
@Environment(\.scarfGoCoordinator) private var coordinator
|
||||||
@State private var vm: IOSDashboardViewModel
|
@State private var vm: IOSDashboardViewModel
|
||||||
|
|
||||||
/// Stable ID used when building the `ServerContext` — tied to the
|
/// Stable ID used when building the `ServerContext` — tied to the
|
||||||
@@ -66,22 +67,34 @@ struct DashboardView: View {
|
|||||||
if !vm.recentSessions.isEmpty {
|
if !vm.recentSessions.isEmpty {
|
||||||
Section("Recent sessions") {
|
Section("Recent sessions") {
|
||||||
ForEach(vm.recentSessions) { session in
|
ForEach(vm.recentSessions) { session in
|
||||||
VStack(alignment: .leading, spacing: 4) {
|
Button {
|
||||||
Text(session.displayTitle)
|
// Route to Chat tab with a resume
|
||||||
.font(.body)
|
// request for this session id. Chat
|
||||||
.lineLimit(2)
|
// will pick it up from the coordinator
|
||||||
HStack(spacing: 12) {
|
// on next appear (M9 #4.1).
|
||||||
Label(session.source, systemImage: session.sourceIcon)
|
coordinator?.resumeSession(session.id)
|
||||||
.font(.caption)
|
} label: {
|
||||||
.foregroundStyle(.secondary)
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
if let started = session.startedAt {
|
Text(session.displayTitle)
|
||||||
Text(started, format: .relative(presentation: .numeric))
|
.font(.body)
|
||||||
|
.lineLimit(2)
|
||||||
|
.foregroundStyle(.primary)
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
Label(session.source, systemImage: session.sourceIcon)
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
.foregroundStyle(.secondary)
|
.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