From 3b3c037fce1ac80d782db97ec000e61557d6836d Mon Sep 17 00:00:00 2001 From: Alan Wizemann Date: Fri, 24 Apr 2026 14:38:02 +0200 Subject: [PATCH] M9 #4.5 (pass-2): project context surfaced in Chat nav + Dashboard rows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pass-2 UX feedback: "When selecting a per-project chat, we should update the chat interface to show that we are 'in a project' — and label them in the sessions list so the user can see the session and understand what project it belongs to." Two related changes: **In-chat indicator** — ChatController gains `currentProjectName`, set by `resetAndStartInProject` (direct: we have the ProjectEntry) and by `startResuming` (resolved via SessionAttributionService + project registry lookup). ChatView's toolbar uses a `.principal` ToolbarItem with a VStack: "Chat" title on top, `Label(name, systemImage: "folder.fill")` subtitle underneath when attributed. Mirrors Mac's SessionInfoBar project-chip pattern but fits the iOS nav-bar real estate instead of eating a full-width horizontal row. **Dashboard row labels** — `IOSDashboardViewModel.load()` now does one additional SFTP read per refresh: pulls the session→project sidecar + project registry, maps session id → project display name into `sessionProjectNames`. Row renders a small tinted folder capsule when attributed. Batched so row renders are O(1) dict lookups — no extra SFTP traffic per cell. Silent on failure (attribution is cosmetic). Not in scope for this commit: Mac's global Sessions list doesn't currently show project attribution either — that gap exists on both platforms, but wiring Mac's ProjectsSidebar + SessionsView for per-row labels is a bigger surgery. Scoped as a post-TestFlight followup. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../ScarfIOS/IOSDashboardViewModel.swift | 38 +++++++++++++++++++ scarf/Scarf iOS/Chat/ChatView.swift | 37 ++++++++++++++++++ scarf/Scarf iOS/Dashboard/DashboardView.swift | 16 ++++++++ 3 files changed, 91 insertions(+) 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)