Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 41 additions & 0 deletions Tests/StackNudgePanelCoreTests/AgentTests.swift
Original file line number Diff line number Diff line change
@@ -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(""), "")
}
}
76 changes: 76 additions & 0 deletions Tests/StackNudgePanelCoreTests/SessionColorTests.swift
Original file line number Diff line number Diff line change
@@ -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)
}
}
166 changes: 166 additions & 0 deletions Tests/StackNudgePanelCoreTests/SessionPersistenceTests.swift
Original file line number Diff line number Diff line change
@@ -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")
}
}
1 change: 1 addition & 0 deletions build.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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 \
Expand Down
45 changes: 38 additions & 7 deletions panel/Panel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand Down Expand Up @@ -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
Expand All @@ -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)
Expand All @@ -328,18 +333,44 @@ 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 {
guard let until = event.snoozedUntil else { return false }
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 {
Expand Down Expand Up @@ -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.,
Expand Down
Loading