Merge nucleic/humble-slate-civet-chwe into dev
This commit is contained in:
@@ -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 } }
|
||||
}
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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`).
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 0…1 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 })
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user