Merge branch 'dev' into canary
This commit is contained in:
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user