From ab17d9d22901cbf5e75d32372e736640f5bf701a Mon Sep 17 00:00:00 2001 From: Nucleic Date: Sat, 27 Jun 2026 13:43:32 -0700 Subject: [PATCH] =?UTF-8?q?Promote=20nvrsion=20trunk=20=E2=86=92=20dev?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../xcshareddata/swiftpm/Package.resolved | 8 +- .../NucleicRemote/Views/Composer.swift | 2 + .../NucleicRemote/Views/HomeView.swift | 1 + .../NucleicRemote/Views/ProjectsView.swift | 2 + .../Views/SessionDetailView.swift | 4 + NucleicRemote/NucleicRemote/Views/Theme.swift | 24 ++ .../NucleicRemote/Views/TodosView.swift | 2 + .../Views/Transcript/ToolGroupRow.swift | 395 ++++++++++++++++-- .../Transcript/TranscriptProjection.swift | 210 +++++++++- .../NucleicRemote/Views/TranscriptRow.swift | 18 +- 10 files changed, 603 insertions(+), 63 deletions(-) diff --git a/NucleicRemote/NucleicRemote.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/NucleicRemote/NucleicRemote.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 5c4c675..78e347e 100644 --- a/NucleicRemote/NucleicRemote.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/NucleicRemote/NucleicRemote.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -96,8 +96,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-atomics.git", "state" : { - "revision" : "b601256eab081c0f92f059e12818ac1d4f178ff7", - "version" : "1.3.0" + "revision" : "0442cb5a3f98ab802acb777929fdb446bda11a34", + "version" : "1.3.1" } }, { @@ -231,8 +231,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-protobuf.git", "state" : { - "revision" : "f6506eaa86ed2e01cb0ae14a75035b7fdbf0918f", - "version" : "1.38.0" + "revision" : "55d7a1cc5666b85c13464aea1c4b4a90feccb4c8", + "version" : "1.38.1" } }, { diff --git a/NucleicRemote/NucleicRemote/Views/Composer.swift b/NucleicRemote/NucleicRemote/Views/Composer.swift index a065cde..a06edc2 100644 --- a/NucleicRemote/NucleicRemote/Views/Composer.swift +++ b/NucleicRemote/NucleicRemote/Views/Composer.swift @@ -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) } diff --git a/NucleicRemote/NucleicRemote/Views/HomeView.swift b/NucleicRemote/NucleicRemote/Views/HomeView.swift index 2259630..449e128 100644 --- a/NucleicRemote/NucleicRemote/Views/HomeView.swift +++ b/NucleicRemote/NucleicRemote/Views/HomeView.swift @@ -70,6 +70,7 @@ struct HomeView: View { } .navigationTitle("Home") .navigationBarTitleDisplayMode(.inline) + .scrollDismissesKeyboard(.interactively) .refreshable { store.refreshSessions() } .safeAreaInset(edge: .bottom) { ConnectionChip().padding(.bottom, 8) } } diff --git a/NucleicRemote/NucleicRemote/Views/ProjectsView.swift b/NucleicRemote/NucleicRemote/Views/ProjectsView.swift index 9949340..32290c1 100644 --- a/NucleicRemote/NucleicRemote/Views/ProjectsView.swift +++ b/NucleicRemote/NucleicRemote/Views/ProjectsView.swift @@ -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 diff --git a/NucleicRemote/NucleicRemote/Views/SessionDetailView.swift b/NucleicRemote/NucleicRemote/Views/SessionDetailView.swift index f65f8e8..c7ba332 100644 --- a/NucleicRemote/NucleicRemote/Views/SessionDetailView.swift +++ b/NucleicRemote/NucleicRemote/Views/SessionDetailView.swift @@ -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) { diff --git a/NucleicRemote/NucleicRemote/Views/Theme.swift b/NucleicRemote/NucleicRemote/Views/Theme.swift index 454e55c..0efe699 100644 --- a/NucleicRemote/NucleicRemote/Views/Theme.swift +++ b/NucleicRemote/NucleicRemote/Views/Theme.swift @@ -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 { diff --git a/NucleicRemote/NucleicRemote/Views/TodosView.swift b/NucleicRemote/NucleicRemote/Views/TodosView.swift index c2cecf9..2635025 100644 --- a/NucleicRemote/NucleicRemote/Views/TodosView.swift +++ b/NucleicRemote/NucleicRemote/Views/TodosView.swift @@ -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() } } } diff --git a/NucleicRemote/NucleicRemote/Views/Transcript/ToolGroupRow.swift b/NucleicRemote/NucleicRemote/Views/Transcript/ToolGroupRow.swift index 99e47ec..40ac069 100644 --- a/NucleicRemote/NucleicRemote/Views/Transcript/ToolGroupRow.swift +++ b/NucleicRemote/NucleicRemote/Views/Transcript/ToolGroupRow.swift @@ -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 { + 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" diff --git a/NucleicRemote/NucleicRemote/Views/Transcript/TranscriptProjection.swift b/NucleicRemote/NucleicRemote/Views/Transcript/TranscriptProjection.swift index 563bf7c..458ff7f 100644 --- a/NucleicRemote/NucleicRemote/Views/Transcript/TranscriptProjection.swift +++ b/NucleicRemote/NucleicRemote/Views/Transcript/TranscriptProjection.swift @@ -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 } } diff --git a/NucleicRemote/NucleicRemote/Views/TranscriptRow.swift b/NucleicRemote/NucleicRemote/Views/TranscriptRow.swift index e8bfeea..511e9a4 100644 --- a/NucleicRemote/NucleicRemote/Views/TranscriptRow.swift +++ b/NucleicRemote/NucleicRemote/Views/TranscriptRow.swift @@ -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):