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 scarfDir: String { home + "/scarf" }
|
||||
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" }
|
||||
|
||||
// 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
|
||||
|
||||
func startNewSession() {
|
||||
func startNewSession(projectPath: String? = nil) {
|
||||
voiceEnabled = false
|
||||
ttsEnabled = false
|
||||
isRecording = false
|
||||
richChatViewModel.reset()
|
||||
|
||||
if displayMode == .richChat {
|
||||
startACPSession(resume: nil)
|
||||
startACPSession(resume: nil, projectPath: projectPath)
|
||||
} 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"])
|
||||
}
|
||||
}
|
||||
@@ -289,13 +294,14 @@ final class ChatViewModel {
|
||||
|
||||
// MARK: - ACP Session Management
|
||||
|
||||
private func startACPSession(resume sessionId: String?) {
|
||||
private func startACPSession(resume sessionId: String?, projectPath: String? = nil) {
|
||||
stopACP()
|
||||
clearACPErrorState()
|
||||
acpStatus = "Starting..."
|
||||
|
||||
let client = ACPClient(context: context)
|
||||
self.acpClient = client
|
||||
let attribution = SessionAttributionService(context: context)
|
||||
|
||||
Task { @MainActor in
|
||||
do {
|
||||
@@ -305,7 +311,19 @@ final class ChatViewModel {
|
||||
startACPEventLoop(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
|
||||
// and doesn't wipe messages with a DB refresh
|
||||
@@ -334,6 +352,17 @@ final class ChatViewModel {
|
||||
richChatViewModel.setSessionId(resolvedSessionId)
|
||||
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
|
||||
await loadRecentSessions()
|
||||
|
||||
|
||||
@@ -3,10 +3,12 @@ import SwiftUI
|
||||
struct ChatView: View {
|
||||
@Environment(ChatViewModel.self) private var viewModel
|
||||
@Environment(HermesFileWatcher.self) private var fileWatcher
|
||||
@Environment(AppCoordinator.self) private var coordinator
|
||||
@State private var showErrorDetails = false
|
||||
|
||||
var body: some View {
|
||||
@Bindable var vm = viewModel
|
||||
@Bindable var coord = coordinator
|
||||
VStack(spacing: 0) {
|
||||
toolbar
|
||||
Divider()
|
||||
@@ -17,11 +19,30 @@ struct ChatView: View {
|
||||
.task {
|
||||
await viewModel.loadRecentSessions()
|
||||
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) {
|
||||
Task { await viewModel.loadRecentSessions() }
|
||||
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
|
||||
|
||||
@@ -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 {
|
||||
case dashboard = "Dashboard"
|
||||
case site = "Site"
|
||||
case sessions = "Sessions"
|
||||
|
||||
var displayName: LocalizedStringResource {
|
||||
switch self {
|
||||
case .dashboard: return "Dashboard"
|
||||
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(.top)
|
||||
.padding(.bottom, 8)
|
||||
if siteWidget != nil {
|
||||
tabBar
|
||||
.padding(.horizontal)
|
||||
.padding(.bottom, 8)
|
||||
}
|
||||
// Sessions tab is always present in v2.3, so the tab
|
||||
// bar always renders when a dashboard is loaded.
|
||||
// Site tab filters out when there's no webview widget
|
||||
// (existing v2.2 behavior preserved).
|
||||
tabBar
|
||||
.padding(.horizontal)
|
||||
.padding(.bottom, 8)
|
||||
switch selectedTab {
|
||||
case .dashboard:
|
||||
widgetsTab(dashboard)
|
||||
@@ -347,6 +359,12 @@ struct ProjectsView: View {
|
||||
} else {
|
||||
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 {
|
||||
@@ -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 {
|
||||
HStack(spacing: 0) {
|
||||
ForEach(DashboardTab.allCases, id: \.self) { tab in
|
||||
ForEach(visibleTabs, id: \.self) { tab in
|
||||
Button {
|
||||
selectedTab = tab
|
||||
} label: {
|
||||
HStack(spacing: 4) {
|
||||
Image(systemName: tab == .dashboard ? "square.grid.2x2" : "globe")
|
||||
Image(systemName: tab.systemImage)
|
||||
.font(.caption)
|
||||
Text(tab.displayName)
|
||||
.font(.subheadline)
|
||||
|
||||
@@ -91,4 +91,15 @@ final class AppCoordinator {
|
||||
var selectedSection: SidebarSection = .dashboard
|
||||
var selectedSessionId: 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