|
| 1 | +"use client"; |
| 2 | + |
| 3 | +import React from "react"; |
| 4 | +import Link from "next/link"; |
| 5 | +import type { |
| 6 | + RunAnalysisPhaseUsage, |
| 7 | + RunAnalysisReport, |
| 8 | + RunAnalysisUsageSnapshot, |
| 9 | + RunStatus, |
| 10 | +} from "@shared/contracts"; |
| 11 | +import { CkCard, CkChip } from "@/components/ui"; |
| 12 | +import { PromptPreview } from "@/components/cockpit/prompt-library/prompt-preview"; |
| 13 | +import { runHref } from "@/lib/run-href"; |
| 14 | + |
| 15 | +function stageLabel(stage: RunAnalysisReport["stage"]): string { |
| 16 | + if (stage === "published") return "PR/MR published"; |
| 17 | + if (stage === "no_change") return "No change needed"; |
| 18 | + return "Research complete"; |
| 19 | +} |
| 20 | + |
| 21 | +function stageTone(stage: RunAnalysisReport["stage"]): "success" | "warn" | "mariner" { |
| 22 | + return stage === "published" ? "success" : stage === "no_change" ? "warn" : "mariner"; |
| 23 | +} |
| 24 | + |
| 25 | +function costLabel(snapshot: RunAnalysisUsageSnapshot | null): string { |
| 26 | + if (!snapshot) return "Not reached"; |
| 27 | + return `$${snapshot.costUsd.toFixed(2)}${snapshot.costKnown ? "" : "+"}`; |
| 28 | +} |
| 29 | + |
| 30 | +function phaseCostLabel(phase: RunAnalysisPhaseUsage): string { |
| 31 | + return phase.costUsd === null ? "Unknown" : `$${phase.costUsd.toFixed(2)}`; |
| 32 | +} |
| 33 | + |
| 34 | +function tokenLabel(tokens: RunAnalysisPhaseUsage["tokens"]): string { |
| 35 | + if (!tokens) return "Unknown"; |
| 36 | + return `${tokens.input} in / ${tokens.cachedInput} cached / ${tokens.output} out`; |
| 37 | +} |
| 38 | + |
| 39 | +function deliveryLabel(delivery: RunAnalysisReport["jira"]["research"]): string { |
| 40 | + if (delivery.state === "posted") return "Posted"; |
| 41 | + if (delivery.state === "failed") return delivery.error ? `Failed: ${delivery.error}` : "Failed"; |
| 42 | + if (delivery.state === "pending") return "Pending"; |
| 43 | + return "Not applicable"; |
| 44 | +} |
| 45 | + |
| 46 | +function DeliveryStatus({ |
| 47 | + label, |
| 48 | + delivery, |
| 49 | +}: { |
| 50 | + label: string; |
| 51 | + delivery: RunAnalysisReport["jira"]["research"]; |
| 52 | +}) { |
| 53 | + return ( |
| 54 | + <span> |
| 55 | + {label}: {delivery.commentUrl ? ( |
| 56 | + <a |
| 57 | + href={delivery.commentUrl} |
| 58 | + target="_blank" |
| 59 | + rel="noreferrer" |
| 60 | + className="text-mariner underline-offset-2 hover:underline" |
| 61 | + > |
| 62 | + {deliveryLabel(delivery)} ↗ |
| 63 | + </a> |
| 64 | + ) : deliveryLabel(delivery)} |
| 65 | + </span> |
| 66 | + ); |
| 67 | +} |
| 68 | + |
| 69 | +function Disclosure({ title, children, defaultOpen = false }: { title: string; children: React.ReactNode; defaultOpen?: boolean }) { |
| 70 | + const [open, setOpen] = React.useState(defaultOpen); |
| 71 | + return ( |
| 72 | + <div className="border-t border-neutral-200 pt-3"> |
| 73 | + <button |
| 74 | + type="button" |
| 75 | + aria-expanded={open} |
| 76 | + onClick={() => setOpen((value) => !value)} |
| 77 | + className="flex w-full items-center justify-between gap-3 border-0 bg-transparent p-0 text-left font-mono text-[11px] uppercase tracking-[0.04em] text-neutral-800" |
| 78 | + > |
| 79 | + <span>{title}</span> |
| 80 | + <span aria-hidden="true">{open ? "−" : "+"}</span> |
| 81 | + </button> |
| 82 | + {open && <div className="mt-3 min-w-0">{children}</div>} |
| 83 | + </div> |
| 84 | + ); |
| 85 | +} |
| 86 | + |
| 87 | +function UsageTable({ report }: { report: RunAnalysisReport }) { |
| 88 | + const rows: Array<[string, RunAnalysisUsageSnapshot | null]> = [ |
| 89 | + ["Research", report.usage.research], |
| 90 | + ["Publication", report.usage.publication], |
| 91 | + ["Final", report.usage.final], |
| 92 | + ]; |
| 93 | + return ( |
| 94 | + <div className="overflow-x-auto"> |
| 95 | + <table className="w-full min-w-[720px] border-collapse font-mono text-[11px]"> |
| 96 | + <thead><tr className="text-left text-neutral-500"><th className="pb-2 pr-3 font-normal">Snapshot / phase</th><th className="pb-2 pr-3 font-normal">Cost</th><th className="pb-2 pr-3 font-normal">Tokens</th><th className="pb-2 pr-3 font-normal">Model</th><th className="pb-2 font-normal">Duration / turns</th></tr></thead> |
| 97 | + <tbody> |
| 98 | + {rows.map(([name, snapshot]) => ( |
| 99 | + <React.Fragment key={name}> |
| 100 | + <tr className="border-t border-neutral-200 align-top"> |
| 101 | + <td className="py-2 pr-3 font-medium text-neutral-900">{name}</td> |
| 102 | + <td className="py-2 pr-3 text-neutral-800">{costLabel(snapshot)}</td> |
| 103 | + <td className="py-2 pr-3 text-neutral-700">{snapshot?.tokensInput === null || !snapshot ? "Unknown" : `${snapshot.tokensInput} in / ${snapshot.tokensCached ?? 0} cached / ${snapshot.tokensOutput ?? 0} out`}</td> |
| 104 | + <td className="py-2 pr-3 text-neutral-500">{snapshot ? `Captured ${new Date(snapshot.capturedAt).toLocaleString()}` : "—"}</td> |
| 105 | + <td className="py-2 text-neutral-500">—</td> |
| 106 | + </tr> |
| 107 | + {snapshot ? Object.entries(snapshot.phases).map(([phaseName, phase]) => ( |
| 108 | + <tr key={`${name}:${phaseName}`} className="border-t border-neutral-100 align-top"> |
| 109 | + <td className="py-2 pr-3 pl-3 text-neutral-700">↳ {phaseName}</td> |
| 110 | + <td className="py-2 pr-3 text-neutral-700">{phaseCostLabel(phase)}</td> |
| 111 | + <td className="py-2 pr-3 text-neutral-700">{tokenLabel(phase.tokens)}</td> |
| 112 | + <td className="py-2 pr-3 text-neutral-700">{phase.model ?? "Unknown"}</td> |
| 113 | + <td className="py-2 text-neutral-700">{phase.durationMs}ms / {phase.numTurns} turns</td> |
| 114 | + </tr> |
| 115 | + )) : null} |
| 116 | + </React.Fragment> |
| 117 | + ))} |
| 118 | + </tbody> |
| 119 | + </table> |
| 120 | + </div> |
| 121 | + ); |
| 122 | +} |
| 123 | + |
| 124 | +export function RunAnalysisReportCard({ report, runStatus: _runStatus, currentRunId }: { report: RunAnalysisReport | null; runStatus: RunStatus; currentRunId: string }) { |
| 125 | + if (!report) return null; |
| 126 | + const finalOrPublication = report.usage.final ?? report.usage.publication ?? report.usage.research; |
| 127 | + const sourceDiffers = report.sourceResearchRunId !== currentRunId; |
| 128 | + return ( |
| 129 | + <CkCard eyebrow="Run analysis" title="Analysis report" action={<CkChip tone={stageTone(report.stage)}>{stageLabel(report.stage)}</CkChip>}> |
| 130 | + <div className="flex min-w-0 flex-col gap-4 font-body text-[13px]"> |
| 131 | + <div className="flex flex-wrap items-center gap-x-4 gap-y-1 text-neutral-600"> |
| 132 | + <span>Captured {new Date(report.researchCompletedAt).toLocaleString()}</span> |
| 133 | + {sourceDiffers && <Link href={runHref({ id: report.sourceResearchRunId, ticket: "" })} className="text-mariner underline-offset-2 hover:underline">Source research run: {report.sourceResearchRunId}</Link>} |
| 134 | + </div> |
| 135 | + <div className="grid grid-cols-2 gap-2 md:grid-cols-4"> |
| 136 | + <Metric label="Repositories inspected" value={report.repositories.length} /> |
| 137 | + <Metric label="Evidence items" value={report.evidenceStatus === "not_retained" ? "—" : report.evidence.length} /> |
| 138 | + <Metric label="Expansion rounds" value={report.expansionRounds} /> |
| 139 | + <Metric label="Current cost" value={costLabel(finalOrPublication)} /> |
| 140 | + </div> |
| 141 | + {report.sanitization.truncated || Object.keys(report.sanitization.redactions).length > 0 ? ( |
| 142 | + <div role="status" className="rounded-[3px] border border-[#F0D9A8] bg-[#FFF9E8] px-3 py-2 text-[12px] text-[#6E5200]">Some report content was redacted or truncated for safety.</div> |
| 143 | + ) : null} |
| 144 | + {report.evidenceStatus === "not_retained" && <div className="rounded-[3px] border border-neutral-200 bg-app-bg px-3 py-2 text-[12px] text-neutral-700">Source evidence was not retained.</div>} |
| 145 | + <div className="min-w-0 overflow-hidden"> |
| 146 | + <h3 className="m-0 mb-2 font-mono text-[10px] uppercase tracking-[0.05em] text-neutral-500">Repositories analyzed</h3> |
| 147 | + <div className="flex min-w-0 flex-col gap-2"> |
| 148 | + {report.repositories.map((repo) => <div key={`${repo.provider}:${repo.repoPath}`} className="grid min-w-0 gap-1 rounded-[3px] border border-neutral-200 bg-off-white p-2 md:grid-cols-[1.3fr_.5fr_1fr_1.4fr] md:items-start"><span className="break-all font-mono text-[11px] text-neutral-900">{repo.provider}:{repo.repoPath}</span><span className="font-mono text-[10px] uppercase text-neutral-600">{repo.access}</span><span className="break-all font-mono text-[10px] text-neutral-600">{repo.researchBranch}@{repo.researchBaseSha ? repo.researchBaseSha.slice(0, 8) : "unknown SHA"}<span className="block text-neutral-500">base: {repo.defaultBranch}</span></span><span className="break-words text-[12px] text-neutral-700">{repo.rationale}</span></div>)} |
| 149 | + </div> |
| 150 | + </div> |
| 151 | + <Disclosure title="What was checked" defaultOpen={report.evidenceStatus === "captured"}> |
| 152 | + {report.evidenceStatus === "not_retained" ? <p className="m-0 text-neutral-600">Source evidence was not retained.</p> : report.evidence.length > 0 ? <ol className="m-0 flex list-decimal flex-col gap-1 pl-5 text-neutral-800">{report.evidence.map((item, index) => <li key={index} className="break-words">{item}</li>)}</ol> : <p className="m-0 text-neutral-600">No evidence items were captured.</p>} |
| 153 | + </Disclosure> |
| 154 | + <Disclosure title="Decisions"> |
| 155 | + <div className="flex flex-col gap-2 text-neutral-800"> |
| 156 | + <p className="m-0">Expansion rounds: {report.expansionRounds}</p> |
| 157 | + <DecisionList title="Requests" items={report.repositoryRequests} /> |
| 158 | + <DecisionList title="Write repositories" items={report.writeRepositories} /> |
| 159 | + {report.resolutionEvidence.length > 0 && <p className="m-0 break-words">Resolution evidence: {report.resolutionEvidence.join(" · ")}</p>} |
| 160 | + </div> |
| 161 | + </Disclosure> |
| 162 | + <Disclosure title="Implementation plan" defaultOpen> |
| 163 | + <div className="min-w-0 overflow-hidden"><PromptPreview body={report.planMarkdown || "No implementation plan was retained."} /></div> |
| 164 | + </Disclosure> |
| 165 | + <div className="border-t border-neutral-200 pt-3"><h3 className="m-0 mb-2 font-mono text-[10px] uppercase tracking-[0.05em] text-neutral-500">Usage</h3><UsageTable report={report} /></div> |
| 166 | + {report.publication && <div className="border-t border-neutral-200 pt-3"><h3 className="m-0 mb-2 font-mono text-[10px] uppercase tracking-[0.05em] text-neutral-500">Published</h3><div className="flex flex-col gap-2"><div className="flex flex-wrap gap-2">{report.publication.prs.map((pr) => <a key={`${pr.provider}:${pr.repoPath}:${pr.id}`} href={pr.url} target="_blank" rel="noreferrer" className="max-w-full break-all text-mariner underline-offset-2 hover:underline">{pr.provider}:{pr.repoPath} #{pr.id} ↗</a>)}</div><p className="m-0 whitespace-pre-wrap break-words text-neutral-800">{report.publication.changeSummary}</p></div></div>} |
| 167 | + <div className="border-t border-neutral-200 pt-3"><h3 className="m-0 mb-2 font-mono text-[10px] uppercase tracking-[0.05em] text-neutral-500">Jira delivery</h3><div className="grid gap-1 font-mono text-[11px] text-neutral-700 md:grid-cols-2"><DeliveryStatus label="Research" delivery={report.jira.research} /><DeliveryStatus label="PR/MR" delivery={report.jira.pullRequest} /></div>{(report.jira.research.state === "failed" || report.jira.pullRequest.state === "failed") && <p className="m-0 mt-2 text-[12px] text-fail-fg">Automatic retries exhausted; code delivery was not blocked.</p>}</div> |
| 168 | + </div> |
| 169 | + </CkCard> |
| 170 | + ); |
| 171 | +} |
| 172 | + |
| 173 | +function Metric({ label, value }: { label: string; value: React.ReactNode }) { |
| 174 | + return <div className="min-w-0 rounded-[3px] border border-neutral-200 bg-off-white px-2.5 py-2"><div className="font-mono text-[9px] uppercase tracking-[0.04em] text-neutral-500">{label}</div><div className="mt-1 break-words font-display text-lg text-neutral-900">{value}</div></div>; |
| 175 | +} |
| 176 | + |
| 177 | +function DecisionList({ |
| 178 | + title, |
| 179 | + items, |
| 180 | +}: { |
| 181 | + title: string; |
| 182 | + items: RunAnalysisReport["repositoryRequests"]; |
| 183 | +}) { |
| 184 | + if (items.length === 0) return <p className="m-0">{title}: none</p>; |
| 185 | + return ( |
| 186 | + <div> |
| 187 | + <p className="m-0">{title}:</p> |
| 188 | + <ul className="m-0 mt-1 flex list-disc flex-col gap-1 pl-5"> |
| 189 | + {items.map((item) => ( |
| 190 | + <li key={`${item.provider}:${item.repoPath}`} className="break-words"> |
| 191 | + <span className="font-mono">{item.provider}:{item.repoPath}</span> — {item.rationale} |
| 192 | + </li> |
| 193 | + ))} |
| 194 | + </ul> |
| 195 | + </div> |
| 196 | + ); |
| 197 | +} |
0 commit comments