Merge branch 'dev' into canary

This commit is contained in:
2026-07-05 04:37:57 -07:00
9 changed files with 124 additions and 57 deletions
@@ -56,10 +56,7 @@ final class LiveActivityManager {
$0.status == .running || $0.status == .provisioning
|| $0.status.needsYou($0.disposition)
}
.sorted { lhs, rhs in
let l = StatusStyle.sortRank(lhs), r = StatusStyle.sortRank(rhs)
return l == r ? lhs.updatedAt > rhs.updatedAt : l < r
}
.sorted(by: StatusStyle.attentionThenRecency)
let approvals = active.reduce(0) { $0 + $1.pendingApprovalCount }
let files = active.reduce(0) { $0 + ($1.diffStat?.filesChanged ?? 0) }
@@ -35,7 +35,20 @@ final class HostConnection {
/// The session RemoteStore currently has open on this host, if any — so snapshot/events are only
/// forwarded (and deduped) for the transcript on screen. Set by RemoteStore on open/close.
var openSessionID: SessionID?
/// Changing which session is open resets the per-session cursor + dedup set (they only make
/// sense within one transcript).
var openSessionID: SessionID? {
didSet {
guard openSessionID != oldValue else { return }
openMaxSeq = nil
seenSeq = []
}
}
/// The highest event `seq` delivered for the open session — the warm-resubscribe cursor. On a
/// reconnect we re-subscribe `sinceSeq: openMaxSeq` so the host replays only what we missed
/// instead of cold-resetting to the 200-event tail (which would truncate a long transcript).
private var openMaxSeq: UInt64?
// MARK: Callbacks up to RemoteStore (aggregate concerns)
@@ -201,6 +214,18 @@ final class HostConnection {
relayMembershipToken: nil, relayURL: nil)
}
/// Whether the dialable endpoints of a gossiped record differ from the one on file — the
/// signal that a known Mac must be re-dialed (its LAN port, tailnet IP, or relay room moved).
/// Ignores relay *credentials* (token/URL), which `mergePairedHost` preserves and which don't
/// change where the Mac is reached.
private static func dialableAddressChanged(from existing: PairedHost, to incoming: PairedHost) -> Bool {
existing.lanHost != incoming.lanHost
|| existing.lanPort != incoming.lanPort
|| existing.tailnetHost != incoming.tailnetHost
|| existing.tailnetPort != incoming.tailnetPort
|| existing.relayRoomID != incoming.relayRoomID
}
/// Split a "host:port" hint (last-colon split so a bracketed IPv6 host survives).
private static func splitHostPort(_ hint: String?) -> (String?, UInt16?) {
guard let hint, let colon = hint.lastIndex(of: ":"),
@@ -428,7 +453,9 @@ final class HostConnection {
callbacks.didUpdate()
send(.listSessions)
send(.listDashboard)
if let id = openSessionID { send(.subscribe(Subscribe(sessionID: id, sinceSeq: nil, verbosity: .full))) }
// Warm-resubscribe from what we already have, so a reconnect on a long transcript replays
// only the gap rather than snapping back to the host's 200-event tail.
if let id = openSessionID { send(.subscribe(Subscribe(sessionID: id, sinceSeq: openMaxSeq, verbosity: .full))) }
case .sessionList(let list):
sessions = list
callbacks.didUpdate()
@@ -449,12 +476,17 @@ final class HostConnection {
callbacks.didUpdate()
case .snapshot(let snapshot):
guard snapshot.summary.sessionID == openSessionID else { break }
seenSeq = Set(snapshot.recentEvents.map(\.seq))
// A snapshot may be a fresh open (tail window) or a warm-resubscribe delta; either way
// union its seqs into the dedup set and advance the cursor. RemoteStore merges the events
// into the transcript rather than replacing, so an existing transcript isn't truncated.
seenSeq.formUnion(snapshot.recentEvents.map(\.seq))
if let maxSeq = snapshot.recentEvents.map(\.seq).max() { openMaxSeq = max(openMaxSeq ?? 0, maxSeq) }
callbacks.openSnapshot(snapshot)
case .events(let batch):
guard batch.sessionID == openSessionID else { break }
let fresh = batch.events.filter { !seenSeq.contains($0.seq) }
for e in fresh { seenSeq.insert(e.seq) }
if let maxSeq = batch.events.map(\.seq).max() { openMaxSeq = max(openMaxSeq ?? 0, maxSeq) }
if !fresh.isEmpty { callbacks.openEvents(EventBatch(sessionID: batch.sessionID, events: fresh)) }
case .approvalRequested(let req):
let title = sessions.first { $0.sessionID == req.sessionID }?.title ?? "Approval"
@@ -478,8 +510,18 @@ final class HostConnection {
else { continue }
let fingerprint = member.staticPublicKey.fingerprintHex
guard fingerprint != self.hostID else { continue } // that's the Mac we're on
if IdentityStore.pairedHost(id: fingerprint) == nil { changed = true }
IdentityStore.mergePairedHost(Self.pairedHost(from: member))
let incoming = Self.pairedHost(from: member)
let existing = IdentityStore.pairedHost(id: fingerprint)
// Re-dial not only when a Mac is brand-new, but also when a known Mac's dialable
// address changed — a Mac's LAN port is OS-assigned (`.any`), so it lands on a
// fresh one every relaunch and re-gossips it. `mergePairedHost` updates the
// registry in place, but a connection already retrying the dead old address won't
// pick that up on its own (its retry loop reuses the address it was last handed),
// so without forcing a fresh reconnect here the phone never reconnects to it.
if existing == nil || Self.dialableAddressChanged(from: existing!, to: incoming) {
changed = true
}
IdentityStore.mergePairedHost(incoming)
}
for tombstone in push.tombstones {
// fingerprint = first 16 hex of the hostID (sha256 prefix), the registry key.
@@ -498,6 +540,10 @@ final class HostConnection {
// `fetchTranscript` this device sent. A phone pulls full history on demand via a
// dedicated fetch path (not this subscribe stream), so nothing to do here yet.
break
case .toolSummaries:
// Collapsed Bash summary lines the owner pushes to peer *Macs* (mesh session sync).
// The phone renders its own deterministic command summaries, so it ignores these.
break
case .relayMembership(let membership):
// The host issued/refreshed this device's relay credential (mesh P2). Persist it
// in place (no reordering — this can arrive from a non-active Mac) so the relay
@@ -477,7 +477,10 @@ final class RemoteStore: ObservableObject {
}
cb.openSnapshot = { [weak self] snap in
guard let self, hostID == self.openSessionHostID, snap.summary.sessionID == self.openSessionID else { return }
self.openEvents = snap.recentEvents
// Merge, don't replace: a fresh open merges into an empty transcript (the tail window),
// while a reconnect's warm-resubscribe delta appends to the history already on screen
// instead of truncating it to the host's 200-event tail.
self.openEvents = Self.mergedEvents(self.openEvents, snap.recentEvents)
self.openApprovals = snap.pendingApprovals
}
cb.openEvents = { [weak self] batch in
@@ -557,6 +560,20 @@ final class RemoteStore: ObservableObject {
}
}
/// Merge transcript events by `seq` (monotonic, globally unique within a session), keeping the
/// union sorted. Lets a reconnect's snapshot fold its events into the transcript already on
/// screen without duplicating what's shown or dropping history outside the host's tail window.
/// A fresh open merges into `[]`, so it's just the tail — the same result as a plain replace.
private static func mergedEvents(_ existing: [AgentEvent], _ incoming: [AgentEvent]) -> [AgentEvent] {
guard !existing.isEmpty else { return incoming }
guard !incoming.isEmpty else { return existing }
var bySeq: [UInt64: AgentEvent] = [:]
bySeq.reserveCapacity(existing.count + incoming.count)
for e in existing { bySeq[e.seq] = e }
for e in incoming { bySeq[e.seq] = e }
return bySeq.values.sorted { $0.seq < $1.seq }
}
/// The flat sessions list: every connected Mac's sessions, deduped by id (ids are globally
/// unique). Views handle sorting/grouping.
private func aggregatedSessions() -> [WireSessionSummary] {
@@ -1242,6 +1259,13 @@ extension DashboardSnapshot {
}
extension WireSessionSummary {
/// Recency key for ordering session lists: the last *user* message, falling back to `updatedAt`
/// for sessions with no user message yet (or summaries from a host that predates the field).
/// Ordering on this keeps a chat's position steady until the user speaks again, rather than
/// reshuffling the list on every bit of agent activity (which bumps `updatedAt` every few
/// seconds). Mirrors the Mac sidebar's last-turn ordering.
var lastTurnAt: Date { lastUserMessageAt ?? updatedAt }
/// A copy with selected fields overridden — the demo simulator's only way to "mutate" a
/// summary, whose stored properties are all `let`. Nullable fields use a double optional so a
/// caller can distinguish "keep" (omit) from "set to nil" (`.some(nil)`). `updatedAt` bumps
@@ -140,10 +140,7 @@ private struct SplitSidebar: View {
/// Live sessions grouped under their project (dashboard order), with anything whose project
/// isn't in the dashboard collected into a trailing "Other" group. Attention-first sort.
private var projectGroups: [(name: String, rows: [WireSessionSummary])] {
let live = store.liveSessions.sorted {
let a = StatusStyle.sortRank($0), b = StatusStyle.sortRank($1)
return a == b ? $0.updatedAt > $1.updatedAt : a < b
}
let live = store.liveSessions.sorted(by: StatusStyle.attentionThenRecency)
var groups: [(name: String, rows: [WireSessionSummary])] = []
var placed = Set<SessionID>()
for project in store.dashboard.projects {
@@ -16,12 +16,12 @@ struct HomeView: View {
private var needsAttention: [WireSessionSummary] {
store.liveSessions
.filter { $0.status.needsYou($0.disposition) }
.sorted { $0.updatedAt > $1.updatedAt }
.sorted { $0.lastTurnAt > $1.lastTurnAt }
}
/// Sessions actively working a turn — drives both the "In progress" stat card and the
/// live list pinned above the to-dos.
private var running: [WireSessionSummary] {
store.liveSessions.filter { $0.status == .running }.sorted { $0.updatedAt > $1.updatedAt }
store.liveSessions.filter { $0.status == .running }.sorted { $0.lastTurnAt > $1.lastTurnAt }
}
/// Outstanding to-dos — everything not yet done (open + dispatched), matching the count the
/// To-dos tab lists. The Mac surfaces this elsewhere on its home; the phone gets a stat card.
@@ -53,7 +53,7 @@ struct ProjectDetailView: View {
private var sessions: [WireSessionSummary] {
store.liveSessions
.filter { $0.projectID == project.id.rawValue }
.sorted { StatusStyle.sortRank($0) < StatusStyle.sortRank($1) }
.sorted(by: StatusStyle.attentionThenRecency)
}
var body: some View {
@@ -10,10 +10,7 @@ struct SessionsView: View {
private var grouped: [(title: String, rows: [WireSessionSummary])] {
let pool = (showArchived ? store.sessions : store.liveSessions)
.sorted {
let a = StatusStyle.sortRank($0), b = StatusStyle.sortRank($1)
return a == b ? $0.updatedAt > $1.updatedAt : a < b
}
.sorted(by: StatusStyle.attentionThenRecency)
let needs = pool.filter { $0.status.needsYou($0.disposition) }
let running = pool.filter { $0.status == .running || $0.status == .provisioning || $0.status == .idle }
let done = pool.filter { !needs.contains($0) && !running.contains($0) }
@@ -281,6 +281,14 @@ enum StatusStyle {
case .finished, .interrupted, .error: return 5
}
}
/// The shared session-list sort: attention-first by `sortRank`, then, within a rank, by
/// last-turn recency (`lastTurnAt`) rather than raw activity — so rows hold their place
/// instead of reshuffling every few seconds as the agent works. Mirrors the Mac sidebar.
static func attentionThenRecency(_ lhs: WireSessionSummary, _ rhs: WireSessionSummary) -> Bool {
let l = sortRank(lhs), r = sortRank(rhs)
return l == r ? lhs.lastTurnAt > rhs.lastTurnAt : l < r
}
}
extension SessionStatus {
@@ -14,7 +14,11 @@ struct SessionLiveActivity: Widget {
ActivityConfiguration(for: NucleicSessionAttributes.self) { context in
LockScreenView(state: context.state, hostName: context.attributes.hostName)
.widgetURL(context.state.tapURL)
.activityBackgroundTint(Color.black.opacity(0.55))
// When something needs the user, wash the whole card in the attention tint;
// otherwise the calm dark background (see the border/header cues in LockScreenView).
.activityBackgroundTint(context.state.needsAttention
? ActivityPalette.attentionTint(context.state).opacity(0.28)
: Color.black.opacity(0.55))
.activitySystemActionForegroundColor(.white)
} dynamicIsland: { context in
let state = context.state
@@ -43,9 +47,6 @@ struct SessionLiveActivity: Widget {
ForEach(state.lines.prefix(2)) { line in
SessionRow(line: line)
}
if state.hasChurn {
ChurnLine(state: state).padding(.top, 1)
}
}
}
} compactLeading: {
@@ -77,12 +78,17 @@ private struct LockScreenView: View {
let hostName: String
var body: some View {
VStack(alignment: .leading, spacing: 8) {
// Header: brand + host, and the headline counts.
let attention = state.needsAttention
let tint = ActivityPalette.attentionTint(state)
return VStack(alignment: .leading, spacing: 8) {
// Header: brand + host, and the headline counts. When the user is needed the brand
// mark becomes the attention glyph in its tint, so the header itself reads "act now".
HStack(spacing: 8) {
Image(systemName: "hexagon.fill")
Image(systemName: attention
? ActivityPalette.glyph(state.approvalCount > 0 ? .approval : .needsInput)
: "hexagon.fill")
.font(.footnote)
.foregroundStyle(ActivityPalette.active)
.foregroundStyle(attention ? tint : ActivityPalette.active)
Text(hostName)
.font(.caption.weight(.semibold))
.lineLimit(1)
@@ -94,15 +100,23 @@ private struct LockScreenView: View {
ForEach(state.lines.prefix(3)) { line in
SessionRow(line: line)
}
// Aggregate churn footer — how much work is in flight right now.
if state.hasChurn {
Divider().overlay(Color.white.opacity(0.12))
ChurnLine(state: state)
}
}
.padding(14)
.foregroundStyle(.white)
// Attention theme: a soft tinted wash + a colored border around the whole glance, so a
// session waiting on the user is unmistakable at a glance even before reading the rows.
.background {
if attention {
RoundedRectangle(cornerRadius: 22, style: .continuous)
.fill(tint.opacity(0.12))
}
}
.overlay {
if attention {
RoundedRectangle(cornerRadius: 22, style: .continuous)
.strokeBorder(tint.opacity(0.9), lineWidth: 2)
}
}
}
}
@@ -182,28 +196,6 @@ private struct CountChips: View {
}
}
/// Aggregate worktree churn: "N files +A −R".
private struct ChurnLine: View {
let state: NucleicSessionAttributes.ContentState
var body: some View {
HStack(spacing: 6) {
Image(systemName: "chevron.left.forwardslash.chevron.right")
.font(.caption2)
.foregroundStyle(.secondary)
Text("\(state.filesChanged) file\(state.filesChanged == 1 ? "" : "s") changed")
.foregroundStyle(.secondary)
Text("+\(state.linesAdded)")
.foregroundStyle(ActivityPalette.added)
Text("−\(state.linesRemoved)")
.foregroundStyle(ActivityPalette.removed)
}
.font(.caption2.weight(.medium))
.monospacedDigit()
.lineLimit(1)
}
}
/// Dynamic Island expanded leading/trailing count badge.
private struct CountBadge: View {
let systemImage: String
@@ -231,6 +223,12 @@ private enum ActivityPalette {
static let added = Color(red: 0.30, green: 0.78, blue: 0.45)
static let removed = Color(red: 0.92, green: 0.34, blue: 0.34)
/// The color the "attention needed" theme paints with: approval amber is loudest, else the
/// needs-input teal. Only meaningful when `state.needsAttention` — callers gate on that.
static func attentionTint(_ state: NucleicSessionAttributes.ContentState) -> Color {
state.approvalCount > 0 ? attention : accent
}
static func glyph(_ kind: NucleicSessionAttributes.Kind) -> String {
switch kind {
case .running: "circle.lefthalf.filled"