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:
@@ -56,10 +56,7 @@ final class LiveActivityManager {
|
|||||||
$0.status == .running || $0.status == .provisioning
|
$0.status == .running || $0.status == .provisioning
|
||||||
|| $0.status.needsYou($0.disposition)
|
|| $0.status.needsYou($0.disposition)
|
||||||
}
|
}
|
||||||
.sorted { lhs, rhs in
|
.sorted(by: StatusStyle.attentionThenRecency)
|
||||||
let l = StatusStyle.sortRank(lhs), r = StatusStyle.sortRank(rhs)
|
|
||||||
return l == r ? lhs.updatedAt > rhs.updatedAt : l < r
|
|
||||||
}
|
|
||||||
|
|
||||||
let approvals = active.reduce(0) { $0 + $1.pendingApprovalCount }
|
let approvals = active.reduce(0) { $0 + $1.pendingApprovalCount }
|
||||||
let files = active.reduce(0) { $0 + ($1.diffStat?.filesChanged ?? 0) }
|
let files = active.reduce(0) { $0 + ($1.diffStat?.filesChanged ?? 0) }
|
||||||
|
|||||||
@@ -201,6 +201,18 @@ final class HostConnection {
|
|||||||
relayMembershipToken: nil, relayURL: nil)
|
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).
|
/// Split a "host:port" hint (last-colon split so a bracketed IPv6 host survives).
|
||||||
private static func splitHostPort(_ hint: String?) -> (String?, UInt16?) {
|
private static func splitHostPort(_ hint: String?) -> (String?, UInt16?) {
|
||||||
guard let hint, let colon = hint.lastIndex(of: ":"),
|
guard let hint, let colon = hint.lastIndex(of: ":"),
|
||||||
@@ -478,8 +490,18 @@ final class HostConnection {
|
|||||||
else { continue }
|
else { continue }
|
||||||
let fingerprint = member.staticPublicKey.fingerprintHex
|
let fingerprint = member.staticPublicKey.fingerprintHex
|
||||||
guard fingerprint != self.hostID else { continue } // that's the Mac we're on
|
guard fingerprint != self.hostID else { continue } // that's the Mac we're on
|
||||||
if IdentityStore.pairedHost(id: fingerprint) == nil { changed = true }
|
let incoming = Self.pairedHost(from: member)
|
||||||
IdentityStore.mergePairedHost(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 {
|
for tombstone in push.tombstones {
|
||||||
// fingerprint = first 16 hex of the hostID (sha256 prefix), the registry key.
|
// 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
|
// `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.
|
// dedicated fetch path (not this subscribe stream), so nothing to do here yet.
|
||||||
break
|
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):
|
case .relayMembership(let membership):
|
||||||
// The host issued/refreshed this device's relay credential (mesh P2). Persist it
|
// 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
|
// in place (no reordering — this can arrive from a non-active Mac) so the relay
|
||||||
|
|||||||
@@ -1242,6 +1242,13 @@ extension DashboardSnapshot {
|
|||||||
}
|
}
|
||||||
|
|
||||||
extension WireSessionSummary {
|
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
|
/// 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
|
/// 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
|
/// 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
|
/// 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.
|
/// isn't in the dashboard collected into a trailing "Other" group. Attention-first sort.
|
||||||
private var projectGroups: [(name: String, rows: [WireSessionSummary])] {
|
private var projectGroups: [(name: String, rows: [WireSessionSummary])] {
|
||||||
let live = store.liveSessions.sorted {
|
let live = store.liveSessions.sorted(by: StatusStyle.attentionThenRecency)
|
||||||
let a = StatusStyle.sortRank($0), b = StatusStyle.sortRank($1)
|
|
||||||
return a == b ? $0.updatedAt > $1.updatedAt : a < b
|
|
||||||
}
|
|
||||||
var groups: [(name: String, rows: [WireSessionSummary])] = []
|
var groups: [(name: String, rows: [WireSessionSummary])] = []
|
||||||
var placed = Set<SessionID>()
|
var placed = Set<SessionID>()
|
||||||
for project in store.dashboard.projects {
|
for project in store.dashboard.projects {
|
||||||
|
|||||||
@@ -16,12 +16,12 @@ struct HomeView: View {
|
|||||||
private var needsAttention: [WireSessionSummary] {
|
private var needsAttention: [WireSessionSummary] {
|
||||||
store.liveSessions
|
store.liveSessions
|
||||||
.filter { $0.status.needsYou($0.disposition) }
|
.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
|
/// Sessions actively working a turn — drives both the "In progress" stat card and the
|
||||||
/// live list pinned above the to-dos.
|
/// live list pinned above the to-dos.
|
||||||
private var running: [WireSessionSummary] {
|
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
|
/// 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.
|
/// 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] {
|
private var sessions: [WireSessionSummary] {
|
||||||
store.liveSessions
|
store.liveSessions
|
||||||
.filter { $0.projectID == project.id.rawValue }
|
.filter { $0.projectID == project.id.rawValue }
|
||||||
.sorted { StatusStyle.sortRank($0) < StatusStyle.sortRank($1) }
|
.sorted(by: StatusStyle.attentionThenRecency)
|
||||||
}
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
|
|||||||
@@ -10,10 +10,7 @@ struct SessionsView: View {
|
|||||||
|
|
||||||
private var grouped: [(title: String, rows: [WireSessionSummary])] {
|
private var grouped: [(title: String, rows: [WireSessionSummary])] {
|
||||||
let pool = (showArchived ? store.sessions : store.liveSessions)
|
let pool = (showArchived ? store.sessions : store.liveSessions)
|
||||||
.sorted {
|
.sorted(by: StatusStyle.attentionThenRecency)
|
||||||
let a = StatusStyle.sortRank($0), b = StatusStyle.sortRank($1)
|
|
||||||
return a == b ? $0.updatedAt > $1.updatedAt : a < b
|
|
||||||
}
|
|
||||||
let needs = pool.filter { $0.status.needsYou($0.disposition) }
|
let needs = pool.filter { $0.status.needsYou($0.disposition) }
|
||||||
let running = pool.filter { $0.status == .running || $0.status == .provisioning || $0.status == .idle }
|
let running = pool.filter { $0.status == .running || $0.status == .provisioning || $0.status == .idle }
|
||||||
let done = pool.filter { !needs.contains($0) && !running.contains($0) }
|
let done = pool.filter { !needs.contains($0) && !running.contains($0) }
|
||||||
|
|||||||
@@ -281,6 +281,14 @@ enum StatusStyle {
|
|||||||
case .finished, .interrupted, .error: return 5
|
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 {
|
extension SessionStatus {
|
||||||
|
|||||||
@@ -14,7 +14,11 @@ struct SessionLiveActivity: Widget {
|
|||||||
ActivityConfiguration(for: NucleicSessionAttributes.self) { context in
|
ActivityConfiguration(for: NucleicSessionAttributes.self) { context in
|
||||||
LockScreenView(state: context.state, hostName: context.attributes.hostName)
|
LockScreenView(state: context.state, hostName: context.attributes.hostName)
|
||||||
.widgetURL(context.state.tapURL)
|
.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)
|
.activitySystemActionForegroundColor(.white)
|
||||||
} dynamicIsland: { context in
|
} dynamicIsland: { context in
|
||||||
let state = context.state
|
let state = context.state
|
||||||
@@ -43,9 +47,6 @@ struct SessionLiveActivity: Widget {
|
|||||||
ForEach(state.lines.prefix(2)) { line in
|
ForEach(state.lines.prefix(2)) { line in
|
||||||
SessionRow(line: line)
|
SessionRow(line: line)
|
||||||
}
|
}
|
||||||
if state.hasChurn {
|
|
||||||
ChurnLine(state: state).padding(.top, 1)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} compactLeading: {
|
} compactLeading: {
|
||||||
@@ -77,12 +78,17 @@ private struct LockScreenView: View {
|
|||||||
let hostName: String
|
let hostName: String
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(alignment: .leading, spacing: 8) {
|
let attention = state.needsAttention
|
||||||
// Header: brand + host, and the headline counts.
|
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) {
|
HStack(spacing: 8) {
|
||||||
Image(systemName: "hexagon.fill")
|
Image(systemName: attention
|
||||||
|
? ActivityPalette.glyph(state.approvalCount > 0 ? .approval : .needsInput)
|
||||||
|
: "hexagon.fill")
|
||||||
.font(.footnote)
|
.font(.footnote)
|
||||||
.foregroundStyle(ActivityPalette.active)
|
.foregroundStyle(attention ? tint : ActivityPalette.active)
|
||||||
Text(hostName)
|
Text(hostName)
|
||||||
.font(.caption.weight(.semibold))
|
.font(.caption.weight(.semibold))
|
||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
@@ -94,15 +100,23 @@ private struct LockScreenView: View {
|
|||||||
ForEach(state.lines.prefix(3)) { line in
|
ForEach(state.lines.prefix(3)) { line in
|
||||||
SessionRow(line: line)
|
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)
|
.padding(14)
|
||||||
.foregroundStyle(.white)
|
.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.
|
/// Dynamic Island expanded leading/trailing count badge.
|
||||||
private struct CountBadge: View {
|
private struct CountBadge: View {
|
||||||
let systemImage: String
|
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 added = Color(red: 0.30, green: 0.78, blue: 0.45)
|
||||||
static let removed = Color(red: 0.92, green: 0.34, blue: 0.34)
|
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 {
|
static func glyph(_ kind: NucleicSessionAttributes.Kind) -> String {
|
||||||
switch kind {
|
switch kind {
|
||||||
case .running: "circle.lefthalf.filled"
|
case .running: "circle.lefthalf.filled"
|
||||||
|
|||||||
Reference in New Issue
Block a user