Merge nucleic/humble-slate-civet-chwe into dev

This commit is contained in:
2026-08-04 20:20:50 -07:00
parent 85137cb1cb
commit 6a637f3822
14 changed files with 1827 additions and 135 deletions
@@ -25,6 +25,8 @@ final class PanelLayoutStore {
static let saved = "nucleic.panels.saved"
static let defaultID = "nucleic.panels.defaultID"
static let vmMonitorHome = "nucleic.panels.vmMonitorHome"
static let subagentsHome = "nucleic.panels.subagentsHome"
static let subagentsAutoHidden = "nucleic.panels.subagentsAutoHidden"
}
init(defaults: UserDefaults = .standard) {
@@ -35,6 +37,11 @@ final class PanelLayoutStore {
self.defaultLayoutID = defaultID
self.vmMonitorHome = Self.decode(
PanelHome.self, from: defaults.data(forKey: Key.vmMonitorHome))
self.subagentsHome = Self.decode(
PanelHome.self, from: defaults.data(forKey: Key.subagentsHome))
// Persisted with the home: quitting while the panel is auto-hidden would otherwise save a
// working layout without it and lose the fact that it's owed back.
self.subagentsAutoHidden = defaults.bool(forKey: Key.subagentsAutoHidden)
// Prefer the last live arrangement; otherwise fall back to the default layout,
// otherwise an empty (chat-only) arrangement.
@@ -129,11 +136,83 @@ final class PanelLayoutStore {
/// left where it is (it lists the open chat's workers on its own); otherwise a fresh one is docked
/// in the right column, where a list of sessions reads like the sidebar it mirrors.
func revealSubagents() {
let hasSubagents = PanelSlot.allCases.contains {
working.instances(in: $0).contains { $0.kind == .subagents }
// An explicit reveal settles the question: whatever the auto-hide driver was owed, the
// panel is open now because the user asked for it.
setSubagentsAutoHidden(false)
guard placedSubagents == nil else { return }
dockSubagents()
}
/// The placed Subagents panel, if one is in the arrangement.
private var placedSubagents: PanelInstance? {
PanelSlot.allCases.lazy
.compactMap { self.working.instances(in: $0).first { $0.kind == .subagents } }
.first
}
/// Where a Subagents panel should reappear when it's docked again same reasoning as
/// ``vmMonitorHome``: the auto-hide driver retracts and re-docks this panel every time the user
/// clicks between a chat with workers and one without, and a panel dragged to the left column
/// or given most of its column's height must not lose that each time.
private var subagentsHome: PanelHome?
/// The driver closed the Subagents panel because the open chat had no workers, and owes it
/// back the moment one does again. False whenever the panel is closed for any *other* reason
/// above all a hand-close, which must stay closed. This flag is the whole guarantee that the
/// panel is only ever re-opened, never opened.
private var subagentsAutoHidden: Bool
/// Place a Subagents panel back where the last one stood, defaulting to the right column,
/// where a list of sessions reads like the sidebar it mirrors.
private func dockSubagents() {
let home = subagentsHome
let slot = home?.slot ?? .right
let instance = PanelInstance(
kind: .subagents, weight: home?.weight ?? newPaneWeight(for: slot))
modify(slot) { items in
items.insert(instance, at: min(max(home?.index ?? items.count, 0), items.count))
}
}
/// Remember where the panel stood, just before it leaves the arrangement for the driver's
/// retraction and a hand-close alike, since either way the next one belongs where the user
/// last put this one.
private func rememberSubagentsHome(_ instanceID: UUID) {
guard let slot = slot(of: instanceID),
let index = working.instances(in: slot).firstIndex(where: { $0.id == instanceID })
else { return }
let home = PanelHome(
slot: slot, index: index, weight: working.instances(in: slot)[index].weight)
subagentsHome = home
defaults.set(Self.encode(home), forKey: Key.subagentsHome)
}
private func setSubagentsAutoHidden(_ hidden: Bool) {
guard subagentsAutoHidden != hidden else { return }
subagentsAutoHidden = hidden
defaults.set(hidden, forKey: Key.subagentsAutoHidden)
}
/// Keep the Subagents panel in step with the chat the user is looking at: hide it while the
/// open chat has no workers to list, and put it back in its own slot, at its own size when
/// they return to one that does.
///
/// Deliberately asymmetric. Hiding applies to *any* placed panel (an empty "No subagents yet"
/// pane is just a hole in the layout), but restoring applies only to a panel this driver itself
/// hid: a panel the user closed by hand, or never opened, stays closed however many workers a
/// chat has. See ``subagentsAutoHidden``.
func syncSubagents(hasSubagents: Bool) {
if hasSubagents {
guard subagentsAutoHidden else { return }
setSubagentsAutoHidden(false)
guard placedSubagents == nil else { return }
dockSubagents()
} else {
guard let instance = placedSubagents else { return }
rememberSubagentsHome(instance.id)
setSubagentsAutoHidden(true)
detach(instance.id)
}
guard !hasSubagents else { return }
add(.subagents, to: .right)
}
/// Surface a VM Monitor panel used by the Control panel's "Observe" action so the user can watch
@@ -226,13 +305,32 @@ final class PanelLayoutStore {
// Closing a monitor by hand suppresses the auto-open driver until the condition that opened it
// goes away otherwise the panel reappears on the driver's next trigger. Its place in the
// column is noted first, so whenever it comes back it comes back *here*.
if PanelSlot.allCases.contains(where: {
working.instances(in: $0).contains { $0.id == instanceID && $0.kind == .vmMonitor }
}) {
if isPlaced(instanceID, kind: .vmMonitor) {
rememberVMMonitorHome(instanceID)
vmMonitorDismissed = true
}
// Same for the Subagents panel, with the opposite default: nothing marks it "owed back", so
// a hand-close simply stands. Clearing the flag matters when the user closes a panel the
// driver had *already* re-docked without it, the next chat switch would bring it back.
if isPlaced(instanceID, kind: .subagents) {
rememberSubagentsHome(instanceID)
setSubagentsAutoHidden(false)
}
if autoVMMonitorID == instanceID { autoVMMonitorID = nil }
detach(instanceID)
}
/// Whether `instanceID` is placed right now and is of `kind`.
private func isPlaced(_ instanceID: UUID, kind: PanelKind) -> Bool {
PanelSlot.allCases.contains {
working.instances(in: $0).contains { $0.id == instanceID && $0.kind == kind }
}
}
/// Pull a pane out of the arrangement, with none of ``remove(_:)``'s "the user meant this"
/// bookkeeping for the auto-hide drivers, which retract a panel without it counting as a
/// dismissal.
private func detach(_ instanceID: UUID) {
for slot in PanelSlot.allCases {
modify(slot) { $0.removeAll { $0.id == instanceID } }
}
+292 -15
View File
@@ -4,40 +4,142 @@ import NucleicCore
/// Lists the subagents (Orchestra workers) this chat spawned via the `nucleic_subagent`
/// MCP tool. These are ordinary sessions kept out of the sidebar (`SessionSummary.isSubagent`)
/// and surfaced here instead, treated as subsessions of their parent chat: each row opens the
/// worker in the detail view. Reads straight off the observable store, so it fills in and
/// updates status live as workers spawn and finish.
/// worker in the detail view, and each row's chevron expands in place to the detail that doesn't
/// fit on one line what it's doing, how long it's been at it, what it has changed. Reads
/// straight off the observable store, so it fills in and updates status live as workers spawn
/// and finish.
struct SubagentsPanel: View {
let session: Session?
@Environment(AppStore.self) private var store
/// Which worker rows are expanded. Keyed by session id rather than index so the open row
/// stays open as workers spawn above and below it.
@State private var expanded: Set<SessionID> = []
/// Whose workers this panel lists: the open chat's, or when the open chat is itself a worker
/// its whole lineage, so drilling into a subagent keeps the fan-out (and its siblings) in
/// view instead of emptying the panel out. Mirrors `AppStore.subagentAnchor(for:)`, which the
/// auto-hide driver asks the same question of.
private var anchor: SessionID? {
guard let session else { return nil }
return session.rootSpawnedBySessionID ?? session.spawnedBySessionID ?? session.id
}
private var subagents: [SessionSummary] {
guard let session else { return [] }
return store.subagentSummaries(for: session.id)
guard let anchor else { return [] }
return store.subagentSummaries(for: anchor)
}
/// The chat that owns this fan-out, when the open session is one of its workers. Every *other*
/// session in the lineage is a row in the list below; the chat at its root is the one thing the
/// list can't take you to, which is what the bar at the top is for.
private var parentChat: SessionSummary? {
guard let session, session.spawnedBySessionID != nil, let anchor, anchor != session.id
else { return nil }
return store.summaries.first { $0.id == anchor }
}
var body: some View {
VStack(spacing: 0) {
if let parentChat {
backBar(to: parentChat)
Divider()
}
content
}
}
@ViewBuilder private var content: some View {
if session == nil {
placeholder("No chat open", systemImage: "person.2")
} else if subagents.isEmpty {
placeholder("No subagents yet", systemImage: "person.2")
} else {
ScrollView {
LazyVStack(alignment: .leading, spacing: 0) {
ForEach(subagents) { sub in
SessionRow(
summary: sub,
isSelected: store.openSessionID == sub.id && store.openHostID == sub.hostID)
.contentShape(Rectangle())
.onTapGesture { store.openSession(sub) }
if sub.id != subagents.last?.id { Divider() }
let workers = subagents
let progress = SubagentProgress(workers)
VStack(spacing: 0) {
tally(progress)
Divider()
ScrollView {
LazyVStack(alignment: .leading, spacing: 0) {
ForEach(workers) { sub in
SubagentPanelRow(
summary: sub,
activity: store.activity(for: sub.id),
isSelected: store.openSessionID == sub.id
&& store.openHostID == sub.hostID,
isExpanded: expanded.contains(sub.id),
parentTitle: parentTitle(of: sub),
toggle: { toggle(sub.id) },
open: { store.openSession(sub) })
if sub.id != workers.last?.id { Divider() }
}
}
.padding(.vertical, 4)
.frame(maxWidth: .infinity, alignment: .leading)
}
.padding(.vertical, 4)
.frame(maxWidth: .infinity, alignment: .leading)
}
}
}
/// Out of a worker and back to the chat that dispatched it. The detail view's header carries the
/// same escape, but while you're switching between siblings your attention is in this panel
/// and the panel is where the whole lineage is, so the way out belongs at the top of it.
private func backBar(to parent: SessionSummary) -> some View {
Button { store.openSession(parent) } label: {
HStack(spacing: 6) {
Image(systemName: "chevron.backward")
.font(.caption2.weight(.semibold))
.foregroundStyle(AppTheme.orchestra)
Text(parent.title)
.font(.caption.weight(.medium))
.foregroundStyle(AppTheme.softText)
.lineLimit(1)
.truncationMode(.tail)
Spacer(minLength: 0)
}
.padding(.horizontal, 10)
.padding(.vertical, 6)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.help("Back to “\(parent.title)")
.accessibilityLabel("Back to parent chat")
}
/// The panel's one-line progress line the same counts the composer's working bar shows,
/// so the two never disagree about how the fan-out is going.
private func tally(_ progress: SubagentProgress) -> some View {
HStack(spacing: 6) {
Image(systemName: "person.2.fill")
.font(.caption2)
.foregroundStyle(progress.isActive ? AppTheme.orchestra : AppTheme.softText)
Text(progress.summary)
.font(.caption)
.foregroundStyle(AppTheme.softText)
if progress.blocked > 0 {
Text("· \(progress.blocked) need\(progress.blocked == 1 ? "s" : "") you")
.font(.caption)
.foregroundStyle(AppTheme.orchestra)
}
Spacer()
}
.padding(.horizontal, 10)
.padding(.vertical, 6)
}
/// The title of the session that spawned `summary`, but only when that's *another worker*
/// naming the chat you're already looking at on every row would be noise. Non-nil is what
/// makes a nested worker (a subagent's subagent) legible in a flat list.
private func parentTitle(of summary: SessionSummary) -> String? {
guard let parent = summary.spawnedBySessionID, parent != anchor else { return nil }
return store.summaries.first { $0.id == parent }?.title
}
private func toggle(_ id: SessionID) {
if expanded.contains(id) { expanded.remove(id) } else { expanded.insert(id) }
}
private func placeholder(_ text: String, systemImage: String) -> some View {
VStack(spacing: 8) {
Image(systemName: systemImage).font(.largeTitle).foregroundStyle(.tertiary)
@@ -46,3 +148,178 @@ struct SubagentsPanel: View {
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
}
/// One worker in the panel: the shared sidebar row (so a subagent reads like any other chat),
/// a disclosure chevron, and when open the detail a one-line row can't carry.
private struct SubagentPanelRow: View {
@Environment(\.appPalette) private var palette
let summary: SessionSummary
/// What this worker is doing right now, if it's mid-turn the whole point of the row's
/// second line. Nil between turns and for a finished worker.
let activity: SessionActivity?
let isSelected: Bool
let isExpanded: Bool
/// The spawning worker's title, for a nested subagent. Nil when the parent is the chat whose
/// panel this is.
let parentTitle: String?
let toggle: () -> Void
let open: () -> Void
private var state: SubagentRunState { SubagentRunState(summary: summary) }
var body: some View {
VStack(alignment: .leading, spacing: 0) {
HStack(spacing: 0) {
SessionRow(summary: summary, isSelected: isSelected)
.contentShape(Rectangle())
.onTapGesture(perform: open)
.layoutPriority(1)
Button(action: toggle) {
Image(systemName: "chevron.right")
.font(.caption2.weight(.semibold))
.foregroundStyle(AppTheme.softText)
.rotationEffect(.degrees(isExpanded ? 90 : 0))
.frame(width: 20, height: 20)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.help(isExpanded ? "Hide details" : "Show details")
.accessibilityLabel(isExpanded ? "Hide subagent details" : "Show subagent details")
.padding(.trailing, 6)
}
// Collapsed, the live line is the row's second line the one thing you'd otherwise
// have to open the worker's transcript to learn. Expanded, it moves into the detail
// block below (as "Doing"), where it gets the width for a long command.
if !isExpanded { liveLine }
if isExpanded { detail }
}
}
/// The worker's current activity as a compact line "Running swift build", "Reading
/// AppStore.swift", "Waiting for approval". Nil when there's nothing live to say: a settled
/// worker, or one between turns.
@ViewBuilder private var liveLine: some View {
if let blocked = blockedLine {
activityLine(icon: "hand.raised.fill", verb: blocked, detail: nil, tint: AppTheme.orchestra)
} else if let activity, state.isRunning {
activityLine(
icon: glyph(for: activity), verb: activity.verb, detail: activity.detail,
tint: AppTheme.softTextDim)
}
}
/// A worker blocked on the user isn't "doing" anything say so instead, since that line is
/// the one that needs an answer.
private var blockedLine: String? {
if (summary.pendingQuestionCount ?? 0) > 0 { return "Waiting on your answer" }
if summary.pendingApprovalCount > 0 { return "Waiting for approval" }
return nil
}
private func activityLine(icon: String, verb: String, detail: String?, tint: Color) -> some View {
HStack(spacing: 5) {
Image(systemName: icon).font(.system(size: 9)).foregroundStyle(tint)
Text(verb).font(.caption2).foregroundStyle(AppTheme.softText)
if let detail, !detail.isEmpty {
Text(detail)
.font(.caption2.monospaced())
.foregroundStyle(AppTheme.softTextDim)
.lineLimit(1)
.truncationMode(.middle)
}
Spacer(minLength: 0)
}
.padding(.leading, 12)
.padding(.trailing, 8)
.padding(.bottom, 5)
}
/// The glyph for a live reading: the tool's own icon when it's in a tool call, and a phase
/// glyph otherwise so the row's second line is legible at a glance, before it's read.
private func glyph(for activity: SessionActivity) -> String {
guard let toolName = activity.toolName else {
switch activity.kind {
case .thinking: return "brain"
case .responding: return "text.bubble"
default: return "ellipsis"
}
}
return TranscriptRow.toolIcon(toolName)
}
private var detail: some View {
VStack(alignment: .leading, spacing: 6) {
HStack(spacing: 6) {
SubagentStatusChip(state: state)
if summary.isOrchestra {
Image(systemName: "music.note.list")
.font(.system(size: 9)).foregroundStyle(AppTheme.orchestra)
.help("Orchestra")
} else if let effort = summary.effort, !effort.isEmpty {
Text(effort).font(.caption2).foregroundStyle(AppTheme.softText)
}
Spacer()
Button("Open", action: open)
.buttonStyle(.plain)
.font(.caption2.weight(.medium))
.foregroundStyle(palette.accent)
}
// "Needs you" first: it's the only line here that's asking for something.
if (summary.pendingQuestionCount ?? 0) > 0 {
field("Blocked", "asked you a question")
} else if summary.pendingApprovalCount > 0 {
field("Blocked", "\(summary.pendingApprovalCount) approval"
+ (summary.pendingApprovalCount == 1 ? "" : "s") + " pending")
}
// What it's in the middle of, with the room a one-line row can't give a long command.
if let activity, state.isRunning {
field("Doing", activity.line, monospaced: activity.detail != nil)
field("On this since", relativeTime(activity.since))
}
if let parentTitle { field("Spawned by", parentTitle) }
field("Started", relativeTime(summary.createdAt))
// For a settled worker `updatedAt` is when it finished; while it runs it's the last
// thing it did. Same field, and in both readings it answers "is this thing moving?".
field(state.isRunning ? "Last activity" : "Finished", relativeTime(summary.updatedAt))
if let diff = summary.diffStat, diff.filesChanged > 0 {
field("Changes", "\(diff.filesChanged) file"
+ (diff.filesChanged == 1 ? "" : "s") + " · +\(diff.added) \(diff.removed)")
}
if let keywords = summary.keywords?.trimmingCharacters(in: .whitespacesAndNewlines),
!keywords.isEmpty {
field("Topics", keywords)
}
}
.padding(.horizontal, 10)
.padding(.bottom, 8)
.padding(.top, 2)
.frame(maxWidth: .infinity, alignment: .leading)
}
private func field(_ label: String, _ value: String, monospaced: Bool = false) -> some View {
HStack(alignment: .firstTextBaseline, spacing: 6) {
Text(label)
.font(.caption2)
.foregroundStyle(AppTheme.softTextDim)
.frame(width: 74, alignment: .leading)
Text(value)
.font(monospaced ? .caption2.monospaced() : .caption2)
.foregroundStyle(AppTheme.softText)
.lineLimit(2)
.textSelection(.enabled)
Spacer(minLength: 0)
}
}
/// Formatter shared across rows: `RelativeDateTimeFormatter` is expensive to build and this
/// runs per field per render while a fan-out streams.
@MainActor private static let relativeFormatter: RelativeDateTimeFormatter = {
let formatter = RelativeDateTimeFormatter()
formatter.unitsStyle = .abbreviated
return formatter
}()
private func relativeTime(_ date: Date) -> String {
Self.relativeFormatter.localizedString(for: date, relativeTo: Date())
}
}
+7
View File
@@ -321,6 +321,13 @@ struct RootView: View {
syncPictureInPictureMonitor()
Task { await applyAutoVMMonitor(running: lastKnownRunningVMs) }
}
// The Subagents panel follows the chat: hidden while the open one has no workers to list,
// put back when the user returns to one that does. Keyed on the *predicate* rather than on
// the session id so it also covers a fan-out starting in the chat you're already in, and
// stays put when both chats have workers.
.onChange(of: store.openSessionHasSubagents, initial: true) { _, hasSubagents in
panels.syncSubagents(hasSubagents: hasSubagents)
}
.modifier(WindowToolbarSurface(fullGlass: ultraGlass))
// and re-render the glass when it goes dormant on occlusion / app idle, which
// pinning the background alone doesn't prevent (see `ToolbarGlassKeeper`).
+177 -21
View File
@@ -50,6 +50,10 @@ struct SessionDetailView: View {
@State private var floatingComposerHeight: CGFloat = 0
@State private var renaming = false
@State private var renameDraft = ""
/// The chat whose in-flight subagent card the user has waved off. Keyed by session (rather
/// than a bool) so dismissing in one chat doesn't silence another's fan-out; cleared when the
/// wave settles, so the next one announces itself. See `visibleSubagentProgress`.
@State private var subagentBarDismissed: SessionID?
@State private var summaryExpanded = false
/// Which chat `summaryExpanded` currently describes. Lets the status handler tell a
/// genuine live turn-transition (animate the card open) from the status delta of a chat
@@ -1652,8 +1656,27 @@ struct SessionDetailView: View {
private var header: some View {
HStack {
// A worker chat is reached by clicking into it from its parent's Subagents panel, and
// it's hidden from the sidebar so without this there is no way back out except
// finding the parent by hand. Sits where a navigation back button would.
if let parent = spawningParent {
Button { store.openSession(parent) } label: {
Image(systemName: "chevron.backward")
.font(.body.weight(.medium))
.foregroundStyle(AppTheme.orchestra)
.frame(width: 22, height: 22)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.keyboardShortcut("[", modifiers: .command)
.help("Back to “\(parent.title)")
.accessibilityLabel("Back to parent chat")
}
VStack(alignment: .leading, spacing: 2) {
Text(session?.title ?? "Session").font(.headline)
HStack(spacing: 6) {
Text(session?.title ?? "Session").font(.headline)
if spawningParent != nil { subagentBadge }
}
if let s = session {
HStack(spacing: 6) {
Circle().fill(palette.status(s.status, disposition: s.lastTurnDisposition))
@@ -1684,6 +1707,25 @@ struct SessionDetailView: View {
.background(DetailSurface(layer: .inner))
}
/// The chat that spawned this one, when the open session is an Orchestra worker
/// (`spawnedBySessionID`). Nil for a user-started chat which is what hides the back button
/// and the badge everywhere else. Subagent lineage isn't carried on the wire, so this only
/// ever resolves for a local worker, exactly the one you can navigate to.
private var spawningParent: SessionSummary? {
guard let parent = session?.spawnedBySessionID else { return nil }
return store.summaries.first { $0.id == parent }
}
/// Names what this chat is, since a worker session otherwise looks like any other chat while
/// being invisible in the sidebar and owned by a parent turn.
private var subagentBadge: some View {
Text("Subagent")
.font(.caption2.weight(.medium))
.foregroundStyle(AppTheme.orchestra)
.padding(.horizontal, 6).padding(.vertical, 2)
.background(AppTheme.orchestra.opacity(0.14), in: Capsule())
}
private func summaryCardView() -> some View {
ConversationSummaryCard(
text: store.openSummary,
@@ -2957,10 +2999,31 @@ struct SessionDetailView: View {
}
/// 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, or any combination.
/// a hang alert, a lock-contention card, a context-switch offer, a subagent fan-out in
/// flight, or any combination.
private var hasComposerRevealContent: Bool {
store.openApprovals.first != nil || openStall != nil || openLockWait != nil
|| visibleContextSwitchOffer != nil
|| visibleContextSwitchOffer != nil || visibleSubagentProgress != nil
}
/// This chat's in-flight Orchestra workers, or nil when none are running.
///
/// Scoped to the *current wave* (see `SubagentProgress.currentWave`) so a long-lived chat
/// that has spawned workers all afternoon reports on the fan-out happening now. A peer-owned
/// chat is excluded: subagent lineage isn't carried on the wire, so a remote chat's workers
/// aren't ours to count or to open.
private var workingSubagents: SubagentProgress? {
guard !isArchived, store.openHostID == nil, let id = session?.id else { return nil }
let progress = SubagentProgress.currentWave(store.subagentSummaries(for: id))
return progress.isActive ? progress : nil
}
/// The same, minus a wave the user has waved off. Purely informational, so unlike the other
/// reveal cards it can be dismissed; the dismissal is keyed to the chat and cleared when the
/// wave settles, so the *next* fan-out announces itself again.
private var visibleSubagentProgress: SubagentProgress? {
guard subagentBarDismissed != session?.id else { return nil }
return workingSubagents
}
/// A real trailing element and fixed scroll target. Kept as its own final stack child so the
@@ -3042,25 +3105,12 @@ struct SessionDetailView: View {
}
/// Turns a tool name into a present-progressive status line "Read" "Reading",
/// mirroring the past-tense families in `ConversationIntelligence`. Unknown tools
/// fall back to "Running <name>" so we never guess a malformed gerund.
/// mirroring the past-tense families in `ConversationIntelligence`. The table lives in
/// `SessionActivity` because the Subagents panel's per-worker line needs the same words: the
/// chat you're looking at and the workers listed beside it should describe a `Bash` call
/// identically.
private static func gerund(for name: String) -> String {
switch name {
case "Read": return "Reading…"
case "Write": return "Writing…"
case "Edit", "MultiEdit", "NotebookEdit": return "Editing…"
case "Bash", "BashOutput": return "Running…"
case "Grep", "Glob": return "Searching…"
case "WebFetch": return "Fetching…"
case "WebSearch": return "Searching the web…"
case "Task": return "Delegating…"
case MCPApprovalServer.qualifiedHostExecToolName:
return "Running a command on host…"
// The VM/container tools name where the work is happening ("Running on a macOS VM")
// rather than echoing their `mcp__nucleic__` wire name.
default: return SandboxToolDisplay.gerund(for: name)
?? "Running \(name)"
}
SessionActivity.verb(forTool: name) + ""
}
private var isBusy: Bool {
@@ -3168,6 +3218,15 @@ struct SessionDetailView: View {
// 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.
VStack(spacing: 8) {
// Informational, so it sits furthest from the composer: anything below it in
// this stack is something the turn is actually blocked on.
if let progress = visibleSubagentProgress {
SubagentsWorkingBar(
progress: progress,
onView: { panels.revealSubagents() },
onDismiss: { subagentBarDismissed = session?.id })
.transition(ComposerMotion.inputTransition(reduceMotion))
}
if let offer = visibleContextSwitchOffer {
ContextSwitchBar(
offer: offer,
@@ -3281,6 +3340,16 @@ struct SessionDetailView: View {
.animation(
transcriptSettling ? nil : ComposerMotion.layout(reduceMotion),
value: composerHeight)
// The subagent card grows and retires the same glass as a wave starts and finishes; its
// counts change constantly while it's up, so the animation keys on presence, not content.
.animation(
transcriptSettling ? nil : ComposerMotion.layout(reduceMotion),
value: visibleSubagentProgress != nil)
// 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".
.onChange(of: workingSubagents == nil) { _, quiet in
if quiet, subagentBarDismissed == session?.id { subagentBarDismissed = nil }
}
.padding(.bottom, 12)
// The one measurement both the transcript's tail spacer and its fade are placed from.
// The cluster's own growth is a spring (`ComposerMotion.layout`), but this measurement
@@ -4727,6 +4796,93 @@ struct LockContentionBanner: View {
/// Nothing here touches the process: Kill routes to the backend's tree-reap, Keep waiting just
/// closes the alert and lets the command run on. The alert also retires itself if the command
/// starts producing output again or exits before the user answers.
/// The composer's "your workers are out there" card: shown while this chat has Orchestra
/// subagents in flight, in the same glass slot as a permission request.
///
/// A fan-out is the one kind of work whose progress is invisible from the chat it was launched
/// from the parent turn sits blocked in `nucleic_supervise` with nothing to say, while the real
/// work happens in sessions hidden from the sidebar. This card is where that shows up: how many
/// are working, how many have reported back, whether any of them is itself blocked on the user,
/// and one button to go look at them.
///
/// Unlike the other reveal cards it asks for nothing it's dismissible, and it retires by itself
/// when the last worker settles.
struct SubagentsWorkingBar: View {
@Environment(\.appPalette) private var palette
let progress: SubagentProgress
let onView: () -> Void
let onDismiss: () -> Void
private let accent = AppTheme.orchestra
private var headline: String {
progress.running == 1
? "A subagent is working" : "\(progress.running) subagents are working in parallel"
}
var body: some View {
VStack(alignment: .leading, spacing: 6) {
HStack(spacing: 6) {
Image(systemName: "person.2.fill").foregroundStyle(accent)
Text(headline)
.font(.callout.weight(.semibold))
.foregroundStyle(accent)
Spacer(minLength: 8)
Button {
onDismiss()
} label: {
Image(systemName: "xmark")
.font(.caption2.weight(.semibold))
.foregroundStyle(AppTheme.softText)
.frame(width: 18, height: 18)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.help("Hide until the next fan-out")
.accessibilityLabel("Hide subagent progress")
}
Text(
"This turn is waiting for them to report back. "
+ (progress.blocked > 0
? "\(progress.blocked) of them \(progress.blocked == 1 ? "is" : "are") "
+ "blocked on you."
: "You can watch their work while they run."))
.font(.caption)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
HStack(spacing: 8) {
// Determinate whenever the wave's size is known "3 of 7" is the metric that
// actually says how far along a fan-out is, where a spinner says only "still".
if let fraction = progress.fraction {
ProgressView(value: fraction)
.progressViewStyle(.linear)
.tint(accent)
.frame(maxWidth: 140)
}
Text(progress.summary)
.font(.caption.monospacedDigit())
.foregroundStyle(AppTheme.softText)
Spacer(minLength: 8)
Button(action: onView) {
Label("View subagents", systemImage: "sidebar.right")
.font(.caption.weight(.semibold))
}
.buttonStyle(.borderedProminent)
.controlSize(.small)
.tint(accent)
.help("Open the Subagents panel and watch them work")
}
}
.padding(12)
.frame(maxWidth: .infinity, alignment: .leading)
// Same reasoning as the alert cards below: a tint tuned for the flat transcript washes
// out inside the composer's glass, so the gold needs more ink here to read as gold.
.background(accent.opacity(0.16), in: .rect(cornerRadius: 10))
.overlay(
RoundedRectangle(cornerRadius: 10).strokeBorder(accent.opacity(0.5), lineWidth: 1))
}
}
struct ProcessStallBar: View {
@Environment(\.appPalette) private var palette
let stall: ProcessStallNote
+146
View File
@@ -0,0 +1,146 @@
import NucleicCore
import SwiftUI
// The shared status vocabulary for delegated work. The transcript's subagent cards spoke it
// first (Working / Done / Failed in the orchestra gold); the Subagents panel and the composer's
// working bar now say the same thing about the same workers, so the three surfaces can't drift
// into three different words for one state.
/// The lifecycle state of a spawned subagent from its `Task`/`nucleic_subagent` tool result
/// inside a transcript card (see `SubagentRunState.init(result:)` in `TranscriptRow`), or from
/// the worker session's own summary anywhere the session is what we have.
enum SubagentRunState: Equatable {
case running, done, failed
/// A worker session's lifecycle mapped onto the three states. A worker is archived the moment
/// it finishes, so "done" has to be read from the run's outcome rather than from the chat
/// still being active.
init(summary: SessionSummary) {
switch summary.status {
case .error, .interrupted: self = .failed
case .finished: self = .done
case .awaitingInput: self = summary.isCompleted ? .done : .running
default: self = .running
}
}
var label: String {
switch self {
case .running: "Working"
case .done: "Done"
case .failed: "Failed"
}
}
var isRunning: Bool { if case .running = self { true } else { false } }
/// Status tint: done/failed reuse the shared palette so they match the rest of the app;
/// a still-running subagent glows in the orchestra gold.
func color(_ palette: AppPalette) -> Color {
switch self {
case .running: AppTheme.orchestra
case .done: palette.success
case .failed: palette.danger
}
}
}
/// A small leading status glyph for a subagent a gold spinner while it works, a green
/// check or red cross once it settles.
struct SubagentStatusGlyph: View {
@Environment(\.appPalette) private var palette
let state: SubagentRunState
var body: some View {
Group {
switch state {
case .running: ProgressView().controlSize(.mini).tint(AppTheme.orchestra)
case .done: Image(systemName: "checkmark.circle.fill").foregroundStyle(palette.success)
case .failed: Image(systemName: "xmark.octagon.fill").foregroundStyle(palette.danger)
}
}
.font(.caption)
.frame(width: 16)
}
}
/// A status pill (spinner / check / cross + word) for the header of a subagent card.
struct SubagentStatusChip: View {
@Environment(\.appPalette) private var palette
let state: SubagentRunState
var body: some View {
HStack(spacing: 4) {
SubagentStatusGlyph(state: state).frame(width: 14)
Text(state.label)
}
.font(.caption2.weight(.medium))
.foregroundStyle(state.color(palette))
.padding(.horizontal, 7).padding(.vertical, 3)
.background(state.color(palette).opacity(0.14), in: Capsule())
}
}
/// How a set of worker sessions is doing, as counts what the Subagents panel puts in its
/// header and what the composer's working bar reports while a fan-out is in flight.
///
/// `Sendable`/`Equatable` so it can drive a SwiftUI `.animation(_:value:)` without re-deriving.
struct SubagentProgress: Equatable, Sendable {
var running = 0
var done = 0
var failed = 0
/// Workers blocked on the *user* a pending approval or an unanswered question. They're
/// counted in `running` as well (they haven't finished); this is the subset that won't move
/// until someone looks at them, which is the one thing worth interrupting for.
var blocked = 0
var total: Int { running + done + failed }
var settled: Int { done + failed }
/// Whether anything is still in flight the composer bar's whole reason to exist.
var isActive: Bool { running > 0 }
/// Completion as a 01 fraction for a determinate bar. Nil when there's nothing to report,
/// so the caller shows a spinner rather than an empty bar.
var fraction: Double? {
guard total > 0 else { return nil }
return Double(settled) / Double(total)
}
/// "3 working · 2 done" only the non-zero parts, so a settled wave doesn't claim workers
/// it doesn't have.
var summary: String {
var parts: [String] = []
if running > 0 { parts.append("\(running) working") }
if done > 0 { parts.append("\(done) done") }
if failed > 0 { parts.append("\(failed) failed") }
if parts.isEmpty { parts.append("no subagents") }
return parts.joined(separator: " · ")
}
/// Every worker in the list, whenever it ran.
init(_ summaries: [SessionSummary]) {
for summary in summaries {
switch SubagentRunState(summary: summary) {
case .running:
running += 1
if summary.needsAttention { blocked += 1 }
case .done: done += 1
case .failed: failed += 1
}
}
}
private init() {}
/// Just the wave still in flight: the workers created at or after the *earliest* one that's
/// still running. A long-lived chat accumulates every worker it ever spawned, and counting
/// those would report "2 working · 47 done" for a fan-out of three a progress number that
/// only ever goes up is no progress number at all.
///
/// Returns an all-zero (inactive) progress when nothing is running.
static func currentWave(_ summaries: [SessionSummary]) -> SubagentProgress {
let running = summaries.filter { SubagentRunState(summary: $0).isRunning }
guard let start = running.map(\.createdAt).min() else { return SubagentProgress() }
return SubagentProgress(summaries.filter { $0.createdAt >= start })
}
}
+16 -92
View File
@@ -626,31 +626,18 @@ struct TranscriptRow: View, Equatable {
/// The single most informative argument of a tool call, for the compact row. File
/// paths render relative to `root` (the project working directory) when given.
///
/// The per-tool parsing lives in `SessionActivity.detail(forTool:input:relativeTo:)` the
/// same vocabulary the Subagents panel's live line uses so a worker's row and its transcript
/// name the call the same way. Only the last-resort JSON compaction is app-side: it's a
/// rendering convenience for a row, not part of what a tool call *is*.
nonisolated static func toolDetail(
_ call: ToolCall, relativeTo root: String? = nil
) -> String? {
func arg(_ key: String) -> String? { call.input[key]?.stringValue }
let detail: String? = switch call.name {
case "Bash": arg("command")
case "Read", "Edit", "MultiEdit", "Write": arg("file_path").map { HeuristicSummary.displayPath($0, relativeTo: root) }
case "NotebookEdit": arg("notebook_path").map { HeuristicSummary.displayPath($0, relativeTo: root) }
case "Grep", "Glob": arg("pattern")
case "WebFetch": arg("url")
case "WebSearch": arg("query")
case "Task", "Agent": arg("description")
// A `nucleic_subagent` worker's short label, so a per-call worker row in a mixed group
// reads as its task rather than the raw `{"task": , "prompt": }` JSON fallback.
case MCPApprovalServer.qualifiedOrchestraSubagentToolName: arg("task")
// The host command itself, so a per-call host_exec row (e.g. inside an expanded group)
// shows "$ command" rather than the raw `{"command": }` JSON the fallback would print.
case MCPApprovalServer.qualifiedHostExecToolName: arg("command")
// The VM/container tools parse their own arguments a shell line as `$ command`, a
// computer-use step as "left click (120, 400)" instead of the raw JSON fallback.
default: SandboxToolDisplay.detail(for: call.name, input: call.input)
if let detail = SessionActivity.detail(
forTool: call.name, input: call.input, relativeTo: root) {
return detail
}
// Trim any worktree path mentioned anywhere in the detail (commands, patterns,
// the JSON fallback), not just the dedicated file-path argument.
if let detail { return HeuristicSummary.relativizePaths(detail, relativeTo: root) }
let compacted = compact(call.input)
return compacted.isEmpty ? nil : HeuristicSummary.relativizePaths(compacted, relativeTo: root)
}
@@ -2294,11 +2281,11 @@ private struct NonResponsePadding: ViewModifier {
// (`ToolCall.parentToolCallID` already links a subagent's inner calls to its parent), so
// no protocol or projection changes are needed.
/// The lifecycle state of a spawned subagent, read from its `Task` tool result: no result
/// yet still working; an error result failed; otherwise done.
private enum SubagentRunState {
case running, done, failed
/// The lifecycle state of a spawned subagent (defined in `SubagentStatus.swift`, which the
/// panel and the composer's working bar share), read from its `Task` tool result: no result
/// yet still working; an error result failed; otherwise done. This initializer stays
/// here because it reads the envelope shapes `Subagent` knows about, which are this file's.
extension SubagentRunState {
init(result: ToolResult?) {
guard let result else { self = .running; return }
// A `Task` subagent surfaces failure as an error result. A `nucleic_subagent` worker
@@ -2307,24 +2294,6 @@ private enum SubagentRunState {
// as "Done".
self = (result.isError || Subagent.workerFailed(result)) ? .failed : .done
}
var label: String {
switch self {
case .running: "Working"
case .done: "Done"
case .failed: "Failed"
}
}
/// Status tint: done/failed reuse the shared palette so they match the rest of the app;
/// a still-running subagent glows in the orchestra gold.
func color(_ palette: AppPalette) -> Color {
switch self {
case .running: AppTheme.orchestra
case .done: palette.success
case .failed: palette.danger
}
}
}
/// Pulls the human-facing bits out of a `Task` tool call so the subagent cards agree on what
@@ -2505,41 +2474,8 @@ private enum DelegatedPlan {
}
}
/// A small leading status glyph for a subagent a gold spinner while it works, a green
/// check or red cross once it settles.
private struct SubagentStatusGlyph: View {
@Environment(\.appPalette) private var palette
let state: SubagentRunState
var body: some View {
Group {
switch state {
case .running: ProgressView().controlSize(.mini).tint(AppTheme.orchestra)
case .done: Image(systemName: "checkmark.circle.fill").foregroundStyle(palette.success)
case .failed: Image(systemName: "xmark.octagon.fill").foregroundStyle(palette.danger)
}
}
.font(.caption)
.frame(width: 16)
}
}
/// A status pill (spinner / check / cross + word) for the header of a subagent card.
private struct SubagentStatusChip: View {
@Environment(\.appPalette) private var palette
let state: SubagentRunState
var body: some View {
HStack(spacing: 4) {
SubagentStatusGlyph(state: state).frame(width: 14)
Text(state.label)
}
.font(.caption2.weight(.medium))
.foregroundStyle(state.color(palette))
.padding(.horizontal, 7).padding(.vertical, 3)
.background(state.color(palette).opacity(0.14), in: Capsule())
}
}
// `SubagentStatusGlyph` and `SubagentStatusChip` live in `SubagentStatus.swift` the panel and
// the composer's working bar show the same spinner/check/cross for the same worker.
/// One delegated subagent (`Task`), as a gold card: its type and the task it was given, a
/// live Working / Done / Failed status, and once it reports back its findings, collapsed
@@ -2920,23 +2856,11 @@ private struct OrchestrationPlanCard: View {
guard !wanted.isEmpty else { return [:] }
var out: [SessionID: SubagentRunState] = [:]
for summary in store.summaries where wanted.contains(summary.id) {
out[summary.id] = Self.state(of: summary)
out[summary.id] = SubagentRunState(summary: summary)
}
return out
}
/// A worker session's lifecycle mapped onto the card's three states. A worker is archived the
/// moment it finishes, so "done" has to be read from the run's outcome rather than from the
/// chat still being active.
private static func state(of summary: SessionSummary) -> SubagentRunState {
switch summary.status {
case .error, .interrupted: return .failed
case .finished: return .done
case .awaitingInput: return summary.isCompleted ? .done : .running
default: return .running
}
}
/// Overall status: a denial or any failed/unspawnable worker fails the card; any worker still
/// running keeps it working; otherwise done. A dispatch whose result hasn't landed yet is
/// working the plan is in flight.
+44
View File
@@ -7880,6 +7880,38 @@ public final class AppStore: ConflictArbiter {
.sorted { $0.createdAt < $1.createdAt }
}
/// The session whose workers a chat's Subagents panel should list: its own id for an ordinary
/// chat, and the *root* of the lineage for a worker so drilling into a subagent keeps showing
/// the whole fan-out (its siblings included) rather than emptying the panel out.
public func subagentAnchor(for sessionID: SessionID) -> SessionID {
guard let summary = summaries.first(where: { $0.id == sessionID }) else { return sessionID }
return summary.rootSpawnedBySessionID ?? summary.spawnedBySessionID ?? sessionID
}
/// Whether the open chat has a Subagents panel worth showing it spawned workers, or it *is*
/// one (in which case the panel lists its lineage). Drives the panel's auto-hide/restore as the
/// user clicks between chats; see `PanelLayoutStore.syncSubagents(hasSubagents:)`.
public var openSessionHasSubagents: Bool {
guard let openSessionID else { return false }
let anchor = subagentAnchor(for: openSessionID)
return summaries.contains {
$0.rootSpawnedBySessionID == anchor || $0.spawnedBySessionID == anchor
}
}
/// What each *subagent* session is doing right now the live line the Subagents panel shows
/// per worker, folded from the event batches already flowing through `ingestUI`. Only workers
/// are tracked: the open chat narrates itself through the transcript, and writing a value per
/// streamed chunk for every session would wake every observer of the store for lines nobody
/// reads. Cleared when a worker's turn ends (see `SessionActivity.advanced(from:by:)`).
public private(set) var sessionActivities: [SessionID: SessionActivity] = [:]
/// What `sessionID` is doing right now, if it's a worker mid-turn. Nil for an ordinary chat, a
/// worker between turns, and any session whose events predate this record.
public func activity(for sessionID: SessionID) -> SessionActivity? {
sessionActivities[sessionID]
}
// MARK: - Session lifecycle
/// Create a randomly-named chat under a project and open it. The session's
@@ -9148,6 +9180,7 @@ public final class AppStore: ConflictArbiter {
if openSessionID == id { openContextSwitchOffer = nil }
persistedSessionRecords[id] = nil
persistedPendingApprovals[id] = nil
sessionActivities[id] = nil // nothing left to be doing anything
controllerHydrationTasks.removeValue(forKey: id)?.cancel()
nashEvents.forget(sessionID: id) // the shell feed is keyed by session; nothing can view it now
forgetShipState(id)
@@ -9956,6 +9989,17 @@ public final class AppStore: ConflictArbiter {
persistedSessionRecords[sessionID] = snapshot.session
persistedPendingApprovals[sessionID] = snapshot.pendingApprovals
upsertSummary(SessionSummary(snapshot.session, pendingApprovals: snapshot.pendingApprovals))
// The Subagents panel's live line: what this worker is doing *right now*, folded out of the
// batch we already have in hand. Workers only a chat you're looking at narrates itself in
// the transcript, and a value written per streamed chunk for every session would wake every
// observer of the store for lines nobody reads. The fold keeps the existing value when the
// batch says the same thing, so a streaming turn writes here only when the reading changes.
if snapshot.session.spawnedBySessionID != nil {
let current = sessionActivities[sessionID]
let next = SessionActivity.advanced(
from: current, by: batch, relativeTo: snapshot.session.worktreePath)
if next != current { sessionActivities[sessionID] = next }
}
// This batch may have flipped whether any chat in the project is mid-turn the gate the
// auto-integrate countdown waits on. Re-evaluate it: a turn ending in the last working chat
// arms the countdown; a turn starting cancels it.
@@ -14,6 +14,14 @@ import NucleicProtocol
/// tool-block summary lines routes its label through here so the same call reads the same way
/// everywhere, and so a new tool is named in exactly one place.
///
/// The Orchestra tools (`nucleic_subagent`, `nucleic_delegate_plan`, `nucleic_supervise`, ) ride
/// along for the same reason: they arrive over the same MCP prefix, and a status line reading
/// "Running mcp__nucleic__nucleic_supervise" says nothing about the fact that the session is
/// blocked waiting on its workers. The same goes for the rest of Nucleic's own tool surface that
/// arrives this way asking the user a question, the mesh messaging trio, and `nucleic_monitor`
/// so that every tool advertised by ``MCPApprovalServer`` has a name here rather than only the
/// ones that happen to escape the sandbox.
///
/// `host_exec` is deliberately absent: it already has bespoke phrasing and a structured card of
/// its own (``HostExecToolCard``), and folding it in here would fight that.
public enum SandboxToolDisplay {
@@ -56,6 +64,16 @@ public enum SandboxToolDisplay {
case MCPApprovalServer.linuxVMComputerToolName,
MCPApprovalServer.linuxVMComputerBatchToolName: "Linux VM screen"
case MCPApprovalServer.linuxContainerToolName: "Linux container"
case MCPApprovalServer.orchestraSubagentToolName: "Subagent"
case MCPApprovalServer.orchestraPlanToolName: "Delegation plan"
case MCPApprovalServer.orchestraSuperviseToolName: "Supervising subagents"
case MCPApprovalServer.orchestraReplyToWorkerToolName: "Reply to subagent"
case MCPApprovalServer.orchestraAskSupervisorToolName: "Question for supervisor"
case MCPApprovalServer.askUserToolName: "Question for you"
case MCPApprovalServer.meshSendMessageToolName: "Mesh message"
case MCPApprovalServer.meshSubscribeTopicToolName: "Mesh topic"
case MCPApprovalServer.meshWaitForMessageToolName: "Waiting for a mesh message"
case MCPApprovalServer.monitorToolName: "Monitor"
default: nil
}
}
@@ -85,6 +103,22 @@ public enum SandboxToolDisplay {
case MCPApprovalServer.macVMOperatorToolName: "Waiting for you to drive the VM…"
case MCPApprovalServer.macVMControlToolName: "Managing the macOS VM…"
case MCPApprovalServer.linuxVMControlToolName: "Managing the Linux VM…"
// The orchestra pair that *blocks* spawning a worker and draining worker events says
// so, since "waiting" is the whole reason the session looks idle while it runs.
case MCPApprovalServer.orchestraSubagentToolName: "Delegating to a subagent…"
case MCPApprovalServer.orchestraPlanToolName: "Delegating a plan to subagents…"
case MCPApprovalServer.orchestraSuperviseToolName: "Waiting on subagents…"
case MCPApprovalServer.orchestraReplyToWorkerToolName: "Replying to a subagent…"
case MCPApprovalServer.orchestraAskSupervisorToolName: "Asking the supervisor…"
// Both of these park the session on a human, which is the one thing a "working" row that
// said "Running" would hide: nothing is going to move until someone answers.
case MCPApprovalServer.askUserToolName: "Waiting on your answer…"
case MCPApprovalServer.meshWaitForMessageToolName: "Waiting for a mesh message…"
case MCPApprovalServer.meshSendMessageToolName: "Sending a mesh message…"
case MCPApprovalServer.meshSubscribeTopicToolName: "Subscribing to a topic…"
// Arming returns immediately the watch itself runs outside the turn so this is a
// moment, not a wait.
case MCPApprovalServer.monitorToolName: "Arming a monitor…"
default: nil
}
}
@@ -104,6 +138,18 @@ public enum SandboxToolDisplay {
case MCPApprovalServer.macVMOperatorToolName: ("macvmoperator", "Asked you to drive the VM")
case MCPApprovalServer.macVMControlToolName: ("macvmcontrol", "Managed the macOS VM")
case MCPApprovalServer.linuxVMControlToolName: ("linuxvmcontrol", "Managed the Linux VM")
// Shares `Task`/`Agent`'s bucket and verb (see `ConversationIntelligence.familyKind`) so a
// turn that spawns workers both ways still reads as one run of subagents.
case MCPApprovalServer.orchestraSubagentToolName: ("task", HeuristicSummary.subagentVerb)
case MCPApprovalServer.orchestraPlanToolName: ("delegateplan", "Delegated a plan")
case MCPApprovalServer.orchestraSuperviseToolName: ("supervise", "Waited on subagents")
case MCPApprovalServer.orchestraReplyToWorkerToolName: ("replytoworker", "Replied to a subagent")
case MCPApprovalServer.orchestraAskSupervisorToolName: ("asksupervisor", "Asked the supervisor")
case MCPApprovalServer.askUserToolName: ("askuser", "Asked you")
case MCPApprovalServer.meshSendMessageToolName: ("meshsend", "Sent a mesh message")
case MCPApprovalServer.meshSubscribeTopicToolName: ("meshsubscribe", "Subscribed to a topic")
case MCPApprovalServer.meshWaitForMessageToolName: ("meshwait", "Waited for a mesh message")
case MCPApprovalServer.monitorToolName: ("monitor", "Armed a monitor")
default: nil
}
}
@@ -123,6 +169,17 @@ public enum SandboxToolDisplay {
case MCPApprovalServer.macVMOperatorToolName: "hand.raised"
case MCPApprovalServer.macVMControlToolName,
MCPApprovalServer.linuxVMControlToolName: "power"
case MCPApprovalServer.orchestraSubagentToolName: "person.2"
case MCPApprovalServer.orchestraPlanToolName: "person.3.sequence"
case MCPApprovalServer.orchestraSuperviseToolName: "eye"
case MCPApprovalServer.orchestraReplyToWorkerToolName: "arrowshape.turn.up.left"
case MCPApprovalServer.orchestraAskSupervisorToolName: "questionmark.bubble"
// Distinct from the supervisor's bubble: this one is pointed at a person.
case MCPApprovalServer.askUserToolName: "person.crop.circle.badge.questionmark"
case MCPApprovalServer.meshSendMessageToolName: "paperplane"
case MCPApprovalServer.meshSubscribeTopicToolName: "dot.radiowaves.left.and.right"
case MCPApprovalServer.meshWaitForMessageToolName: "tray.and.arrow.down"
case MCPApprovalServer.monitorToolName: "waveform.path.ecg"
default: nil
}
}
@@ -168,6 +225,44 @@ public enum SandboxToolDisplay {
case MCPApprovalServer.macVMClearNotificationsToolName:
return nil
// The task label is what the worker was sent off to do the prompt is the card's body,
// not its headline.
case MCPApprovalServer.orchestraSubagentToolName:
return arg("task").map(singleLine)
case MCPApprovalServer.orchestraPlanToolName:
return planSummary(input)
// Scoped to one batch or draining every worker; either way the count isn't known until
// the call returns, so the scope is all there is to say up front.
case MCPApprovalServer.orchestraSuperviseToolName:
return arg("batch").map { "batch \(singleLine($0))" } ?? "all workers"
case MCPApprovalServer.orchestraReplyToWorkerToolName:
return arg("reply").map(singleLine)
case MCPApprovalServer.orchestraAskSupervisorToolName:
return arg("question").map(singleLine)
// What's actually being asked, rather than "1 item" for the `questions` array a generic
// fallback would print.
case MCPApprovalServer.askUserToolName:
return questionSummary(input)
case MCPApprovalServer.meshSendMessageToolName:
return messageSummary(input)
case MCPApprovalServer.meshSubscribeTopicToolName:
return arg("topic").map { "#\(singleLine($0))" }
// A wait scoped to one topic is a different wait from a wait for anything addressed to this
// session, and the row is the only place that distinction shows.
case MCPApprovalServer.meshWaitForMessageToolName:
return arg("topic").map { "#\(singleLine($0))" } ?? "any message"
case MCPApprovalServer.monitorToolName:
return monitorSummary(input)
default:
return nil
}
@@ -269,6 +364,71 @@ public enum SandboxToolDisplay {
return "\(op)\(subject)"
}
/// A delegation plan as "7 tasks in 3 batches", falling back to the plan prose when the call
/// carries no batches. The shape how much work is fanning out, and in how many waves is
/// what makes one plan call different from another; the prose is the card's body.
private static func planSummary(_ input: JSONValue) -> String? {
let batches = input["batches"]?.arrayValue ?? []
let tasks = batches.reduce(0) { $0 + ($1["tasks"]?.arrayValue?.count ?? 0) }
guard tasks > 0 else {
guard let plan = input["plan"]?.stringValue else { return nil }
let trimmed = plan.trimmingCharacters(in: .whitespacesAndNewlines)
return trimmed.isEmpty ? nil : singleLine(trimmed)
}
let taskPhrase = "\(tasks) task\(tasks == 1 ? "" : "s")"
guard batches.count > 1 else { return taskPhrase }
return "\(taskPhrase) in \(batches.count) batches"
}
/// An `nucleic_ask_user` call as the question itself the thing the user is about to be asked,
/// which is the whole content of the call. Several questions arrive as one prompt, so the first
/// one leads and the rest are counted rather than concatenated into an unreadable run-on.
private static func questionSummary(_ input: JSONValue) -> String? {
let questions = input["questions"]?.arrayValue ?? []
let texts = questions.compactMap {
$0["question"]?.stringValue?.trimmingCharacters(in: .whitespacesAndNewlines)
}
.filter { !$0.isEmpty }
guard let first = texts.first else { return nil }
guard texts.count > 1 else { return singleLine(first) }
return "\(singleLine(first)) (+\(texts.count - 1) more)"
}
/// A mesh send as "where what": the destination (a `#topic` broadcast, or a host, or a
/// specific session on one) and the opening of the body. Which of the two destinations was used
/// is the first thing to know about the call, since broadcasting and addressing one session are
/// different acts.
private static func messageSummary(_ input: JSONValue) -> String? {
func arg(_ key: String) -> String? {
guard let value = input[key]?.stringValue else { return nil }
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
return trimmed.isEmpty ? nil : trimmed
}
let destination: String? = {
if let topic = arg("topic") { return "#\(topic)" }
guard let host = arg("to") else { return nil }
return arg("session").map { "\(host)/\($0)" } ?? host
}()
let body = arg("body").map { clip(singleLine($0), limit: 60) }
let parts = [destination, body].compactMap { $0 }
return parts.isEmpty ? nil : parts.joined(separator: "")
}
/// A monitor as what it's watching. The `description` is the label every delivered event carries,
/// so it's the name the user will see again later; the source (the shell pipeline, or the socket)
/// is the fallback when the call didn't bother with one.
private static func monitorSummary(_ input: JSONValue) -> String? {
func arg(_ key: String) -> String? {
guard let value = input[key]?.stringValue else { return nil }
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
return trimmed.isEmpty ? nil : trimmed
}
if let description = arg("description") { return singleLine(description) }
if let command = arg("command") { return "$ \(singleLine(command))" }
if let url = input["ws"]?["url"]?.stringValue, !url.isEmpty { return url }
return nil
}
/// A VM lifecycle op as a phrase rather than a bare verb, so "suspend" reads as an action
/// taken on the VM and "status" doesn't look like a state label.
private static func controlOpPhrase(_ op: String) -> String {
+237
View File
@@ -0,0 +1,237 @@
import Foundation
import NucleicProtocol
/// What a session is doing *right now*, in one line: the tool it's in the middle of and the
/// argument that identifies it ("Running `swift build`", "Reading Sources/AppStore.swift"), or
/// the non-tool phase it's in (thinking, writing its answer).
///
/// This exists for the sessions you *aren't* looking at the Orchestra workers in the Subagents
/// panel. The open chat narrates itself through the transcript; a worker is a row in a list, and
/// "Running" alone doesn't tell you whether it's compiling, waiting on a long test, or stuck on a
/// tool that never returns. The panel shows this line so the user can follow a fan-out without
/// opening each worker's transcript in turn.
///
/// Derived by folding the event batch that already flows through `AppStore.ingestUI` (see
/// ``advanced(from:by:relativeTo:)``) no extra subscription, no transcript re-read. Kept small
/// and `Equatable` so an unchanged reading is written back as a no-op and doesn't wake every
/// observer of the store on each streamed chunk.
public struct SessionActivity: Equatable, Sendable {
/// Which kind of work the line describes the panel picks its glyph from this.
public enum Kind: Sendable, Equatable {
/// In a tool call that hasn't returned yet.
case tool
/// Reasoning (an extended-thinking block is streaming).
case thinking
/// Writing its answer.
case responding
/// Between the two a tool just returned, or a turn just started.
case working
}
public let kind: Kind
/// The present-progressive verb, *without* a trailing ellipsis: "Running", "Reading",
/// "Searching on a macOS VM". Pair it with ``detail``, or use ``gerund`` on its own.
public let verb: String
/// The one argument that identifies the call the shell command, the file path, the search
/// pattern collapsed to a single line and capped for a list row. Nil for a tool that takes
/// nothing worth showing, and for the non-tool phases.
public let detail: String?
/// The wire tool name, so the UI can reuse its existing icon table rather than this file
/// growing a second one. Nil for the non-tool phases.
public let toolName: String?
/// The call this line describes, so its result event retires exactly this activity and not a
/// later one that overtook it.
public let toolCallID: String?
/// When this reading *started* preserved across the repeated events that describe the same
/// thing (a streaming `thinking`, a `toolCallStarted` followed by its `toolCallCompleted`),
/// so a row can say how long the worker has been on it.
public let since: Date
public init(
kind: Kind, verb: String, detail: String? = nil, toolName: String? = nil,
toolCallID: String? = nil, since: Date
) {
self.kind = kind
self.verb = verb
self.detail = detail
self.toolName = toolName
self.toolCallID = toolCallID
self.since = since
}
/// The verb alone, as a working-row line: "Running".
public var gerund: String { verb + "" }
/// The whole line: verb plus the argument that identifies the call, or just the gerund when
/// there's nothing to name.
public var line: String {
guard let detail, !detail.isEmpty else { return gerund }
return "\(verb) \(detail)"
}
/// Whether two readings describe the same work everything but when it started. Used to
/// carry ``since`` forward (and to keep the store's value untouched) while a tool call streams
/// its arguments or a thinking block streams its text.
func describesSameWork(as other: SessionActivity) -> Bool {
kind == other.kind && verb == other.verb && detail == other.detail
&& toolName == other.toolName && toolCallID == other.toolCallID
}
}
// MARK: - Deriving one from the event stream
extension SessionActivity {
/// Fold a whole batch: later events win, and an event that says nothing about what the session
/// is doing (usage, file changes, notes) leaves the reading alone.
public static func advanced(
from current: SessionActivity?, by batch: [AgentEvent], relativeTo root: String?
) -> SessionActivity? {
batch.reduce(current) { advanced(from: $0, by: $1, relativeTo: root) }
}
/// Apply one event. `nil` out means "not doing anything" the turn ended.
public static func advanced(
from current: SessionActivity?, by event: AgentEvent, relativeTo root: String?
) -> SessionActivity? {
switch event.kind {
// A call's arguments arrive in two waves: the name on `started`, the authoritative input on
// `completed`. Both map to the same reading, so the second refines the first in place
// rather than restarting the clock. (`toolCallInputDelta` is deliberately ignored it's a
// JSON fragment, and re-parsing a partial object per fragment would churn the store for a
// line that can't change until the input is whole.)
case .toolCallStarted(let call), .toolCallCompleted(let call):
return settle(
SessionActivity(
kind: .tool,
verb: verb(forTool: call.name),
detail: compactLine(detail(forTool: call.name, input: call.input, relativeTo: root)),
toolName: call.name,
toolCallID: call.toolCallID,
since: event.at),
against: current)
// The call returned: the session is thinking about the result, not still in the tool. Only
// *its own* result retires it a nested subagent's result can land while the parent's call
// is still open.
case .toolResult(let result):
guard let current, current.toolCallID == result.toolCallID else { return current }
return settle(
SessionActivity(kind: .working, verb: "Working", since: event.at), against: current)
case .thinking:
return settle(
SessionActivity(kind: .thinking, verb: "Thinking", since: event.at), against: current)
case .assistantText:
return settle(
SessionActivity(kind: .responding, verb: "Responding", since: event.at),
against: current)
// A prompt landed (the worker's assignment, or a supervisor's reply) the turn is starting.
case .userText:
return settle(
SessionActivity(kind: .working, verb: "Working", since: event.at), against: current)
// The turn is over: no line at all, rather than a stale "Running swift build" frozen on a
// finished worker's row.
case .runFinished:
return nil
default:
return current
}
}
/// Carry the clock across events that describe one piece of work. An identical reading is
/// returned *as* the current one, so the store sees no change at all; a refinement of the same
/// call `toolCallCompleted` filling in the arguments `toolCallStarted` didn't carry keeps
/// the start time the call already had, so the row's "on this since" counts from when the
/// worker entered the tool rather than from the last event to mention it. A reading for
/// *different* work (including the result that retires the call) starts its own clock.
private static func settle(_ next: SessionActivity, against current: SessionActivity?)
-> SessionActivity
{
guard let current else { return next }
if current.describesSameWork(as: next) { return current }
guard let id = current.toolCallID, id == next.toolCallID else { return next }
return SessionActivity(
kind: next.kind, verb: next.verb, detail: next.detail, toolName: next.toolName,
toolCallID: next.toolCallID, since: current.since)
}
}
// MARK: - Tool vocabulary
extension SessionActivity {
/// The present-progressive verb for a tool call, ellipsis-free "Read" "Reading". Covers
/// the core tool set here and defers to ``SandboxToolDisplay`` for Nucleic's own VM/container/
/// orchestra tools, so a worker on the macOS VM reads "Running on a macOS VM" rather than the
/// wire name. Unknown tools fall back to "Running <name>" rather than guessing a gerund.
///
/// The open chat's working row (`SessionDetailView.gerund`) reads the same table, so the line
/// under a worker in the Subagents panel and the line under the chat you opened it in can't
/// drift apart.
public static func verb(forTool name: String) -> String {
switch name {
case "Read", "NotebookRead": return "Reading"
case "Write": return "Writing"
case "Edit", "MultiEdit", "NotebookEdit": return "Editing"
case "Bash", "BashOutput": return "Running"
case "Grep", "Glob": return "Searching"
case "WebFetch": return "Fetching"
case "WebSearch": return "Searching the web"
case "Task", "Agent": return "Delegating"
case "TodoWrite", "TaskCreate", "TaskUpdate": return "Planning"
case MCPApprovalServer.qualifiedHostExecToolName, MCPApprovalServer.hostExecToolName:
return "Running a command on host"
default:
// The sandbox gerunds are already full phrases ("Running on a macOS VM"); drop the
// ellipsis so they compose with a detail the same way the core verbs do.
if let gerund = SandboxToolDisplay.gerund(for: name) {
return gerund.hasSuffix("") ? String(gerund.dropLast()) : gerund
}
return "Running \(SandboxToolDisplay.bareName(name))"
}
}
/// The single most informative argument of a tool call the shell command, the file path
/// (trimmed to where it sits in the worktree), the search pattern. Shared with the transcript's
/// compact tool rows (`TranscriptRow.toolDetail`) so a worker's live line and its transcript
/// name the same thing. Nil when the tool carries nothing worth showing.
public static func detail(forTool name: String, input: JSONValue, relativeTo root: String?)
-> String?
{
func arg(_ key: String) -> String? { input[key]?.stringValue }
let detail: String? = switch name {
case "Bash": arg("command")
case "Read", "Edit", "MultiEdit", "Write":
arg("file_path").map { HeuristicSummary.displayPath($0, relativeTo: root) }
case "NotebookEdit":
arg("notebook_path").map { HeuristicSummary.displayPath($0, relativeTo: root) }
case "Grep", "Glob": arg("pattern")
case "WebFetch": arg("url")
case "WebSearch": arg("query")
case "Task", "Agent": arg("description")
// The host command itself, rather than the raw `{"command": }` JSON a generic fallback
// would print. (The VM/container/orchestra tools parse their own arguments below.)
case MCPApprovalServer.qualifiedHostExecToolName: arg("command")
default: SandboxToolDisplay.detail(for: name, input: input)
}
// Trim any worktree path mentioned *anywhere* in the detail (commands, patterns), not just
// in the dedicated file-path argument.
guard let detail else { return nil }
return HeuristicSummary.relativizePaths(detail, relativeTo: root)
}
/// Collapse a detail to one bounded line: newlines and runs of whitespace become single
/// spaces, and anything past `limit` is elided. A heredoc or a multi-megabyte `Write` body
/// would otherwise ride in the observable store and get truncated by the row anyway.
static func compactLine(_ text: String?, limit: Int = 160) -> String? {
guard let text else { return nil }
let collapsed = text.split(whereSeparator: \.isWhitespace).joined(separator: " ")
if collapsed.isEmpty { return nil }
return collapsed.count <= limit ? collapsed : String(collapsed.prefix(limit)) + ""
}
}
@@ -1261,6 +1261,14 @@ extension TranscriptRenderIndex.Record {
case "Task", "Agent": ["description", "prompt"]
case MCPApprovalServer.qualifiedHostExecToolName: ["command"]
case MCPApprovalServer.qualifiedOrchestraSubagentToolName: ["task", "prompt"]
case MCPApprovalServer.qualifiedOrchestraPlanToolName: ["plan"]
case MCPApprovalServer.qualifiedOrchestraSuperviseToolName: ["batch"]
case MCPApprovalServer.qualifiedOrchestraReplyToWorkerToolName: ["reply", "worker_id"]
case MCPApprovalServer.qualifiedOrchestraAskSupervisorToolName: ["question"]
case MCPApprovalServer.qualifiedMeshSendMessageToolName: ["topic", "to", "body"]
case MCPApprovalServer.qualifiedMeshSubscribeTopicToolName: ["topic"]
case MCPApprovalServer.qualifiedMeshWaitForMessageToolName: ["topic"]
case MCPApprovalServer.qualifiedMonitorToolName: ["description", "command"]
default: ["command", "file_path", "path", "query", "pattern", "description", "task"]
}
for key in preferredKeys {
@@ -1294,6 +1302,14 @@ extension TranscriptRenderIndex.Record {
case "Task", "Agent": "description"
case MCPApprovalServer.qualifiedHostExecToolName: "command"
case MCPApprovalServer.qualifiedOrchestraSubagentToolName: "task"
case MCPApprovalServer.qualifiedOrchestraPlanToolName: "plan"
case MCPApprovalServer.qualifiedOrchestraSuperviseToolName: "batch"
case MCPApprovalServer.qualifiedOrchestraReplyToWorkerToolName: "reply"
case MCPApprovalServer.qualifiedOrchestraAskSupervisorToolName: "question"
case MCPApprovalServer.qualifiedMeshSendMessageToolName: "body"
case MCPApprovalServer.qualifiedMeshSubscribeTopicToolName,
MCPApprovalServer.qualifiedMeshWaitForMessageToolName: "topic"
case MCPApprovalServer.qualifiedMonitorToolName: "description"
default: "detail"
}
return .object([key: .string(detail)])
@@ -0,0 +1,99 @@
import Foundation
import NucleicCore
import Testing
@testable import NucleicApp
/// The counts behind the Subagents panel's header line and the composer's working card. Both
/// surfaces read the same struct, so these pin the arithmetic once for both.
@Suite("Subagent progress")
struct SubagentProgressTests {
/// A worker session as the list projection sees it: only status/disposition/timing matter to
/// the tally.
private func worker(
_ name: String,
status: SessionStatus,
disposition: TurnDisposition? = nil,
pendingApprovals: Int = 0,
createdAt: TimeInterval
) -> SessionSummary {
let session = Session(
id: SessionID(rawValue: name),
projectID: ProjectID(rawValue: "p"),
backend: .claudeCode,
title: name,
status: status,
spawnedBySessionID: SessionID(rawValue: "parent"),
rootSpawnedBySessionID: SessionID(rawValue: "parent"),
transcriptPath: "/tmp/\(name).jsonl",
lastTurnDisposition: disposition,
createdAt: Date(timeIntervalSince1970: createdAt),
updatedAt: Date(timeIntervalSince1970: createdAt))
return SessionSummary(session, pendingApprovalCount: pendingApprovals)
}
/// A worker is archived the moment it finishes, so "done" comes from the run's outcome:
/// `.finished`, or an `.awaitingInput` turn classified `.completed`. Everything else is still
/// working, and an errored or interrupted run failed.
@Test func stateReadsTheRunOutcome() {
#expect(SubagentRunState(summary: worker("a", status: .finished, createdAt: 0)) == .done)
#expect(
SubagentRunState(
summary: worker("b", status: .awaitingInput, disposition: .completed, createdAt: 0))
== .done)
// Awaiting input *unclassified* is a worker mid-flight, not one that's done.
#expect(
SubagentRunState(summary: worker("c", status: .awaitingInput, createdAt: 0)) == .running)
#expect(SubagentRunState(summary: worker("d", status: .running, createdAt: 0)) == .running)
#expect(SubagentRunState(summary: worker("e", status: .error, createdAt: 0)) == .failed)
#expect(SubagentRunState(summary: worker("f", status: .interrupted, createdAt: 0)) == .failed)
}
/// The tally counts each bucket and reports blocked workers as a subset of the running ones
/// a worker waiting on an approval hasn't finished, it just won't move on its own.
@Test func tallyCountsEachBucket() {
let progress = SubagentProgress([
worker("run", status: .running, createdAt: 0),
worker("blocked", status: .awaitingApproval, pendingApprovals: 1, createdAt: 0),
worker("done", status: .finished, createdAt: 0),
worker("failed", status: .error, createdAt: 0),
])
#expect(progress.running == 2)
#expect(progress.blocked == 1)
#expect(progress.done == 1)
#expect(progress.failed == 1)
#expect(progress.total == 4)
#expect(progress.isActive)
#expect(progress.fraction == 0.5)
#expect(progress.summary == "2 working · 1 done · 1 failed")
}
/// The card's whole claim is "this turn is waiting on workers", so with nothing running it
/// must report inactive rather than describing a wave that has already landed.
@Test func settledWorkIsInactive() {
let progress = SubagentProgress.currentWave([
worker("done", status: .finished, createdAt: 0),
worker("failed", status: .error, createdAt: 10),
])
#expect(!progress.isActive)
#expect(progress.total == 0)
#expect(progress.summary == "no subagents")
}
/// The wave is what's happening *now*: workers from earlier fan-outs in the same long-lived
/// chat are excluded, so the card doesn't report "1 working · 40 done" for a wave of three.
@Test func waveExcludesEarlierFanOuts() {
let progress = SubagentProgress.currentWave([
worker("old-1", status: .finished, createdAt: 0),
worker("old-2", status: .finished, createdAt: 1),
worker("new-1", status: .running, createdAt: 100),
worker("new-2", status: .finished, createdAt: 101),
worker("new-3", status: .running, createdAt: 102),
])
#expect(progress.total == 3)
#expect(progress.running == 2)
#expect(progress.done == 1)
#expect(progress.summary == "2 working · 1 done")
}
}
@@ -0,0 +1,164 @@
import Foundation
import Testing
@testable import NucleicApp
/// The Subagents panel follows the chat: it retracts while the open one has no workers to list and
/// comes back when the user returns to one that does. What makes that feel like help rather than
/// interference is the asymmetry the driver may only ever *re*-open a panel it closed itself so
/// these pin both halves, plus the hand-close that has to stand against it.
@Suite("Subagents panel auto-hide")
@MainActor
struct SubagentsPanelAutoHideTests {
private func store() -> PanelLayoutStore { PanelLayoutStore(defaults: MemoryDefaults()) }
private func hasSubagentsPanel(_ store: PanelLayoutStore) -> Bool {
subagentsInstance(store) != nil
}
private func subagentsInstance(_ store: PanelLayoutStore) -> PanelInstance? {
PanelSlot.allCases.lazy
.compactMap { store.working.instances(in: $0).first { $0.kind == .subagents } }
.first
}
/// The round trip the request describes: open panel, click to a chat with no workers, click
/// back.
@Test func hidesOnAChatWithoutWorkersAndRestoresOnOneWithThem() {
let panels = store()
panels.revealSubagents()
#expect(hasSubagentsPanel(panels))
panels.syncSubagents(hasSubagents: false)
#expect(!hasSubagentsPanel(panels))
panels.syncSubagents(hasSubagents: true)
#expect(hasSubagentsPanel(panels))
}
/// The load-bearing half: a chat full of workers must not *open* a panel the user never had.
/// Only a panel this driver hid is owed back.
@Test func neverOpensAPanelTheUserDidNotHave() {
let panels = store()
panels.syncSubagents(hasSubagents: true)
#expect(!hasSubagentsPanel(panels))
// Nor does passing through a chat without workers manufacture a debt there was nothing
// to hide.
panels.syncSubagents(hasSubagents: false)
panels.syncSubagents(hasSubagents: true)
#expect(!hasSubagentsPanel(panels))
}
/// Closing the panel by hand is a decision, not a retraction: the next chat with workers leaves
/// it closed. Without this the close button would look broken on the very next click.
@Test func handCloseStandsThroughLaterChats() throws {
let panels = store()
panels.revealSubagents()
panels.remove(try #require(subagentsInstance(panels)).id)
#expect(!hasSubagentsPanel(panels))
panels.syncSubagents(hasSubagents: true)
#expect(!hasSubagentsPanel(panels))
}
/// A hand-close *after* the driver re-docked the panel also stands closing it has to retire
/// the flag that owed it back, not merely the next hide.
@Test func handCloseAfterARestoreAlsoStands() throws {
let panels = store()
panels.revealSubagents()
panels.syncSubagents(hasSubagents: false)
panels.syncSubagents(hasSubagents: true)
panels.remove(try #require(subagentsInstance(panels)).id)
panels.syncSubagents(hasSubagents: false)
panels.syncSubagents(hasSubagents: true)
#expect(!hasSubagentsPanel(panels))
}
/// The panel comes back where the user put it same column, same place in it rather than
/// drifting to the bottom of the right column every time they click between chats.
@Test func restoresToWhereItStood() {
let panels = store()
panels.add(.subagents, to: .left)
panels.add(.fileExplorer, to: .left)
#expect(panels.working.instances(in: .left).map(\.kind) == [.subagents, .fileExplorer])
panels.syncSubagents(hasSubagents: false)
#expect(panels.working.instances(in: .left).map(\.kind) == [.fileExplorer])
panels.syncSubagents(hasSubagents: true)
#expect(panels.working.instances(in: .left).map(\.kind) == [.subagents, .fileExplorer])
#expect(panels.working.instances(in: .right).isEmpty)
}
/// A panel that's already on screen isn't disturbed by arriving at another chat with workers
/// no second copy, no reshuffle.
@Test func aChatWithWorkersLeavesAnOpenPanelAlone() throws {
let panels = store()
panels.revealSubagents()
let instance = try #require(subagentsInstance(panels))
panels.syncSubagents(hasSubagents: true)
#expect(subagentsInstance(panels)?.id == instance.id)
#expect(panels.working.instances(in: .right).count == 1)
}
/// The debt survives a relaunch: quitting while the panel is auto-hidden saves a working layout
/// without it, so if the flag didn't persist alongside, the user's panel would be gone for good.
@Test func theDebtOutlivesALaunch() {
let defaults = MemoryDefaults()
let first = PanelLayoutStore(defaults: defaults)
first.revealSubagents()
first.syncSubagents(hasSubagents: false)
#expect(!hasSubagentsPanel(first))
let relaunched = PanelLayoutStore(defaults: defaults)
#expect(!hasSubagentsPanel(relaunched))
relaunched.syncSubagents(hasSubagents: true)
#expect(hasSubagentsPanel(relaunched))
}
/// An explicit reveal settles the question the other way: the panel is open because the user
/// asked for it, so nothing is owed back until the driver hides it again.
@Test func explicitRevealTakesOverFromTheDriver() {
let panels = store()
panels.revealSubagents()
panels.syncSubagents(hasSubagents: false)
#expect(!hasSubagentsPanel(panels))
panels.revealSubagents()
#expect(hasSubagentsPanel(panels))
panels.syncSubagents(hasSubagents: true)
#expect(hasSubagentsPanel(panels))
}
}
/// An in-memory `UserDefaults` for the layout store: a real `UserDefaults(suiteName:)` reaches
/// cfprefsd and leaves a plist behind per test, and these tests write the same keys the running app
/// persists its arrangement to. Only the accessors `PanelLayoutStore` uses are overridden.
private final class MemoryDefaults: UserDefaults, @unchecked Sendable {
private let lock = NSLock()
private var storage: [String: Any] = [:]
init() { super.init(suiteName: "nucleic-panels-test-\(UUID().uuidString)")! }
private func get(_ key: String) -> Any? {
lock.lock()
defer { lock.unlock() }
return storage[key]
}
private func put(_ value: Any?, _ key: String) {
lock.lock()
defer { lock.unlock() }
storage[key] = value
}
override func object(forKey key: String) -> Any? { get(key) }
override func data(forKey key: String) -> Data? { get(key) as? Data }
override func string(forKey key: String) -> String? { get(key) as? String }
override func bool(forKey key: String) -> Bool { get(key) as? Bool ?? false }
override func set(_ value: Any?, forKey key: String) { put(value, key) }
override func set(_ value: Bool, forKey key: String) { put(value, key) }
override func removeObject(forKey key: String) { put(nil, key) }
}
@@ -172,4 +172,215 @@ struct SandboxToolDisplayTests {
input: .object(["command": .string("swift test")])))
== "ran on a macOS VM `swift test`")
}
// MARK: - Orchestra
/// The orchestra tools are the ones a user is most likely to meet as raw wire names a turn
/// that fans out spends most of its time inside them so each gets a name and a line that
/// says what the session is doing, in both the qualified and bare forms.
@Test func orchestraToolsReadAsDelegation() {
#expect(SandboxToolDisplay.label(for: "mcp__nucleic__nucleic_supervise")
== "Supervising subagents")
#expect(SandboxToolDisplay.label(for: "nucleic_delegate_plan") == "Delegation plan")
#expect(SandboxToolDisplay.label(for: "nucleic_subagent") == "Subagent")
#expect(SandboxToolDisplay.label(for: "nucleic_reply_to_worker") == "Reply to subagent")
#expect(SandboxToolDisplay.label(for: "nucleic_ask_supervisor") == "Question for supervisor")
}
/// The status line's whole job here is to explain a session that looks idle: it's blocked on
/// its workers, not stuck.
@Test func orchestraGerundsNameTheWait() {
#expect(SandboxToolDisplay.gerund(for: "mcp__nucleic__nucleic_supervise")
== "Waiting on subagents…")
#expect(SandboxToolDisplay.gerund(for: "nucleic_subagent") == "Delegating to a subagent…")
#expect(SandboxToolDisplay.gerund(for: "mcp__nucleic__nucleic_delegate_plan")
== "Delegating a plan to subagents…")
}
/// A spawn shows the worker's task label, not its (long) prompt; a supervise call names the
/// scope it's draining even when it has no arguments at all.
@Test func orchestraDetailsShowTheAssignment() {
let spawn = JSONValue.object([
"task": .string("Audit the parser"),
"prompt": .string("A very long prompt the card body owns"),
])
#expect(SandboxToolDisplay.detail(for: "nucleic_subagent", input: spawn)
== "Audit the parser")
#expect(SandboxToolDisplay.detail(for: "nucleic_supervise", input: .object([:]))
== "all workers")
#expect(SandboxToolDisplay.detail(
for: "nucleic_supervise", input: .object(["batch": .string("api")]))
== "batch api")
#expect(SandboxToolDisplay.detail(
for: "nucleic_ask_supervisor",
input: .object(["question": .string("Which schema wins?")]))
== "Which schema wins?")
}
/// A plan's shape how much work is fanning out, in how many waves is what distinguishes
/// one plan call from another. The prose is the fallback for a plan that carries no batches.
@Test func planDetailCountsTheFanOut() {
func batch(_ name: String, _ tasks: Int) -> JSONValue {
.object([
"name": .string(name),
"tasks": .array((0..<tasks).map { _ in .object(["task": .string("t")]) }),
])
}
#expect(SandboxToolDisplay.detail(
for: "nucleic_delegate_plan",
input: .object(["plan": .string("Split the audit"),
"batches": .array([batch("a", 4), batch("b", 3)])]))
== "7 tasks in 2 batches")
// One batch needs no wave count, and one task must not read as "1 tasks".
#expect(SandboxToolDisplay.detail(
for: "nucleic_delegate_plan", input: .object(["batches": .array([batch("a", 1)])]))
== "1 task")
#expect(SandboxToolDisplay.detail(
for: "nucleic_delegate_plan", input: .object(["plan": .string("Split the audit")]))
== "Split the audit")
}
// MARK: - The rest of Nucleic's own tools
/// Asking the human is the other half of `nucleic_ask_supervisor` a supervisor relays a
/// worker's question through it and it parks the turn the same way, so it needs the same
/// "nothing is moving until someone answers" line rather than a generic "Running".
@Test func askUserReadsAsAWaitOnTheHuman() {
#expect(SandboxToolDisplay.label(for: "mcp__nucleic__nucleic_ask_user") == "Question for you")
#expect(SandboxToolDisplay.gerund(for: "nucleic_ask_user") == "Waiting on your answer…")
#expect(SandboxToolDisplay.family(for: "nucleic_ask_user")?.verb == "Asked you")
}
/// The question itself, dug out of the nested `questions` array a generic renderer would print
/// as "1 item". Several questions arrive as one prompt, so the first leads and the rest are
/// counted rather than run together.
@Test func askUserDetailShowsTheQuestion() {
func question(_ text: String) -> JSONValue {
.object(["question": .string(text), "header": .string("h")])
}
#expect(SandboxToolDisplay.detail(
for: "nucleic_ask_user", input: .object(["questions": .array([question("Ship it?")])]))
== "Ship it?")
#expect(SandboxToolDisplay.detail(
for: "nucleic_ask_user",
input: .object(["questions": .array([question("Ship it?"), question("Which branch?")])]))
== "Ship it? (+1 more)")
#expect(SandboxToolDisplay.detail(for: "nucleic_ask_user", input: .object([:])) == nil)
}
/// Broadcasting on a topic and addressing one session on one host are different acts, so the
/// destination leads the line with the body behind it, since "sent a mesh message" alone says
/// nothing about what was sent.
@Test func meshSendShowsDestinationThenBody() {
#expect(SandboxToolDisplay.detail(
for: "nucleic_send_message",
input: .object(["topic": .string("deploys"), "body": .string("build green")]))
== "#deploys — build green")
#expect(SandboxToolDisplay.detail(
for: "mcp__nucleic__nucleic_send_message",
input: .object(["to": .string("studio"), "session": .string("s-1"),
"body": .string("take over")]))
== "studio/s-1 — take over")
#expect(SandboxToolDisplay.detail(
for: "nucleic_send_message", input: .object(["body": .string("hello")])) == "hello")
}
/// A wait scoped to a topic is a different wait from one for anything addressed to this session,
/// and this line is the only place that distinction shows an argumentless call must not go
/// blank and read as the scoped one.
@Test func meshWaitNamesItsScope() {
#expect(SandboxToolDisplay.detail(
for: "nucleic_wait_for_message", input: .object(["topic": .string("deploys")]))
== "#deploys")
#expect(SandboxToolDisplay.detail(for: "nucleic_wait_for_message", input: .object([:]))
== "any message")
#expect(SandboxToolDisplay.detail(
for: "nucleic_subscribe_topic", input: .object(["topic": .string("deploys")]))
== "#deploys")
#expect(SandboxToolDisplay.gerund(for: "nucleic_wait_for_message")
== "Waiting for a mesh message…")
}
/// A monitor is named by the label its events will arrive under; the source is the fallback for
/// a call that didn't supply one, and a WebSocket watch has no `command` to fall back to at all.
@Test func monitorShowsWhatItWatches() {
#expect(SandboxToolDisplay.detail(
for: "nucleic_monitor",
input: .object(["description": .string("errors in deploy.log"),
"command": .string("tail -f deploy.log")]))
== "errors in deploy.log")
#expect(SandboxToolDisplay.detail(
for: "nucleic_monitor", input: .object(["command": .string("tail -f deploy.log")]))
== "$ tail -f deploy.log")
#expect(SandboxToolDisplay.detail(
for: "mcp__nucleic__nucleic_monitor",
input: .object(["ws": .object(["url": .string("wss://ci.example/stream")])]))
== "wss://ci.example/stream")
// Arming returns at once the watch outlives the turn so this is not phrased as a wait.
#expect(SandboxToolDisplay.gerund(for: "nucleic_monitor") == "Arming a monitor…")
}
/// The backstop against a new tool shipping as a raw `mcp__nucleic__` row: every tool the
/// approval server advertises must have all four presentations. `approve` is excluded (it is the
/// gate itself, never rendered as a call) and so is `host_exec`, which has its own card.
@Test func everyAdvertisedToolIsNamed() {
let tools = [
MCPApprovalServer.qualifiedMacVMExecToolName,
MCPApprovalServer.qualifiedMacVMControlToolName,
MCPApprovalServer.qualifiedMacVMComputerToolName,
MCPApprovalServer.qualifiedMacVMComputerBatchToolName,
MCPApprovalServer.qualifiedMacVMClearNotificationsToolName,
MCPApprovalServer.qualifiedMacVMOperatorToolName,
MCPApprovalServer.qualifiedLinuxVMExecToolName,
MCPApprovalServer.qualifiedLinuxVMControlToolName,
MCPApprovalServer.qualifiedLinuxVMComputerToolName,
MCPApprovalServer.qualifiedLinuxVMComputerBatchToolName,
MCPApprovalServer.qualifiedLinuxContainerToolName,
MCPApprovalServer.qualifiedOrchestraSubagentToolName,
MCPApprovalServer.qualifiedOrchestraPlanToolName,
MCPApprovalServer.qualifiedOrchestraSuperviseToolName,
MCPApprovalServer.qualifiedOrchestraReplyToWorkerToolName,
MCPApprovalServer.qualifiedOrchestraAskSupervisorToolName,
MCPApprovalServer.qualifiedAskUserToolName,
MCPApprovalServer.qualifiedMeshSendMessageToolName,
MCPApprovalServer.qualifiedMeshSubscribeTopicToolName,
MCPApprovalServer.qualifiedMeshWaitForMessageToolName,
MCPApprovalServer.qualifiedMonitorToolName,
]
for tool in tools {
#expect(SandboxToolDisplay.label(for: tool) != nil, "\(tool) has no label")
#expect(SandboxToolDisplay.gerund(for: tool) != nil, "\(tool) has no gerund")
#expect(SandboxToolDisplay.family(for: tool) != nil, "\(tool) has no summary verb")
#expect(SandboxToolDisplay.icon(for: tool) != nil, "\(tool) has no icon")
// And the bare form resolves too, since backends differ in which they record.
#expect(SandboxToolDisplay.handles(SandboxToolDisplay.bareName(tool)))
}
}
/// Family keys are what group a run of calls into one headline two tools sharing a key by
/// accident would silently merge unrelated work under one verb. (`nucleic_subagent` shares
/// `Task`'s bucket on purpose; that pairing is pinned by its own test below.)
@Test func familyKeysAreDistinct() {
let keys = [
"mac_vm_exec", "mac_vm_control", "mac_vm_computer", "mac_vm_clear_notifications",
"mac_vm_request_operator", "linux_vm_exec", "linux_vm_control", "linux_vm_computer",
"linux_container", "nucleic_delegate_plan", "nucleic_supervise",
"nucleic_reply_to_worker", "nucleic_ask_supervisor", "nucleic_ask_user",
"nucleic_send_message", "nucleic_subscribe_topic", "nucleic_wait_for_message",
"nucleic_monitor",
].compactMap { SandboxToolDisplay.family(for: $0)?.key }
#expect(Set(keys).count == keys.count)
}
/// A spawn shares `Task`'s summary bucket and verb, so a turn that delegates both ways still
/// reads as one run of subagents rather than two unrelated families.
@Test func spawnSharesTheSubagentFamily() {
#expect(SandboxToolDisplay.family(for: "nucleic_subagent")?.key == "task")
#expect(SandboxToolDisplay.family(for: "nucleic_subagent")?.verb
== HeuristicSummary.subagentVerb)
#expect(HeuristicSummary.actionDescription(
ToolCall(toolCallID: "1", name: "mcp__nucleic__nucleic_subagent",
input: .object(["task": .string("Audit the parser")])))
== "ran a subagent `Audit the parser`")
}
}
@@ -0,0 +1,153 @@
import Foundation
import NucleicProtocol
import Testing
@testable import NucleicCore
/// `SessionActivity` is the Subagents panel's live line what a worker is doing right now, folded
/// out of the same event batch the store already ingests. These pin the fold (which events move the
/// reading, which leave it alone, and when it clears) and the vocabulary the line is built from,
/// since a wrong reading here is a row that quietly lies about a worker's state.
@Suite("Session activity")
struct SessionActivityTests {
private let root = "/tmp/worktree"
private func event(_ kind: AgentEvent.Kind, at seconds: TimeInterval = 0) -> AgentEvent {
AgentEvent(
sessionID: SessionID(rawValue: "w1"), seq: UInt64(seconds), at: Date(timeIntervalSince1970: seconds),
backend: .claudeCode, nativeType: nil, kind: kind)
}
private func call(
_ name: String, _ input: JSONValue, id: String = "call-1"
) -> ToolCall {
ToolCall(toolCallID: id, name: name, input: input)
}
private func advance(_ batch: [AgentEvent], from current: SessionActivity? = nil)
-> SessionActivity?
{
SessionActivity.advanced(from: current, by: batch, relativeTo: root)
}
// MARK: - The fold
/// The headline case: a worker in a shell call reads as the command it's running, not as the
/// bare word "Running" naming the command is the whole reason the line exists.
@Test func toolCallNamesTheCommand() {
let activity = advance([
event(.toolCallStarted(call("Bash", .object(["command": .string("swift build")]))))
])
#expect(activity?.kind == .tool)
#expect(activity?.verb == "Running")
#expect(activity?.detail == "swift build")
#expect(activity?.line == "Running swift build")
#expect(activity?.toolName == "Bash")
}
/// A file path is shown where it sits in the worktree, so a row says "Sources/AppStore.swift"
/// rather than spending its width on the absolute path every worker shares.
@Test func filePathsAreWorktreeRelative() {
let activity = advance([
event(.toolCallStarted(call("Read", .object([
"file_path": .string("/tmp/worktree/Sources/AppStore.swift")
]))))
])
#expect(activity?.line == "Reading Sources/AppStore.swift")
}
/// A call's arguments arrive twice the name on `started`, the full input on `completed`
/// and the second wave must refine the line in place, not restart the clock behind it.
@Test func completionRefinesWithoutRestartingTheClock() {
let started = advance([event(.toolCallStarted(call("Bash", .object([:]))), at: 10)])
#expect(started?.detail == nil)
let completed = advance(
[event(.toolCallCompleted(call("Bash", .object(["command": .string("make test")]))), at: 20)],
from: started)
#expect(completed?.detail == "make test")
#expect(completed?.since == Date(timeIntervalSince1970: 10))
}
/// A streamed phase repeats its event many times a second. The fold must return the *same*
/// value each time, so the store writes nothing and no observer wakes for an unchanged line.
@Test func repeatedReadingsAreUnchanged() {
let first = advance([event(.thinking(chunk("thinking hard")), at: 5)])
let second = advance([event(.thinking(chunk("still thinking")), at: 6)], from: first)
#expect(first == second)
#expect(second?.since == Date(timeIntervalSince1970: 5))
}
/// Only the result of the call currently on screen retires it: a nested worker's result can
/// land while the parent's own call is still open.
@Test func onlyItsOwnResultRetiresTheCall() {
let running = advance([
event(.toolCallStarted(call("Bash", .object(["command": .string("sleep 30")]), id: "a")))
])
let other = advance(
[event(.toolResult(ToolResult(toolCallID: "b", content: .string("done"), isError: false)))],
from: running)
#expect(other == running)
let own = advance(
[event(.toolResult(ToolResult(toolCallID: "a", content: .string("done"), isError: false)))],
from: running)
#expect(own?.kind == .working)
#expect(own?.line == "Working…")
}
/// A finished turn clears the line outright a settled worker showing "Running swift build"
/// forever is worse than showing nothing.
@Test func finishedTurnClearsTheLine() {
let running = advance([
event(.toolCallStarted(call("Bash", .object(["command": .string("swift build")]))))
])
let finished = advance(
[event(.runFinished(RunFinished(outcome: .completed)))], from: running)
#expect(finished == nil)
}
/// Events that say nothing about what the session is *doing* leave the reading alone, so a
/// usage ping mid-command doesn't blank the line.
@Test func unrelatedEventsLeaveItAlone() {
let running = advance([
event(.toolCallStarted(call("Grep", .object(["pattern": .string("TODO")]))))
])
let after = advance([event(.usage(Usage(inputTokens: 1, outputTokens: 2)))], from: running)
#expect(after == running)
}
// MARK: - Vocabulary
/// The verbs the line is built from, including the fall-through that names Nucleic's own
/// sandbox tools by where the work is happening rather than by their `mcp__nucleic__` name.
@Test func verbsReadAsWork() {
#expect(SessionActivity.verb(forTool: "Read") == "Reading")
#expect(SessionActivity.verb(forTool: "Write") == "Writing")
#expect(SessionActivity.verb(forTool: "Grep") == "Searching")
#expect(SessionActivity.verb(forTool: "mcp__nucleic__mac_vm_exec") == "Running on a macOS VM")
#expect(SessionActivity.verb(forTool: "mcp__nucleic__nucleic_supervise") == "Waiting on subagents")
#expect(SessionActivity.verb(forTool: "Whatever") == "Running Whatever")
}
/// A long or multi-line argument (a heredoc, a big `Write` body) is collapsed to one bounded
/// line before it ever reaches the store the row can't show more, and the value is held for
/// every live worker at once.
@Test func detailIsCollapsedAndBounded() {
let heredoc = "cat <<'EOF' > x.txt\n line one\n line two\nEOF"
let activity = advance([
event(.toolCallStarted(call("Bash", .object(["command": .string(heredoc)]))))
])
#expect(activity?.detail == "cat <<'EOF' > x.txt line one line two EOF")
let long = String(repeating: "x", count: 400)
let capped = advance([
event(.toolCallStarted(call("Bash", .object(["command": .string(long)]))))
])
#expect((capped?.detail?.count ?? 0) <= 161)
#expect(capped?.detail?.hasSuffix("") == true)
}
private func chunk(_ text: String) -> TextChunk {
TextChunk(messageID: "m1", text: text, isPartial: true)
}
}