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 == .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) }
|
||||||
|
|||||||
@@ -35,7 +35,20 @@ final class HostConnection {
|
|||||||
|
|
||||||
/// The session RemoteStore currently has open on this host, if any — so snapshot/events are only
|
/// 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.
|
/// 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)
|
// MARK: Callbacks up to RemoteStore (aggregate concerns)
|
||||||
|
|
||||||
@@ -201,6 +214,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: ":"),
|
||||||
@@ -428,7 +453,9 @@ final class HostConnection {
|
|||||||
callbacks.didUpdate()
|
callbacks.didUpdate()
|
||||||
send(.listSessions)
|
send(.listSessions)
|
||||||
send(.listDashboard)
|
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):
|
case .sessionList(let list):
|
||||||
sessions = list
|
sessions = list
|
||||||
callbacks.didUpdate()
|
callbacks.didUpdate()
|
||||||
@@ -449,12 +476,17 @@ final class HostConnection {
|
|||||||
callbacks.didUpdate()
|
callbacks.didUpdate()
|
||||||
case .snapshot(let snapshot):
|
case .snapshot(let snapshot):
|
||||||
guard snapshot.summary.sessionID == openSessionID else { break }
|
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)
|
callbacks.openSnapshot(snapshot)
|
||||||
case .events(let batch):
|
case .events(let batch):
|
||||||
guard batch.sessionID == openSessionID else { break }
|
guard batch.sessionID == openSessionID else { break }
|
||||||
let fresh = batch.events.filter { !seenSeq.contains($0.seq) }
|
let fresh = batch.events.filter { !seenSeq.contains($0.seq) }
|
||||||
for e in fresh { seenSeq.insert(e.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)) }
|
if !fresh.isEmpty { callbacks.openEvents(EventBatch(sessionID: batch.sessionID, events: fresh)) }
|
||||||
case .approvalRequested(let req):
|
case .approvalRequested(let req):
|
||||||
let title = sessions.first { $0.sessionID == req.sessionID }?.title ?? "Approval"
|
let title = sessions.first { $0.sessionID == req.sessionID }?.title ?? "Approval"
|
||||||
@@ -478,8 +510,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 +540,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
|
||||||
|
|||||||
@@ -477,7 +477,10 @@ final class RemoteStore: ObservableObject {
|
|||||||
}
|
}
|
||||||
cb.openSnapshot = { [weak self] snap in
|
cb.openSnapshot = { [weak self] snap in
|
||||||
guard let self, hostID == self.openSessionHostID, snap.summary.sessionID == self.openSessionID else { return }
|
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
|
self.openApprovals = snap.pendingApprovals
|
||||||
}
|
}
|
||||||
cb.openEvents = { [weak self] batch in
|
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
|
/// The flat sessions list: every connected Mac's sessions, deduped by id (ids are globally
|
||||||
/// unique). Views handle sorting/grouping.
|
/// unique). Views handle sorting/grouping.
|
||||||
private func aggregatedSessions() -> [WireSessionSummary] {
|
private func aggregatedSessions() -> [WireSessionSummary] {
|
||||||
@@ -1242,6 +1259,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