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..0dba1bd 100644 --- a/NucleicRemote/NucleicRemote/Models/HostConnection.swift +++ b/NucleicRemote/NucleicRemote/Models/HostConnection.swift @@ -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 diff --git a/NucleicRemote/NucleicRemote/Models/RemoteStore.swift b/NucleicRemote/NucleicRemote/Models/RemoteStore.swift index e05c020..f5016b2 100644 --- a/NucleicRemote/NucleicRemote/Models/RemoteStore.swift +++ b/NucleicRemote/NucleicRemote/Models/RemoteStore.swift @@ -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 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"