From 51681986564d423289230936a5b6c4aa09dd3f0d Mon Sep 17 00:00:00 2001 From: Andrew Blakeslee Moore Date: Sun, 5 Jul 2026 04:24:25 -0700 Subject: [PATCH] =?UTF-8?q?nvrsion:=20Add:=20Reconnect=20Icon=20Update,=20?= =?UTF-8?q?fix=20mesh=20iPhone=20discovery=20for=20new=20Macs,=20fix=20ses?= =?UTF-8?q?sion=20order=20adjustment=20to=20pin=20sidebar=20sessions=20for?= =?UTF-8?q?=20remote=20projects,=20fix=20session=20status=20synchronizatio?= =?UTF-8?q?n=20for=20remote=20viewer=20Macs,=20fix=20session=20order=20adj?= =?UTF-8?q?ustment=20for=20iOS=20app=20session=20list,=20fix=20session=20s?= =?UTF-8?q?tatus=20synchronization=20for=20remote=20sessions,=20fix=20iOS?= =?UTF-8?q?=20app=20live=20activity=20height=20by=20removing=20the=20?= =?UTF-8?q?=E2=80=9C=20files=20changed=E2=80=9D=20line,=20fix=20Tool=20?= =?UTF-8?q?Call=20Card=20Mesh=20so=20AI=20bash=20summaries=20transit=20the?= =?UTF-8?q?=20mesh=20layer=20to=20other=20Macs,=20replace=20=E2=80=9CResol?= =?UTF-8?q?ved=20by=20mac-ui=E2=80=9D=20in=20transcripts=20with=20device?= =?UTF-8?q?=20ID,=20fix=20Chat:=20Print=20Wait=20Message,=20fix=20Chat:=20?= =?UTF-8?q?Reconnect=20Icon=20Update,=20fix=20Chat:=20Activity=20Theme=20A?= =?UTF-8?q?djustment=20for=20=E2=80=9Cattention=20needed=E2=80=9D=20live?= =?UTF-8?q?=20activity=20theme.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Nucleic-Promote: 1 Co-authored-by: Nucleic --- .../NucleicRemote/LiveActivityManager.swift | 5 +- .../NucleicRemote/Models/HostConnection.swift | 30 +++++++- .../NucleicRemote/Models/RemoteStore.swift | 7 ++ .../Views/AdaptiveRootView.swift | 5 +- .../NucleicRemote/Views/HomeView.swift | 4 +- .../NucleicRemote/Views/ProjectsView.swift | 2 +- .../NucleicRemote/Views/SessionsView.swift | 5 +- NucleicRemote/NucleicRemote/Views/Theme.swift | 8 +++ .../SessionLiveActivity.swift | 70 +++++++++---------- 9 files changed, 83 insertions(+), 53 deletions(-) diff --git a/NucleicRemote/NucleicRemote/LiveActivityManager.swift b/NucleicRemote/NucleicRemote/LiveActivityManager.swift index 04f5cb6..0ed2631 100644 --- a/NucleicRemote/NucleicRemote/LiveActivityManager.swift +++ b/NucleicRemote/NucleicRemote/LiveActivityManager.swift @@ -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) } diff --git a/NucleicRemote/NucleicRemote/Models/HostConnection.swift b/NucleicRemote/NucleicRemote/Models/HostConnection.swift index 1523236..8436ad5 100644 --- a/NucleicRemote/NucleicRemote/Models/HostConnection.swift +++ b/NucleicRemote/NucleicRemote/Models/HostConnection.swift @@ -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 diff --git a/NucleicRemote/NucleicRemote/Models/RemoteStore.swift b/NucleicRemote/NucleicRemote/Models/RemoteStore.swift index e05c020..cfe8834 100644 --- a/NucleicRemote/NucleicRemote/Models/RemoteStore.swift +++ b/NucleicRemote/NucleicRemote/Models/RemoteStore.swift @@ -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 diff --git a/NucleicRemote/NucleicRemote/Views/AdaptiveRootView.swift b/NucleicRemote/NucleicRemote/Views/AdaptiveRootView.swift index 574c482..9c4a02b 100644 --- a/NucleicRemote/NucleicRemote/Views/AdaptiveRootView.swift +++ b/NucleicRemote/NucleicRemote/Views/AdaptiveRootView.swift @@ -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() for project in store.dashboard.projects { diff --git a/NucleicRemote/NucleicRemote/Views/HomeView.swift b/NucleicRemote/NucleicRemote/Views/HomeView.swift index 8c169f4..dfab55d 100644 --- a/NucleicRemote/NucleicRemote/Views/HomeView.swift +++ b/NucleicRemote/NucleicRemote/Views/HomeView.swift @@ -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. diff --git a/NucleicRemote/NucleicRemote/Views/ProjectsView.swift b/NucleicRemote/NucleicRemote/Views/ProjectsView.swift index 32290c1..0ad0cfc 100644 --- a/NucleicRemote/NucleicRemote/Views/ProjectsView.swift +++ b/NucleicRemote/NucleicRemote/Views/ProjectsView.swift @@ -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 { diff --git a/NucleicRemote/NucleicRemote/Views/SessionsView.swift b/NucleicRemote/NucleicRemote/Views/SessionsView.swift index e42285a..22fb067 100644 --- a/NucleicRemote/NucleicRemote/Views/SessionsView.swift +++ b/NucleicRemote/NucleicRemote/Views/SessionsView.swift @@ -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) } diff --git a/NucleicRemote/NucleicRemote/Views/Theme.swift b/NucleicRemote/NucleicRemote/Views/Theme.swift index 9da8d5b..1417988 100644 --- a/NucleicRemote/NucleicRemote/Views/Theme.swift +++ b/NucleicRemote/NucleicRemote/Views/Theme.swift @@ -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 { diff --git a/NucleicRemote/NucleicRemoteWidgets/SessionLiveActivity.swift b/NucleicRemote/NucleicRemoteWidgets/SessionLiveActivity.swift index deee70b..5e2f336 100644 --- a/NucleicRemote/NucleicRemoteWidgets/SessionLiveActivity.swift +++ b/NucleicRemote/NucleicRemoteWidgets/SessionLiveActivity.swift @@ -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"