nvrsion: Add, fix, and refactor model, view, and controller updates for chat performance; prevent sidebar from closing; add fixed widths to sidebar and settings panel.
Nucleic-Promote: 1 Co-authored-by: Nucleic <[email protected]>
This commit is contained in:
@@ -639,20 +639,23 @@ final class RemoteStore: ObservableObject {
|
|||||||
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 }
|
||||||
// Merge, don't replace: a fresh open merges into an empty transcript (the tail window),
|
// 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
|
// 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.
|
// instead of truncating it to the host's 200-event tail. Drain the streaming buffer
|
||||||
|
// first so the seq-dedup merge sees the full stream (a buffered event the snapshot
|
||||||
|
// also carries would otherwise be re-appended as a duplicate after the merge).
|
||||||
|
self.drainPendingOpenEvents()
|
||||||
self.openEvents = Self.mergedEvents(self.openEvents, snap.recentEvents)
|
self.openEvents = Self.mergedEvents(self.openEvents, snap.recentEvents)
|
||||||
self.openApprovals = snap.pendingApprovals
|
self.openApprovals = snap.pendingApprovals
|
||||||
self.persistOpenTranscript()
|
self.persistOpenTranscript()
|
||||||
}
|
}
|
||||||
cb.openEvents = { [weak self] batch in
|
cb.openEvents = { [weak self] batch in
|
||||||
guard let self, hostID == self.openSessionHostID, batch.sessionID == self.openSessionID else { return }
|
guard let self, hostID == self.openSessionHostID, batch.sessionID == self.openSessionID else { return }
|
||||||
self.openEvents.append(contentsOf: batch.events)
|
self.enqueueOpenEvents(batch.events)
|
||||||
self.persistOpenTranscript()
|
|
||||||
}
|
}
|
||||||
cb.openBackfill = { [weak self] batch in
|
cb.openBackfill = { [weak self] batch in
|
||||||
guard let self, hostID == self.openSessionHostID, batch.sessionID == self.openSessionID else { return }
|
guard let self, hostID == self.openSessionHostID, batch.sessionID == self.openSessionID else { return }
|
||||||
// Full-history backfill precedes what's on screen — merge by seq so it slots in above the
|
// Full-history backfill precedes what's on screen — merge by seq so it slots in above the
|
||||||
// tail rather than appending out of order.
|
// tail rather than appending out of order. Drain first (same reason as openSnapshot).
|
||||||
|
self.drainPendingOpenEvents()
|
||||||
self.openEvents = Self.mergedEvents(self.openEvents, batch.events)
|
self.openEvents = Self.mergedEvents(self.openEvents, batch.events)
|
||||||
self.persistOpenTranscript()
|
self.persistOpenTranscript()
|
||||||
}
|
}
|
||||||
@@ -722,41 +725,54 @@ final class RemoteStore: ObservableObject {
|
|||||||
/// or a representative one. No-op in demo, which seeds the aggregate directly.
|
/// or a representative one. No-op in demo, which seeds the aggregate directly.
|
||||||
private func rebuildAggregate() {
|
private func rebuildAggregate() {
|
||||||
guard !demoMode else { return }
|
guard !demoMode else { return }
|
||||||
|
// Every assignment below goes through `setIfChanged`: `didUpdate` fires on *every* wire
|
||||||
|
// frame from *any* host (session churn, dashboard refresh, diff-stat ticks), and a plain
|
||||||
|
// `@Published` assignment fires `objectWillChange` even when the value is identical —
|
||||||
|
// re-evaluating every view observing the store for nothing. Equality checks over these
|
||||||
|
// small aggregates are far cheaper than a whole-tree SwiftUI invalidation.
|
||||||
let live = aggregatedSessions()
|
let live = aggregatedSessions()
|
||||||
if !live.isEmpty {
|
if !live.isEmpty {
|
||||||
sessions = live
|
if sessions != live {
|
||||||
cachedSummaries = live
|
sessions = live
|
||||||
persistSummaries(live)
|
cachedSummaries = live
|
||||||
|
persistSummaries(live)
|
||||||
|
}
|
||||||
} else if connections.values.contains(where: { $0.connectivity.isLive }) {
|
} else if connections.values.contains(where: { $0.connectivity.isLive }) {
|
||||||
// Connected, but the host genuinely has no sessions — reflect that honestly.
|
// Connected, but the host genuinely has no sessions — reflect that honestly.
|
||||||
sessions = []
|
setIfChanged(\.sessions, [])
|
||||||
} else {
|
} else {
|
||||||
// Offline: keep showing the saved history rather than blanking the list.
|
// Offline: keep showing the saved history rather than blanking the list.
|
||||||
sessions = cachedSummaries
|
setIfChanged(\.sessions, cachedSummaries)
|
||||||
}
|
}
|
||||||
dashboard = DashboardSnapshot.merged(connections.values.map(\.dashboard))
|
setIfChanged(\.dashboard, DashboardSnapshot.merged(connections.values.map(\.dashboard)))
|
||||||
meshPeers = connections.values.flatMap(\.meshPeers)
|
setIfChanged(\.meshPeers, connections.values.flatMap(\.meshPeers))
|
||||||
|
|
||||||
let ctx = contextConnection
|
let ctx = contextConnection
|
||||||
hostName = ctx?.hostName ?? ""
|
setIfChanged(\.hostName, ctx?.hostName ?? "")
|
||||||
capabilities = ctx?.capabilities
|
setIfChanged(\.capabilities, ctx?.capabilities
|
||||||
?? WireCapabilities(canModifyToolInput: false, allowAlwaysScopes: [])
|
?? WireCapabilities(canModifyToolInput: false, allowAlwaysScopes: []))
|
||||||
modelCatalog = ctx?.modelCatalog ?? .empty
|
setIfChanged(\.modelCatalog, ctx?.modelCatalog ?? .empty)
|
||||||
|
|
||||||
if let id = openSessionHostID, let conn = connections[id] {
|
if let id = openSessionHostID, let conn = connections[id] {
|
||||||
// While a transcript is open, the composer/controls act on *that* Mac — its connectivity
|
// While a transcript is open, the composer/controls act on *that* Mac — its connectivity
|
||||||
// gates send and its scope drives the control affordances.
|
// gates send and its scope drives the control affordances.
|
||||||
connectivity = conn.connectivity
|
setIfChanged(\.connectivity, conn.connectivity)
|
||||||
grantedScope = conn.grantedScope
|
setIfChanged(\.grantedScope, conn.grantedScope)
|
||||||
} else {
|
} else {
|
||||||
connectivity = aggregateConnectivity()
|
setIfChanged(\.connectivity, aggregateConnectivity())
|
||||||
// Optimistic new-chat gating: enabled if *any* Mac grants control (the owning Mac still
|
// Optimistic new-chat gating: enabled if *any* Mac grants control (the owning Mac still
|
||||||
// enforces scope when the intent lands there).
|
// enforces scope when the intent lands there).
|
||||||
grantedScope = connections.values
|
setIfChanged(\.grantedScope, connections.values
|
||||||
.filter { $0.connectivity.isLive }.map(\.grantedScope).max() ?? .approve
|
.filter { $0.connectivity.isLive }.map(\.grantedScope).max() ?? .approve)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Assign a `@Published` property only when the value actually differs, so a no-op rebuild
|
||||||
|
/// doesn't fire `objectWillChange` (and with it a whole-tree view re-evaluation).
|
||||||
|
private func setIfChanged<T: Equatable>(_ keyPath: ReferenceWritableKeyPath<RemoteStore, T>, _ value: T) {
|
||||||
|
if self[keyPath: keyPath] != value { self[keyPath: keyPath] = value }
|
||||||
|
}
|
||||||
|
|
||||||
/// Merge transcript events by `seq` (monotonic, globally unique within a session), keeping the
|
/// 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
|
/// 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.
|
/// screen without duplicating what's shown or dropping history outside the host's tail window.
|
||||||
@@ -790,10 +806,67 @@ final class RemoteStore: ObservableObject {
|
|||||||
Task { [weak self] in
|
Task { [weak self] in
|
||||||
let cached = await SessionCache.loadEvents(sessionID)
|
let cached = await SessionCache.loadEvents(sessionID)
|
||||||
guard let self, self.openSessionID == sessionID, !cached.isEmpty else { return }
|
guard let self, self.openSessionID == sessionID, !cached.isEmpty else { return }
|
||||||
|
// Drain any live deltas first so the seq-dedup merge sees the complete stream.
|
||||||
|
self.drainPendingOpenEvents()
|
||||||
self.openEvents = Self.mergedEvents(cached, self.openEvents)
|
self.openEvents = Self.mergedEvents(cached, self.openEvents)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Streaming delta coalescing
|
||||||
|
|
||||||
|
/// Streaming transcript deltas arrive one wire frame at a time — often dozens per second
|
||||||
|
/// while the agent talks — and every `openEvents` mutation fires `objectWillChange`, which
|
||||||
|
/// re-evaluates *every* view observing the store (the Sessions/Home tabs stay mounted behind
|
||||||
|
/// the pushed session detail, so they pay this too). Buffer incoming deltas and publish at
|
||||||
|
/// most one append per `openEventsFlushInterval`: the first delta after a quiet gap applies
|
||||||
|
/// immediately (the leading edge — first-token latency stays imperceptible), followers ride
|
||||||
|
/// the next scheduled flush. ~10 UI updates/sec still reads as live streaming; the view tree
|
||||||
|
/// stops being invalidated per wire frame. Merge/close/flush paths drain the buffer first, so
|
||||||
|
/// nothing downstream ever sees a partial stream.
|
||||||
|
private var pendingOpenEvents: [AgentEvent] = []
|
||||||
|
private var openEventsFlushTask: Task<Void, Never>?
|
||||||
|
private var lastOpenEventsFlushAt = Date.distantPast
|
||||||
|
private static let openEventsFlushInterval: TimeInterval = 0.1
|
||||||
|
|
||||||
|
private func enqueueOpenEvents(_ events: [AgentEvent]) {
|
||||||
|
pendingOpenEvents.append(contentsOf: events)
|
||||||
|
guard openEventsFlushTask == nil else { return } // a trailing flush is already scheduled
|
||||||
|
let elapsed = Date().timeIntervalSince(lastOpenEventsFlushAt)
|
||||||
|
if elapsed >= Self.openEventsFlushInterval {
|
||||||
|
drainPendingOpenEvents()
|
||||||
|
} else {
|
||||||
|
let delay = Self.openEventsFlushInterval - elapsed
|
||||||
|
openEventsFlushTask = Task { [weak self] in
|
||||||
|
try? await Task.sleep(nanoseconds: UInt64(delay * 1_000_000_000))
|
||||||
|
guard let self, !Task.isCancelled else { return }
|
||||||
|
self.openEventsFlushTask = nil
|
||||||
|
self.drainPendingOpenEvents()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Publish the buffered deltas (and schedule persistence). Called on the flush cadence, and
|
||||||
|
/// eagerly by anything that merges, persists, or clears `openEvents`, so those paths always
|
||||||
|
/// operate on the complete stream.
|
||||||
|
private func drainPendingOpenEvents() {
|
||||||
|
openEventsFlushTask?.cancel()
|
||||||
|
openEventsFlushTask = nil
|
||||||
|
guard !pendingOpenEvents.isEmpty else { return }
|
||||||
|
lastOpenEventsFlushAt = Date()
|
||||||
|
openEvents.append(contentsOf: pendingOpenEvents)
|
||||||
|
pendingOpenEvents.removeAll(keepingCapacity: true)
|
||||||
|
persistOpenTranscript()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Drop buffered deltas without publishing — for session switch/close/unpair, where the
|
||||||
|
/// buffer belongs to a transcript that is being cleared (a stale session's tail must never
|
||||||
|
/// leak into the next session's freshly-opened transcript).
|
||||||
|
private func discardPendingOpenEvents() {
|
||||||
|
openEventsFlushTask?.cancel()
|
||||||
|
openEventsFlushTask = nil
|
||||||
|
pendingOpenEvents.removeAll(keepingCapacity: true)
|
||||||
|
}
|
||||||
|
|
||||||
/// Debounced write of the open transcript, called after each batch of events lands.
|
/// Debounced write of the open transcript, called after each batch of events lands.
|
||||||
private func persistOpenTranscript() {
|
private func persistOpenTranscript() {
|
||||||
guard !demoMode, let id = openSessionID, !openEvents.isEmpty else { return }
|
guard !demoMode, let id = openSessionID, !openEvents.isEmpty else { return }
|
||||||
@@ -910,6 +983,7 @@ final class RemoteStore: ObservableObject {
|
|||||||
openSessionID = nil
|
openSessionID = nil
|
||||||
compactDetailPresented = false
|
compactDetailPresented = false
|
||||||
openSessionHostID = nil
|
openSessionHostID = nil
|
||||||
|
discardPendingOpenEvents()
|
||||||
openEvents = []; openApprovals = []; openDiff = nil; diffLoading = false
|
openEvents = []; openApprovals = []; openDiff = nil; diffLoading = false
|
||||||
connectivity = .unpaired
|
connectivity = .unpaired
|
||||||
// No Mac left whose history to hold — drop the offline cache too.
|
// No Mac left whose history to hold — drop the offline cache too.
|
||||||
@@ -944,6 +1018,7 @@ final class RemoteStore: ObservableObject {
|
|||||||
openSessionID = nil
|
openSessionID = nil
|
||||||
compactDetailPresented = false
|
compactDetailPresented = false
|
||||||
openSessionHostID = nil
|
openSessionHostID = nil
|
||||||
|
discardPendingOpenEvents()
|
||||||
openEvents = []
|
openEvents = []
|
||||||
openApprovals = []
|
openApprovals = []
|
||||||
openDiff = nil
|
openDiff = nil
|
||||||
@@ -972,6 +1047,7 @@ final class RemoteStore: ObservableObject {
|
|||||||
// Record which Mac owns this session (mesh P3) so its connection forwards the transcript and
|
// Record which Mac owns this session (mesh P3) so its connection forwards the transcript and
|
||||||
// the host-specific projected values follow it.
|
// the host-specific projected values follow it.
|
||||||
openSessionHostID = connection(owningSession: sessionID)?.hostID
|
openSessionHostID = connection(owningSession: sessionID)?.hostID
|
||||||
|
discardPendingOpenEvents()
|
||||||
openEvents = []
|
openEvents = []
|
||||||
openApprovals = []
|
openApprovals = []
|
||||||
openDiff = nil
|
openDiff = nil
|
||||||
@@ -1149,6 +1225,8 @@ final class RemoteStore: ObservableObject {
|
|||||||
compactDetailPresented = false
|
compactDetailPresented = false
|
||||||
openSessionHostID = nil
|
openSessionHostID = nil
|
||||||
// Persist the final transcript before clearing it, so it's warm for the next open / offline.
|
// Persist the final transcript before clearing it, so it's warm for the next open / offline.
|
||||||
|
// Buffered streaming deltas are part of that transcript — publish them first.
|
||||||
|
drainPendingOpenEvents()
|
||||||
flushOpenTranscript(target, openEvents)
|
flushOpenTranscript(target, openEvents)
|
||||||
openEvents = []
|
openEvents = []
|
||||||
openApprovals = []
|
openApprovals = []
|
||||||
|
|||||||
@@ -123,17 +123,37 @@ struct SessionDetailView: View {
|
|||||||
set: { store.setSessionEffort(sessionID, $0) })
|
set: { store.setSessionEffort(sessionID, $0) })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Memo for the context-occupancy scan below. The backward scan usually stops at the last
|
||||||
|
/// turn's usage event, but a stream with sparse (or no) usage reporting walks the whole
|
||||||
|
/// transcript — and `body` re-evaluates on every store change and keystroke, so an O(N) scan
|
||||||
|
/// per evaluation quietly compounds on long sessions. `(count, lastSeq)` pins the stream, as
|
||||||
|
/// in the projection cache; a class held in `@State` so updating it from `body` doesn't
|
||||||
|
/// itself invalidate the view.
|
||||||
|
private final class ContextScanCache {
|
||||||
|
var count = -1
|
||||||
|
var lastSeq: UInt64 = 0
|
||||||
|
var used: Int?
|
||||||
|
}
|
||||||
|
@State private var contextScanCache = ContextScanCache()
|
||||||
|
|
||||||
/// Live context-window occupancy (newest turn's input tokens ÷ the model's window), read
|
/// Live context-window occupancy (newest turn's input tokens ÷ the model's window), read
|
||||||
/// from the transcript exactly as the Mac header does.
|
/// from the transcript exactly as the Mac header does. Only the token scan is memoized —
|
||||||
|
/// the window division stays live, so a model switch reflects immediately.
|
||||||
private var contextPercent: Int? {
|
private var contextPercent: Int? {
|
||||||
let used = store.openEvents.reversed().lazy.compactMap { event -> Int? in
|
let events = store.openEvents
|
||||||
switch event.kind {
|
let cache = contextScanCache
|
||||||
case .turnCompleted(let turn): return turn.usage?.contextInputTokens
|
if cache.count != events.count || cache.lastSeq != (events.last?.seq ?? 0) {
|
||||||
case .usage(let usage): return usage.contextInputTokens
|
cache.count = events.count
|
||||||
default: return nil
|
cache.lastSeq = events.last?.seq ?? 0
|
||||||
}
|
cache.used = events.reversed().lazy.compactMap { event -> Int? in
|
||||||
}.first { $0 > 0 }
|
switch event.kind {
|
||||||
guard let used else { return nil }
|
case .turnCompleted(let turn): return turn.usage?.contextInputTokens
|
||||||
|
case .usage(let usage): return usage.contextInputTokens
|
||||||
|
default: return nil
|
||||||
|
}
|
||||||
|
}.first { $0 > 0 }
|
||||||
|
}
|
||||||
|
guard let used = cache.used else { return nil }
|
||||||
let window = store.modelCatalog.contextWindow(summary?.model)
|
let window = store.modelCatalog.contextWindow(summary?.model)
|
||||||
guard window > 0 else { return nil }
|
guard window > 0 else { return nil }
|
||||||
return min(100, Int((Double(used) / Double(window)) * 100))
|
return min(100, Int((Double(used) / Double(window)) * 100))
|
||||||
|
|||||||
@@ -11,9 +11,15 @@ 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(by: StatusStyle.attentionThenRecency)
|
.sorted(by: StatusStyle.attentionThenRecency)
|
||||||
let needs = pool.filter { $0.status.needsYou($0.disposition) }
|
// One pass, first bucket wins — the old `done = pool.filter { !needs.contains($0) … }`
|
||||||
let running = pool.filter { $0.status == .running || $0.status == .provisioning || $0.status == .idle }
|
// ran O(rows²) full-summary equality scans on every body evaluation.
|
||||||
let done = pool.filter { !needs.contains($0) && !running.contains($0) }
|
var needs: [WireSessionSummary] = [], running: [WireSessionSummary] = [], done: [WireSessionSummary] = []
|
||||||
|
for summary in pool {
|
||||||
|
if summary.status.needsYou(summary.disposition) { needs.append(summary) }
|
||||||
|
else if summary.status == .running || summary.status == .provisioning || summary.status == .idle {
|
||||||
|
running.append(summary)
|
||||||
|
} else { done.append(summary) }
|
||||||
|
}
|
||||||
return [("Needs you", needs), ("Running", running), ("Done", done)].filter { !$0.rows.isEmpty }
|
return [("Needs you", needs), ("Running", running), ("Done", done)].filter { !$0.rows.isEmpty }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user