Promote nvrsion trunk → dev

Squash-merge nucleic/trunk into dev (234 commits of accumulated nvrsion work).

Resolved two auto-generated-file conflicts:
- cloud/nucleic-edge/wrangler.jsonc: kept dev's real KV namespace ids and the
  xyz.blakeslee.nucleic.remote APNS topic, with binding names corrected to
  RELAY_TOKENS/PUSH_TOKENS to match the worker code on both branches.
- Package.resolved: took trunk's newer dependency pins.

Nucleic-Promote: 1
Co-Authored-By: Claude Opus 4.8 <[email protected]>
This commit is contained in:
Nucleic
2026-06-27 13:43:32 -07:00
co-authored by Claude Opus 4.8
parent d78f7ccef1
commit ab17d9d229
10 changed files with 603 additions and 63 deletions
@@ -48,6 +48,7 @@ struct StartChatComposer: View {
.textFieldStyle(.roundedBorder)
.lineLimit(1...5)
.orchestraGlow(active: MobileEfforts.isOrchestra(effort) && controlled)
.keyboardDismissable()
Button {
if let project = selected {
store.startChat(in: project.id, message: draft, model: model, effort: effort, auto: auto)
@@ -92,6 +93,7 @@ struct QuickTodos: View {
TextField("Capture an idea…", text: $draft)
.textFieldStyle(.roundedBorder)
.onSubmit(capture)
.keyboardDismissable()
Button(action: capture) { Image(systemName: "plus.circle.fill").font(.title3) }
.disabled(draft.trimmingCharacters(in: .whitespaces).isEmpty || !store.canControl)
}
@@ -70,6 +70,7 @@ struct HomeView: View {
}
.navigationTitle("Home")
.navigationBarTitleDisplayMode(.inline)
.scrollDismissesKeyboard(.interactively)
.refreshable { store.refreshSessions() }
.safeAreaInset(edge: .bottom) { ConnectionChip().padding(.bottom, 8) }
}
@@ -70,6 +70,7 @@ struct ProjectDetailView: View {
TextField("Start a chat in \(project.name)…", text: $draft, axis: .vertical)
.textFieldStyle(.roundedBorder).lineLimit(1...4)
.orchestraGlow(active: MobileEfforts.isOrchestra(effort) && project.isNucleicControlled)
.keyboardDismissable()
Button {
store.startChat(in: project.id, message: draft, model: model, effort: effort)
draft = ""
@@ -91,6 +92,7 @@ struct ProjectDetailView: View {
}
.navigationTitle(project.name)
.navigationBarTitleDisplayMode(.inline)
.scrollDismissesKeyboard(.interactively)
.onChange(of: model) { _, newModel in
let levels = store.modelCatalog.offeredEfforts(forModel: newModel)
let sentinel = store.modelCatalog.orchestraSentinelOrFallback
@@ -193,6 +193,7 @@ struct SessionDetailView: View {
text: $draft, axis: .vertical)
.textFieldStyle(.roundedBorder)
.lineLimit(1...4)
.keyboardDismissable()
Button {
store.sendInput(draft, to: sessionID)
draft = ""
@@ -228,6 +229,9 @@ struct TranscriptList: View {
}
.padding()
}
// A drag on the transcript dismisses the keyboard, so a tall multiline composer can
// be put away without leaving the session.
.scrollDismissesKeyboard(.interactively)
// Coalescing means item count lags event count; key the autoscroll on the raw stream
// so every streamed delta keeps the view pinned to the bottom.
.onChange(of: events.count) {
@@ -103,6 +103,26 @@ extension Risk {
}
}
/// Adds a "Done" button to the keyboard's accessory toolbar so any focused composer can always
/// dismiss the keyboard. The multiline composers (`axis: .vertical`) turn Return into a newline,
/// so without this there's no way to release the keyboard once it opens. The modifier carries its
/// own `@FocusState` and binds it to the field, keeping callers to a single `.keyboardDismissable()`.
struct KeyboardDismissable: ViewModifier {
@FocusState private var focused: Bool
func body(content: Content) -> some View {
content
.focused($focused)
.toolbar {
ToolbarItemGroup(placement: .keyboard) {
Spacer()
Button { focused = false } label: {
Label("Done", systemImage: "keyboard.chevron.compact.down")
}
}
}
}
}
/// A lifted card surface with a hairline border — the mobile echo of `AppTheme.surface`.
struct CardBackground: ViewModifier {
var padding: CGFloat = 14
@@ -120,6 +140,10 @@ extension View {
func card(padding: CGFloat = 14) -> some View { modifier(CardBackground(padding: padding)) }
/// Apply the teal accent app-wide.
func nucleicTint() -> some View { tint(Palette.accent) }
/// Give a text field a way to release the keyboard. Without this, iOS leaves a focused
/// composer no dismiss affordance (no Return key on a vertical/multiline field, nothing to
/// tap) and the keyboard stays up. See `KeyboardDismissable`.
func keyboardDismissable() -> some View { modifier(KeyboardDismissable()) }
/// Wrap a composer field in the orchestra purple ring while `active` (mirrors the Mac's
/// animated composer glow, lighter touch).
func orchestraGlow(active: Bool, cornerRadius: CGFloat = 8) -> some View {
@@ -25,6 +25,7 @@ struct TodosView: View {
TextField("Capture an idea…", text: $draft)
.textFieldStyle(.roundedBorder)
.onSubmit(capture)
.keyboardDismissable()
Button(action: capture) { Image(systemName: "plus.circle.fill").font(.title3) }
.disabled(draft.trimmingCharacters(in: .whitespaces).isEmpty || !store.canControl)
}
@@ -40,6 +41,7 @@ struct TodosView: View {
}
}
.navigationTitle("To-dos")
.scrollDismissesKeyboard(.interactively)
.refreshable { store.refreshSessions() }
}
}
@@ -1,23 +1,31 @@
import SwiftUI
import NucleicProtocol
/// One tool call as a collapsible card — the mobile echo of the Mac's tool group. Collapsed it
/// shows the tool, a one-line input gist, and a running spinner; expanded it reveals the full
/// input, the result, and any files the call touched. The old remote rendered `started` and
/// `completed` as two separate one-line rows and dropped results/file-changes entirely.
struct ToolGroupRow: View {
/// One tool call as a collapsible card — the mobile echo of the Mac's `ToolCallRow`. Collapsed
/// it shows the tool, a one-line input gist, and a running spinner; expanded it reveals the full
/// input, the result, and any files the call touched. When `inGroup` is set the card renders
/// flush (no background/border) because an enclosing `ToolBlockCard` supplies the single
/// contiguous container — exactly as the Mac groups a run of calls.
struct ToolCallCard: View {
let group: ToolGroup
/// Renders flush inside a `ToolBlockCard` (the block draws the one card) when true.
var inGroup: Bool = false
@State private var expanded = false
var body: some View {
VStack(alignment: .leading, spacing: 8) {
let content = VStack(alignment: .leading, spacing: 8) {
Button { expanded.toggle() } label: { header }.buttonStyle(.plain)
if expanded { details }
}
.padding(10)
.background(Color(.secondarySystemBackground), in: RoundedRectangle(cornerRadius: 10))
.overlay(RoundedRectangle(cornerRadius: 10).strokeBorder(
group.isError ? Palette.danger.opacity(0.5) : Color.primary.opacity(0.05), lineWidth: 1))
if inGroup {
content.padding(.horizontal, 10).padding(.vertical, 8)
} else {
content
.padding(10)
.background(Color(.secondarySystemBackground), in: RoundedRectangle(cornerRadius: 10))
.overlay(RoundedRectangle(cornerRadius: 10).strokeBorder(
group.isError ? Palette.danger.opacity(0.5) : Color.primary.opacity(0.05), lineWidth: 1))
}
}
private var header: some View {
@@ -55,44 +63,355 @@ struct ToolGroupRow: View {
}
}
/// A subagent spawn (`Task`/`Agent`) — the gold Orchestra card, mirroring the Mac's
/// OrchestrationCard at phone fidelity so an orchestrated run reads distinctly from a plain tool.
struct OrchestrationCard: View {
let group: ToolGroup
/// A run of two or more consecutive tool calls rendered as ONE contiguous card — the mobile echo
/// of the Mac's `ToolGroupRow`. Collapsed it lists up to five one-line summaries (glyph + tool +
/// input gist) with a call count and a "See more"; tapping the block reveals every call in full,
/// each its own flush `ToolCallCard` with result and file changes.
struct ToolBlockCard: View {
let groups: [ToolGroup]
@State private var expanded = false
private var subtitle: String {
group.input["description"]?.stringValue
?? group.input["prompt"]?.stringValue
?? group.input.compactSummary
/// A collapsed block shows at most this many summary lines before a "See more".
private static let collapsedLineLimit = 5
private var allFinished: Bool { groups.allSatisfy(\.finished) }
private var hiddenCount: Int { max(0, groups.count - Self.collapsedLineLimit) }
private var shownGroups: ArraySlice<ToolGroup> {
expanded ? groups[...] : groups.prefix(Self.collapsedLineLimit)
}
var body: some View {
VStack(alignment: .leading, spacing: 8) {
Button { expanded.toggle() } label: {
HStack(spacing: 8) {
Image(systemName: "sparkles").foregroundStyle(Palette.orchestra)
Text(group.finished ? "Orchestrated" : "Orchestrating")
.font(.caption.weight(.semibold)).foregroundStyle(Palette.orchestra)
Text(subtitle).font(.caption).foregroundStyle(.secondary).lineLimit(1)
Spacer(minLength: 4)
if !group.finished { ProgressView().controlSize(.mini) }
Image(systemName: expanded ? "chevron.down" : "chevron.right")
.font(.caption2).foregroundStyle(.tertiary)
}
VStack(alignment: .leading, spacing: 0) {
header
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.onTapGesture { withAnimation(.easeInOut(duration: 0.15)) { expanded.toggle() } }
if expanded {
ToolBlock(label: "Task", text: group.input.approvalDetail, mono: false)
if let result = group.result {
ToolBlock(label: "Result", text: result.compactSummary, mono: false)
ForEach(groups, id: \.toolCallID) { group in
Divider().overlay(Color.primary.opacity(0.06))
ToolCallCard(group: group, inGroup: true)
}
Divider().overlay(Color.primary.opacity(0.06))
collapseFooter
}
}
.background(Color(.secondarySystemBackground), in: RoundedRectangle(cornerRadius: 10))
.overlay(RoundedRectangle(cornerRadius: 10).strokeBorder(Color.primary.opacity(0.05), lineWidth: 1))
}
private var header: some View {
VStack(alignment: .leading, spacing: 7) {
ForEach(Array(shownGroups.enumerated()), id: \.element.toolCallID) { index, group in
HStack(spacing: 8) {
Image(systemName: ToolGlyph.icon(group.name))
.font(.caption).foregroundStyle(group.isError ? Palette.danger : .secondary)
.frame(width: 16)
Text(group.name).font(.caption.weight(.semibold))
Text(group.input.compactSummary)
.font(.caption.monospaced()).foregroundStyle(.secondary).lineLimit(1)
Spacer(minLength: 4)
if index == 0 { trailingControls }
}
}
if !expanded, hiddenCount > 0 {
Text("See \(hiddenCount) more…")
.font(.caption.weight(.medium)).foregroundStyle(Palette.accent)
.padding(.leading, 24)
}
}
.padding(10)
.background(Palette.orchestra.opacity(0.08), in: RoundedRectangle(cornerRadius: 10))
.overlay(RoundedRectangle(cornerRadius: 10).strokeBorder(Palette.orchestra.opacity(0.35), lineWidth: 1))
}
private var trailingControls: some View {
HStack(spacing: 6) {
Text("\(groups.count)")
.font(.caption2.weight(.semibold).monospacedDigit()).foregroundStyle(.secondary)
if !allFinished { ProgressView().controlSize(.mini) }
Image(systemName: expanded ? "chevron.down" : "chevron.right")
.font(.caption2).foregroundStyle(.tertiary)
}
}
private var collapseFooter: some View {
Button {
withAnimation(.easeInOut(duration: 0.15)) { expanded = false }
} label: {
HStack(spacing: 6) {
Image(systemName: "chevron.up").font(.caption2.weight(.semibold))
Text("Collapse").font(.caption.weight(.medium))
Spacer(minLength: 0)
}
.foregroundStyle(Palette.accent).contentShape(Rectangle())
}
.buttonStyle(.plain)
.padding(.horizontal, 10).padding(.vertical, 6)
}
}
// MARK: - Subagent & multi-agent cards
//
// Claude Code spawns subagents through the `Task` tool. Nucleic surfaces that delegated work in
// the orchestra gold so it reads distinctly from the agent's own tool calls: a lone `Task`
// becomes a `SubagentCard`, and a parallel wave of them becomes an `OrchestrationCard`. Both
// build from the projection's nested `children` (a subagent's inner Read/Bash/Edit/thinking),
// mirroring the Mac at phone fidelity.
/// The lifecycle state of a spawned subagent, read from its `Task` group: not finished → still
/// working; an error result → failed; otherwise → done.
enum SubagentRunState {
case running, done, failed
init(group: ToolGroup) {
if !group.finished { self = .running }
else if group.isError { self = .failed }
else { self = .done }
}
var label: String {
switch self {
case .running: "Working"
case .done: "Done"
case .failed: "Failed"
}
}
var color: Color {
switch self {
case .running: Palette.orchestra
case .done: Palette.success
case .failed: Palette.danger
}
}
}
/// The human-facing bits of a `Task` tool call, so the subagent cards agree on what to show.
enum Subagent {
/// The subagent flavor (`subagent_type`: "Explore", "general-purpose", …), or nil.
static func type(_ group: ToolGroup) -> String? {
let raw = group.input["subagent_type"]?.stringValue?
.trimmingCharacters(in: .whitespacesAndNewlines)
return (raw?.isEmpty == false) ? raw : nil
}
/// The one-line task the parent handed the subagent (`description`), falling back to the
/// full `prompt`, then a generic label.
static func taskLabel(_ group: ToolGroup) -> String {
if let description = group.input["description"]?.stringValue?
.trimmingCharacters(in: .whitespacesAndNewlines), !description.isEmpty {
return description
}
if let prompt = group.input["prompt"]?.stringValue?
.trimmingCharacters(in: .whitespacesAndNewlines), !prompt.isEmpty {
return prompt
}
return "Subagent task"
}
/// "Explore — find the composer", joining the flavor and task for a compact one-liner.
static func headline(_ group: ToolGroup) -> String {
let label = taskLabel(group)
if let type = type(group) { return "\(type) — \(label)" }
return label
}
/// The subagent's returned report, or nil while it's still working / reported nothing.
static func report(_ group: ToolGroup) -> String? {
guard let result = group.result else { return nil }
let text = result.compactSummary
return text.isEmpty ? nil : text
}
}
/// 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 {
let state: SubagentRunState
var body: some View {
Group {
switch state {
case .running: ProgressView().controlSize(.mini).tint(Palette.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 a subagent card header.
struct SubagentStatusChip: View {
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)
.padding(.horizontal, 7).padding(.vertical, 3)
.background(state.color.opacity(0.14), in: Capsule())
}
}
/// One delegated subagent (`Task`), as a gold card: its type and the task it was given, a live
/// Working / Done / Failed status, and — on expand — its nested activity stream (or, for a
/// replayed transcript with none, its returned report). Mirrors the Mac's `SubagentCard`.
struct SubagentCard: View {
let group: ToolGroup
@State private var expanded = false
private var state: SubagentRunState { SubagentRunState(group: group) }
private var report: String? { Subagent.report(group) }
private var hasDetail: Bool { !group.children.isEmpty || report != nil }
var body: some View {
VStack(alignment: .leading, spacing: 0) {
header
.contentShape(Rectangle())
.onTapGesture {
guard hasDetail else { return }
withAnimation(.easeInOut(duration: 0.15)) { expanded.toggle() }
}
if expanded, hasDetail {
Divider().overlay(Palette.orchestra.opacity(0.2))
SubagentActivityStream(children: group.children, fallbackReport: report)
}
}
.background(Palette.orchestra.opacity(0.08))
.overlay(RoundedRectangle(cornerRadius: 10).strokeBorder(Palette.orchestra.opacity(0.3), lineWidth: 1))
.clipShape(RoundedRectangle(cornerRadius: 10))
}
private var header: some View {
VStack(alignment: .leading, spacing: 6) {
HStack(alignment: .firstTextBaseline, spacing: 10) {
Image(systemName: "person.2.fill")
.font(.callout).foregroundStyle(Palette.orchestra).frame(width: 18)
(Text("Subagent")
+ (Subagent.type(group).map { Text(" · \($0)").foregroundColor(.secondary) } ?? Text("")))
.font(.callout.weight(.semibold))
Spacer(minLength: 6)
SubagentStatusChip(state: state)
if hasDetail {
Image(systemName: expanded ? "chevron.up" : "chevron.down")
.font(.caption2).foregroundStyle(.secondary)
}
}
Text(Subagent.taskLabel(group))
.font(.caption).foregroundStyle(.secondary)
.lineLimit(expanded ? nil : 2).truncationMode(.tail)
.fixedSize(horizontal: false, vertical: true)
}
.padding(.horizontal, 12).padding(.vertical, 10)
}
}
/// A multi-agent fan-out: a wave of two or more subagents (`Task` calls) spawned together. The
/// header sums up the wave (count + overall status); each subagent is its own row that expands
/// independently to reveal its activity stream and report. Mirrors the Mac's `OrchestrationCard`.
struct OrchestrationCard: View {
let groups: [ToolGroup]
/// Overall wave status: failed if any failed, else running if any is still working, else done.
private var overall: SubagentRunState {
let states = groups.map { SubagentRunState(group: $0) }
if states.contains(where: { if case .failed = $0 { true } else { false } }) { return .failed }
if states.contains(where: { if case .running = $0 { true } else { false } }) { return .running }
return .done
}
private var doneCount: Int { groups.filter(\.finished).count }
var body: some View {
VStack(alignment: .leading, spacing: 0) {
header
ForEach(groups, id: \.toolCallID) { group in
Divider().overlay(Palette.orchestra.opacity(0.18))
OrchestrationAgentRow(group: group)
}
}
.background(Palette.orchestra.opacity(0.08))
.overlay(RoundedRectangle(cornerRadius: 10).strokeBorder(Palette.orchestra.opacity(0.3), lineWidth: 1))
.clipShape(RoundedRectangle(cornerRadius: 10))
}
private var header: some View {
HStack(alignment: .firstTextBaseline, spacing: 10) {
Image(systemName: "rectangle.3.group.fill")
.font(.callout).foregroundStyle(Palette.orchestra).frame(width: 18)
VStack(alignment: .leading, spacing: 1) {
Text("Multi-agent fan-out").font(.callout.weight(.semibold))
Text("\(groups.count) subagents · \(doneCount) done")
.font(.caption2).foregroundStyle(.secondary)
}
Spacer(minLength: 6)
SubagentStatusChip(state: overall)
}
.padding(.horizontal, 12).padding(.vertical, 10)
}
}
/// One subagent within a fan-out: a one-line status header that expands on tap to reveal that
/// agent's own activity stream — and, failing that, its returned report. Each row tracks its own
/// expansion so agents open independently.
private struct OrchestrationAgentRow: View {
let group: ToolGroup
@State private var expanded = false
private var state: SubagentRunState { SubagentRunState(group: group) }
private var report: String? { Subagent.report(group) }
private var hasDetail: Bool { !group.children.isEmpty || report != nil }
var body: some View {
VStack(alignment: .leading, spacing: 0) {
header
.contentShape(Rectangle())
.onTapGesture {
guard hasDetail else { return }
withAnimation(.easeInOut(duration: 0.15)) { expanded.toggle() }
}
if expanded, hasDetail {
SubagentActivityStream(children: group.children, fallbackReport: report)
}
}
}
private var header: some View {
HStack(alignment: .firstTextBaseline, spacing: 9) {
SubagentStatusGlyph(state: state)
Text(Subagent.headline(group))
.font(.caption).lineLimit(1).truncationMode(.tail)
Spacer(minLength: 6)
if hasDetail {
Image(systemName: expanded ? "chevron.up" : "chevron.down")
.font(.caption2).foregroundStyle(.secondary)
}
}
.padding(.horizontal, 12).padding(.vertical, 8)
}
}
/// A subagent's nested activity — the calls, thinking, and prose it produced — rendered with the
/// same row views the main transcript uses, recursing into nested subagents' own cards. Falls
/// back to the returned report when there's no captured inner activity (a replayed transcript).
private struct SubagentActivityStream: View {
let children: [TranscriptItem]
var fallbackReport: String? = nil
var body: some View {
if !children.isEmpty {
VStack(alignment: .leading, spacing: 8) {
ForEach(children) { child in
TranscriptRow(item: child)
}
}
.padding(.horizontal, 12).padding(.vertical, 8)
.frame(maxWidth: .infinity, alignment: .leading)
} else if let fallbackReport {
Text(fallbackReport)
.font(.caption).foregroundStyle(.secondary)
.textSelection(.enabled)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal, 12).padding(.vertical, 10)
}
}
}
@@ -125,7 +444,7 @@ enum ToolGlyph {
case "Edit", "Write", "MultiEdit", "NotebookEdit": return "pencil"
case "Grep", "Glob", "Search": return "magnifyingglass"
case "WebFetch", "WebSearch": return "globe"
case "Task", "Agent": return "sparkles"
case "Task", "Agent": return "person.2"
case "TodoWrite": return "checklist"
case "AskUserQuestion": return "questionmark.bubble"
default: return "wrench.and.screwdriver"
@@ -4,8 +4,10 @@ import NucleicProtocol
/// One render-ready row, folded from the raw `[AgentEvent]` stream. The phone subscribes at
/// `.full`, so it receives streaming text deltas and every step of a tool call's lifecycle;
/// this projection coalesces those into the same shapes the Mac transcript shows — one bubble
/// per message, one card per tool call — so the view layer stays dumb. (Mirrors the desktop's
/// `TranscriptProjection` at phone fidelity.)
/// per message, one card per tool call, a single contiguous block for a run of consecutive
/// calls, and a subagent spawn carrying its own nested activity — so the view layer stays dumb.
/// (Mirrors the desktop's `TranscriptProjection` at phone fidelity, including its event
/// partitioning, tool-run coalescing, and subagent nesting.)
struct TranscriptItem: Identifiable, Equatable {
let id: String
/// The seq of the first event that created this item — stable scroll anchor + ordering.
@@ -17,9 +19,14 @@ struct TranscriptItem: Identifiable, Equatable {
enum Kind: Equatable {
case message(role: Role, text: String)
case thinking(text: String)
/// A single tool call. A subagent spawn (`Task`/`Agent`) carries its inner activity in
/// `group.children` and renders as the gold subagent card; every other tool is a plain
/// collapsible card.
case tool(ToolGroup)
/// A `Task`/`Agent` spawn — rendered as the gold Orchestra card.
case orchestration(ToolGroup)
/// A run of ≥2 consecutive tool calls coalesced into one contiguous block — the phone
/// echo of the Mac's `.toolGroup`. A run made entirely of subagent spawns is a
/// multi-agent fan-out (the gold orchestration card); any other run is a tool block.
case toolBlock([ToolGroup])
case sessionStarted(model: String, cwd: String)
case usage(Usage)
case rateLimit(RateLimit)
@@ -30,6 +37,17 @@ struct TranscriptItem: Identifiable, Equatable {
case note(text: String, icon: String?, lockEvent: Bool)
case raw(type: String, body: String)
}
/// A row that draws nothing visible — an empty/redacted `.thinking` block (the agent streams
/// a finalized empty thinking item whenever the reasoning itself is redacted). Held aside
/// during tool-run coalescing so it doesn't split an otherwise-contiguous run of calls into
/// two cards with a gap. Mirrors the desktop projection's `rendersNothing`.
var rendersNothing: Bool {
if case .thinking(let text) = kind {
return text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
}
return false
}
}
/// One tool call's coalesced lifecycle: start → input deltas → complete → result → file changes.
@@ -42,6 +60,10 @@ struct ToolGroup: Equatable {
var finished: Bool = false
/// Paths the call touched (from `fileChange` events tagged with this tool call).
var fileChanges: [FilePatch] = []
/// For a subagent spawn (`Task`/`Agent`): the inner activity it produced — its own tool
/// calls, thinking, and prose — projected the same way and nested here, so the card can show
/// its live Read/Bash/Edit stream. Empty for every ordinary (non-subagent) tool.
var children: [TranscriptItem] = []
struct FilePatch: Equatable { let path: String; let change: FileChange.ChangeKind }
@@ -51,9 +73,167 @@ struct ToolGroup: Equatable {
}
enum TranscriptProjection {
/// How deep subagent nesting is followed before deeper descendants are left unattached — a
/// safety bound on the recursion (a subagent spawning a subagent spawning a subagent …), far
/// past any real fan-out depth. Mirrors the desktop projection.
private static let maxSubagentDepth = 4
/// Fold the raw event stream into render rows. `showRaw` surfaces unrecognized passthrough
/// events (debug); `showLockEvents` keeps file-lock lifecycle notes (off = quieter feed).
///
/// Work that happens *inside* a spawned subagent arrives in this same stream tagged with the
/// parent `Task`'s id (`parentToolCallID`). We partition events by which subagent (if any)
/// owns them, project the main agent's own events at the top level, and recursively project
/// each subagent's events into the `children` of its spawn — so a subagent's inner work nests
/// under its card instead of leaking (and interleaving) into the main transcript.
static func build(_ events: [AgentEvent], showRaw: Bool, showLockEvents: Bool) -> [TranscriptItem] {
let (topLevel, byParent) = partition(events)
return project(topLevel, byParent: byParent, depth: 0, showRaw: showRaw, showLockEvents: showLockEvents)
}
// MARK: - Subagent partitioning
/// Split a scope's events into the main agent's own (`topLevel`) and each subagent's, keyed by
/// the spawning `Task`'s id.
private static func partition(_ events: [AgentEvent])
-> (topLevel: [AgentEvent], byParent: [String: [AgentEvent]])
{
let parentOf = toolParentMap(events)
var topLevel: [AgentEvent] = []
var byParent: [String: [AgentEvent]] = [:]
for event in events {
if let owner = subagentOwner(of: event, parentOf: parentOf) {
byParent[owner, default: []].append(event)
} else {
topLevel.append(event)
}
}
return (topLevel, byParent)
}
/// Maps each tool-call id to its parent subagent's id, built from the call start/complete
/// events. A tool *result* or *file change* names only a tool id, so it inherits its subagent
/// scope from the call it belongs to via this map.
private static func toolParentMap(_ events: [AgentEvent]) -> [String: String] {
var map: [String: String] = [:]
for event in events {
switch event.kind {
case .toolCallStarted(let call), .toolCallCompleted(let call):
if let parent = call.parentToolCallID { map[call.toolCallID] = parent }
default:
break
}
}
return map
}
/// The id of the subagent whose scope this event belongs to, or nil for the main agent's own
/// turn. Tool calls and the subagent's prose/thinking carry the parent link directly; a tool
/// result or file change inherits it from the call it references.
private static func subagentOwner(of event: AgentEvent, parentOf: [String: String]) -> String? {
switch event.kind {
case .toolCallStarted(let call), .toolCallCompleted(let call):
return call.parentToolCallID
case .toolResult(let result):
return parentOf[result.toolCallID]
case .fileChange(let change):
return change.toolCallID.flatMap { parentOf[$0] }
case .assistantText(let chunk), .thinking(let chunk):
return chunk.parentToolCallID
default:
return nil
}
}
/// Projects one scope's events into items, coalesces runs of tool calls, then nests each
/// subagent spawn's own activity (drawn from `byParent`) as its `children`, recursing for
/// subagents-within-subagents up to `maxSubagentDepth`.
private static func project(
_ events: [AgentEvent], byParent: [String: [AgentEvent]], depth: Int,
showRaw: Bool, showLockEvents: Bool
) -> [TranscriptItem] {
let items = coalesceToolRuns(flatItems(events, showRaw: showRaw, showLockEvents: showLockEvents))
guard depth < maxSubagentDepth else { return items }
return items.map {
attachSubagentChildren($0, byParent: byParent, depth: depth,
showRaw: showRaw, showLockEvents: showLockEvents)
}
}
/// For a subagent spawn (`Task`/`Agent`) — lone or inside a fan-out block — projects the
/// events it owns into its `children`. Leaves every ordinary tool untouched.
private static func attachSubagentChildren(
_ item: TranscriptItem, byParent: [String: [AgentEvent]], depth: Int,
showRaw: Bool, showLockEvents: Bool
) -> TranscriptItem {
func childrenFor(_ group: ToolGroup) -> ToolGroup {
guard group.isOrchestration else { return group }
var g = group
g.children = project(byParent[group.toolCallID] ?? [], byParent: byParent, depth: depth + 1,
showRaw: showRaw, showLockEvents: showLockEvents)
return g
}
switch item.kind {
case .tool(let group):
guard group.isOrchestration else { return item }
return TranscriptItem(id: item.id, seq: item.seq, kind: .tool(childrenFor(group)))
case .toolBlock(let groups):
return TranscriptItem(id: item.id, seq: item.seq, kind: .toolBlock(groups.map(childrenFor)))
default:
return item
}
}
// MARK: - Tool-run coalescing
/// Collapses maximal runs of adjacent `.tool` items (length ≥2) into one `.toolBlock`.
///
/// Items that draw nothing (empty `.thinking` blocks — see `rendersNothing`) never break a
/// run: an invisible row landing between two tool calls must not split them into two cards
/// with a gap. Such items are held aside and re-emitted right after the block, so they still
/// render in place while the calls stay one contiguous block. Mirrors the desktop projection.
static func coalesceToolRuns(_ flat: [TranscriptItem]) -> [TranscriptItem] {
var out: [TranscriptItem] = []
var run: [TranscriptItem] = [] // consecutive `.tool` items
var held: [TranscriptItem] = [] // non-rendering rows seen mid-run, held so they don't split it
func flush() {
if run.count == 1 {
out.append(run[0])
} else if run.count >= 2 {
let groups = run.compactMap { item -> ToolGroup? in
if case .tool(let g) = item.kind { return g } else { return nil }
}
out.append(TranscriptItem(id: "toolblock-\(groups[0].toolCallID)",
seq: run[0].seq, kind: .toolBlock(groups)))
}
run.removeAll(keepingCapacity: true)
out.append(contentsOf: held)
held.removeAll(keepingCapacity: true)
}
for item in flat {
if case .tool = item.kind {
run.append(item)
} else if item.rendersNothing, !run.isEmpty {
held.append(item)
} else {
flush()
out.append(item)
}
}
flush()
return out
}
// MARK: - Flat projection (one scope)
/// Fold one scope's raw events into interleaved, display-ready rows: streaming assistant /
/// thinking deltas accumulate into one growing row, and a tool call shows the moment it starts
/// (`toolCallStarted`), with its full input/result/file-changes filled in as they arrive.
private static func flatItems(
_ events: [AgentEvent], showRaw: Bool, showLockEvents: Bool
) -> [TranscriptItem] {
var items: [TranscriptItem] = []
var messageIndex: [String: Int] = [:] // messageID → items index (text coalescing)
var thinkingIndex: [String: Int] = [:]
@@ -145,11 +325,11 @@ enum TranscriptProjection {
group.name = call.name
if !call.input.isEmptyValue { group.input = call.input }
group.finished = group.finished || finished
items[i].kind = wrap(group)
items[i].kind = .tool(group)
} else {
index[call.toolCallID] = items.count
let group = ToolGroup(toolCallID: call.toolCallID, name: call.name, input: call.input, finished: finished)
items.append(.init(id: "tool-\(call.toolCallID)", seq: seq, kind: wrap(group)))
items.append(.init(id: "tool-\(call.toolCallID)", seq: seq, kind: .tool(group)))
}
}
@@ -160,7 +340,7 @@ enum TranscriptProjection {
group.result = result.content
group.isError = result.isError
group.finished = true
items[i].kind = wrap(group)
items[i].kind = .tool(group)
}
private static func attachFileChange(
@@ -168,22 +348,16 @@ enum TranscriptProjection {
) {
if let id = change.toolCallID, let i = index[id], var group = currentGroup(items[i]) {
group.fileChanges.append(.init(path: change.path, change: change.kind))
items[i].kind = wrap(group)
items[i].kind = .tool(group)
}
// Untagged file changes are folded into the diff stat, not the transcript.
}
/// Extract a tool group from either the plain or orchestration kind.
/// Extract a tool group from a `.tool` item (the only kind `flatItems` produces for a call;
/// blocks/children are formed later, after coalescing).
private static func currentGroup(_ item: TranscriptItem) -> ToolGroup? {
switch item.kind {
case .tool(let g), .orchestration(let g): return g
default: return nil
}
}
/// Wrap a group in the right kind — orchestration spawns get the gold card.
private static func wrap(_ group: ToolGroup) -> TranscriptItem.Kind {
group.isOrchestration ? .orchestration(group) : .tool(group)
if case .tool(let g) = item.kind { return g }
return nil
}
}
@@ -15,9 +15,21 @@ struct TranscriptRow: View {
case .thinking(let text):
ThinkingRow(text: text)
case .tool(let group):
ToolGroupRow(group: group)
case .orchestration(let group):
OrchestrationCard(group: group)
// A lone subagent spawn reads as its own gold subagent card (type, task, status, and
// its nested activity on expand); every other tool is a plain collapsible card.
if group.isOrchestration {
SubagentCard(group: group)
} else {
ToolCallCard(group: group)
}
case .toolBlock(let groups):
// A run made entirely of subagent spawns is a multi-agent fan-out — the gold
// orchestration card; any other run is one contiguous tool block.
if groups.allSatisfy(\.isOrchestration) {
OrchestrationCard(groups: groups)
} else {
ToolBlockCard(groups: groups)
}
case .sessionStarted(let model, let cwd):
InfoLine(icon: "play.circle", text: "Session started · \(modelLabel(model))", detail: cwd)
case .usage(let usage):