M9 #4.5 (pass-2): project context surfaced in Chat nav + Dashboard rows

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) <noreply@anthropic.com>
This commit is contained in:
Alan Wizemann
2026-04-24 14:38:02 +02:00
parent 1c2939dbbe
commit 3b3c037fce
3 changed files with 91 additions and 0 deletions
@@ -32,6 +32,13 @@ public final class IOSDashboardViewModel {
public var sessionPreviews: [String: String] = [:] public var sessionPreviews: [String: String] = [:]
public var isLoading: Bool = true 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 /// Surfaced when the SQLite snapshot or DB open fails. Shown in a
/// yellow banner above the stats with a "Retry" button. `nil` means /// yellow banner above the stats with a "Retry" button. `nil` means
/// the last load was healthy. /// the last load was healthy.
@@ -58,10 +65,41 @@ public final class IOSDashboardViewModel {
recentSessions = await dataService.fetchSessions(limit: 5) recentSessions = await dataService.fetchSessions(limit: 5)
sessionPreviews = await dataService.fetchSessionPreviews(limit: 5) sessionPreviews = await dataService.fetchSessionPreviews(limit: 5)
// Attribution lookup (pass-2 UX): load the sessionproject
// 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() await dataService.close()
isLoading = false 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. /// Called from the pull-to-refresh gesture.
public func refresh() async { public func refresh() async {
await load() await load()
+37
View File
@@ -49,6 +49,21 @@ struct ChatView: View {
.navigationTitle("Chat") .navigationTitle("Chat")
.navigationBarTitleDisplayMode(.inline) .navigationBarTitleDisplayMode(.inline)
.toolbar { .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) { ToolbarItem(placement: .topBarTrailing) {
Button { Button {
showProjectPicker = true showProjectPicker = true
@@ -357,6 +372,13 @@ final class ChatController {
private(set) var state: State = .idle private(set) var state: State = .idle
var vm: RichChatViewModel var vm: RichChatViewModel
var draft: String = "" 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 let context: ServerContext
private var client: ACPClient? private var client: ACPClient?
@@ -475,6 +497,7 @@ final class ChatController {
func resetAndStartNewSession() async { func resetAndStartNewSession() async {
await stop() await stop()
vm.reset() vm.reset()
currentProjectName = nil
await start() await start()
} }
@@ -486,6 +509,7 @@ final class ChatController {
func resetAndStartInProject(_ project: ProjectEntry) async { func resetAndStartInProject(_ project: ProjectEntry) async {
await stop() await stop()
vm.reset() vm.reset()
currentProjectName = project.name
// Write the context block first. Non-fatal on failure chat // Write the context block first. Non-fatal on failure chat
// still starts, just without the managed block; the user sees // still starts, just without the managed block; the user sees
// the error via controller.state if it escalates. // the error via controller.state if it escalates.
@@ -595,6 +619,19 @@ final class ChatController {
func startResuming(sessionID: String) async { func startResuming(sessionID: String) async {
await stop() await stop()
vm.reset() 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 state = .connecting
let client = ACPClient.forIOSApp( let client = ACPClient.forIOSApp(
context: context, context: context,
@@ -89,6 +89,22 @@ struct DashboardView: View {
.foregroundStyle(.secondary) .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) .padding(.vertical, 2)
.frame(maxWidth: .infinity, alignment: .leading) .frame(maxWidth: .infinity, alignment: .leading)