feat(mac-sessions): project filter + badges (v2.5 parity with iOS)

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) <noreply@anthropic.com>
This commit is contained in:
Alan Wizemann
2026-04-25 07:54:34 +02:00
parent 4fc12ca790
commit 1174c5abc7
3 changed files with 172 additions and 13 deletions
@@ -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,11 +212,22 @@ struct SessionRow: View {
VStack(alignment: .leading, spacing: 2) {
Text(preview ?? session.displayTitle)
.lineLimit(1)
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()
HStack(spacing: 12) {
@@ -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()
}
@@ -17,6 +17,10 @@ struct SessionsView: View {
statsBar(stats)
Divider()
}
if !viewModel.allProjects.isEmpty {
filterBar
Divider()
}
HSplitView {
sessionList
.frame(minWidth: 280, idealWidth: 320)
@@ -104,8 +108,12 @@ struct SessionsView: View {
}
}
} else {
ForEach(viewModel.sessions) { session in
SessionRow(session: session, preview: viewModel.previewFor(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) }
@@ -119,6 +127,83 @@ struct SessionsView: View {
.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 {