mirror of
https://github.com/awizemann/scarf.git
synced 2026-05-10 10:36:35 +00:00
feat(projects): per-project Sessions tab + sidecar attribution
Third and final v2.3 commit. Adds the Sessions tab alongside Dashboard and Site, and introduces the attribution sidecar that makes per-project session filtering possible without any upstream Hermes change. ## Sidecar Hermes's state.db has no cwd column on sessions — the cwd passed to `hermes acp` at session create is ephemeral from its side. Scarf now records session_id → project_path in ~/.hermes/scarf/session_project_map.json, owned end-to-end by Scarf. Written atomically on session creation; read by the per- project Sessions tab. Missing file = empty map; corrupt file = empty map (logged warning, no crash). Forward-only attribution: only sessions Scarf starts with a project context get mapped; CLI- started sessions still surface in the global Sessions sidebar unchanged. New pieces: - Core/Models/SessionProjectMap.swift — Codable sidecar shape (mappings dict + updatedAt timestamp). - Core/Services/SessionAttributionService.swift — load / attribute / forget / reverse-lookup, all idempotent, all going through atomic write. - HermesPathSet.sessionProjectMap — canonical path resolution. ## Chat plumbing ChatViewModel.startNewSession and the private startACPSession gain an optional projectPath parameter. When non-nil it overrides the default cwd = context.resolvedUserHome() and, on successful session creation, SessionAttributionService.attribute is called. Default-nil call sites keep v2.2 behavior exactly — terminal-mode chats and the global "New Chat" button are unaffected. ## Coordinator handoff AppCoordinator gains pendingProjectChat: String?. The per-project Sessions tab sets it + switches selectedSection = .chat. ChatView observes it (.task cold-launch + .onChange live), consumes the path by calling startNewSession(projectPath:), and clears the field. Clean separation: the Projects feature never reaches into ChatViewModel directly. ## UI - New DashboardTab.sessions case in ProjectsView. Tab bar now always renders when a dashboard is loaded (was gated on siteWidget before); .site still filters out when there's no webview widget. - ProjectSessionsView — per-project session list with a "New Chat" button. Empty-state hint distinguishes "no attributions yet" from "stale sidecar entries". Reuses HermesDataService.fetchSessions and filters by the attribution map. - ProjectSessionRow — local row view independent of the global sessions sidebar so the two can evolve separately. ## Tests SessionAttributionServiceTests (7 tests): - Missing file → empty map - attribute writes + persists via fresh service instance - attribute is idempotent (same pair twice doesn't bump timestamp) - re-attribute changes mapping (session moves between projects) - reverse lookup returns all + distinguishes by project - forget removes mapping, is idempotent on missing sessions - Corrupted JSON → empty map, no crash 80/80 Swift tests pass (was 73; 7 new). 24/24 Python tests still pass. Both prep + feature commits stand independently; commit 3 depends on commit 1 (folder/archive fields) and commit 2 (sidebar UI) only for the full flow to work end-to-end. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -55,6 +55,18 @@ struct HermesPathSet: Sendable, Hashable {
|
|||||||
nonisolated var gatewayLog: String { home + "/logs/gateway.log" }
|
nonisolated var gatewayLog: String { home + "/logs/gateway.log" }
|
||||||
nonisolated var scarfDir: String { home + "/scarf" }
|
nonisolated var scarfDir: String { home + "/scarf" }
|
||||||
nonisolated var projectsRegistry: String { scarfDir + "/projects.json" }
|
nonisolated var projectsRegistry: String { scarfDir + "/projects.json" }
|
||||||
|
|
||||||
|
/// Maps Hermes session IDs to the Scarf project path a chat was
|
||||||
|
/// started for. Written by `SessionAttributionService` when
|
||||||
|
/// Scarf spawns `hermes acp` with a project-scoped cwd; read by
|
||||||
|
/// the per-project Sessions tab (v2.3) to filter the session list
|
||||||
|
/// to just those attributed to a given project.
|
||||||
|
///
|
||||||
|
/// Scarf-owned — Hermes never touches this file. Forward-only:
|
||||||
|
/// we only attribute sessions Scarf creates in a project context;
|
||||||
|
/// older / CLI-started sessions stay unattributed and surface in
|
||||||
|
/// the global Sessions sidebar unchanged.
|
||||||
|
nonisolated var sessionProjectMap: String { scarfDir + "/session_project_map.json" }
|
||||||
nonisolated var mcpTokensDir: String { home + "/mcp-tokens" }
|
nonisolated var mcpTokensDir: String { home + "/mcp-tokens" }
|
||||||
|
|
||||||
// MARK: - Binary resolution
|
// MARK: - Binary resolution
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Scarf-owned sidecar mapping Hermes session IDs to the Scarf
|
||||||
|
/// project path a chat was started for. Written on session create
|
||||||
|
/// when Scarf spawns `hermes acp` with a project-scoped cwd; read
|
||||||
|
/// by the per-project Sessions tab.
|
||||||
|
///
|
||||||
|
/// Hermes's own `state.db` has no `cwd` column on the sessions
|
||||||
|
/// table — the cwd is passed at runtime via ACP but not persisted
|
||||||
|
/// on its side. This sidecar is how we recover the attribution
|
||||||
|
/// without requiring an upstream schema change.
|
||||||
|
///
|
||||||
|
/// Stored at `~/.hermes/scarf/session_project_map.json`. Forward-
|
||||||
|
/// compatible: if Hermes ever gains a canonical `cwd` column, Scarf
|
||||||
|
/// can prefer that and fall back to this file for pre-upgrade
|
||||||
|
/// sessions. Missing file → empty map (nothing attributed yet).
|
||||||
|
struct SessionProjectMap: Codable, Sendable {
|
||||||
|
/// session-id → absolute-project-path. Both strings are opaque
|
||||||
|
/// from this file's perspective; the service validates project
|
||||||
|
/// paths against the live registry when building the reverse
|
||||||
|
/// lookup used by the Sessions tab, so stale entries for
|
||||||
|
/// removed projects are ignored at read time without needing a
|
||||||
|
/// write-side cleanup.
|
||||||
|
var mappings: [String: String]
|
||||||
|
|
||||||
|
/// ISO-8601 timestamp of the most recent write. Informational
|
||||||
|
/// only — not used for any decision logic. Useful when debugging
|
||||||
|
/// a stale sidecar ("when was this last updated?").
|
||||||
|
var updatedAt: String?
|
||||||
|
|
||||||
|
init(mappings: [String: String] = [:], updatedAt: String? = nil) {
|
||||||
|
self.mappings = mappings
|
||||||
|
self.updatedAt = updatedAt
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Current time in ISO-8601 format, suitable for the
|
||||||
|
/// `updatedAt` field. Matches the format used elsewhere in
|
||||||
|
/// Scarf (e.g. `TemplateLock.installedAt`) so tooling that
|
||||||
|
/// greps across .json files sees consistent timestamps.
|
||||||
|
static func nowISO8601() -> String {
|
||||||
|
ISO8601DateFormatter().string(from: Date())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
import Foundation
|
||||||
|
import os
|
||||||
|
|
||||||
|
/// Owns the sidecar that attributes Hermes session IDs to Scarf
|
||||||
|
/// project paths. The `cwd` passed to `hermes acp` at session
|
||||||
|
/// creation is ephemeral from Hermes's perspective (not written to
|
||||||
|
/// `state.db`), so Scarf keeps this Scarf-owned record parallel to
|
||||||
|
/// Hermes's session store.
|
||||||
|
///
|
||||||
|
/// File: `~/.hermes/scarf/session_project_map.json` (resolved via
|
||||||
|
/// `HermesPathSet.sessionProjectMap`).
|
||||||
|
///
|
||||||
|
/// Thread safety: all public methods are `nonisolated` and each
|
||||||
|
/// performs a single read-modify-write cycle that's atomic on
|
||||||
|
/// disk. Concurrent writers (two Scarf windows on the same
|
||||||
|
/// `~/.hermes`) are safe at the file level — last write wins —
|
||||||
|
/// but the in-memory read in one window may lag until that window
|
||||||
|
/// reloads. Acceptable for v2.3's scale; revisit if multi-window
|
||||||
|
/// cross-talk becomes a problem.
|
||||||
|
struct SessionAttributionService: Sendable {
|
||||||
|
private static let logger = Logger(subsystem: "com.scarf", category: "SessionAttributionService")
|
||||||
|
|
||||||
|
let context: ServerContext
|
||||||
|
|
||||||
|
nonisolated init(context: ServerContext = .local) {
|
||||||
|
self.context = context
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Read
|
||||||
|
|
||||||
|
/// Load the current sidecar contents. Missing file or unparseable
|
||||||
|
/// JSON returns an empty map — the sidecar is a convenience
|
||||||
|
/// index, not a source of truth for anything load-bearing.
|
||||||
|
nonisolated func load() -> SessionProjectMap {
|
||||||
|
let path = context.paths.sessionProjectMap
|
||||||
|
let transport = context.makeTransport()
|
||||||
|
guard transport.fileExists(path) else {
|
||||||
|
return SessionProjectMap()
|
||||||
|
}
|
||||||
|
do {
|
||||||
|
let data = try transport.readFile(path)
|
||||||
|
return try JSONDecoder().decode(SessionProjectMap.self, from: data)
|
||||||
|
} catch {
|
||||||
|
Self.logger.warning("session-project-map parse failed at \(path, privacy: .public): \(error.localizedDescription, privacy: .public); returning empty map")
|
||||||
|
return SessionProjectMap()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Look up the project path a given session was attributed to.
|
||||||
|
/// Returns nil for unattributed sessions (CLI-started, or
|
||||||
|
/// started before v2.3) — those surface in the global Sessions
|
||||||
|
/// sidebar unchanged and don't appear in any project's Sessions
|
||||||
|
/// tab.
|
||||||
|
nonisolated func projectPath(for sessionID: String) -> String? {
|
||||||
|
load().mappings[sessionID]
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reverse lookup: every session ID attributed to the given
|
||||||
|
/// project path. Used by the per-project Sessions tab to filter
|
||||||
|
/// the global session list. Comparison is exact-string; the
|
||||||
|
/// registry stores absolute paths and we write absolute paths,
|
||||||
|
/// so no normalisation is needed in practice.
|
||||||
|
nonisolated func sessionIDs(forProject projectPath: String) -> Set<String> {
|
||||||
|
let map = load()
|
||||||
|
return Set(map.mappings.filter { $0.value == projectPath }.keys)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Write
|
||||||
|
|
||||||
|
/// Record that `sessionID` was created under the given project
|
||||||
|
/// path. Idempotent — repeated calls for the same pair are no-
|
||||||
|
/// ops. Replacing an existing mapping (session moved to a
|
||||||
|
/// different project) is legal but expected to be rare; the
|
||||||
|
/// caller decides when that's correct.
|
||||||
|
nonisolated func attribute(sessionID: String, toProjectPath projectPath: String) {
|
||||||
|
var map = load()
|
||||||
|
if map.mappings[sessionID] == projectPath {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
map.mappings[sessionID] = projectPath
|
||||||
|
map.updatedAt = SessionProjectMap.nowISO8601()
|
||||||
|
persist(map)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Remove a mapping. Called in v2.3's Sessions-tab code path is
|
||||||
|
/// minimal — we don't currently prune on session delete because
|
||||||
|
/// Hermes owns session lifecycle and we don't observe deletes.
|
||||||
|
/// Exposed for future roadmap items (e.g. explicit "detach
|
||||||
|
/// from project" action) and tests.
|
||||||
|
nonisolated func forget(sessionID: String) {
|
||||||
|
var map = load()
|
||||||
|
guard map.mappings.removeValue(forKey: sessionID) != nil else { return }
|
||||||
|
map.updatedAt = SessionProjectMap.nowISO8601()
|
||||||
|
persist(map)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Private
|
||||||
|
|
||||||
|
private func persist(_ map: SessionProjectMap) {
|
||||||
|
let path = context.paths.sessionProjectMap
|
||||||
|
let transport = context.makeTransport()
|
||||||
|
let dir = context.paths.scarfDir
|
||||||
|
do {
|
||||||
|
if !transport.fileExists(dir) {
|
||||||
|
try transport.createDirectory(dir)
|
||||||
|
}
|
||||||
|
let encoder = JSONEncoder()
|
||||||
|
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
|
||||||
|
let data = try encoder.encode(map)
|
||||||
|
try transport.writeFile(path, data: data)
|
||||||
|
} catch {
|
||||||
|
Self.logger.error("failed to persist session-project-map at \(path, privacy: .public): \(error.localizedDescription, privacy: .public)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -118,15 +118,20 @@ final class ChatViewModel {
|
|||||||
|
|
||||||
// MARK: - Session Lifecycle
|
// MARK: - Session Lifecycle
|
||||||
|
|
||||||
func startNewSession() {
|
func startNewSession(projectPath: String? = nil) {
|
||||||
voiceEnabled = false
|
voiceEnabled = false
|
||||||
ttsEnabled = false
|
ttsEnabled = false
|
||||||
isRecording = false
|
isRecording = false
|
||||||
richChatViewModel.reset()
|
richChatViewModel.reset()
|
||||||
|
|
||||||
if displayMode == .richChat {
|
if displayMode == .richChat {
|
||||||
startACPSession(resume: nil)
|
startACPSession(resume: nil, projectPath: projectPath)
|
||||||
} else {
|
} else {
|
||||||
|
// Terminal mode doesn't surface project attribution today —
|
||||||
|
// `hermes chat` uses the shell's cwd, so starting a terminal
|
||||||
|
// chat from a project button would require changing the
|
||||||
|
// shell's cwd too. Out of scope for v2.3 — Rich Chat is
|
||||||
|
// the primary surface for project-scoped sessions.
|
||||||
launchTerminal(arguments: ["chat"])
|
launchTerminal(arguments: ["chat"])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -289,13 +294,14 @@ final class ChatViewModel {
|
|||||||
|
|
||||||
// MARK: - ACP Session Management
|
// MARK: - ACP Session Management
|
||||||
|
|
||||||
private func startACPSession(resume sessionId: String?) {
|
private func startACPSession(resume sessionId: String?, projectPath: String? = nil) {
|
||||||
stopACP()
|
stopACP()
|
||||||
clearACPErrorState()
|
clearACPErrorState()
|
||||||
acpStatus = "Starting..."
|
acpStatus = "Starting..."
|
||||||
|
|
||||||
let client = ACPClient(context: context)
|
let client = ACPClient(context: context)
|
||||||
self.acpClient = client
|
self.acpClient = client
|
||||||
|
let attribution = SessionAttributionService(context: context)
|
||||||
|
|
||||||
Task { @MainActor in
|
Task { @MainActor in
|
||||||
do {
|
do {
|
||||||
@@ -305,7 +311,19 @@ final class ChatViewModel {
|
|||||||
startACPEventLoop(client: client)
|
startACPEventLoop(client: client)
|
||||||
startHealthMonitor(client: client)
|
startHealthMonitor(client: client)
|
||||||
|
|
||||||
let cwd = await context.resolvedUserHome()
|
// Project-scoped chats pass the project's absolute path
|
||||||
|
// as cwd so Hermes tool calls and subsequent ACP ops
|
||||||
|
// resolve relative paths against the project's files.
|
||||||
|
// Falls back to the user's home (existing v2.2 behavior)
|
||||||
|
// when the caller didn't request a project scope.
|
||||||
|
// `??` can't wrap an async autoclosure, so we
|
||||||
|
// materialize the fallback with an if-let.
|
||||||
|
let cwd: String
|
||||||
|
if let projectPath {
|
||||||
|
cwd = projectPath
|
||||||
|
} else {
|
||||||
|
cwd = await context.resolvedUserHome()
|
||||||
|
}
|
||||||
|
|
||||||
// Mark active BEFORE setting session ID so .task(id:) sees isACPMode=true
|
// Mark active BEFORE setting session ID so .task(id:) sees isACPMode=true
|
||||||
// and doesn't wipe messages with a DB refresh
|
// and doesn't wipe messages with a DB refresh
|
||||||
@@ -334,6 +352,17 @@ final class ChatViewModel {
|
|||||||
richChatViewModel.setSessionId(resolvedSessionId)
|
richChatViewModel.setSessionId(resolvedSessionId)
|
||||||
acpStatus = "Connected (\(resolvedSessionId.prefix(12)))"
|
acpStatus = "Connected (\(resolvedSessionId.prefix(12)))"
|
||||||
|
|
||||||
|
// Attribute this session to the project it was started
|
||||||
|
// under, so the per-project Sessions tab can surface it
|
||||||
|
// without a user action. No-op when projectPath is nil.
|
||||||
|
// Idempotent: re-attribution of the same pair is free.
|
||||||
|
if let projectPath {
|
||||||
|
attribution.attribute(
|
||||||
|
sessionID: resolvedSessionId,
|
||||||
|
toProjectPath: projectPath
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// Refresh session list so the new ACP session appears in the Resume menu
|
// Refresh session list so the new ACP session appears in the Resume menu
|
||||||
await loadRecentSessions()
|
await loadRecentSessions()
|
||||||
|
|
||||||
|
|||||||
@@ -3,10 +3,12 @@ import SwiftUI
|
|||||||
struct ChatView: View {
|
struct ChatView: View {
|
||||||
@Environment(ChatViewModel.self) private var viewModel
|
@Environment(ChatViewModel.self) private var viewModel
|
||||||
@Environment(HermesFileWatcher.self) private var fileWatcher
|
@Environment(HermesFileWatcher.self) private var fileWatcher
|
||||||
|
@Environment(AppCoordinator.self) private var coordinator
|
||||||
@State private var showErrorDetails = false
|
@State private var showErrorDetails = false
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
@Bindable var vm = viewModel
|
@Bindable var vm = viewModel
|
||||||
|
@Bindable var coord = coordinator
|
||||||
VStack(spacing: 0) {
|
VStack(spacing: 0) {
|
||||||
toolbar
|
toolbar
|
||||||
Divider()
|
Divider()
|
||||||
@@ -17,11 +19,30 @@ struct ChatView: View {
|
|||||||
.task {
|
.task {
|
||||||
await viewModel.loadRecentSessions()
|
await viewModel.loadRecentSessions()
|
||||||
viewModel.refreshCredentialPreflight()
|
viewModel.refreshCredentialPreflight()
|
||||||
|
// Cold-launch handoff: if the user clicked "New Chat" on
|
||||||
|
// a project before ChatView had a chance to render, the
|
||||||
|
// coordinator was already populated. Consume the request
|
||||||
|
// here. The onChange below handles the live case.
|
||||||
|
if let pending = coordinator.pendingProjectChat {
|
||||||
|
coordinator.pendingProjectChat = nil
|
||||||
|
viewModel.startNewSession(projectPath: pending)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.onChange(of: fileWatcher.lastChangeDate) {
|
.onChange(of: fileWatcher.lastChangeDate) {
|
||||||
Task { await viewModel.loadRecentSessions() }
|
Task { await viewModel.loadRecentSessions() }
|
||||||
viewModel.refreshCredentialPreflight()
|
viewModel.refreshCredentialPreflight()
|
||||||
}
|
}
|
||||||
|
// Live handoff from the per-project Sessions tab: the tab
|
||||||
|
// sets `pendingProjectChat` + flips `selectedSection` to
|
||||||
|
// `.chat`; this view consumes the path and starts a fresh
|
||||||
|
// session with cwd=projectPath. Attribution happens inside
|
||||||
|
// ChatViewModel on successful session creation.
|
||||||
|
.onChange(of: coord.pendingProjectChat) { _, new in
|
||||||
|
if let projectPath = new {
|
||||||
|
coordinator.pendingProjectChat = nil
|
||||||
|
viewModel.startNewSession(projectPath: projectPath)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Banner rendered between the toolbar and the chat area when either
|
/// Banner rendered between the toolbar and the chat area when either
|
||||||
|
|||||||
@@ -0,0 +1,76 @@
|
|||||||
|
import Foundation
|
||||||
|
import os
|
||||||
|
|
||||||
|
/// Drives the per-project Sessions tab introduced in v2.3. Pulls the
|
||||||
|
/// global session list from `HermesDataService`, filters by the
|
||||||
|
/// attribution sidecar, and exposes a minimal surface for the view:
|
||||||
|
/// the filtered sessions array, loading state, and a refresh entry
|
||||||
|
/// point that the view can call on appearance + on file-watcher
|
||||||
|
/// change.
|
||||||
|
@Observable
|
||||||
|
@MainActor
|
||||||
|
final class ProjectSessionsViewModel {
|
||||||
|
private static let logger = Logger(subsystem: "com.scarf", category: "ProjectSessionsViewModel")
|
||||||
|
|
||||||
|
private let dataService: HermesDataService
|
||||||
|
private let attribution: SessionAttributionService
|
||||||
|
private let project: ProjectEntry
|
||||||
|
|
||||||
|
init(context: ServerContext, project: ProjectEntry) {
|
||||||
|
self.dataService = HermesDataService(context: context)
|
||||||
|
self.attribution = SessionAttributionService(context: context)
|
||||||
|
self.project = project
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sessions attributed to the owning project, in the order
|
||||||
|
/// `HermesDataService.fetchSessions` returns them (newest first).
|
||||||
|
var sessions: [HermesSession] = []
|
||||||
|
|
||||||
|
/// True from `load()` start to its completion. The view renders
|
||||||
|
/// a ProgressView during the first fetch; afterwards, re-fetches
|
||||||
|
/// triggered by file-watcher changes happen silently.
|
||||||
|
var isLoading: Bool = false
|
||||||
|
|
||||||
|
/// Short diagnostic string for an empty list — nil when sessions
|
||||||
|
/// are loaded and populated, otherwise explains the empty state
|
||||||
|
/// (no sessions ever created in this project, vs. no sessions
|
||||||
|
/// matched the project's attribution map).
|
||||||
|
var emptyStateHint: String?
|
||||||
|
|
||||||
|
/// Refresh the session list. Safe to call repeatedly; the data
|
||||||
|
/// service reconnects to state.db on demand and the attribution
|
||||||
|
/// service reads the sidecar afresh each call.
|
||||||
|
func load() async {
|
||||||
|
isLoading = true
|
||||||
|
defer { isLoading = false }
|
||||||
|
|
||||||
|
let attributed = attribution.sessionIDs(forProject: project.path)
|
||||||
|
if attributed.isEmpty {
|
||||||
|
sessions = []
|
||||||
|
emptyStateHint = "No chats have been started in this project yet. Click New Chat to begin."
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch a generous page; we filter client-side by attribution
|
||||||
|
// map membership. The 200 ceiling matches other feature VMs
|
||||||
|
// (ActivityViewModel, InsightsViewModel). HermesDataService
|
||||||
|
// is an actor so this crosses the isolation boundary — the
|
||||||
|
// SQLite read happens off the MainActor. If a single project
|
||||||
|
// accumulates more than 200 attributed sessions, we'll need
|
||||||
|
// a paged query; roadmap item, not a v2.3 problem.
|
||||||
|
let all = await dataService.fetchSessions(limit: 200)
|
||||||
|
let filtered = all.filter { attributed.contains($0.id) }
|
||||||
|
sessions = filtered
|
||||||
|
|
||||||
|
if filtered.isEmpty {
|
||||||
|
// Attribution map has entries but none appear in the
|
||||||
|
// recent session fetch — likely stale sidecar entries
|
||||||
|
// for sessions Hermes has since deleted. The view shows
|
||||||
|
// an informational empty state; pruning stale entries
|
||||||
|
// is a roadmap follow-up, not a blocker.
|
||||||
|
emptyStateHint = "This project has \(attributed.count) attributed session\(attributed.count == 1 ? "" : "s"), but none are in the recent history. They may have been deleted from Hermes."
|
||||||
|
} else {
|
||||||
|
emptyStateHint = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,181 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
/// Per-project Sessions tab (v2.3). Lives beside the Dashboard and
|
||||||
|
/// Site tabs in the project view; populated from the session
|
||||||
|
/// attribution sidecar maintained by ChatViewModel. A "New Chat"
|
||||||
|
/// button spawns a fresh ACP session at cwd = project.path and
|
||||||
|
/// routes the user into the Chat feature via AppCoordinator.
|
||||||
|
struct ProjectSessionsView: View {
|
||||||
|
let project: ProjectEntry
|
||||||
|
|
||||||
|
@Environment(AppCoordinator.self) private var coordinator
|
||||||
|
@Environment(HermesFileWatcher.self) private var fileWatcher
|
||||||
|
@Environment(\.serverContext) private var serverContext
|
||||||
|
|
||||||
|
@State private var viewModel: ProjectSessionsViewModel?
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
header
|
||||||
|
Divider()
|
||||||
|
content
|
||||||
|
}
|
||||||
|
.task(id: project.id) {
|
||||||
|
// Rebuild the VM when the project changes so stale state
|
||||||
|
// from a previously-selected project doesn't bleed
|
||||||
|
// through.
|
||||||
|
viewModel = ProjectSessionsViewModel(
|
||||||
|
context: serverContext,
|
||||||
|
project: project
|
||||||
|
)
|
||||||
|
await viewModel?.load()
|
||||||
|
}
|
||||||
|
.onChange(of: fileWatcher.lastChangeDate) {
|
||||||
|
Task { await viewModel?.load() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Header
|
||||||
|
|
||||||
|
private var header: some View {
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text("Sessions in this project")
|
||||||
|
.font(.headline)
|
||||||
|
Text("Chats you start here get attributed automatically. Older CLI-started sessions live in the global Sessions sidebar.")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
|
}
|
||||||
|
Spacer()
|
||||||
|
Button {
|
||||||
|
// Route into the Chat feature with a cwd override.
|
||||||
|
// ChatView observes this via its onChange and starts
|
||||||
|
// a fresh session with projectPath = our project.
|
||||||
|
coordinator.pendingProjectChat = project.path
|
||||||
|
coordinator.selectedSection = .chat
|
||||||
|
} label: {
|
||||||
|
Label("New Chat", systemImage: "message.badge.filled.fill")
|
||||||
|
}
|
||||||
|
.buttonStyle(.borderedProminent)
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Content
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private var content: some View {
|
||||||
|
if let vm = viewModel {
|
||||||
|
if vm.isLoading && vm.sessions.isEmpty {
|
||||||
|
ProgressView()
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
|
} else if vm.sessions.isEmpty {
|
||||||
|
emptyState(hint: vm.emptyStateHint)
|
||||||
|
} else {
|
||||||
|
sessionList(vm.sessions)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
ProgressView()
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func emptyState(hint: String?) -> some View {
|
||||||
|
VStack(spacing: 10) {
|
||||||
|
Image(systemName: "bubble.left.and.bubble.right")
|
||||||
|
.font(.system(size: 36))
|
||||||
|
.foregroundStyle(.tertiary)
|
||||||
|
Text(hint ?? "No sessions yet.")
|
||||||
|
.font(.callout)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
|
.padding(.horizontal, 40)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func sessionList(_ sessions: [HermesSession]) -> some View {
|
||||||
|
List(sessions) { session in
|
||||||
|
ProjectSessionRow(session: session)
|
||||||
|
.contentShape(Rectangle())
|
||||||
|
.onTapGesture {
|
||||||
|
// Route into the Chat feature with this session
|
||||||
|
// as a resume target. Existing ChatView logic
|
||||||
|
// handles ACP reconnect.
|
||||||
|
coordinator.selectedSessionId = session.id
|
||||||
|
coordinator.selectedSection = .chat
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.listStyle(.plain)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Single row in the per-project Sessions list. Intentionally small
|
||||||
|
/// and self-contained so it can evolve independently of the global
|
||||||
|
/// Sessions sidebar's row UI — if the two visualisations diverge
|
||||||
|
/// (e.g. the project tab wants to hide the `source` badge that's
|
||||||
|
/// useful in the global list), they don't pull each other along.
|
||||||
|
private struct ProjectSessionRow: View {
|
||||||
|
let session: HermesSession
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
HStack(spacing: 10) {
|
||||||
|
Image(systemName: iconForSource(session.source))
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.frame(width: 22)
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text(displayTitle)
|
||||||
|
.font(.callout)
|
||||||
|
.lineLimit(1)
|
||||||
|
HStack(spacing: 6) {
|
||||||
|
Text(session.id.prefix(12))
|
||||||
|
.font(.caption2.monospaced())
|
||||||
|
.foregroundStyle(.tertiary)
|
||||||
|
if let started = formattedStart {
|
||||||
|
Text("·")
|
||||||
|
.foregroundStyle(.tertiary)
|
||||||
|
Text(started)
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Spacer(minLength: 12)
|
||||||
|
VStack(alignment: .trailing, spacing: 2) {
|
||||||
|
Text("\(session.messageCount)")
|
||||||
|
.font(.caption.monospaced())
|
||||||
|
Text("msgs")
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.vertical, 4)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var displayTitle: String {
|
||||||
|
if let t = session.title, !t.isEmpty { return t }
|
||||||
|
return "Untitled session"
|
||||||
|
}
|
||||||
|
|
||||||
|
private var formattedStart: String? {
|
||||||
|
// `startedAt` is `Date?` — the DB column can be null for
|
||||||
|
// sessions in unusual states. Locale-aware short form keeps
|
||||||
|
// us consistent with Insights + Activity.
|
||||||
|
guard let date = session.startedAt else { return nil }
|
||||||
|
let formatter = DateFormatter()
|
||||||
|
formatter.dateStyle = .short
|
||||||
|
formatter.timeStyle = .short
|
||||||
|
return formatter.string(from: date)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func iconForSource(_ source: String) -> String {
|
||||||
|
switch source.lowercased() {
|
||||||
|
case "cli", "acp": return "terminal"
|
||||||
|
case "telegram": return "paperplane"
|
||||||
|
case "discord": return "bubble.left.and.bubble.right"
|
||||||
|
default: return "message"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,11 +4,21 @@ import UniformTypeIdentifiers
|
|||||||
private enum DashboardTab: String, CaseIterable {
|
private enum DashboardTab: String, CaseIterable {
|
||||||
case dashboard = "Dashboard"
|
case dashboard = "Dashboard"
|
||||||
case site = "Site"
|
case site = "Site"
|
||||||
|
case sessions = "Sessions"
|
||||||
|
|
||||||
var displayName: LocalizedStringResource {
|
var displayName: LocalizedStringResource {
|
||||||
switch self {
|
switch self {
|
||||||
case .dashboard: return "Dashboard"
|
case .dashboard: return "Dashboard"
|
||||||
case .site: return "Site"
|
case .site: return "Site"
|
||||||
|
case .sessions: return "Sessions"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var systemImage: String {
|
||||||
|
switch self {
|
||||||
|
case .dashboard: return "square.grid.2x2"
|
||||||
|
case .site: return "globe"
|
||||||
|
case .sessions: return "bubble.left.and.bubble.right"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -333,11 +343,13 @@ struct ProjectsView: View {
|
|||||||
.padding(.horizontal)
|
.padding(.horizontal)
|
||||||
.padding(.top)
|
.padding(.top)
|
||||||
.padding(.bottom, 8)
|
.padding(.bottom, 8)
|
||||||
if siteWidget != nil {
|
// Sessions tab is always present in v2.3, so the tab
|
||||||
tabBar
|
// bar always renders when a dashboard is loaded.
|
||||||
.padding(.horizontal)
|
// Site tab filters out when there's no webview widget
|
||||||
.padding(.bottom, 8)
|
// (existing v2.2 behavior preserved).
|
||||||
}
|
tabBar
|
||||||
|
.padding(.horizontal)
|
||||||
|
.padding(.bottom, 8)
|
||||||
switch selectedTab {
|
switch selectedTab {
|
||||||
case .dashboard:
|
case .dashboard:
|
||||||
widgetsTab(dashboard)
|
widgetsTab(dashboard)
|
||||||
@@ -347,6 +359,12 @@ struct ProjectsView: View {
|
|||||||
} else {
|
} else {
|
||||||
widgetsTab(dashboard)
|
widgetsTab(dashboard)
|
||||||
}
|
}
|
||||||
|
case .sessions:
|
||||||
|
if let project = viewModel.selectedProject {
|
||||||
|
ProjectSessionsView(project: project)
|
||||||
|
} else {
|
||||||
|
ContentUnavailableView("No project selected", systemImage: "bubble.left.and.bubble.right")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else if let error = viewModel.dashboardError {
|
} else if let error = viewModel.dashboardError {
|
||||||
@@ -372,14 +390,23 @@ struct ProjectsView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Tabs that should appear for the current project. `.site` is
|
||||||
|
/// gated on the dashboard actually containing a webview widget,
|
||||||
|
/// per v2.2 behavior — the Site tab is meaningless without one.
|
||||||
|
private var visibleTabs: [DashboardTab] {
|
||||||
|
DashboardTab.allCases.filter { tab in
|
||||||
|
tab != .site || siteWidget != nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private var tabBar: some View {
|
private var tabBar: some View {
|
||||||
HStack(spacing: 0) {
|
HStack(spacing: 0) {
|
||||||
ForEach(DashboardTab.allCases, id: \.self) { tab in
|
ForEach(visibleTabs, id: \.self) { tab in
|
||||||
Button {
|
Button {
|
||||||
selectedTab = tab
|
selectedTab = tab
|
||||||
} label: {
|
} label: {
|
||||||
HStack(spacing: 4) {
|
HStack(spacing: 4) {
|
||||||
Image(systemName: tab == .dashboard ? "square.grid.2x2" : "globe")
|
Image(systemName: tab.systemImage)
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
Text(tab.displayName)
|
Text(tab.displayName)
|
||||||
.font(.subheadline)
|
.font(.subheadline)
|
||||||
|
|||||||
@@ -91,4 +91,15 @@ final class AppCoordinator {
|
|||||||
var selectedSection: SidebarSection = .dashboard
|
var selectedSection: SidebarSection = .dashboard
|
||||||
var selectedSessionId: String?
|
var selectedSessionId: String?
|
||||||
var selectedProjectName: String?
|
var selectedProjectName: String?
|
||||||
|
|
||||||
|
/// When non-nil, ChatView should start a fresh ACP session with
|
||||||
|
/// this absolute project path as cwd and then clear the value.
|
||||||
|
/// Wired from the per-project Sessions tab's "New Chat" button
|
||||||
|
/// (v2.3): the tab sets this, switches `selectedSection` to
|
||||||
|
/// `.chat`, and ChatView reacts on its next render.
|
||||||
|
///
|
||||||
|
/// Separate from `selectedSessionId` (which resumes an existing
|
||||||
|
/// session) — a new session needs a cwd override Scarf doesn't
|
||||||
|
/// yet have an id for.
|
||||||
|
var pendingProjectChat: String?
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,153 @@
|
|||||||
|
import Testing
|
||||||
|
import Foundation
|
||||||
|
@testable import scarf
|
||||||
|
|
||||||
|
/// Exercises the v2.3 sidecar at `~/.hermes/scarf/session_project_map.json`
|
||||||
|
/// via the real `ServerContext.local`. Each test snapshots + restores
|
||||||
|
/// the file through `TestRegistryLock` (reused — the sidecar lives
|
||||||
|
/// in the same scarf/ dir as projects.json, so serialising on one
|
||||||
|
/// lock prevents both cross-suite races).
|
||||||
|
///
|
||||||
|
/// We scope the shared lock to this file's registry helper so tests
|
||||||
|
/// here don't step on the real registry either.
|
||||||
|
@Suite(.serialized) struct SessionAttributionServiceTests {
|
||||||
|
|
||||||
|
@Test func loadOnMissingFileReturnsEmptyMap() throws {
|
||||||
|
let snapshot = Self.snapshot()
|
||||||
|
defer { Self.restore(snapshot) }
|
||||||
|
Self.deleteSidecar()
|
||||||
|
|
||||||
|
let svc = SessionAttributionService(context: .local)
|
||||||
|
let map = svc.load()
|
||||||
|
#expect(map.mappings.isEmpty)
|
||||||
|
#expect(svc.projectPath(for: "anything") == nil)
|
||||||
|
#expect(svc.sessionIDs(forProject: "/anything").isEmpty)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func attributeWritesMappingAndPersists() throws {
|
||||||
|
let snapshot = Self.snapshot()
|
||||||
|
defer { Self.restore(snapshot) }
|
||||||
|
Self.deleteSidecar()
|
||||||
|
|
||||||
|
let svc = SessionAttributionService(context: .local)
|
||||||
|
svc.attribute(sessionID: "sess-1", toProjectPath: "/proj/a")
|
||||||
|
|
||||||
|
// Read back via a fresh service instance — confirms the
|
||||||
|
// write actually landed on disk, not just the in-memory map.
|
||||||
|
let fresh = SessionAttributionService(context: .local)
|
||||||
|
#expect(fresh.projectPath(for: "sess-1") == "/proj/a")
|
||||||
|
|
||||||
|
// updatedAt populated on write.
|
||||||
|
let map = fresh.load()
|
||||||
|
let ts = try #require(map.updatedAt)
|
||||||
|
#expect(!ts.isEmpty)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func attributeIsIdempotent() throws {
|
||||||
|
let snapshot = Self.snapshot()
|
||||||
|
defer { Self.restore(snapshot) }
|
||||||
|
Self.deleteSidecar()
|
||||||
|
|
||||||
|
let svc = SessionAttributionService(context: .local)
|
||||||
|
svc.attribute(sessionID: "s", toProjectPath: "/p")
|
||||||
|
let firstStamp = svc.load().updatedAt
|
||||||
|
// Call again with the same pair — should short-circuit, NOT
|
||||||
|
// bump updatedAt. We check that the timestamp didn't change
|
||||||
|
// even if the file would have been rewritten.
|
||||||
|
svc.attribute(sessionID: "s", toProjectPath: "/p")
|
||||||
|
let secondStamp = svc.load().updatedAt
|
||||||
|
#expect(firstStamp == secondStamp)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func reattributeChangesMapping() throws {
|
||||||
|
let snapshot = Self.snapshot()
|
||||||
|
defer { Self.restore(snapshot) }
|
||||||
|
Self.deleteSidecar()
|
||||||
|
|
||||||
|
let svc = SessionAttributionService(context: .local)
|
||||||
|
svc.attribute(sessionID: "s", toProjectPath: "/a")
|
||||||
|
svc.attribute(sessionID: "s", toProjectPath: "/b")
|
||||||
|
#expect(svc.projectPath(for: "s") == "/b")
|
||||||
|
#expect(svc.sessionIDs(forProject: "/a").isEmpty)
|
||||||
|
#expect(svc.sessionIDs(forProject: "/b") == ["s"])
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func reverseLookupReturnsAllAttributedSessions() throws {
|
||||||
|
let snapshot = Self.snapshot()
|
||||||
|
defer { Self.restore(snapshot) }
|
||||||
|
Self.deleteSidecar()
|
||||||
|
|
||||||
|
let svc = SessionAttributionService(context: .local)
|
||||||
|
svc.attribute(sessionID: "s1", toProjectPath: "/proj")
|
||||||
|
svc.attribute(sessionID: "s2", toProjectPath: "/proj")
|
||||||
|
svc.attribute(sessionID: "s3", toProjectPath: "/other")
|
||||||
|
|
||||||
|
#expect(svc.sessionIDs(forProject: "/proj") == ["s1", "s2"])
|
||||||
|
#expect(svc.sessionIDs(forProject: "/other") == ["s3"])
|
||||||
|
#expect(svc.sessionIDs(forProject: "/nobody").isEmpty)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func forgetRemovesMapping() throws {
|
||||||
|
let snapshot = Self.snapshot()
|
||||||
|
defer { Self.restore(snapshot) }
|
||||||
|
Self.deleteSidecar()
|
||||||
|
|
||||||
|
let svc = SessionAttributionService(context: .local)
|
||||||
|
svc.attribute(sessionID: "s", toProjectPath: "/p")
|
||||||
|
#expect(svc.projectPath(for: "s") == "/p")
|
||||||
|
|
||||||
|
svc.forget(sessionID: "s")
|
||||||
|
#expect(svc.projectPath(for: "s") == nil)
|
||||||
|
// Forget on a missing session is a no-op, not an error.
|
||||||
|
svc.forget(sessionID: "s")
|
||||||
|
#expect(svc.projectPath(for: "s") == nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func corruptedFileReturnsEmptyMap() throws {
|
||||||
|
let snapshot = Self.snapshot()
|
||||||
|
defer { Self.restore(snapshot) }
|
||||||
|
// Write garbage to the sidecar path and confirm the service
|
||||||
|
// treats it as "no attributions" rather than crashing. Users
|
||||||
|
// hand-editing the JSON shouldn't soft-brick the Sessions tab.
|
||||||
|
let path = ServerContext.local.paths.sessionProjectMap
|
||||||
|
try FileManager.default.createDirectory(
|
||||||
|
atPath: (path as NSString).deletingLastPathComponent,
|
||||||
|
withIntermediateDirectories: true
|
||||||
|
)
|
||||||
|
try "not json at all".data(using: .utf8)!.write(to: URL(fileURLWithPath: path))
|
||||||
|
|
||||||
|
let svc = SessionAttributionService(context: .local)
|
||||||
|
let map = svc.load()
|
||||||
|
#expect(map.mappings.isEmpty)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Helpers
|
||||||
|
|
||||||
|
/// Snapshot + restore the sidecar file (and delete if missing).
|
||||||
|
/// Uses the shared TestRegistryLock so this suite serialises
|
||||||
|
/// with any other registry-writing suite — both touch scarfDir.
|
||||||
|
static func snapshot() -> (lockToken: Any, data: Data?) {
|
||||||
|
// Re-use the ProjectTemplateTests lock implementation —
|
||||||
|
// same NSLock gates all scarfDir writes across suites.
|
||||||
|
let projectSnapshot = TestRegistryLock.acquireAndSnapshot()
|
||||||
|
let path = ServerContext.local.paths.sessionProjectMap
|
||||||
|
let sidecarData = try? Data(contentsOf: URL(fileURLWithPath: path))
|
||||||
|
return (lockToken: projectSnapshot as Any, data: sidecarData)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func restore(_ snapshot: (lockToken: Any, data: Data?)) {
|
||||||
|
let path = ServerContext.local.paths.sessionProjectMap
|
||||||
|
if let data = snapshot.data {
|
||||||
|
try? data.write(to: URL(fileURLWithPath: path))
|
||||||
|
} else {
|
||||||
|
try? FileManager.default.removeItem(atPath: path)
|
||||||
|
}
|
||||||
|
// Release the shared lock via the existing helper.
|
||||||
|
TestRegistryLock.restore(snapshot.lockToken as? Data)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func deleteSidecar() {
|
||||||
|
let path = ServerContext.local.paths.sessionProjectMap
|
||||||
|
try? FileManager.default.removeItem(atPath: path)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user