From cd0edb8cb3561fb307c172d8be334bb634c029c8 Mon Sep 17 00:00:00 2001 From: StuBehan Date: Wed, 20 May 2026 20:53:29 +0100 Subject: [PATCH] feat(): session and event identification --- .../StackNudgePanelCoreTests/AgentTests.swift | 41 ++++ .../SessionColorTests.swift | 76 ++++++ .../SessionPersistenceTests.swift | 166 +++++++++++++ build.sh | 1 + panel/Panel.swift | 45 +++- panel/SessionPersistence.swift | 167 ++++++++++++++ panel/SessionStore.swift | 30 ++- panel/Sessions.swift | 218 +++++++++++++----- 8 files changed, 678 insertions(+), 66 deletions(-) create mode 100644 Tests/StackNudgePanelCoreTests/AgentTests.swift create mode 100644 Tests/StackNudgePanelCoreTests/SessionColorTests.swift create mode 100644 Tests/StackNudgePanelCoreTests/SessionPersistenceTests.swift create mode 100644 panel/SessionPersistence.swift diff --git a/Tests/StackNudgePanelCoreTests/AgentTests.swift b/Tests/StackNudgePanelCoreTests/AgentTests.swift new file mode 100644 index 0000000..390d5f6 --- /dev/null +++ b/Tests/StackNudgePanelCoreTests/AgentTests.swift @@ -0,0 +1,41 @@ +import XCTest + +@testable import StackNudgePanelCore + +// Agent.canonical bridges the names emitted on the notify.sh wire +// ("claude-code", "cursor", "gemini", "codex") to the names the process +// scanner sees ("claude", "gemini", "codex"). The whole event ↔ session +// link rides on this — a regression here would silently break the +// events-tab session label and the per-session color accent. +final class AgentTests: XCTestCase { + + func test_canonical_claudeCodeMapsToClaude() { + XCTAssertEqual(Agent.canonical("claude-code"), "claude") + } + + func test_canonical_cursorMapsToClaude() { + // Cursor wraps Claude Code under the hood; the process scanner + // observes a "claude" binary, so the canonical form must match. + XCTAssertEqual(Agent.canonical("cursor"), "claude") + } + + func test_canonical_claudeUnchanged() { + XCTAssertEqual(Agent.canonical("claude"), "claude") + } + + func test_canonical_geminiUnchanged() { + XCTAssertEqual(Agent.canonical("gemini"), "gemini") + } + + func test_canonical_codexUnchanged() { + XCTAssertEqual(Agent.canonical("codex"), "codex") + } + + func test_canonical_unknownAgentPassesThrough() { + XCTAssertEqual(Agent.canonical("some-future-agent"), "some-future-agent") + } + + func test_canonical_emptyStringPassesThrough() { + XCTAssertEqual(Agent.canonical(""), "") + } +} diff --git a/Tests/StackNudgePanelCoreTests/SessionColorTests.swift b/Tests/StackNudgePanelCoreTests/SessionColorTests.swift new file mode 100644 index 0000000..cee348d --- /dev/null +++ b/Tests/StackNudgePanelCoreTests/SessionColorTests.swift @@ -0,0 +1,76 @@ +import SwiftUI +import XCTest + +@testable import StackNudgePanelCore + +// SessionColor maps (canonical-agent, projectPath) → one of the 6 palette +// colors deterministically. Stability across launches is the whole point +// (Swift's built-in String hashing is randomized per launch as a security +// feature, so this hand-rolled FNV-1a is the contract). These tests pin +// that contract so a future "let's just use .hashValue" mistake fails +// loudly here instead of producing rainbow rows in the wild. +final class SessionColorTests: XCTestCase { + + func test_color_sameKey_returnsSameColor() { + let a = SessionColor.color(agent: "claude", projectPath: "/x") + let b = SessionColor.color(agent: "claude", projectPath: "/x") + XCTAssertNotNil(a) + XCTAssertEqual(a, b) + } + + func test_color_nilProjectPath_returnsNil() { + XCTAssertNil(SessionColor.color(agent: "claude", projectPath: nil)) + } + + func test_color_emptyProjectPath_returnsNil() { + XCTAssertNil(SessionColor.color(agent: "claude", projectPath: "")) + } + + func test_color_canonicalizesAgent() { + // claude-code and claude must resolve to the same color for the + // same project — same join key after canonicalization. + let viaWire = SessionColor.color(agent: "claude-code", projectPath: "/x") + let viaCanon = SessionColor.color(agent: "claude", projectPath: "/x") + let viaCursor = SessionColor.color(agent: "cursor", projectPath: "/x") + XCTAssertNotNil(viaWire) + XCTAssertEqual(viaWire, viaCanon) + XCTAssertEqual(viaCanon, viaCursor) + } + + func test_color_differentAgents_canHaveDifferentColors() { + // Different canonical agents (claude vs gemini) hash to different + // keys, so they MAY differ in color. We can't assert non-equality + // because the palette has only 6 slots and collisions are normal; + // we just confirm both resolve to valid palette members. + let a = SessionColor.color(agent: "claude", projectPath: "/x") + let b = SessionColor.color(agent: "gemini", projectPath: "/x") + XCTAssertNotNil(a) + XCTAssertNotNil(b) + XCTAssertTrue(SessionColor.palette.contains(a!)) + XCTAssertTrue(SessionColor.palette.contains(b!)) + } + + func test_color_isAlwaysFromPalette() { + // Sample a handful of distinct keys and confirm every result is + // a palette member — guards against an off-by-one in the modulo. + let probes: [(String, String)] = [ + ("claude", "/a"), + ("claude", "/b/c"), + ("gemini", "/a"), + ("codex", "/d/e/f"), + ("claude", "/Users/x/very/long/path/here"), + ("claude", "/"), + ] + for (agent, path) in probes { + let c = SessionColor.color(agent: agent, projectPath: path) + XCTAssertNotNil(c, "expected a color for (\(agent), \(path))") + XCTAssertTrue(SessionColor.palette.contains(c!), + "color for (\(agent), \(path)) is outside the palette") + } + } + + func test_palette_isNonEmpty() { + // A zero-sized palette would crash the modulo. Belt + braces. + XCTAssertFalse(SessionColor.palette.isEmpty) + } +} diff --git a/Tests/StackNudgePanelCoreTests/SessionPersistenceTests.swift b/Tests/StackNudgePanelCoreTests/SessionPersistenceTests.swift new file mode 100644 index 0000000..850595f --- /dev/null +++ b/Tests/StackNudgePanelCoreTests/SessionPersistenceTests.swift @@ -0,0 +1,166 @@ +import XCTest + +@testable import StackNudgePanelCore + +@MainActor +final class SessionPersistenceTests: XCTestCase { + + private var tempURL: URL! + + override func setUp() { + super.setUp() + // Unique path per test so parallel runs don't clobber each other, + // and so a leftover file from a crashed run doesn't bleed in. + tempURL = FileManager.default.temporaryDirectory + .appendingPathComponent("stack-nudge-persist-\(UUID().uuidString).json") + } + + override func tearDown() { + if let tempURL { try? FileManager.default.removeItem(at: tempURL) } + super.tearDown() + } + + private func makeStore() -> SessionPersistence { + SessionPersistence(path: tempURL) + } + + // MARK: - load / empty state + + func test_init_withNoFile_hasEmptyEntries() { + let store = makeStore() + XCTAssertTrue(store.entries.isEmpty) + XCTAssertNil(store.customName(agent: "claude", projectPath: "/x")) + } + + func test_load_malformedFile_startsFreshWithoutCrashing() throws { + try Data("{ not json".utf8).write(to: tempURL) + let store = makeStore() + XCTAssertTrue(store.entries.isEmpty) + // Verify the store is still usable after a recovered load. + store.setCustomName(agent: "claude", projectPath: "/x", "Auth") + XCTAssertEqual(store.customName(agent: "claude", projectPath: "/x"), "Auth") + } + + // MARK: - set / read roundtrip + + func test_setCustomName_persistsValue() { + let store = makeStore() + store.setCustomName(agent: "claude", projectPath: "/x", "Auth") + XCTAssertEqual(store.customName(agent: "claude", projectPath: "/x"), "Auth") + } + + func test_setCustomName_survivesNewInstanceFromSameFile() { + let first = makeStore() + first.setCustomName(agent: "claude", projectPath: "/x", "Auth") + + let second = makeStore() + XCTAssertEqual(second.customName(agent: "claude", projectPath: "/x"), "Auth") + } + + func test_setCustomName_trimsSurroundingWhitespace() { + let store = makeStore() + store.setCustomName(agent: "claude", projectPath: "/x", " Auth ") + XCTAssertEqual(store.customName(agent: "claude", projectPath: "/x"), "Auth") + } + + func test_setCustomName_doesNotDisturbOtherKeys() { + let store = makeStore() + store.setCustomName(agent: "claude", projectPath: "/auth", "Auth") + store.setCustomName(agent: "claude", projectPath: "/other", "Other") + XCTAssertEqual(store.customName(agent: "claude", projectPath: "/auth"), "Auth") + XCTAssertEqual(store.customName(agent: "claude", projectPath: "/other"), "Other") + } + + // MARK: - removal + + func test_setCustomName_emptyString_removesEntry() { + let store = makeStore() + store.setCustomName(agent: "claude", projectPath: "/x", "Auth") + store.setCustomName(agent: "claude", projectPath: "/x", "") + XCTAssertNil(store.customName(agent: "claude", projectPath: "/x")) + } + + func test_setCustomName_whitespaceOnly_removesEntry() { + let store = makeStore() + store.setCustomName(agent: "claude", projectPath: "/x", "Auth") + store.setCustomName(agent: "claude", projectPath: "/x", " \n ") + XCTAssertNil(store.customName(agent: "claude", projectPath: "/x")) + } + + func test_setCustomName_nil_removesEntry() { + let store = makeStore() + store.setCustomName(agent: "claude", projectPath: "/x", "Auth") + store.setCustomName(agent: "claude", projectPath: "/x", nil) + XCTAssertNil(store.customName(agent: "claude", projectPath: "/x")) + } + + func test_removal_survivesNewInstance() { + let first = makeStore() + first.setCustomName(agent: "claude", projectPath: "/x", "Auth") + first.setCustomName(agent: "claude", projectPath: "/x", nil) + + let second = makeStore() + XCTAssertNil(second.customName(agent: "claude", projectPath: "/x")) + } + + // MARK: - lookup edge cases + + func test_customName_unknownKey_returnsNil() { + let store = makeStore() + XCTAssertNil(store.customName(agent: "claude", projectPath: "/missing")) + } + + func test_customName_nilProjectPath_returnsNil() { + let store = makeStore() + store.setCustomName(agent: "claude", projectPath: "/x", "Auth") + XCTAssertNil(store.customName(agent: "claude", projectPath: nil)) + } + + // MARK: - agent canonicalization + + // notify.sh emits "claude-code" while SessionStore observes "claude" — + // the persistence layer must canonicalize both sides so an event for + // "claude-code" finds the entry the user wrote via a "claude" session. + func test_customName_canonicalizesAgent_claudeCodeFindsClaudeEntry() { + let store = makeStore() + store.setCustomName(agent: "claude", projectPath: "/x", "Auth") + XCTAssertEqual(store.customName(agent: "claude-code", projectPath: "/x"), "Auth") + } + + func test_setCustomName_canonicalizesAgent_cursorAndClaudeShareEntry() { + let store = makeStore() + store.setCustomName(agent: "cursor", projectPath: "/x", "Auth") + XCTAssertEqual(store.customName(agent: "claude", projectPath: "/x"), "Auth") + } + + // MARK: - noteSeen + + func test_noteSeen_unknownEntry_isNoOp() { + let store = makeStore() + XCTAssertFalse(FileManager.default.fileExists(atPath: tempURL.path)) + store.noteSeen(agent: "claude", projectPath: "/never-renamed") + // No write should have happened — we don't track unrenamed sessions. + XCTAssertFalse(FileManager.default.fileExists(atPath: tempURL.path)) + } + + func test_noteSeen_nilProjectPath_isNoOp() { + let store = makeStore() + store.setCustomName(agent: "claude", projectPath: "/x", "Auth") + // Doesn't crash and doesn't affect persisted state. + store.noteSeen(agent: "claude", projectPath: nil) + XCTAssertEqual(store.customName(agent: "claude", projectPath: "/x"), "Auth") + } + + // The second-call-same-launch guard is hard to observe externally; the + // best proxy is that lastSeenAt doesn't move backward and the value + // remains readable. Here we exercise the path without assertion of + // file mtime (too brittle), just confirming the entry still resolves. + func test_noteSeen_repeatCalls_stableLookup() { + let store = makeStore() + store.setCustomName(agent: "claude", projectPath: "/x", "Auth") + store.noteSeen(agent: "claude", projectPath: "/x") + store.noteSeen(agent: "claude", projectPath: "/x") + store.noteSeen(agent: "claude-code", projectPath: "/x") // canonicalized too + XCTAssertEqual(store.customName(agent: "claude", projectPath: "/x"), "Auth") + } +} diff --git a/build.sh b/build.sh index 244c853..0754b73 100755 --- a/build.sh +++ b/build.sh @@ -227,6 +227,7 @@ build_app "$APP" "stack-nudge" \ panel/MenuBar.swift \ panel/Permissions.swift \ panel/Settings.swift \ + panel/SessionPersistence.swift \ panel/SessionStore.swift \ panel/SessionUsage.swift \ panel/Sessions.swift \ diff --git a/panel/Panel.swift b/panel/Panel.swift index c6e3f35..cc086ea 100644 --- a/panel/Panel.swift +++ b/panel/Panel.swift @@ -102,7 +102,7 @@ struct PanelContentView: View { switch nav.mode { case .events: eventsBody - case .sessions: SessionsView(store: sessions) + case .sessions: SessionsView(store: sessions, events: store) case .usage: UsageView(nav: nav) case .settings: SettingsView(nav: nav) case .phrases: PhrasesView(model: phrases) { nav.mode = .settings } @@ -283,6 +283,11 @@ struct EventRow: View { let event: NudgeEvent let selected: Bool + // The disk-backed name store. Observed so a rename in the Sessions + // tab immediately re-renders any visible event for the same + // (agent, projectPath) — render-time lookup, not ingest-time snapshot. + @EnvironmentObject private var persistence: SessionPersistence + private static let timeFormatter: RelativeDateTimeFormatter = { let formatter = RelativeDateTimeFormatter() formatter.unitsStyle = .abbreviated @@ -302,11 +307,11 @@ struct EventRow: View { .font(.subheadline.weight(.medium)) .lineLimit(1) .truncationMode(.tail) - if let project = event.projectPath { + if let label = sessionLabel { Text("·") .font(.caption) .foregroundStyle(.tertiary) - Text((project as NSString).lastPathComponent) + Text(label) .font(.caption) .foregroundStyle(.tertiary) .lineLimit(1) @@ -328,11 +333,26 @@ struct EventRow: View { .padding(.horizontal, 10) .padding(.vertical, 8) .frame(maxWidth: .infinity, alignment: .leading) - .background( + .background(rowBackground) + .padding(.horizontal, 6) + } + + // Composes the selection tint with a 3pt left-edge accent in the + // session's stable color — same treatment as the Sessions tab card, + // so the eye can connect a nudge to its session at a glance. + private var rowBackground: some View { + ZStack(alignment: .leading) { RoundedRectangle(cornerRadius: 8, style: .continuous) .fill(selected ? Color.accentColor.opacity(0.22) : Color.clear) - ) - .padding(.horizontal, 6) + if let accent = SessionColor.color( + agent: event.agent, projectPath: event.projectPath + ) { + Rectangle() + .fill(accent.opacity(0.85)) + .frame(width: 3) + } + } + .clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous)) } private var isSnoozed: Bool { @@ -340,6 +360,17 @@ struct EventRow: View { return until > Date() } + // Show the user-chosen session name when one is set, otherwise the + // project folder's basename. Nil → no chip at all (events with no + // projectPath stay as a clean title row). + private var sessionLabel: String? { + if let custom = persistence.customName(agent: event.agent, projectPath: event.projectPath) { + return custom + } + guard let project = event.projectPath else { return nil } + return (project as NSString).lastPathComponent + } + // For snoozed events show "snoozed Xm" in place of the relative // timestamp so the user can see how long is left until the re-fire. private var rightTimestamp: String { @@ -422,7 +453,7 @@ final class PanelController: NSObject, NSApplicationDelegate, PanelKeyDelegate, let host = NSHostingView(rootView: PanelContentView( store: store, sessions: sessions, nav: nav, phrases: phrases, onGrantPermissions: { [weak self] in self?.handleGrantPermissions() } - )) + ).environmentObject(SessionPersistence.shared)) // Don't let SwiftUI's preferred / intrinsic content size drive // the NSPanel frame. The panel is user-resizable + size-persisted; // a tab whose root view reports a different sizeThatFits (e.g., diff --git a/panel/SessionPersistence.swift b/panel/SessionPersistence.swift new file mode 100644 index 0000000..0c155d7 --- /dev/null +++ b/panel/SessionPersistence.swift @@ -0,0 +1,167 @@ +import Foundation +import SwiftUI + +// Bridges the agent names emitted on the wire (notify.sh uses +// "claude-code", "cursor", "gemini", "codex") to the names the +// SessionStore observes from the process list ("claude", "gemini", +// "codex"). Without canonicalization, an event for "claude-code" would +// never match a session for "claude" and the events tab would lose its +// session label. +enum Agent { + static func canonical(_ raw: String) -> String { + switch raw { + case "claude-code", "cursor": return "claude" + default: return raw + } + } +} + +// Per-session settings that survive app restarts. Today the only thing +// we keep is the user-chosen display name; `lastSeenAt` exists so a +// future cleanup pass can evict long-dormant entries without us needing +// to re-shape the file. We deliberately do NOT store an entry for every +// session we ever observe — only entries the user has explicitly named. +// That keeps the file small, sidesteps cleanup for v1, and means an +// un-renamed session has zero on-disk footprint. +struct SessionEntry: Codable { + var customName: String + var lastSeenAt: TimeInterval +} + +// Disk-backed store of session names, keyed by "::". +// PID is intentionally not part of the key — pids churn on every shell +// restart, and the user's mental model is "this is the auth project", +// not "this is pid 12345". `terminalApp` is also out: notify.sh derives +// it from env vars, which are unreliable for non-standard shells; using +// it would silently break event-tab name resolution. +// +// Concurrency: all reads/writes happen on the main thread. SessionStore's +// background poll queue produces raw Sessions and hands them to merge() +// (which is dispatched to main) where the persistence lookup happens. +final class SessionPersistence: ObservableObject { + + static let shared = SessionPersistence() + + @Published private(set) var entries: [String: SessionEntry] = [:] + + private let url: URL + // Tracks which keys have already had lastSeenAt bumped this launch, + // so background polling can't trigger a disk write every 3 seconds + // even for active renamed sessions. + private var seenThisLaunch: Set = [] + + init(path: URL? = nil) { + let dir = URL(fileURLWithPath: NSHomeDirectory()) + .appendingPathComponent(".stack-nudge", isDirectory: true) + self.url = path ?? dir.appendingPathComponent("sessions.json", isDirectory: false) + load() + } + + // MARK: - Lookup + + func customName(agent: String, projectPath: String?) -> String? { + guard let projectPath else { return nil } + let key = Self.key(agent: Agent.canonical(agent), projectPath: projectPath) + let trimmed = entries[key]?.customName + guard let trimmed, !trimmed.isEmpty else { return nil } + return trimmed + } + + // MARK: - Mutation + + // Sets (or clears, when `name` is nil/empty) the custom name for a + // session keyed by (agent, projectPath). Persists immediately. + func setCustomName(agent: String, projectPath: String?, _ name: String?) { + guard let projectPath else { return } + let key = Self.key(agent: Agent.canonical(agent), projectPath: projectPath) + let trimmed = name?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + if trimmed.isEmpty { + guard entries.removeValue(forKey: key) != nil else { return } + } else { + entries[key] = SessionEntry( + customName: trimmed, + lastSeenAt: Date().timeIntervalSince1970 + ) + } + save() + } + + // Bump lastSeenAt for an existing entry, but only on the first sight + // per launch — that way the file is rewritten once per active named + // session per app run, not every poll tick. No-op for sessions we + // don't have an entry for; we don't track unrenamed sessions. + func noteSeen(agent: String, projectPath: String?) { + guard let projectPath else { return } + let key = Self.key(agent: Agent.canonical(agent), projectPath: projectPath) + guard !seenThisLaunch.contains(key), entries[key] != nil else { return } + seenThisLaunch.insert(key) + entries[key]?.lastSeenAt = Date().timeIntervalSince1970 + save() + } + + // MARK: - I/O + + private static func key(agent: String, projectPath: String) -> String { + "\(agent)::\(projectPath)" + } + + private func load() { + guard FileManager.default.fileExists(atPath: url.path), + let data = try? Data(contentsOf: url) else { return } + do { + entries = try JSONDecoder().decode([String: SessionEntry].self, from: data) + } catch { + // Don't crash — a malformed file shouldn't take down the app. + // Logging is enough; the next setCustomName call will rewrite. + FileHandle.standardError.write(Data( + "stack-nudge: sessions.json decode failed (\(error)); starting fresh\n".utf8)) + entries = [:] + } + } + + private func save() { + do { + try FileManager.default.createDirectory( + at: url.deletingLastPathComponent(), + withIntermediateDirectories: true + ) + let encoder = JSONEncoder() + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] + let data = try encoder.encode(entries) + // `.atomic` writes to a sibling temp file and rename()s — so + // a killed app can't leave a half-written JSON behind. + try data.write(to: url, options: .atomic) + } catch { + FileHandle.standardError.write(Data( + "stack-nudge: sessions.json save failed (\(error))\n".utf8)) + } + } +} + +// Deterministic per-session color. Used as a left-edge accent on session +// cards and event rows so the eye can connect "this nudge" to "this +// session" without reading the project label. Swift's built-in String +// hashing is randomized per launch (a security feature), so we roll our +// own FNV-1a — same project → same color across restarts. +enum SessionColor { + + static let palette: [Color] = [ + .blue, .teal, .mint, .indigo, .purple, .pink, + ] + + static func color(agent: String, projectPath: String?) -> Color? { + guard let projectPath, !projectPath.isEmpty else { return nil } + let key = "\(Agent.canonical(agent))::\(projectPath)" + let idx = Int(fnv1a(key) % UInt32(palette.count)) + return palette[idx] + } + + private static func fnv1a(_ s: String) -> UInt32 { + var hash: UInt32 = 2166136261 + for byte in s.utf8 { + hash ^= UInt32(byte) + hash = hash &* 16777619 + } + return hash + } +} diff --git a/panel/SessionStore.swift b/panel/SessionStore.swift index bdcbfb4..2bfaad4 100644 --- a/panel/SessionStore.swift +++ b/panel/SessionStore.swift @@ -32,11 +32,16 @@ final class SessionStore: ObservableObject { @Published private(set) var isScanning: Bool = false @Published private(set) var didFirstScan: Bool = false + private let persistence: SessionPersistence private var pollTimer: Timer? private let queue = DispatchQueue(label: "stack-nudge.sessions", qos: .utility) private static let agentBinaries: Set = ["claude", "gemini", "codex"] private static let pollInterval: TimeInterval = 3.0 + init(persistence: SessionPersistence = .shared) { + self.persistence = persistence + } + func startPolling() { scan() pollTimer?.invalidate() @@ -61,9 +66,15 @@ final class SessionStore: ObservableObject { } func rename(_ pid: Int, to name: String?) { - if let idx = sessions.firstIndex(where: { $0.pid == pid }) { - sessions[idx].customName = (name?.isEmpty ?? true) ? nil : name - } + guard let idx = sessions.firstIndex(where: { $0.pid == pid }) else { return } + let trimmed = name?.trimmingCharacters(in: .whitespaces) + let final: String? = (trimmed?.isEmpty ?? true) ? nil : trimmed + sessions[idx].customName = final + persistence.setCustomName( + agent: sessions[idx].agent, + projectPath: sessions[idx].projectPath, + final + ) } func startRenaming(_ pid: Int) { @@ -121,9 +132,18 @@ final class SessionStore: ObservableObject { } } - // Add genuinely new sessions. + // Add genuinely new sessions, seeding customName from persistence + // so a renamed (agent, projectPath) keeps its name across restarts + // and across process churn within a single launch. let knownPIDs = Set(next.map(\.pid)) - for session in found where !knownPIDs.contains(session.pid) { + for var session in found where !knownPIDs.contains(session.pid) { + if let persisted = persistence.customName( + agent: session.agent, + projectPath: session.projectPath + ) { + session.customName = persisted + persistence.noteSeen(agent: session.agent, projectPath: session.projectPath) + } next.append(session) } diff --git a/panel/Sessions.swift b/panel/Sessions.swift index 87c46b7..2cdf663 100644 --- a/panel/Sessions.swift +++ b/panel/Sessions.swift @@ -4,6 +4,7 @@ import SwiftUI struct SessionsView: View { @ObservedObject var store: SessionStore + @ObservedObject var events: EventStore var body: some View { VStack(alignment: .leading, spacing: 0) { @@ -45,6 +46,8 @@ struct SessionsView: View { selected: store.selectedPID == session.pid, isEditing: store.renamingPID == session.pid, renameBuffer: $store.renameBuffer, + activeNudgeCount: nudgeCount(for: session), + lastNudgeAt: lastNudgeAt(for: session), onCommit: { store.commitRename() }, onCancel: { store.cancelRename() } ) @@ -56,6 +59,7 @@ struct SessionsView: View { .padding(.vertical, 4) .background(ThinScrollers()) } + .frame(maxHeight: .infinity) .onChange(of: store.selectedPID) { newValue in guard let pid = newValue else { return } withAnimation(.easeOut(duration: 0.15)) { @@ -79,6 +83,26 @@ struct SessionsView: View { } } } + + // Count of currently-active (undismissed, unsnoozed-or-elapsed-snooze) + // nudges that match this session's (agent, projectPath). The plan + // deliberately scopes this to "in the store right now" — lifetime + // totals across restarts would be fuzzy and aren't asked for. + private func nudgeCount(for session: Session) -> Int { + events.events.filter { matches(event: $0, session: session) }.count + } + + private func lastNudgeAt(for session: Session) -> Date? { + events.events + .filter { matches(event: $0, session: session) } + .map(\.timestamp) + .max() + } + + private func matches(event: NudgeEvent, session: Session) -> Bool { + Agent.canonical(event.agent) == Agent.canonical(session.agent) + && event.projectPath == session.projectPath + } } private struct SessionRow: View { @@ -87,78 +111,141 @@ private struct SessionRow: View { let selected: Bool let isEditing: Bool @Binding var renameBuffer: String + let activeNudgeCount: Int + let lastNudgeAt: Date? let onCommit: () -> Void let onCancel: () -> Void @FocusState private var nameFieldFocused: Bool + private static let timeFormatter: RelativeDateTimeFormatter = { + let f = RelativeDateTimeFormatter() + f.unitsStyle = .abbreviated + return f + }() + var body: some View { HStack(alignment: .top, spacing: 10) { Image(systemName: glyph) .font(.body) .foregroundStyle(glyphColor) .frame(width: 20, alignment: .center) + // Keep the glyph anchored to the title's baseline even when + // the card expands with extra rows below. + .padding(.top, 1) - VStack(alignment: .leading, spacing: 2) { - HStack(alignment: .firstTextBaseline, spacing: 8) { - if isEditing { - TextField("Session name", text: $renameBuffer) - .textFieldStyle(.plain) - .font(.subheadline.weight(.medium)) - .padding(.horizontal, 6) - .padding(.vertical, 2) - .background( - RoundedRectangle(cornerRadius: 4) - .fill(Color.primary.opacity(0.15)) - ) - .focused($nameFieldFocused) - .onSubmit(onCommit) - .onExitCommand(perform: onCancel) - .onAppear { - // Defer focus by one tick — SwiftUI's @FocusState - // binding inside an NSHostingView sometimes needs - // the run-loop to settle before it accepts the - // assignment. - DispatchQueue.main.async { - nameFieldFocused = true - } - } - } else { - Text(displayName) - .font(.subheadline.weight(.medium)) - .foregroundStyle(isActive ? Color.primary : Color.secondary) - } - Text(session.agent) - .font(.caption.monospaced()) - .foregroundStyle(.tertiary) - Spacer(minLength: 8) - Text(rightMeta) - .font(.caption2) - .foregroundStyle(.tertiary) - } - HStack(spacing: 8) { - if let term = session.terminalApp { - Text(term) - .font(.caption2) - .foregroundStyle(.secondary) - } - Text("pid \(session.pid)") - .font(.caption2.monospaced()) - .foregroundStyle(.tertiary) + VStack(alignment: .leading, spacing: 3) { + titleRow + metaRow + if showNudgeRow { + nudgeRow } } } .padding(.horizontal, 10) .padding(.vertical, 8) .frame(maxWidth: .infinity, alignment: .leading) - .background( + .background(rowBackground) + .padding(.horizontal, 6) + .opacity(isActive ? 1.0 : 0.6) + } + + // Background composes the selection tint with a 3pt accent bar in the + // session's stable color. ZStack with clip to the rounded shape so + // the bar gets the same corner radius as the row. + private var rowBackground: some View { + ZStack(alignment: .leading) { RoundedRectangle(cornerRadius: 8, style: .continuous) .fill(rowBackgroundColor) - ) - .padding(.horizontal, 6) - .opacity(isActive ? 1.0 : 0.55) + if let accent = SessionColor.color( + agent: session.agent, projectPath: session.projectPath + ) { + Rectangle() + .fill(accent.opacity(isActive ? 0.85 : 0.45)) + .frame(width: 3) + } + } + .clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous)) + } + + // MARK: - Subviews + + private var titleRow: some View { + HStack(alignment: .firstTextBaseline, spacing: 8) { + if isEditing { + TextField("Session name", text: $renameBuffer) + .textFieldStyle(.plain) + .font(.subheadline.weight(.medium)) + .padding(.horizontal, 6) + .padding(.vertical, 2) + .background( + RoundedRectangle(cornerRadius: 4) + .fill(Color.primary.opacity(0.15)) + ) + .focused($nameFieldFocused) + .onSubmit(onCommit) + .onExitCommand(perform: onCancel) + .onAppear { + DispatchQueue.main.async { nameFieldFocused = true } + } + } else { + Text(displayName) + .font(.subheadline.weight(.medium)) + .foregroundStyle(isActive ? Color.primary : Color.secondary) + .lineLimit(1) + .truncationMode(.tail) + } + agentTag + Spacer(minLength: 8) + Text(statusLabel) + .font(.caption2) + .foregroundStyle(.tertiary) + } + } + + private var metaRow: some View { + HStack(spacing: 6) { + if let term = session.terminalApp { + Text(term) + .font(.caption2) + .foregroundStyle(.secondary) + Text("·") + .font(.caption2) + .foregroundStyle(.tertiary) + } + Text(displayPath) + .font(.caption2.monospaced()) + .foregroundStyle(.tertiary) + .lineLimit(1) + .truncationMode(.middle) + } + } + + private var nudgeRow: some View { + HStack(spacing: 6) { + Image(systemName: "bell.fill") + .font(.caption2) + .foregroundStyle(.tertiary) + Text(nudgeSummary) + .font(.caption2) + .foregroundStyle(.secondary) + } + .padding(.top, 1) + } + + private var agentTag: some View { + Text(session.agent) + .font(.caption2.weight(.semibold).monospaced()) + .foregroundStyle(.secondary) + .padding(.horizontal, 5) + .padding(.vertical, 1) + .background( + Capsule().fill(Color.primary.opacity(0.08)) + ) } + // MARK: - Derived state + private var rowBackgroundColor: Color { if isEditing { return Color.accentColor.opacity(0.30) } if selected { return Color.accentColor.opacity(0.22) } @@ -172,6 +259,13 @@ private struct SessionRow: View { return session.projectName ?? "(no project)" } + // Full cwd with $HOME replaced by ~ for compactness. Falls back to + // pid when there's no path at all — so the row never shows nothing. + private var displayPath: String { + guard let path = session.projectPath else { return "pid \(session.pid)" } + return (path as NSString).abbreviatingWithTildeInPath + } + private var glyph: String { switch session.status { case .active: return "circle.fill" @@ -186,13 +280,29 @@ private struct SessionRow: View { } } - private var rightMeta: String { + // "active 14m" while the process is running, "ended 5m ago" once it + // exits. We use ps etime for the former since it's already in hand; + // RelativeDateTimeFormatter for the latter. + private var statusLabel: String { switch session.status { case .active: - return session.elapsed ?? "" + let elapsed = session.elapsed?.trimmingCharacters(in: .whitespaces) ?? "" + return elapsed.isEmpty ? "active" : "active · \(elapsed)" case .finished(let at): - let interval = Int(Date().timeIntervalSince(at)) - return "ended \(interval)s ago" + return "ended " + Self.timeFormatter.localizedString(for: at, relativeTo: Date()) } } + + // Show the nudge row only on the selected card AND when there's + // something interesting to report. Keeps non-selected rows compact. + private var showNudgeRow: Bool { + selected && activeNudgeCount > 0 + } + + private var nudgeSummary: String { + let countLabel = activeNudgeCount == 1 ? "1 nudge" : "\(activeNudgeCount) nudges" + guard let last = lastNudgeAt else { return countLabel } + let when = Self.timeFormatter.localizedString(for: last, relativeTo: Date()) + return "\(countLabel) · last \(when)" + } }