mirror of
https://github.com/awizemann/scarf.git
synced 2026-05-10 10:36:35 +00:00
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:
@@ -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 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()
|
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()
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user