Nucleic: Monitoring Popup Text Handling
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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 }
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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`
|
||||||
|
|||||||
Reference in New Issue
Block a user