diff --git a/scarf/Packages/ScarfIOS/Sources/ScarfIOS/IOSDashboardViewModel.swift b/scarf/Packages/ScarfIOS/Sources/ScarfIOS/IOSDashboardViewModel.swift index 3b052b4..d28a3e2 100644 --- a/scarf/Packages/ScarfIOS/Sources/ScarfIOS/IOSDashboardViewModel.swift +++ b/scarf/Packages/ScarfIOS/Sources/ScarfIOS/IOSDashboardViewModel.swift @@ -32,6 +32,13 @@ public final class IOSDashboardViewModel { public var sessionPreviews: [String: String] = [:] public var isLoading: Bool = true + /// session-id → project display name, for sessions attributed to + /// a registered Scarf project. Populated in `load()` by a single + /// SFTP read of `session_project_map.json` + the project registry; + /// subsequent row renders are O(1) dict lookups. Empty when no + /// sessions on screen are attributed. + public private(set) var sessionProjectNames: [String: String] = [:] + /// Surfaced when the SQLite snapshot or DB open fails. Shown in a /// yellow banner above the stats with a "Retry" button. `nil` means /// the last load was healthy. @@ -58,10 +65,41 @@ public final class IOSDashboardViewModel { recentSessions = await dataService.fetchSessions(limit: 5) sessionPreviews = await dataService.fetchSessionPreviews(limit: 5) + // Attribution lookup (pass-2 UX): load the session→project + // sidecar + project registry once so Dashboard rows can show + // which project each session belongs to. Batched (not per-row) + // so we don't pay a SFTP round-trip for every Recent Sessions + // cell. Failure is silent — the absence of project labels is + // a cosmetic degradation, not a data-loss problem. + let ctx = context + let attributions: [String: String] = await Task.detached { + let attribution = SessionAttributionService(context: ctx) + let projectRegistry = ProjectDashboardService(context: ctx).loadRegistry() + let pathToName = Dictionary( + uniqueKeysWithValues: projectRegistry.projects.map { ($0.path, $0.name) } + ) + let map = attribution.load().mappings + var result: [String: String] = [:] + for (sessionID, path) in map { + if let name = pathToName[path] { + result[sessionID] = name + } + } + return result + }.value + sessionProjectNames = attributions + await dataService.close() isLoading = false } + /// Helper used by DashboardView rows. Returns the project display + /// name a session is attributed to, or nil for unattributed + /// sessions (CLI-started, or started before v2.3). + public func projectName(for session: HermesSession) -> String? { + sessionProjectNames[session.id] + } + /// Called from the pull-to-refresh gesture. public func refresh() async { await load() diff --git a/scarf/Scarf iOS/Chat/ChatView.swift b/scarf/Scarf iOS/Chat/ChatView.swift index 0ca1825..09f1e99 100644 --- a/scarf/Scarf iOS/Chat/ChatView.swift +++ b/scarf/Scarf iOS/Chat/ChatView.swift @@ -49,6 +49,21 @@ struct ChatView: View { .navigationTitle("Chat") .navigationBarTitleDisplayMode(.inline) .toolbar { + // Principal: "Chat" title + small folder chip below when + // the current session is project-attributed. iOS-native + // equivalent of Mac's SessionInfoBar project-chip pattern. + ToolbarItem(placement: .principal) { + VStack(spacing: 1) { + Text("Chat") + .font(.headline) + if let projectName = controller.currentProjectName { + Label(projectName, systemImage: "folder.fill") + .font(.caption2) + .foregroundStyle(.tint) + .lineLimit(1) + } + } + } ToolbarItem(placement: .topBarTrailing) { Button { showProjectPicker = true @@ -357,6 +372,13 @@ final class ChatController { private(set) var state: State = .idle var vm: RichChatViewModel var draft: String = "" + /// Display name of the Scarf project this session is scoped to, + /// or nil for "quick chat" / global sessions. Surfaced as a + /// subtitle under the "Chat" title in the nav bar so users can + /// see at a glance which project the agent is operating inside. + /// Set by `resetAndStartInProject` and by `startResuming` when + /// the resumed session is attributed to a registered project. + private(set) var currentProjectName: String? private let context: ServerContext private var client: ACPClient? @@ -475,6 +497,7 @@ final class ChatController { func resetAndStartNewSession() async { await stop() vm.reset() + currentProjectName = nil await start() } @@ -486,6 +509,7 @@ final class ChatController { func resetAndStartInProject(_ project: ProjectEntry) async { await stop() vm.reset() + currentProjectName = project.name // Write the context block first. Non-fatal on failure — chat // still starts, just without the managed block; the user sees // the error via controller.state if it escalates. @@ -595,6 +619,19 @@ final class ChatController { func startResuming(sessionID: String) async { await stop() vm.reset() + // Resolve the project name for this session (if any) via the + // attribution sidecar + project registry. Set BEFORE the ACP + // handshake so the nav-bar subtitle is visible the moment the + // "Connecting…" overlay disappears. Run off-thread so we + // don't block while the SFTP reads happen. + let ctx = context + currentProjectName = await Task.detached { + let attribution = SessionAttributionService(context: ctx) + guard let path = attribution.projectPath(for: sessionID) else { return nil } + let registry = ProjectDashboardService(context: ctx).loadRegistry() + return registry.projects.first(where: { $0.path == path })?.name + }.value + state = .connecting let client = ACPClient.forIOSApp( context: context, diff --git a/scarf/Scarf iOS/Dashboard/DashboardView.swift b/scarf/Scarf iOS/Dashboard/DashboardView.swift index 764f799..3d57b3b 100644 --- a/scarf/Scarf iOS/Dashboard/DashboardView.swift +++ b/scarf/Scarf iOS/Dashboard/DashboardView.swift @@ -89,6 +89,22 @@ struct DashboardView: View { .foregroundStyle(.secondary) } } + // Project chip — only shows for + // attributed sessions. Small + tinted + // so it pops without dominating the + // row. Pass-2 UX recommendation: + // users wanted to see at a glance + // which project each session + // belongs to. + if let projectName = vm.projectName(for: session) { + Label(projectName, systemImage: "folder.fill") + .font(.caption2) + .foregroundStyle(.tint) + .labelStyle(.titleAndIcon) + .padding(.vertical, 2) + .padding(.horizontal, 6) + .background(.tint.opacity(0.12), in: Capsule()) + } } .padding(.vertical, 2) .frame(maxWidth: .infinity, alignment: .leading)