From 1174c5abc724a6e8a0746ea334f9966bd0a759a7 Mon Sep 17 00:00:00 2001 From: Alan Wizemann Date: Sat, 25 Apr 2026 07:54:34 +0200 Subject: [PATCH] feat(mac-sessions): project filter + badges (v2.5 parity with iOS) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Mac global Sessions feature rendered all sessions with no project context. ScarfGo's new Sessions tab added a project filter Menu and badge chips on each row in v2.5 — bring the same to Mac so v2.5 lands as a user-visible upgrade on both platforms, not just iOS. Changes: - `SessionsViewModel`: load `~/.hermes/scarf/session_project_map.json` + the project registry off the main actor (single batched read, matches the iOS Dashboard pattern). Exposes `sessionProjectNames`, `allProjects`, `projectFilter`, `filteredSessions`, and `projectName(for:)`. - `SessionsView`: filter bar above the list (shown only when at least one project is registered) with a Menu listing "All projects", "Unattributed", and each registered project. An xmark button clears the filter. The right side shows "X of Y shown" so the filter's effect is obvious. - `SessionRow` (shared with Dashboard): gains an optional `projectName: String?` parameter that renders a tinted folder chip alongside the relative date when set. Both services already lived in ScarfCore (moved there in v2.5's iOS work), so this is pure UI consumption — no new shared logic. Verified: Mac build succeeds. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Dashboard/Views/DashboardView.swift | 24 +++- .../ViewModels/SessionsViewModel.swift | 58 ++++++++++ .../Sessions/Views/SessionsView.swift | 103 ++++++++++++++++-- 3 files changed, 172 insertions(+), 13 deletions(-) diff --git a/scarf/scarf/Features/Dashboard/Views/DashboardView.swift b/scarf/scarf/Features/Dashboard/Views/DashboardView.swift index c2febcb..209ed6a 100644 --- a/scarf/scarf/Features/Dashboard/Views/DashboardView.swift +++ b/scarf/scarf/Features/Dashboard/Views/DashboardView.swift @@ -198,6 +198,11 @@ struct StatCard: View { struct SessionRow: View { let session: HermesSession var preview: String? + /// Optional project display name to render as a chip below the title. + /// Nil for unattributed / quick-chat sessions. Surfaced on the + /// Sessions list (v2.5) and reusable from any other site that wants + /// the same visual. + var projectName: String? = nil var body: some View { HStack { @@ -207,10 +212,21 @@ struct SessionRow: View { VStack(alignment: .leading, spacing: 2) { Text(preview ?? session.displayTitle) .lineLimit(1) - if let date = session.startedAt { - Text(date, style: .relative) - .font(.caption) - .foregroundStyle(.secondary) + HStack(spacing: 6) { + if let date = session.startedAt { + Text(date, style: .relative) + .font(.caption) + .foregroundStyle(.secondary) + } + if let projectName, !projectName.isEmpty { + Label(projectName, systemImage: "folder.fill") + .font(.caption2) + .foregroundStyle(.tint) + .labelStyle(.titleAndIcon) + .padding(.vertical, 1) + .padding(.horizontal, 5) + .background(.tint.opacity(0.12), in: Capsule()) + } } } Spacer() diff --git a/scarf/scarf/Features/Sessions/ViewModels/SessionsViewModel.swift b/scarf/scarf/Features/Sessions/ViewModels/SessionsViewModel.swift index 2cf5f42..d7ee42c 100644 --- a/scarf/scarf/Features/Sessions/ViewModels/SessionsViewModel.swift +++ b/scarf/scarf/Features/Sessions/ViewModels/SessionsViewModel.swift @@ -37,6 +37,40 @@ final class SessionsViewModel { var showDeleteConfirmation = false var deleteSessionId: String? + // MARK: - Project attribution (v2.5) + // + // Session-to-project lookup populated from `~/.hermes/scarf/session_project_map.json` + // + the project registry. Drives the "Project" filter Menu above the + // list and the badge chip in each session row. Mirrors the same + // services iOS uses on the Dashboard's Sessions tab — both platforms + // read the same sidecar. + + /// session ID → project display name. Empty when no sessions on screen + /// are project-attributed. + private(set) var sessionProjectNames: [String: String] = [:] + /// Every project in the registry, used to populate the filter Menu. + private(set) var allProjects: [ProjectEntry] = [] + /// Currently selected project filter. + /// - `nil` (default): show all sessions. + /// - `""` sentinel: show only unattributed sessions. + /// - any other string: project name to match against `sessionProjectNames`. + var projectFilter: String? + + /// Sessions to actually render — applies `projectFilter` over `sessions`. + /// Inset is O(n) which is fine at the 500-session window we load. + var filteredSessions: [HermesSession] { + guard let filter = projectFilter else { return sessions } + if filter.isEmpty { + return sessions.filter { sessionProjectNames[$0.id] == nil } + } + return sessions.filter { sessionProjectNames[$0.id] == filter } + } + + /// Project display name for a session, or nil for unattributed. + func projectName(for session: HermesSession) -> String? { + sessionProjectNames[session.id] + } + func load() async { // refresh() forces a fresh snapshot on remote contexts. The DB stays // open after load() so selectSession()/search() can query without @@ -45,6 +79,30 @@ final class SessionsViewModel { guard opened else { return } sessions = await dataService.fetchSessions(limit: 500) sessionPreviews = await dataService.fetchSessionPreviews(limit: 500) + + // Load attribution + registry off the main actor in one batch so + // 500 rows don't trigger 500 SFTP reads. Failure is silent — the + // absence of project labels is a cosmetic degradation, not a + // data-loss problem (matches the iOS Dashboard pattern). + let ctx = context + let bundle: (names: [String: String], projects: [ProjectEntry]) = await Task.detached { + let attribution = SessionAttributionService(context: ctx) + let registry = ProjectDashboardService(context: ctx).loadRegistry() + let pathToName = Dictionary( + uniqueKeysWithValues: registry.projects.map { ($0.path, $0.name) } + ) + let map = attribution.load().mappings + var names: [String: String] = [:] + for (sessionID, path) in map { + if let name = pathToName[path] { + names[sessionID] = name + } + } + return (names: names, projects: registry.projects) + }.value + sessionProjectNames = bundle.names + allProjects = bundle.projects + computeStats() } diff --git a/scarf/scarf/Features/Sessions/Views/SessionsView.swift b/scarf/scarf/Features/Sessions/Views/SessionsView.swift index d8093da..c8c6f88 100644 --- a/scarf/scarf/Features/Sessions/Views/SessionsView.swift +++ b/scarf/scarf/Features/Sessions/Views/SessionsView.swift @@ -17,6 +17,10 @@ struct SessionsView: View { statsBar(stats) Divider() } + if !viewModel.allProjects.isEmpty { + filterBar + Divider() + } HSplitView { sessionList .frame(minWidth: 280, idealWidth: 320) @@ -104,21 +108,102 @@ struct SessionsView: View { } } } else { - ForEach(viewModel.sessions) { session in - SessionRow(session: session, preview: viewModel.previewFor(session)) - .tag(session.id) - .contextMenu { - Button("Rename...") { viewModel.beginRename(session) } - Button("Export...") { viewModel.exportSession(session) } - Divider() - Button("Delete...", role: .destructive) { viewModel.beginDelete(session) } - } + ForEach(viewModel.filteredSessions) { session in + SessionRow( + session: session, + preview: viewModel.previewFor(session), + projectName: viewModel.projectName(for: session) + ) + .tag(session.id) + .contextMenu { + Button("Rename...") { viewModel.beginRename(session) } + Button("Export...") { viewModel.exportSession(session) } + Divider() + Button("Delete...", role: .destructive) { viewModel.beginDelete(session) } + } } } } .listStyle(.inset) } + /// Project filter Menu shown above the list when at least one + /// project is registered. Mirrors the Dashboard's Sessions tab on + /// iOS, with an "Unattributed" entry for quick-chat / pre-v2.3 + /// sessions that have no project mapping. + private var filterBar: some View { + HStack(spacing: 8) { + Menu { + Button { + viewModel.projectFilter = nil + } label: { + Label("All projects", systemImage: "tray.full") + } + Button { + viewModel.projectFilter = "" + } label: { + Label("Unattributed", systemImage: "questionmark.folder") + } + Divider() + ForEach(viewModel.allProjects.sorted { $0.name < $1.name }) { project in + Button { + viewModel.projectFilter = project.name + } label: { + Label(project.name, systemImage: "folder.fill") + } + } + } label: { + HStack(spacing: 6) { + Image(systemName: filterIconName) + Text(filterLabel) + .lineLimit(1) + Image(systemName: "chevron.down") + .font(.caption2) + } + .font(.caption) + .foregroundStyle(.tint) + .padding(.horizontal, 10) + .padding(.vertical, 4) + .background(.tint.opacity(0.1), in: Capsule()) + } + .menuStyle(.borderlessButton) + .fixedSize() + + if viewModel.projectFilter != nil { + Button { + viewModel.projectFilter = nil + } label: { + Image(systemName: "xmark.circle.fill") + .foregroundStyle(.secondary) + } + .buttonStyle(.plain) + .help("Clear filter") + } + + Spacer() + + Text("\(viewModel.filteredSessions.count) of \(viewModel.sessions.count) shown") + .font(.caption) + .foregroundStyle(.secondary) + } + .padding(.horizontal, 12) + .padding(.vertical, 6) + } + + private var filterIconName: String { + viewModel.projectFilter == nil + ? "line.3.horizontal.decrease.circle" + : "line.3.horizontal.decrease.circle.fill" + } + + private var filterLabel: String { + switch viewModel.projectFilter { + case .none: return "All projects" + case .some(let s) where s.isEmpty: return "Unattributed" + case .some(let s): return s + } + } + @ViewBuilder private var sessionDetail: some View { if let session = viewModel.selectedSession {