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"