Skip to content
Merged
Original file line number Diff line number Diff line change
Expand Up @@ -3,50 +3,101 @@ public import CmuxNextSidebar
public import Foundation
import os

/// The chats the Priority view ranks (every lane of this Mac's acpmux and the Chief home's) and
/// what a row does to one: mark read, pin, snooze, archive. One per app: the registered row
/// The chats the Priority view ranks (every lane of this Mac's acpmux and the Chief home's, and
/// every agent chat on the other machines the app shows: ``SidebarPriorityMachine``) and what a
/// row does to one: mark read, pin, snooze, archive. One per app: the registered row
/// actions write through it, so a hover button, the row's menu and the palette change the same
/// marks (`AgentPriorityMarks`). Each returns false when no store lists the chat.
@MainActor
public final class AgentPriorityChats {
private static let logger = Logger(subsystem: "com.cmuxterm.app.next", category: "sidebar.priority")

/// The machines the rows name, read inside an observation scope.
public struct Machines {
/// The other machines whose daemon trees list agent chats.
public var remote: @MainActor () -> [SidebarPriorityMachine]
/// The display name of the machine an acpmux peer is; nil when none matches (the row
/// then shows the peer's name).
public var peer: @MainActor (String) -> String?
/// This Mac's tag: its name while `sidebar.showLocalMachine` is on, else nil.
public var local: @MainActor () -> String?

public init(remote: @escaping @MainActor () -> [SidebarPriorityMachine], peer: @escaping @MainActor (String) -> String?,
local: @escaping @MainActor () -> String?) {
self.remote = remote
self.peer = peer
self.local = local
}

/// No other machine, every peer by its own name, this Mac untagged.
public static var none: Self { Self(remote: { [] }, peer: { _ in nil }, local: { nil }) }
}

public let marks: AgentPriorityMarks
public let stores: [AgentLaneStore]
/// Set by the App once its machines exist (the app's chats outlive no window).
public var machines: Machines
private let ranking = AgentPriorityRanking()
/// `_acpmux/archive` for a session on the acpmux socket at the first argument.
private let archiveRequest: @Sendable (_ socketPath: String, _ sessionId: String) async throws -> Void
/// Whether a chat's pull request checks fail (Red CI), by default the session tree feed's answer.
private let checksFailing: @MainActor (AgentActivityLane) -> Bool

public init(marks: AgentPriorityMarks = .shared, stores: [AgentLaneStore] = [.shared, .chief],
public init(marks: AgentPriorityMarks = .shared, stores: [AgentLaneStore] = [.shared, .chief], machines: Machines = .none,
checksFailing: (@MainActor (AgentActivityLane) -> Bool)? = nil,
archive: @escaping @Sendable (_ socketPath: String, _ sessionId: String) async throws -> Void) {
self.marks = marks
self.stores = stores
self.machines = machines
self.checksFailing = checksFailing ?? { SidebarSessionTreeFeed.shared.checksFailing($0) }
self.archiveRequest = archive
}

/// Every lane of every store, this Mac's first when an id repeats.
public var lanes: [AgentActivityLane] {
/// Every lane of every store, then of every other machine's tree, this Mac's first when an id
/// repeats (a peer's chat that its machine's tree shows too), and each one's machine tag.
func chats() -> (lanes: [AgentActivityLane], tags: [String: String]) {
var seen = Set<String>()
return stores.flatMap { $0.lanes.values.sorted { $0.id < $1.id } }.filter { seen.insert($0.id).inserted }
var tags: [String: String] = [:]
let localTag = machines.local()
var lanes = stores.flatMap { $0.lanes.values.sorted { $0.id < $1.id } }.filter { seen.insert($0.id).inserted }
for lane in lanes {
tags[lane.id] = lane.peer.map { machines.peer($0) ?? $0 } ?? localTag
}
for machine in machines.remote() {
for lane in machine.lanes() where seen.insert(lane.id).inserted {
lanes.append(lane)
tags[lane.id] = machine.name ?? localTag
}
}
return (lanes, tags)
}

/// Every chat the view ranks.
public var lanes: [AgentActivityLane] { chats().lanes }

/// Every watched store and every other machine's tree is live: `lanes` is every chat, not a
/// gap, so a chat missing from it may lose its marks.
public var complete: Bool {
stores.allSatisfy { $0.live || $0.socketPath == nil } && machines.remote().allSatisfy(\.live)
}

public func lane(_ id: String) -> AgentActivityLane? {
stores.lazy.compactMap { $0.lanes[id] }.first
stores.lazy.compactMap { $0.lanes[id] }.first ?? lanes.first { $0.id == id }
}

/// The chats with a pull request whose checks fail (Red CI).
/// The chats with a pull request whose checks fail (Red CI). Only this Mac's and the Chief
/// home's: the session tree reads pull requests from a lane's folder and opened URLs, which
/// another machine's chat tab does not carry, so a remote row is never Red CI.
public var failingChecks: Set<String> {
Set(lanes.filter { checksFailing($0) }.map(\.id))
var seen = Set<String>()
return Set(stores.flatMap { $0.lanes.values }.filter { seen.insert($0.id).inserted && checksFailing($0) }.map(\.id))
}

/// The rows as the Priority view ranks them now, Red CI included, so a row's menu and the
/// palette read the state the row shows.
/// The rows as the Priority view ranks them now, Red CI included and each named by its
/// machine, so a row's menu and the palette read the state the row shows.
public func entries(now: Date = Date()) -> [SidebarPriorityEntry] {
ranking.entries(lanes, marks: marks.state, now: now, failingChecks: failingChecks)
let all = chats()
return ranking.entries(all.lanes, marks: marks.state, now: now, failingChecks: failingChecks) { all.tags[$0.id] }
}

/// The chat's row, nil when the view does not list it (archived, snoozed, idle too long).
Expand Down Expand Up @@ -76,7 +127,7 @@ public final class AgentPriorityChats {
}

/// Hides the chat here at once, then archives it in the acpmux that reports it (this Mac's or
/// the Chief home's). A refusal (a peer's chat this acpmux does not own, a dropped socket)
/// the Chief home's; another machine's chat has no socket here and is not archived). A refusal (a peer's chat this acpmux does not own, a dropped socket)
/// shows the chat again.
@discardableResult
public func archive(_ id: String) -> Bool {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,17 +17,19 @@ public nonisolated struct AgentPriorityRanking: Sendable {

/// The rows in rank order: snoozed and archived chats left out, and idle ones older than
/// `idleHorizon` unless pinned. `failingChecks` are the ids of the chats with a pull request
/// whose checks fail.
/// whose checks fail; `machine` is each row's machine tag (the lane's peer unless the caller
/// names its machine).
public func entries(_ lanes: [AgentActivityLane], marks: AgentPriorityMarks.State, now: Date,
failingChecks: Set<String> = []) -> [SidebarPriorityEntry] {
failingChecks: Set<String> = [],
machine: (AgentActivityLane) -> String? = { $0.peer }) -> [SidebarPriorityEntry] {
let ranked = lanes.compactMap { lane -> (entry: SidebarPriorityEntry, tier: Int, since: Date)? in
let stamp = AgentPriorityMarks.stamp(lane)
guard !marks.archived.contains(lane.id), !marks.isSnoozed(lane.id, stamp: stamp, now: now) else { return nil }
let read = marks.read[lane.id] == stamp
let pinned = marks.pinned.contains(lane.id)
let state = Self.state(lane, read: read, failingChecks: failingChecks.contains(lane.id))
if state == .idle, !pinned, let since = lane.since, now.timeIntervalSince(since) > idleHorizon { return nil }
let entry = SidebarPriorityEntry(id: lane.id, title: lane.title, project: lane.project, machine: lane.peer,
let entry = SidebarPriorityEntry(id: lane.id, title: lane.title, project: lane.project, machine: machine(lane),
preview: Self.preview(lane), state: state, unread: lane.unread && !read, pinned: pinned)
return (entry, Self.tier(state), lane.since ?? .distantPast)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,8 @@ import Foundation
import Observation

/// The sidebar's Priority view (`sidebar.priorityView.enabled`, an App Store item, off by default):
/// every acpmux chat on this Mac, its peers and the Chief home, ranked by what needs the person
/// (`AgentPriorityRanking`), at the top of each window's sidebar while the titlebar bell is on
/// every acpmux chat on this Mac, its peers and the Chief home, and every agent chat on the other
/// machines the app shows, ranked by what needs the person (`AgentPriorityRanking`), at the top of each window's sidebar while the titlebar bell is on
/// (`sidebar.priorityView.shown`). Event-driven: it redraws when a lane, a mark or the setting
/// changes, and at the next snooze end, never on a poll. The App passes the settings, the
/// shortcut, the chat jump and the row actions' runner (its own target keeps them out of
Expand Down Expand Up @@ -49,7 +49,6 @@ public final class SidebarPriorityFeed {
private let hooks: Hooks
private let chats: AgentPriorityChats
private var marks: AgentPriorityMarks { chats.marks }
private var stores: [AgentLaneStore] { chats.stores }
private let ranking = AgentPriorityRanking()
/// Bumped when a snooze ends or an idle chat ages out, so the rows redraw with no other change.
private let clock = PriorityClock()
Expand All @@ -70,9 +69,10 @@ public final class SidebarPriorityFeed {
for await panel in ObservationStream({ self.panel() }) {
view?.showPriority(panel)
scheduleNextChange()
// A store is empty while its watch is down or not up yet: pruning then would drop
// the marks of every chat it reports, so prune only while every watched store is live.
if stores.allSatisfy({ $0.live || $0.socketPath == nil }) { marks.prune(keeping: Set(lanes.map(\.id))) }
// A store is empty while its watch is down or not up yet (a machine's tree while it
// is not connected): pruning then would drop the marks of every chat it reports, so
// prune only while every one is live.
if chats.complete { marks.prune(keeping: Set(lanes.map(\.id))) }
}
}
}
Expand All @@ -86,7 +86,7 @@ public final class SidebarPriorityFeed {
guard settings.enabled() else { return nil }
let shown = settings.shown(), state = marks.state
_ = clock.ticks
let entries = ranking.entries(lanes, marks: state, now: Date(), failingChecks: failingChecks)
let entries = chats.entries(now: Date())
return SidebarPriorityPanel(entries: shown ? entries : entries.filter(\.needsAttention), shown: shown,
collapsed: state.collapsed, shortcut: hooks.shortcut())
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
import CmuxNextAgentActivity
public import CmuxNextDaemon
import Foundation

/// Another machine whose agent chats the Priority view ranks (a Cloud VM, an SSH machine, a
/// paired server): its display name and the daemon tree the app already mirrors. Its chats are
/// the agent chat tabs of that tree, and what they need comes from the notices that machine's
/// daemon posts on them (cx-sg1qz: needs input, finished, failed or stopped responding, read
/// again once the chat is seen or answered anywhere). Nothing here asks that machine anything.
@MainActor
public struct SidebarPriorityMachine {
/// The machine's name as the sidebar shows it (`MachineRegistry.machineName`): each row's
/// tag, and the name a machine's color is keyed on. Nil for a tree that runs on this Mac (a
/// server reached at a Unix socket here), whose rows follow this Mac's tag.
public var name: String?
public var store: DaemonStore

public init(name: String?, store: DaemonStore) {
self.name = name
self.store = store
}

/// The tree is that daemon's current one: connected, past its first snapshot. Only then may
/// a chat missing from it lose its marks.
var live: Bool {
guard case .connected = store.connectionState else { return false }
return store.isLoaded && !store.isProvisional
}

/// One lane per acpmux session the tree's agent chat tabs show, in tree order. A session two
/// tabs show is one lane, at the stronger of their states.
func lanes() -> [AgentActivityLane] {
var order: [String] = []
var lanes: [String: AgentActivityLane] = [:]
for tab in store.workspaces.flatMap(\.screens).flatMap(\.panes).flatMap(\.tabs) {
guard let ref = tab.agentSession, let session = ref.session, !session.isEmpty else { continue }
let lane = lane(tab, ref: ref, session: session)
if let held = lanes[session] {
if Self.strength(lane) > Self.strength(held) { lanes[session] = lane }
} else {
order.append(session)
lanes[session] = lane
}
}
return order.compactMap { lanes[$0] }
}

/// The tab's chat as a lane: an unread warning is a question or permission prompt, an unread
/// error a failed turn or a gone agent, any other unread notice a result nobody has seen. It
/// has no folder or pull requests, so it is never Red CI (``AgentPriorityChats/failingChecks``).
private func lane(_ tab: TabModel, ref: AgentSessionRef, session: String) -> AgentActivityLane {
let notice = tab.hasUnread ? tab.notification : nil
let state: AgentActivityLane.State = switch notice?.level {
case .warning?: .input
case .error?: .failed
default: .idle
}
let text = notice == nil ? nil : store.notificationText(for: tab)
let createdAtMs = notice.flatMap { marker in
marker.createdAtMs ?? store.notifications.last { $0.notification == marker.notification }?.createdAtMs
}
// A notice posted before the app connected has no time here: it counts from when the app
// first saw it, so each new notice is a new state (a read or snooze mark on the last one
// no longer holds).
let since = createdAtMs.map { Date(timeIntervalSince1970: Double($0) / 1000) }
?? notice.map { Self.firstSeen("\(session)#\($0.notification.rawValue)") }
let title = tab.displayTitle.trimmingCharacters(in: .whitespacesAndNewlines)
return AgentActivityLane(id: session, name: session, title: title.isEmpty ? ref.harness ?? session : title, peer: name,
parent: nil, state: state, since: since,
error: state == .failed ? text : nil, harness: ref.harness, step: state == .failed ? nil : text,
unread: notice != nil && state == .idle)
}

/// When the app first saw each untimed notice, by session and notice (bounded).
private static var seen: [String: Date] = [:]

private static func firstSeen(_ key: String) -> Date {
if let date = seen[key] { return date }
if seen.count >= 4096 { seen.removeAll() }
let now = Date()
seen[key] = now
return now
}

private static func strength(_ lane: AgentActivityLane) -> Int {
switch lane.state {
case .input: 3
case .failed, .lost: 2
case .idle, .working: lane.unread ? 1 : 0
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import CmuxNextAgentLanes
import Foundation

extension MachineRegistry {
/// The sidebar name (``machineName(_:)``) of the machine an acpmux peer is: the SSH machine
/// whose host, short host or sidebar name is the peer's name, else the Cloud machine or
/// server of that name. Nil when no machine the app shows matches.
func machineName(peer: String) -> String? {
let wanted = peer.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
guard !wanted.isEmpty else { return nil }
let matches = { (names: [String?]) in names.contains { $0?.lowercased() == wanted } }
let id = ssh.first { matches([$0.host.destination.host, $0.host.destination.displayName, $0.host.label]) }?.machineID
?? cloud.first { matches([$0.machine.title]) }?.machineID
?? servers.first { matches([$0.name]) }?.machineID
return id.flatMap { machineName($0) }
}

/// The other machines whose agent chats the sidebar's Priority view ranks: each reachable
/// one's daemon tree under its sidebar name. A tree that runs on this Mac (``tabBadge(_:)``
/// nil: a server reached at a Unix socket here) is named as this Mac is.
var priorityMachines: [SidebarPriorityMachine] {
remoteDaemons.compactMap { daemon in
guard self.daemon(machine: daemon.machineID) != nil else { return nil }
return SidebarPriorityMachine(name: tabBadge(daemon.machineID).flatMap { _ in machineName(daemon.machineID) }, store: daemon.store)
}
}

/// The tab of a remote machine's tree that shows acpmux session `session`, if any.
func remoteAgentTab(showing session: String) -> String? {
for daemon in remoteDaemons {
for workspace in daemon.store.workspaces {
for tab in workspace.screens.flatMap(\.panes).flatMap(\.tabs) where tab.agentSession?.session == session {
return tab.id
}
}
}
return nil
}
}
Loading