Files
nucleic/Sources/NucleicApp/Panels/PlanPanel.swift
T

66 lines
2.6 KiB
Swift

import NucleicCore
import SwiftUI
/// A dedicated reading surface for the latest plan in the open chat. It follows the live pending
/// approval first, then falls back to the newest plan tool call in the transcript, so the plan
/// remains available after Accept / Revise / Deny removes the composer popup.
struct PlanPanel: View {
@Environment(AppStore.self) private var store
let session: Session?
private var plan: PlanReview.Plan? {
guard session?.id == store.openSessionID else { return nil }
return Self.latestPlan(events: store.openTranscript, approvals: store.openApprovals)
}
var body: some View {
Group {
if let plan {
ScrollView {
VStack(alignment: .leading, spacing: 14) {
MarkdownText(markdown: plan.markdown, bodySize: 13)
.textSelection(.enabled)
.frame(maxWidth: .infinity, alignment: .leading)
if let filePath = plan.filePath {
Label(filePath, systemImage: "doc.text")
.font(.caption.monospaced())
.foregroundStyle(.secondary)
.textSelection(.enabled)
}
}
.padding(16)
.frame(maxWidth: .infinity, alignment: .leading)
}
} else {
ContentUnavailableView(
"No Plan Yet",
systemImage: "list.bullet.clipboard",
description: Text(
"When an agent presents a plan for review, it will remain available here."))
}
}
.background(DetailSurface(layer: .inner))
}
/// Pure selection helper kept internal for the pane header and focused tests.
static func latestPlan(
events: [AgentEvent], approvals: [ApprovalRequest]
) -> PlanReview.Plan? {
if let current = approvals.first(where: { PlanReview.isPlanTool($0.toolName) }),
let plan = PlanReview.plan(from: current.input) {
return plan
}
for event in events.reversed() {
let call: ToolCall? = switch event.kind {
case .toolCallStarted(let call), .toolCallCompleted(let call): call
default: nil
}
if let call, PlanReview.isPlanTool(call.name),
let plan = PlanReview.plan(from: call.input) {
return plan
}
}
return nil
}
}