nvrsion: Add: Reconnect Icon Update, fix mesh iPhone discovery for new Macs, fix session order adjustment to pin sidebar sessions for remote projects, fix session status synchronization for remote viewer Macs, fix session order adjustment for iOS app session list, fix session status synchronization for remote sessions, fix iOS app live activity height by removing the “<n> files changed” line, fix Tool Call Card Mesh so AI bash summaries transit the mesh layer to other Macs, replace “Resolved by mac-ui” in transcripts with device ID, fix Chat: Print Wait Message, fix Chat: Reconnect Icon Update, fix Chat: Activity Theme Adjustment for “attention needed” live activity theme.

Nucleic-Promote: 1
Co-authored-by: Nucleic <[email protected]>
This commit is contained in:
2026-07-05 04:24:25 -07:00
co-authored by Nucleic
parent c9cffa55ca
commit 5168198656
9 changed files with 83 additions and 53 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) }
@@ -201,6 +201,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: ":"),
@@ -478,8 +490,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 +520,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
@@ -1242,6 +1242,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"