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 {