diff --git a/Tests/StackNudgePanelCoreTests/SessionMuteTests.swift b/Tests/StackNudgePanelCoreTests/SessionMuteTests.swift new file mode 100644 index 0000000..61a2b3b --- /dev/null +++ b/Tests/StackNudgePanelCoreTests/SessionMuteTests.swift @@ -0,0 +1,181 @@ +import XCTest + +@testable import StackNudgePanelCore + +// Per-session mute: which sessions share a mute, and whether unmuting is the +// inverse of muting. Both went wrong for sessions in the same project, which +// is the common case for anyone running two agents in one repo. +@MainActor +final class SessionMuteTests: XCTestCase { + + private var tempURL: URL! + + override func setUp() { + super.setUp() + tempURL = FileManager.default.temporaryDirectory + .appendingPathComponent("stack-nudge-mute-\(UUID().uuidString).json") + } + + override func tearDown() { + if let tempURL { try? FileManager.default.removeItem(at: tempURL) } + super.tearDown() + } + + private func makeStore() -> SessionPersistence { SessionPersistence(path: tempURL) } + + private func session(pid: Int, + path: String = "/Users/x/redteaming", + tabId: String? = nil, + tty: String? = nil, + claudeSessionID: String? = nil) -> Session { + Session( + id: pid, pid: pid, agent: "claude", + projectPath: path, projectName: "redteaming", + terminalPID: 1, terminalApp: "Zed", elapsed: "01:00", + customName: nil, status: .active, + tabId: tabId, tabName: nil, tty: tty, + claudeSessionID: claudeSessionID + ) + } + + // MARK: - Scope ladder + + func test_muteScope_prefersTabIdThenTtyThenSessionID() { + XCTAssertEqual(SessionPersistence.muteScope( + for: session(pid: 1, tabId: "w1", tty: "ttys014", claudeSessionID: "uuid")), "w1") + XCTAssertEqual(SessionPersistence.muteScope( + for: session(pid: 1, tty: "ttys014", claudeSessionID: "uuid")), "ttys014") + // Sidecar-only sessions (no controlling terminal) still get an identity. + XCTAssertEqual(SessionPersistence.muteScope( + for: session(pid: 1, claudeSessionID: "uuid")), "uuid") + XCTAssertNil(SessionPersistence.muteScope(for: session(pid: 1))) + } + + func test_muteScope_ignoresEmptyStrings() { + XCTAssertEqual(SessionPersistence.muteScope( + for: session(pid: 1, tabId: "", tty: "ttys014")), "ttys014") + } + + // MARK: - Isolation + + // The reported bug: two agents in one repo under a terminal we have no + // conformer for (Zed, a bare shell) both resolved to the project-wide key, + // so muting either silenced both. + func test_mutingOneSession_leavesSiblingInSameProjectAudible() { + let store = makeStore() + let first = session(pid: 1, tty: "ttys014") + let second = session(pid: 2, tty: "ttys016") + + store.toggleMuted(first) + + XCTAssertTrue(store.isMuted(first)) + XCTAssertFalse(store.isMuted(second)) + } + + func test_mutesAreScopedPerSessionForAgentsWithoutASidecar() { + // codex and gemini expose no per-session id at all; the tty is the only + // thing separating them, so this is the whole fix for those agents. + let store = makeStore() + func codex(pid: Int, tty: String) -> Session { + Session(id: pid, pid: pid, agent: "codex", + projectPath: "/Users/x/redteaming", projectName: "redteaming", + terminalPID: 1, terminalApp: "Zed", elapsed: "01:00", + customName: nil, status: .active, + tabId: nil, tabName: nil, tty: tty) + } + store.toggleMuted(codex(pid: 1, tty: "ttys037")) + + XCTAssertTrue(store.isMuted(codex(pid: 1, tty: "ttys037"))) + XCTAssertFalse(store.isMuted(codex(pid: 2, tty: "ttys038"))) + } + + // MARK: - Toggle is an inverse + + func test_toggleMuted_roundTrips() { + let store = makeStore() + let target = session(pid: 1, tty: "ttys014") + + store.toggleMuted(target) + XCTAssertTrue(store.isMuted(target)) + store.toggleMuted(target) + XCTAssertFalse(store.isMuted(target)) + } + + func test_unmuting_leavesNoEntryBehind() { + let store = makeStore() + let target = session(pid: 1, tty: "ttys014") + + store.toggleMuted(target) + store.toggleMuted(target) + + XCTAssertTrue(store.entries.isEmpty, "an unmuted, unnamed session should hold no preference") + } + + // A project-wide entry is what the legacy muted-sessions.json migration + // writes, and what any pre-fix mute left behind. Sessions inherit it... + func test_projectWideMute_isInheritedBySessionsWithoutAnOpinion() { + let store = makeStore() + store.toggleMuted(session(pid: 1)) // no scope → project-wide key + + XCTAssertTrue(store.isMuted(session(pid: 2, tty: "ttys014"))) + } + + // ...and clicking unmute on one of them used to negate the *stored* flag, + // which was false, so the session re-muted itself and could never be + // cleared from its own row. + func test_inheritedMute_canBeClearedOnOneSessionOnly() { + let store = makeStore() + store.toggleMuted(session(pid: 1)) + let target = session(pid: 2, tty: "ttys014") + let sibling = session(pid: 3, tty: "ttys016") + + store.toggleMuted(target) + + XCTAssertFalse(store.isMuted(target)) + XCTAssertTrue(store.isMuted(sibling), "the project-wide mute still covers everyone else") + } + + func test_clearedInheritedMute_canBeReapplied() { + let store = makeStore() + store.toggleMuted(session(pid: 1)) + let target = session(pid: 2, tty: "ttys014") + + store.toggleMuted(target) + store.toggleMuted(target) + + XCTAssertTrue(store.isMuted(target)) + } + + func test_explicitUnmute_survivesReload() { + let first = makeStore() + first.toggleMuted(session(pid: 1)) + let target = session(pid: 2, tty: "ttys014") + first.toggleMuted(target) + + XCTAssertFalse(makeStore().isMuted(target)) + } + + // MARK: - Legacy files + + func test_legacyEntryWithoutMutedField_decodesAsNoOpinion() throws { + try Data(#"{"claude::/Users/x/redteaming":{"customName":"attack","lastSeenAt":1}}"#.utf8) + .write(to: tempURL) + let store = makeStore() + + XCTAssertFalse(store.isMuted(session(pid: 1, tty: "ttys014"))) + XCTAssertEqual(store.customName(agent: "claude", projectPath: "/Users/x/redteaming"), "attack") + } + + func test_renamedSession_keepsItsNameWhenMuteIsCleared() { + let store = makeStore() + store.setCustomName(agent: "claude", projectPath: "/Users/x/redteaming", tabId: "w1", "attack") + let target = session(pid: 1, tabId: "w1") + + store.toggleMuted(target) + store.toggleMuted(target) + + XCTAssertFalse(store.isMuted(target)) + XCTAssertEqual(store.customName(agent: "claude", projectPath: "/Users/x/redteaming", tabId: "w1"), + "attack") + } +} diff --git a/panel/SessionPersistence.swift b/panel/SessionPersistence.swift index 7ec09e8..f781065 100644 --- a/panel/SessionPersistence.swift +++ b/panel/SessionPersistence.swift @@ -23,10 +23,14 @@ enum Agent { // future dormancy-based eviction pass. struct SessionEntry: Codable { var customName: String? - var muted: Bool + // Tri-state on purpose. nil means "no opinion, ask the project-level + // entry"; `false` is an explicit override that outranks it. Without the + // distinction a session inheriting a project-wide mute had no way to say + // no — see `toggleMuted`. + var muted: Bool? var lastSeenAt: TimeInterval - init(customName: String? = nil, muted: Bool = false, lastSeenAt: TimeInterval) { + init(customName: String? = nil, muted: Bool? = nil, lastSeenAt: TimeInterval) { self.customName = customName self.muted = muted self.lastSeenAt = lastSeenAt @@ -37,7 +41,7 @@ struct SessionEntry: Codable { init(from decoder: Decoder) throws { let c = try decoder.container(keyedBy: CodingKeys.self) self.customName = try c.decodeIfPresent(String.self, forKey: .customName) - self.muted = (try c.decodeIfPresent(Bool.self, forKey: .muted)) ?? false + self.muted = try c.decodeIfPresent(Bool.self, forKey: .muted) self.lastSeenAt = try c.decode(TimeInterval.self, forKey: .lastSeenAt) } } @@ -82,12 +86,12 @@ final class SessionPersistence: ObservableObject { guard let projectPath else { return nil } let canon = Agent.canonical(agent) if let tabId, !tabId.isEmpty { - if let trimmed = entries[Self.key(agent: canon, projectPath: projectPath, tabId: tabId)]?.customName, + if let trimmed = entries[Self.key(agent: canon, projectPath: projectPath, scope: tabId)]?.customName, !trimmed.isEmpty { return trimmed } } - let trimmed = entries[Self.key(agent: canon, projectPath: projectPath, tabId: nil)]?.customName + let trimmed = entries[Self.key(agent: canon, projectPath: projectPath, scope: nil)]?.customName guard let trimmed, !trimmed.isEmpty else { return nil } return trimmed } @@ -102,14 +106,14 @@ final class SessionPersistence: ObservableObject { // (if any) keeps working as the fallback. func setCustomName(agent: String, projectPath: String?, tabId: String? = nil, _ name: String?) { guard let projectPath else { return } - let key = Self.key(agent: Agent.canonical(agent), projectPath: projectPath, tabId: tabId) + let key = Self.key(agent: Agent.canonical(agent), projectPath: projectPath, scope: tabId) let trimmed = name?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" let now = Date().timeIntervalSince1970 if trimmed.isEmpty { // Clearing the name: keep the entry only if it has other prefs. guard var existing = entries[key] else { return } existing.customName = nil - if existing.muted { + if existing.muted != nil { existing.lastSeenAt = now entries[key] = existing } else { @@ -126,31 +130,56 @@ final class SessionPersistence: ObservableObject { // MARK: - Mute - func isMuted(agent: String, projectPath: String?, tabId: String? = nil) -> Bool { + // Which segment scopes a session's mute. Ordered most- to least-specific: + // an integration's tab id where we have one, else the tty, else Claude's + // sidecar session id for the rare tty-less session. Names deliberately do + // not use this ladder — they're also looked up from events, which carry a + // tab id but no tty, so widening the key there would orphan every rename. + static func muteScope(for session: Session) -> String? { + for candidate in [session.tabId, session.tty, session.claudeSessionID] { + if let candidate, !candidate.isEmpty { return candidate } + } + return nil + } + + // The most specific entry that exists wins outright, including when it says + // `false`. Falling back past an explicit override would resurrect a mute the + // user just cleared. + func isMuted(agent: String, projectPath: String?, scope: String? = nil) -> Bool { guard let projectPath else { return false } let canon = Agent.canonical(agent) - if let tabId, !tabId.isEmpty, - entries[Self.key(agent: canon, projectPath: projectPath, tabId: tabId)]?.muted == true { - return true + if let scope, !scope.isEmpty, + let own = entries[Self.key(agent: canon, projectPath: projectPath, scope: scope)]?.muted { + return own } - return entries[Self.key(agent: canon, projectPath: projectPath, tabId: nil)]?.muted == true + return entries[Self.key(agent: canon, projectPath: projectPath, scope: nil)]?.muted == true } func isMuted(_ session: Session) -> Bool { - isMuted(agent: session.agent, projectPath: session.projectPath, tabId: session.tabId) + isMuted(agent: session.agent, projectPath: session.projectPath, + scope: Self.muteScope(for: session)) } func toggleMuted(_ session: Session) { guard let projectPath = session.projectPath, !projectPath.isEmpty else { return } - let key = Self.key(agent: Agent.canonical(session.agent), - projectPath: projectPath, tabId: session.tabId) + let canon = Agent.canonical(session.agent) + let key = Self.key(agent: canon, projectPath: projectPath, + scope: Self.muteScope(for: session)) + let projectKey = Self.key(agent: canon, projectPath: projectPath, scope: nil) + let inherited = key != projectKey && entries[projectKey]?.muted == true + // Negate what the user is looking at, not the stored flag — they differ + // whenever the visible state came from the project-level entry, and + // negating the flag there re-muted an already-muted session. + let next = !isMuted(session) let now = Date().timeIntervalSince1970 var entry = entries[key] ?? SessionEntry(lastSeenAt: now) - entry.muted = !entry.muted + // Unmuting only needs recording when a project-level mute would + // otherwise reassert; otherwise absence says the same thing. + entry.muted = (next || inherited) ? next : nil entry.lastSeenAt = now // Drop entries that hold no surviving preference, matching the // "deliberate user intent only" invariant for the on-disk store. - if entry.muted == false, entry.customName?.isEmpty ?? true { + if entry.muted == nil, entry.customName?.isEmpty ?? true { entries.removeValue(forKey: key) } else { entries[key] = entry @@ -170,11 +199,11 @@ final class SessionPersistence: ObservableObject { let candidates: [String] = { if let tabId, !tabId.isEmpty { return [ - Self.key(agent: canon, projectPath: projectPath, tabId: tabId), - Self.key(agent: canon, projectPath: projectPath, tabId: nil), + Self.key(agent: canon, projectPath: projectPath, scope: tabId), + Self.key(agent: canon, projectPath: projectPath, scope: nil), ] } - return [Self.key(agent: canon, projectPath: projectPath, tabId: nil)] + return [Self.key(agent: canon, projectPath: projectPath, scope: nil)] }() for key in candidates { guard !seenThisLaunch.contains(key), entries[key] != nil else { continue } @@ -187,11 +216,13 @@ final class SessionPersistence: ObservableObject { // MARK: - I/O - // Stable across PID churn and restarts; shared with PanelNav.mutedSessions. - static func key(agent: String, projectPath: String, tabId: String?) -> String { + // Stable across PID churn and restarts. The trailing segment scopes the + // entry within a project — a tab id for names, `muteScope` for mutes — and + // is omitted entirely when there is none, which is the project-wide key. + static func key(agent: String, projectPath: String, scope: String?) -> String { let canon = Agent.canonical(agent) - if let tabId, !tabId.isEmpty { - return "\(canon)::\(projectPath)::\(tabId)" + if let scope, !scope.isEmpty { + return "\(canon)::\(projectPath)::\(scope)" } return "\(canon)::\(projectPath)" } @@ -199,7 +230,7 @@ final class SessionPersistence: ObservableObject { // nil when the session has no projectPath — no stable identity possible. static func key(for session: Session) -> String? { guard let path = session.projectPath, !path.isEmpty else { return nil } - return key(agent: session.agent, projectPath: path, tabId: session.tabId) + return key(agent: session.agent, projectPath: path, scope: session.tabId) } private func load() { diff --git a/panel/SessionStore.swift b/panel/SessionStore.swift index 3153737..aeebd41 100644 --- a/panel/SessionStore.swift +++ b/panel/SessionStore.swift @@ -24,6 +24,11 @@ struct Session: Identifiable, Equatable { // which is exactly the pre-Stage-2 behaviour. var tabId: String? var tabName: String? + // Controlling terminal ("ttys014"), straight from ps. The only per-session + // discriminator every agent and every terminal has: integrations only cover + // the terminals we've written conformers for, and Zed / a bare shell leave + // tabId nil, which collapses two sessions in one project onto one identity. + var tty: String? // Claude's per-pid sidecar (~/.claude/sessions/.json) gives an // authoritative session id without waiting for a hook event. var claudeSessionID: String? @@ -324,7 +329,7 @@ final class SessionStore: ObservableObject { let lines = runProcess("/bin/ps", ["-axo", "pid=,etime=,tty=,args="]) .split(separator: "\n") - var candidates: [(pid: Int, elapsed: String, hasTTY: Bool, agent: String)] = [] + var candidates: [(pid: Int, elapsed: String, tty: String?, agent: String)] = [] for raw in lines { let line = String(raw).trimmingCharacters(in: .whitespaces) // pid (digits) etime tty args... @@ -337,8 +342,9 @@ final class SessionStore: ObservableObject { let args = String(parts[3]) guard let agent = detectAgent(args: args) else { continue } + let ownedTTY = (tty == "??" || tty.isEmpty) ? nil : tty candidates.append((pid: pid, elapsed: etime, - hasTTY: tty != "??" && !tty.isEmpty, agent: agent)) + tty: ownedTTY, agent: agent)) } let pids = candidates.map(\.pid) @@ -357,7 +363,7 @@ final class SessionStore: ObservableObject { // so it showed up as a row the finished-session prune could never // reach. The subcommand denylist in detectAgent catches the ones // we know by name; this catches whatever 2.2 invents next. - if candidate.agent == "claude", !candidate.hasTTY, sidecar == nil { continue } + if candidate.agent == "claude", candidate.tty == nil, sidecar == nil { continue } let cwd = cwdByPID[candidate.pid] let chain = walkParentChain(from: candidate.pid, processTable: processTable) found.append(Session( @@ -373,6 +379,7 @@ final class SessionStore: ObservableObject { status: .active, tabId: nil, tabName: nil, + tty: candidate.tty, claudeSessionID: sidecar?.sessionId, liveTitle: sidecar?.name, liveTitleSource: sidecar?.nameSource, @@ -626,6 +633,7 @@ private extension Session { status: status, tabId: tabId ?? self.tabId, tabName: tabName ?? self.tabName, + tty: self.tty, // Preserve the live sidecar values from the freshly-discovered // snapshot — these change turn-to-turn (status especially), so // we want the merge to surface the latest, not the stale value