Nucleic: Monitoring Popup Text Handling

This commit is contained in:
2026-08-06 18:20:10 -07:00
parent e3fa590b75
commit 89e229c155
7 changed files with 201 additions and 9 deletions
+86 -2
View File
@@ -3026,10 +3026,27 @@ struct SessionDetailView: View {
/// Whether the composer's reveal slot has anything to show a permission/question request, /// Whether the composer's reveal slot has anything to show a permission/question request,
/// a hang alert, a lock-contention card, a context-switch offer, a subagent fan-out in /// a hang alert, a lock-contention card, a context-switch offer, a subagent fan-out in
/// flight, or any combination. /// flight, a background watch being kept, or any combination.
private var hasComposerRevealContent: Bool { private var hasComposerRevealContent: Bool {
store.openApprovals.first != nil || openStall != nil || openLockWait != nil store.openApprovals.first != nil || openStall != nil || openLockWait != nil
|| visibleContextSwitchOffer != nil || visibleSubagentProgress != nil || visibleContextSwitchOffer != nil || visibleSubagentProgress != nil || isMonitoring
}
/// Whether this chat's last turn ended parked on an armed background watch (`nucleic_monitor`)
/// rather than finished the state the composer's `MonitoringBar` announces.
///
/// Read from exactly the signal the sidebar's "Monitoring" label reads (`RootView`), so the
/// two can't disagree: a chat labelled Monitoring in the list always shows the card when opened,
/// and both retire together. It clears on its own the moment the watch fires and re-invokes the
/// agent (the status leaves `.awaitingInput`), when the user sends anything (`sendInput` nils
/// the disposition), or when they press Stop (`stopBackgroundWatches` re-stamps it `.completed`).
///
/// An archived chat is excluded: nothing is armed for a chat that has been put away, and its
/// composer is a read-only shell.
private var isMonitoring: Bool {
guard !isArchived else { return false }
return session?.status == .awaitingInput
&& session?.lastTurnDisposition == .waitingBackground
} }
/// This chat's in-flight Orchestra workers, or nil when none are running. /// This chat's in-flight Orchestra workers, or nil when none are running.
@@ -3244,6 +3261,13 @@ struct SessionDetailView: View {
// Both cards share one reveal slot, stacked, so a lock wait that coincides with a // Both cards share one reveal slot, stacked, so a lock wait that coincides with a
// permission request grows the same glass instead of opening a second surface. // permission request grows the same glass instead of opening a second surface.
VStack(spacing: 8) { VStack(spacing: 8) {
// Also informational, and the calmest thing in this stack: nothing is blocked,
// the chat is simply still on the job. Furthest from the composer for the same
// reason as the subagent card everything below it wants an answer.
if isMonitoring {
MonitoringBar()
.transition(ComposerMotion.inputTransition(reduceMotion))
}
// Informational, so it sits furthest from the composer: anything below it in // Informational, so it sits furthest from the composer: anything below it in
// this stack is something the turn is actually blocked on. // this stack is something the turn is actually blocked on.
if let progress = visibleSubagentProgress { if let progress = visibleSubagentProgress {
@@ -3368,6 +3392,11 @@ struct SessionDetailView: View {
.animation( .animation(
transcriptSettling ? nil : ComposerMotion.layout(reduceMotion), transcriptSettling ? nil : ComposerMotion.layout(reduceMotion),
value: visibleSubagentProgress != nil) value: visibleSubagentProgress != nil)
// The monitoring card arrives as a turn ends and retires when the watch fires or is
// stopped both moments the user is looking at the composer, so both get the spring.
.animation(
transcriptSettling ? nil : ComposerMotion.layout(reduceMotion),
value: isMonitoring)
// A wave that has fully settled re-arms the card for the next fan-out a dismissal is // A wave that has fully settled re-arms the card for the next fan-out a dismissal is
// "not this one", not "never again in this chat". // "not this one", not "never again in this chat".
.onChange(of: workingSubagents == nil) { _, quiet in .onChange(of: workingSubagents == nil) { _, quiet in
@@ -3704,6 +3733,19 @@ struct SessionDetailView: View {
} }
.buttonStyle(.plain) .buttonStyle(.plain)
.help("Interrupt the running turn") .help("Interrupt the running turn")
} else if isMonitoring && !canSend && store.openHostID == nil {
// Nothing is running, but the chat isn't finished either it's holding a watch
// open, and until that fires (or the user types) it will sit here. Stop is the
// deliberate way out, and it belongs in the slot the user already reaches for to
// make a chat stop. Only with an empty draft: the moment there's something to
// send, sending it is the more useful button and it ends the wait anyway.
// Owner-only: the watches are processes on the host that armed them, so a
// mirrored peer chat keeps its Send button here.
Button { Task { await store.stopOpenSessionMonitors() } } label: {
Image(systemName: "stop.circle.fill").font(.title2)
}
.buttonStyle(.plain)
.help("Stop watching — end the background watches this chat is waiting on")
} else { } else {
Button(action: send) { Button(action: send) {
SubmitKeyIcon(mode: submitMode) SubmitKeyIcon(mode: submitMode)
@@ -3712,6 +3754,8 @@ struct SessionDetailView: View {
.help(routedRouteAvailable .help(routedRouteAvailable
? (isBusy ? (isBusy
? "Queue this message — it sends when the agent finishes its turn" ? "Queue this message — it sends when the agent finishes its turn"
: isMonitoring
? "Send now — this chat is still watching in the background"
: submitMode.detail) : submitMode.detail)
: quotaHoldNote) : quotaHoldNote)
// Locked while another device is typing here the mesh-wide "one typer // Locked while another device is typing here the mesh-wide "one typer
@@ -4733,6 +4777,46 @@ struct ContextSwitchBar: View {
} }
} }
/// Composer card for a chat parked on an armed background watch (`nucleic_monitor`): the agent
/// ended its turn not because it was finished but so it can be re-invoked when the watch fires a
/// log line matches, a WebSocket frame lands, a poll loop finds what it was waiting for.
///
/// Without it that state is invisible from the chat itself. The turn ends, the transcript goes
/// quiet, and the composer looks exactly like a finished conversation while a watch is still out
/// there and the agent will speak again on its own. (The "Run completed" row that used to close
/// such a turn claimed the opposite of what was true, so it's dropped
/// `TranscriptItem.isBackgroundWaitEnd`.) This card is where that wait becomes legible, in the
/// composer's glass beside the permission prompts, because that's where someone is looking when
/// they're deciding whether the chat is still alive.
///
/// It asks for nothing and carries no controls of its own. The wait ends when the watch fires, when
/// the user sends the next message, or via the Stop button in the composer's action column beside
/// it (`AppStore.stopOpenSessionMonitors`) the same slot that interrupts a running turn, since
/// this is the same shape of "make it stop".
struct MonitoringBar: View {
@Environment(\.appPalette) private var palette
var body: some View {
VStack(alignment: .leading, spacing: 6) {
Label("Monitoring…", systemImage: "binoculars")
.font(.callout.weight(.semibold))
.foregroundStyle(palette.active)
Text(
"This chat is watching something in the background and will pick up on its own "
+ "when there's news. You can keep typing — messages send right away.")
.font(.caption)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
}
.padding(12)
.frame(maxWidth: .infinity, alignment: .leading)
.background(AppTheme.composerFill, in: .rect(cornerRadius: 10))
.overlay(
RoundedRectangle(cornerRadius: 10)
.strokeBorder(AppTheme.composerStroke, lineWidth: 1))
}
}
/// Composer card for a chat parked in the file-lock queue (LOCKING §4): names the contended files /// Composer card for a chat parked in the file-lock queue (LOCKING §4): names the contended files
/// and the chat(s) holding them, escalates to the attention style for a genuine deadlock, and /// and the chat(s) holding them, escalates to the attention style for a genuine deadlock, and
/// offers the manual escape hatch releasing the holders' locks. It rides in the composer's glass /// offers the manual escape hatch releasing the holders' locks. It rides in the composer's glass
+9 -1
View File
@@ -741,7 +741,15 @@ struct TranscriptRow: View, Equatable {
// arrives as an errored result carrying the reason in `finalText` there is no separate // arrives as an errored result carrying the reason in `finalText` there is no separate
// `.error` event to catch so surface the same one-click "Log in" row here rather than a // `.error` event to catch so surface the same one-click "Log in" row here rather than a
// bare "Run errored" line. // bare "Run errored" line.
if finished.outcome == .errored, if finished.waitingOnBackground,
finished.outcome == .completed || finished.outcome == .maxTurns {
// Not an ending the turn only stepped aside for an armed watch, and a checkered
// flag every time one is armed fills the scrollback with false finishes. The live
// version of this state is the composer's "Monitoring" card. The projection drops
// the item outright (an invisible row still claims its inter-row gap); this covers
// the surfaces that render raw events without it.
EmptyView()
} else if finished.outcome == .errored,
let text = finished.finalText, Self.isAuthError(text) { let text = finished.finalText, Self.isAuthError(text) {
AuthErrorRow(rawMessage: text, onLogin: onLogin) AuthErrorRow(rawMessage: text, onLogin: onLogin)
} else { } else {
+22
View File
@@ -9262,6 +9262,28 @@ public final class AppStore: ConflictArbiter {
await resolveProcessStall(stallID, kill: kill, inSession: sessionID) await resolveProcessStall(stallID, kill: kill, inSession: sessionID)
} }
/// Stop the open chat's armed background watches the composer's Stop button while the chat
/// reads "Monitoring". The wait ends here: the watches are torn down, the parked turn settles as
/// done (`SessionController.stopBackgroundWatches`), and the guests pinned *for* the wait go back
/// on the ordinary idle-stop / auto-kill policy they'd have been on had the turn simply finished.
/// A note records what was stopped, so the transcript still explains why a chat that was watching
/// something no longer is.
///
/// Owner-only: the watches are processes on the host that armed them, so a mirrored peer chat has
/// nothing here to stop.
public func stopOpenSessionMonitors() async {
guard openHostID == nil, let sessionID = openSessionID else { return }
var stopped: [String] = []
await mutateOpenSession { stopped = await $0.stopBackgroundWatches() }
guard !stopped.isEmpty else { return }
await containerManager?.releaseBackgroundWait(sessionID)
await macVMManager?.releaseBackgroundWait(sessionID)
let what = stopped.count == 1
? "\(stopped[0])"
: "\(stopped.count) watches (\(stopped.map { "\($0)" }.joined(separator: ", ")))"
await controllers[sessionID]?.note("Stopped watching \(what).", icon: "binoculars")
}
public func renameOpenSession(to title: String) async { public func renameOpenSession(to title: String) async {
if await sendToOpenSessionOwner({ .renameSession($0, title) }, if await sendToOpenSessionOwner({ .renameSession($0, title) },
onSent: { if var s = openSession { s.title = title; openSession = s } }) { return } onSent: { if var s = openSession { s.title = title; openSession = s } }) { return }
+12
View File
@@ -1081,6 +1081,14 @@ public protocol AgentBackend: Sendable {
/// backend). /// backend).
func resolveProcessStall(stallID: String, kill: Bool) async func resolveProcessStall(stallID: String, kill: Bool) async
/// Tear down every background watch (`nucleic_monitor`) this session has armed, returning each
/// stopped watch's label. This is the user's deliberate end to a wait: a turn that ended with a
/// watch armed leaves the chat parked "Monitoring" until the watch fires, and this is the Stop
/// that ends it. Silent by design the person pressing Stop is looking at the chat, so waking
/// the agent for a turn that only says "your watches were stopped" buys nothing. Returns an
/// empty array when nothing was armed.
func stopArmedMonitors() async -> [String]
/// Terminate the process/connection and release resources. /// Terminate the process/connection and release resources.
func shutdown() async func shutdown() async
} }
@@ -1093,6 +1101,10 @@ extension AgentBackend {
/// Default: no host-command stall alerts to resolve. Backends that don't run host commands /// Default: no host-command stall alerts to resolve. Backends that don't run host commands
/// inherit this no-op. /// inherit this no-op.
public func resolveProcessStall(stallID: String, kill: Bool) async {} public func resolveProcessStall(stallID: String, kill: Bool) async {}
/// Default: nothing armed. Only the Claude backend owns `nucleic_monitor` watches; every other
/// backend inherits this no-op.
public func stopArmedMonitors() async -> [String] { [] }
} }
public enum BackendError: Error, Sendable { public enum BackendError: Error, Sendable {
@@ -2297,6 +2297,29 @@ public actor ClaudeCodeBackend: AgentBackend {
} }
} }
/// Kill every armed monitor because the *user* said so the composer's Stop button on a chat
/// parked "Monitoring" (``AgentBackend/stopArmedMonitors()``). Same teardown as
/// ``teardownMonitors()``, and silent for the same reason: the session isn't going away, but the
/// person ending the wait is looking right at the chat, so re-invoking the agent to tell it the
/// watches are gone would spend a whole turn on something the next thing they type will say
/// anyway. Parked administrative notices go with them they were only waiting for another watch
/// to carry them out, and with none left they'd sit until the backstop fires and buy a turn to
/// report a wait that is already over. Returns each stopped watch's description so the host can
/// name them in the transcript.
public func stopArmedMonitors() async -> [String] {
pendingNoticeTask?.cancel()
pendingNoticeTask = nil
pendingMonitorNotices.removeAll()
var stopped: [String] = []
for id in Array(activeMonitors.keys) { // snapshot: each claim mutates `activeMonitors`
guard let run = claimMonitor(id: id) else { continue }
await terminateMonitorSource(run)
await releaseMonitorContainer(run)
stopped.append(run.description)
}
return stopped
}
// MARK: - Host exec bridge (HOST_EXEC) // MARK: - Host exec bridge (HOST_EXEC)
/// Gate + run a `host_exec` call. This path NEVER consults the auto-approve blanket or /// Gate + run a `host_exec` call. This path NEVER consults the auto-approve blanket or
@@ -1866,6 +1866,28 @@ public actor SessionController {
await backend.resolveProcessStall(stallID: stallID, kill: kill) await backend.resolveProcessStall(stallID: stallID, kill: kill)
} }
/// End a background wait on the user's say-so: tear down every armed `nucleic_monitor` watch and
/// settle the turn that was parked on them. Returns each stopped watch's label (empty when none
/// were armed) so the caller can name them in the transcript.
///
/// The disposition move is the point. A turn that ended waiting on a watch is stamped
/// `.waitingBackground` "Monitoring", neither done nor blocked on you and nothing else would
/// ever clear it once the watches are gone: the disposition classifier is skipped for a background
/// wait, and the watch that would have re-invoked the agent no longer exists. Re-stamping it
/// `.completed` here is what retires the sidebar label and the composer's card. Only that exact
/// disposition is touched, so a turn that has since moved on (a new run, a fresh classification)
/// is left alone.
public func stopBackgroundWatches() async -> [String] {
let stopped = await backend.stopArmedMonitors()
guard session.status == .awaitingInput,
session.lastTurnDisposition == .waitingBackground
else { return stopped }
session.lastTurnDisposition = .completed
session.updatedAt = now()
try? await metadataStore?.saveSession(session)
return stopped
}
// MARK: - Config (applies to subsequent turns) // MARK: - Config (applies to subsequent turns)
public func rename(_ title: String) async { public func rename(_ title: String) async {
+25 -4
View File
@@ -83,11 +83,29 @@ public enum TranscriptItem: Identifiable, Sendable {
return text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty return text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
} }
/// Items that draw no visible row in the default conversation: advanced-detail events and /// A clean turn end that only paused for an armed background watch (`nucleic_monitor`) the
/// an empty `.thinking` block. /// agent ended its turn to be re-invoked when the watch fires, not because the work was done.
///
/// It draws no row. "Run completed" is a lie here: nothing completed, and a checkered flag every
/// time a watch is armed turns one ongoing task into a scrollback full of false endings. The
/// state it *does* represent is live rather than historical the chat is monitoring *now* so
/// it belongs in the composer's glass (the "Monitoring" card), where it can retire the moment
/// the watch does, instead of as a permanent line in the record.
///
/// Only a *clean* end is dropped. An errored or interrupted turn keeps its row: what happened to
/// that turn is the news, and an armed watch doesn't make it less so.
var isBackgroundWaitEnd: Bool {
guard case .event(let event) = self, case .runFinished(let finished) = event.kind,
finished.waitingOnBackground
else { return false }
return finished.outcome == .completed || finished.outcome == .maxTurns
}
/// Items that draw no visible row in the default conversation: advanced-detail events, an empty
/// `.thinking` block, and a turn end that only paused for a background watch.
/// These must not break a run of tool calls when coalescing: an invisible row /// These must not break a run of tool calls when coalescing: an invisible row
/// sitting between two calls would otherwise split them into two cards with a gap. /// sitting between two calls would otherwise split them into two cards with a gap.
var rendersNothing: Bool { isDebug || isEmptyThinking } var rendersNothing: Bool { isDebug || isEmptyThinking || isBackgroundWaitEnd }
} }
/// One tool call within a coalesced group (or a subagent's child list): the call, its result, /// One tool call within a coalesced group (or a subagent's child list): the call, its result,
@@ -841,7 +859,10 @@ public enum TranscriptProjection {
// the upserts, so a *streaming* thinking item is judged on its accumulated text // the upserts, so a *streaming* thinking item is judged on its accumulated text
// rather than on the empty first delta. Debug rows stay: they're real rows under // rather than on the empty first delta. Debug rows stay: they're real rows under
// "advanced detail", and the view filters them out when it hides them. // "advanced detail", and the view filters them out when it hides them.
return items.filter { !$0.isEmptyThinking } // A turn that ended only to wait on an armed watch goes with it, and for the same reason:
// it draws nothing (see `isBackgroundWaitEnd`), so leaving it in would spend a gap saying
// "Run completed" invisibly. The live version of that state is the composer's card.
return items.filter { !$0.isEmptyThinking && !$0.isBackgroundWaitEnd }
} }
/// The cumulative + per-ping token estimate of a Claude `system/thinking_tokens` /// The cumulative + per-ping token estimate of a Claude `system/thinking_tokens`