diff --git a/app/src/components/AgentStrip.test.tsx b/app/src/components/AgentStrip.test.tsx new file mode 100644 index 0000000..fabccff --- /dev/null +++ b/app/src/components/AgentStrip.test.tsx @@ -0,0 +1,105 @@ +import { describe, it, expect, vi } from "vitest"; +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { AgentStrip } from "./AgentStrip"; +import { makeFinding } from "../test/fixtures"; + +describe("AgentStrip — presence", () => { + it("renders nothing when there is neither intent nor findings", () => { + const { container } = render( + , + ); + expect(container.firstChild).toBeNull(); + }); + + it("renders with intent only", () => { + render( + , + ); + expect(screen.getByText("AGENT'S READ")).toBeInTheDocument(); + expect( + screen.getByText("Streams the parser instead of buffering it."), + ).toBeInTheDocument(); + }); + + it("renders with findings only (no intent)", () => { + render( + , + ); + expect(screen.getByText("AGENT'S READ")).toBeInTheDocument(); + expect(screen.getByText("CRIT")).toBeInTheDocument(); + }); +}); + +describe("AgentStrip — intent line", () => { + it("strips markdown to plain text (never renders rich content)", () => { + render( + , + ); + expect(screen.getByText("Bold intent line")).toBeInTheDocument(); + }); +}); + +describe("AgentStrip — severity chips", () => { + it("shows a chip per non-zero severity, Info included", () => { + render( + , + ); + expect(screen.getByTitle("2 Critical findings")).toBeInTheDocument(); + expect(screen.getByTitle("1 Warning finding")).toBeInTheDocument(); + expect(screen.getByTitle("1 Info finding")).toBeInTheDocument(); + expect(screen.getByText("CRIT")).toBeInTheDocument(); + expect(screen.getByText("WARN")).toBeInTheDocument(); + expect(screen.getByText("INFO")).toBeInTheDocument(); + }); + + it("omits a severity with a zero count", () => { + render( + , + ); + expect(screen.getByText("WARN")).toBeInTheDocument(); + expect(screen.queryByText("CRIT")).not.toBeInTheDocument(); + expect(screen.queryByText("INFO")).not.toBeInTheDocument(); + }); +}); + +describe("AgentStrip — Briefing link", () => { + it("invokes onOpenBriefing when the Briefing link is clicked", async () => { + const user = userEvent.setup(); + const onOpenBriefing = vi.fn(); + render( + , + ); + await user.click(screen.getByRole("button", { name: /Briefing/ })); + expect(onOpenBriefing).toHaveBeenCalledTimes(1); + }); +}); diff --git a/app/src/components/AgentStrip.tsx b/app/src/components/AgentStrip.tsx new file mode 100644 index 0000000..6f3d2fa --- /dev/null +++ b/app/src/components/AgentStrip.tsx @@ -0,0 +1,100 @@ +/** + * The persistent agent strip for the Diff tab (mockup A2). + * + * A single always-visible, never-collapsible line directly under the workspace + * header carrying the agent's voice: a brand left rail, the `AGENT'S READ` + * eyebrow, one plain-text line of the distilled intent (markdown stripped, never + * rendered — a clamp of rich text looks broken), the live severity chips for the + * review's advisory findings, and a link back to the full Briefing tab. + * + * Props-driven with no store reads so PR 7's board preview rail can reuse it. + * Renders nothing when the review has neither an intent nor any findings, so a + * bare review carries no empty strip. + */ + +import type { ReviewFinding } from "../bindings/ReviewFinding"; +import { AgentMark } from "@/lib/agent-event"; +import { findingCounts, severityMeta } from "@/lib/finding-severity"; +import { previewText, stripMarkdown } from "@/lib/markdown-text"; +import { cn } from "@/lib/utils"; +import { ArrowUpRight } from "lucide-react"; + +interface AgentStripProps { + /** The agent's distilled read of what the PR does; null when the pre-pass has not run. */ + readonly intent: string | null; + /** Every advisory finding for the review; drives the live severity chips. */ + readonly findings: readonly ReviewFinding[]; + /** Switch the enclosing workspace to the Briefing tab. */ + readonly onOpenBriefing: () => void; +} + +/** + * The Diff tab's agent strip. Intent line + severity chips + a Briefing link, in + * the shared agent voice. Renders nothing without an intent or a finding. + */ +export function AgentStrip({ intent, findings, onOpenBriefing }: AgentStripProps) { + if (intent === null && findings.length === 0) return null; + + const counts = findingCounts(findings); + // Every non-zero severity shows on the strip (Info included, unlike the + // board cards), highest severity first. + const chips = [ + { count: counts.critical, meta: severityMeta("Critical") }, + { count: counts.warning, meta: severityMeta("Warning") }, + { count: counts.info, meta: severityMeta("Info") }, + ].filter((c) => c.count > 0); + + return ( +
+ + + + {"AGENT'S READ"} + + + + {intent !== null ? ( + + {previewText(intent)} + + ) : ( + // No intent yet — keep the flex spacer so the chips + link stay right-aligned. + + )} + + {chips.length > 0 && ( + + {chips.map((c) => ( + + + ))} + + )} + + +
+ ); +} diff --git a/app/src/components/DiffView.tsx b/app/src/components/DiffView.tsx index 75f5079..87dc1d7 100644 --- a/app/src/components/DiffView.tsx +++ b/app/src/components/DiffView.tsx @@ -31,11 +31,9 @@ import type { MirrorResult } from "../bindings/MirrorResult"; import type { Anchor } from "../bindings/Anchor"; import type { DiffSide } from "../bindings/DiffSide"; import type { CiSummary } from "../bindings/CiSummary"; -import type { CiCheck } from "../bindings/CiCheck"; import { summarizeChecks, ciState, parseCiUpdate } from "@/lib/ci"; import type { ReviewEvent } from "../bindings/ReviewEvent"; import type { SubmitReviewResult } from "../bindings/SubmitReviewResult"; -import type { EvidenceSummary } from "../bindings/EvidenceSummary"; import type { FilePair } from "../bindings/FilePair"; import type { ReviewFinding } from "../bindings/ReviewFinding"; import type { FindingSeverity } from "../bindings/FindingSeverity"; @@ -56,15 +54,10 @@ import { attachLspClient, type LspAttachment } from "@/lib/lsp-client"; import { Button } from "@/components/ui/button"; import { Badge } from "@/components/ui/badge"; import { GatePill } from "./GatePill"; -import { IntentPanel } from "./IntentPanel"; +import { AgentStrip } from "./AgentStrip"; import { ConversationBand } from "./ConversationBand"; import { AddressedRequests } from "./AddressedRequests"; -import { EvidenceStrip } from "./EvidenceStrip"; -import { - FindingsPanel, - findingsAutoExpand, - findingsBreakdown, -} from "./FindingsPanel"; +import { FindingsPanel, findingsAutoExpand } from "./FindingsPanel"; import { SubmitReviewControl } from "./SubmitReviewControl"; import { cn } from "@/lib/utils"; import { invoke } from "@tauri-apps/api/core"; @@ -110,6 +103,8 @@ interface DiffViewProps { readonly onMirrorComments: () => Promise; /** Switch the enclosing workspace to the canonical Agent tab. */ readonly onOpenAgent: () => void; + /** Switch the enclosing workspace to the Briefing tab (from the agent strip). */ + readonly onOpenBriefing: () => void; } // --------------------------------------------------------------------------- @@ -617,6 +612,7 @@ export function DiffView({ onRequestChanges, onMirrorComments, onOpenAgent, + onOpenBriefing, }: DiffViewProps) { // -- Agent C: editor theme from store -- const editorTheme = useAppStore((s) => s.editorTheme); @@ -635,11 +631,9 @@ export function DiffView({ const fetchConversation = useAppStore((s) => s.fetchConversation); const fetchTeammateInterdiff = useAppStore((s) => s.fetchTeammateInterdiff); - // -- Phase B store actions (evidence / pre-review / full-file) -- - const fetchEvidence = useAppStore((s) => s.fetchEvidence); + // -- Phase B store actions (pre-review / full-file) -- const preReview = useAppStore((s) => s.preReview); const fetchFilePair = useAppStore((s) => s.fetchFilePair); - const listCiChecks = useAppStore((s) => s.listCiChecks); // -- D10: authored interdiff (changes since the last review dispatch) -- // A dispatch snapshot survives the Reworked→InReview reopen (it is cleared @@ -679,12 +673,6 @@ export function DiffView({ // otherwise the full review diff. const activeDiffRaw = interdiffActive ? interdiff.raw : diff.raw; - // -- B1/B3: review-time evidence bundle (refetched on head change) -- - const [evidence, setEvidence] = useState(null); - // Raw CI checks (for the evidence strip's failing-job name), kept alongside - // the summarized badge; populated from the initial fetch and `ci-updated`. - const [ciChecks, setCiChecks] = useState([]); - // -- B2: advisory pre-pass reviewer + component-local finding dismissals -- const [preReviewing, setPreReviewing] = useState(false); const [dismissedFindings, setDismissedFindings] = useState< @@ -693,9 +681,9 @@ export function DiffView({ const [findingPortals, setFindingPortals] = useState< readonly FindingPortalEntry[] >([]); - // Findings-panel disclosure. Owned here so the evidence-strip findings chip - // can force it open; auto-expands on entry only when a Critical finding - // exists (see findingsAutoExpand). Set once per review below. + // Findings-panel disclosure. Auto-expands on entry only when a Critical + // finding exists (see findingsAutoExpand); otherwise the panel stays a + // visible-but-collapsed header the reviewer can open. Set once per review below. const [findingsOpen, setFindingsOpen] = useState(false); // -- B4: Hunks vs Full-file view + the resolved full-file pair -- @@ -756,11 +744,6 @@ export function DiffView({ const [githubSubmitResult, setGithubSubmitResult] = useState(null); - // -- D4: per-review Intent disclosure open state (component-only memory) -- - const [intentOpenByPr, setIntentOpenByPr] = useState< - Readonly> - >({}); - // -- D10: Addressed-requests panel open state (open by default on entry) -- const [addressedOpen, setAddressedOpen] = useState(true); @@ -795,13 +778,12 @@ export function DiffView({ useRef(null); const flashTimeoutRef = useRef | null>(null); const lspAttachmentRef = useRef(null); - // The findings-panel band, so the evidence-strip chip can scroll it into view. - const findingsPanelRef = useRef(null); // The Monaco editor container, so a jump can scroll the code into view after // switching files (the band stack above it is height-capped, not the page). const editorContainerRef = useRef(null); - // Pending jump-to-line request (from evidence-strip weakening chips), applied - // once the target file's editor + line map are ready. + // Pending jump-to-line request (from the findings panel, addressed-requests, + // or a cross-tab Briefing jump), applied once the target file's editor + line + // map are ready. const pendingJumpRef = useRef<{ readonly path: string; readonly side: DiffSide; @@ -859,13 +841,6 @@ export function DiffView({ [review.review_findings, selectedFile, dismissedFindings], ); - // Per-severity counts of every non-dismissed finding across all files, for the - // evidence-strip findings chip and the findings-panel header. - const findingsCounts = useMemo( - () => findingsBreakdown(review.review_findings, dismissedFindings), - [review.review_findings, dismissedFindings], - ); - // Per-file finding summary (excluding dismissed): highest severity + count, // for the file-tree severity dot and its tooltip. const findingsByFile = useMemo(() => { @@ -921,19 +896,9 @@ export function DiffView({ // -- Agent reason line (replaces the raw PID readout) -- const agentReason = useMemo(() => agentReasonLine(review), [review]); - // -- D4: PR intent header + collapsible disclosure -- + // -- PR title for the header identity zone. The PR intent (body + distilled + // read) now lives on the Briefing tab and the persistent agent strip. -- const headerTitle = review.title.trim() !== "" ? review.title : review.branch; - const showIntent = - review.title.trim() !== "" || - review.body.trim() !== "" || - review.distilled_intent !== null; - const intentOpen = intentOpenByPr[review.pr] ?? false; - const toggleIntent = useCallback(() => { - setIntentOpenByPr((prev) => ({ - ...prev, - [review.pr]: !(prev[review.pr] ?? false), - })); - }, [review.pr]); // -- D1: pair each dispatched request to the interdiff region that answers it. // Paired against the interdiff specifically (not the toggled diff source) so @@ -1584,17 +1549,11 @@ export function DiffView({ console.error("fetch_ci_checks failed", e); }); - // Raw checks (for the evidence strip's failing-job name). Best-effort. - void listCiChecks(prRef).then((checks) => { - if (!cancelled) setCiChecks(checks); - }); - // Live updates: the backend pushes the full checks list via `ci-updated`. const unlisten = listen("ci-updated", (event) => { const update = parseCiUpdate(event.payload); if (update === null || update.pr !== prRef) return; setCiSummary(summarizeChecks(update.checks)); - setCiChecks(update.checks); }); return () => { @@ -1603,19 +1562,12 @@ export function DiffView({ f(); }); }; - }, [prRef, listCiChecks]); + }, [prRef]); - // -- B1/B3: fetch the evidence bundle on entry and on each head change. -- + // Head SHA drives the full-file pair refetch below (a new head invalidates a + // resolved pair). The evidence bundle for the diff-gate bands moved to the + // Briefing tab, so it is no longer fetched here. const reviewHead = review.head_sha; - useEffect(() => { - let cancelled = false; - void fetchEvidence(reviewPr).then((ev) => { - if (!cancelled) setEvidence(ev); - }); - return () => { - cancelled = true; - }; - }, [reviewPr, reviewHead, fetchEvidence]); // -- B4: resolve the full-file pair when Full-file mode is active. The pair is // reset on file/mode switch so stale content never flashes; the store memoizes @@ -1643,7 +1595,7 @@ export function DiffView({ ]); // -- B3: apply a pending jump-to-line once the target file's editor + line - // map are ready (from evidence-strip weakening chips / findings / addressed). + // map are ready (from findings, addressed-requests, or Briefing jumps). // The reveal is deferred until Monaco has actually swapped in the target // file's content: on a file switch @monaco-editor/react updates the model in // its own effect, so revealing synchronously here would act on the previous @@ -1738,7 +1690,7 @@ export function DiffView({ // Cross-tab jump: consume a `pendingDiffJump` published by a sibling tab (e.g. // a Briefing finding's "Jump"), reusing the same reveal machinery as the - // in-tab findings/evidence jumps. Keyed on the request nonce so an identical + // in-tab findings/addressed jumps. Keyed on the request nonce so an identical // target still re-fires, and scoped to this review's PR. const pendingDiffJump = useAppStore((s) => s.pendingDiffJump); const consumedJumpNonceRef = useRef(-1); @@ -1767,12 +1719,6 @@ export function DiffView({ // eslint-disable-next-line react-hooks/exhaustive-deps }, [reviewPr]); - // -- Expand + scroll to the findings panel (from the evidence-strip chip). -- - const handleShowFindings = useCallback(() => { - setFindingsOpen(true); - findingsPanelRef.current?.scrollIntoView({ block: "nearest" }); - }, []); - const ciBadgeState = useMemo( () => (ciSummary !== null ? ciState(ciSummary) : "none"), [ciSummary], @@ -2351,7 +2297,18 @@ export function DiffView({ {/* ----------------------------------------------------------------- */} - {/* Band stack — Intent / Conversation / Evidence / Findings / … */} + {/* Persistent agent strip (A2) — always visible when the review has */} + {/* agent data; the one-line "agent's read" + live severity chips + a */} + {/* link to the full Briefing. The intent/evidence bands moved there. */} + {/* ----------------------------------------------------------------- */} + + + {/* ----------------------------------------------------------------- */} + {/* Band stack — Conversation / Findings / Addressed / Stack */} {/* ----------------------------------------------------------------- */} {/* Height-capped with its own scroll (rather than letting the whole */} {/* column scroll) because Monaco needs a bounded, non-zero flex height */} @@ -2360,23 +2317,6 @@ export function DiffView({ {/* Each band also self-bounds (see the components) so no single band */} {/* dominates the cap. Transient banners live outside this scroll. */}
- {/* --------------------------------------------------------------- */} - {/* Intent disclosure (D4) */} - {/* --------------------------------------------------------------- */} - {showIntent && ( - { - void openExternal(href); - }} - /> - )} - {/* ----------------------------------------------------------------- */} {/* GitHub conversation — read-only teammate context (E1) */} {/* ----------------------------------------------------------------- */} @@ -2391,38 +2331,25 @@ export function DiffView({ )} {/* ----------------------------------------------------------------- */} - {/* Evidence strip — deterministic review signals (B3) */} + {/* Pre-review findings — advisory triage layer (findings surfacing) */} {/* ----------------------------------------------------------------- */} - { + setFindingsOpen((prev) => !prev); + }} + onDismiss={(id) => { + setDismissedFindings((prev) => { + const next = new Set(prev); + next.add(id); + return next; + }); + }} onJumpTo={handleJumpTo} - findingsCount={findingsCounts} - onShowFindings={handleShowFindings} /> - {/* ----------------------------------------------------------------- */} - {/* Pre-review findings — advisory triage layer (findings surfacing) */} - {/* ----------------------------------------------------------------- */} -
- { - setFindingsOpen((prev) => !prev); - }} - onDismiss={(id) => { - setDismissedFindings((prev) => { - const next = new Set(prev); - next.add(id); - return next; - }); - }} - onJumpTo={handleJumpTo} - /> -
- {/* ----------------------------------------------------------------- */} {/* Addressed requests — read-only interdiff history (D10) */} {/* ----------------------------------------------------------------- */} diff --git a/app/src/components/EvidenceStrip.test.tsx b/app/src/components/EvidenceStrip.test.tsx deleted file mode 100644 index b8c2a5a..0000000 --- a/app/src/components/EvidenceStrip.test.tsx +++ /dev/null @@ -1,231 +0,0 @@ -import { describe, it, expect, vi } from "vitest"; -import { render, screen, fireEvent } from "@testing-library/react"; -import { - EvidenceStrip, - isVerificationCommand, - classifyAgentRan, -} from "./EvidenceStrip"; -import type { EvidenceSummary } from "../bindings/EvidenceSummary"; -import type { CiCheck } from "../bindings/CiCheck"; -import type { CommandRun } from "../bindings/CommandRun"; -import { makeCheck } from "../test/fixtures"; - -/** An evidence bundle exercising every chip kind. */ -function makeEvidence(overrides: Partial = {}): EvidenceSummary { - return { - signals: { - additions: 120, - deletions: 30, - files_changed: 4, - size_class: "M", - test_delta: { - test_files_changed: 1, - assertions_added: 2, - assertions_removed: 1, - }, - risk_paths: [{ flag: "Migration", path: "db/migrations/001_init.sql" }], - weakening: [ - { - kind: "DeletedAssertion", - path: "tests/foo.rs", - line: 3, - side: "Old", - excerpt: "assert_eq!(x, 42);", - }, - ], - }, - ci: { passed: 3, total: 4, failed: 1, pending: 0 }, - agent_ran: [{ command: "cargo test", ok: true }], - ...overrides, - }; -} - -describe("EvidenceStrip", () => { - it("renders nothing when evidence is null", () => { - const { container } = render(); - expect(container).toBeEmptyDOMElement(); - }); - - it("renders a chip for each evidence kind", () => { - const checks: CiCheck[] = [ - makeCheck({ name: "build", workflow: "Nightly", bucket: "fail" }), - ]; - render(); - - // CI rollup + the failing workflow name. - expect(screen.getByText("3/4")).toBeInTheDocument(); - expect(screen.getByText("CI")).toBeInTheDocument(); - expect(screen.getByText("Nightly")).toBeInTheDocument(); - // Size chip. - expect(screen.getByText("M")).toBeInTheDocument(); - expect(screen.getByText("4 files")).toBeInTheDocument(); - // Test delta. - expect(screen.getByText("Tests")).toBeInTheDocument(); - expect(screen.getByText("+2")).toBeInTheDocument(); - // Risk path. - expect(screen.getByText("Migration")).toBeInTheDocument(); - // Weakening flag. - expect(screen.getByText("Deleted assertion")).toBeInTheDocument(); - // Agent command. - expect(screen.getByText("cargo test")).toBeInTheDocument(); - }); - - it("omits the CI chip when the bundle has no CI", () => { - render(); - expect(screen.queryByText("CI")).not.toBeInTheDocument(); - // The size chip is still present. - expect(screen.getByText("M")).toBeInTheDocument(); - }); - - it("jumps to the offending hunk when a weakening chip is clicked", () => { - const onJumpTo = vi.fn(); - render(); - - fireEvent.click( - screen.getByRole("button", { name: /Deleted assertion/ }), - ); - expect(onJumpTo).toHaveBeenCalledWith("tests/foo.rs", "Old", 3); - }); - - it("shows no findings chip when the count is null or all-zero", () => { - const { rerender } = render( - , - ); - expect(screen.queryByText(/finding/)).not.toBeInTheDocument(); - - rerender( - , - ); - expect(screen.queryByText(/finding/)).not.toBeInTheDocument(); - }); - - it("shows a findings chip and expands the panel when clicked", () => { - const onShowFindings = vi.fn(); - render( - , - ); - const chip = screen.getByRole("button", { name: /3 findings/ }); - expect(chip).toBeInTheDocument(); - fireEvent.click(chip); - expect(onShowFindings).toHaveBeenCalledOnce(); - }); - - it("caps verification chips and folds the noisy tail into one count", () => { - const greps: CommandRun[] = Array.from({ length: 28 }, (_, i) => ({ - command: `grep -rn pattern-${String(i)} src/`, - ok: true, - })); - const evidence = makeEvidence({ - agent_ran: [ - { command: "cargo test", ok: true }, - { command: "cargo clippy --all-targets", ok: false }, - ...greps, - ], - }); - render(); - - // The two verification commands render as chips. - expect(screen.getByText("cargo test")).toBeInTheDocument(); - expect(screen.getByText("cargo clippy --all-targets")).toBeInTheDocument(); - // The 28 greps collapse into a single muted count chip. - expect(screen.getByText("28 other commands")).toBeInTheDocument(); - // No individual grep pill survives. - expect(screen.queryByText(/grep -rn pattern-0/)).not.toBeInTheDocument(); - }); - - it("shows only the muted count when no command is a verification step", () => { - const evidence = makeEvidence({ - agent_ran: [ - { command: "cat README.md", ok: true }, - { command: "ls -la", ok: true }, - ], - }); - render(); - expect(screen.getByText("2 other commands")).toBeInTheDocument(); - expect(screen.queryByText("cat README.md")).not.toBeInTheDocument(); - }); -}); - -describe("isVerificationCommand", () => { - it("recognizes test runners, builds, linters, and typecheckers", () => { - for (const cmd of [ - "cargo test", - "cargo nextest run", - "npm test", - "npm run test", - "pnpm test", - "yarn test", - "npx vitest run", - "vitest", - "jest --ci", - "pytest -q", - "go test ./...", - "cargo build --release", - "cargo check", - "npm run build", - "vite build", - "make", - "cargo clippy -- -D warnings", - "eslint .", - "biome check", - "ruff check .", - "flake8 src", - "golangci-lint run", - "tsc --noEmit", - "mypy .", - "pyright", - ]) { - expect(isVerificationCommand(cmd)).toBe(true); - } - }); - - it("does not flag exploration / navigation commands", () => { - for (const cmd of [ - "grep -rn foo src/", - "cat src/lib.rs", - "ls -la", - "cd crates/core", - "echo hello", - "git status", - "find . -name '*.rs'", - "rg TODO", - ]) { - expect(isVerificationCommand(cmd)).toBe(false); - } - }); -}); - -describe("classifyAgentRan", () => { - it("caps verification at six and folds the rest into the count", () => { - const runs: CommandRun[] = [ - ...Array.from({ length: 8 }, (_, i) => ({ - command: `cargo test case_${String(i)}`, - ok: true, - })), - { command: "grep foo", ok: true }, - { command: "cat bar", ok: true }, - ]; - const view = classifyAgentRan(runs); - // Six verification chips shown; the 2 overflow + 2 others fold in. - expect(view.verification).toHaveLength(6); - expect(view.otherCount).toBe(4); - }); - - it("lists at most ten commands in the tooltip", () => { - const runs: CommandRun[] = Array.from({ length: 15 }, (_, i) => ({ - command: `grep n-${String(i)}`, - ok: true, - })); - const view = classifyAgentRan(runs); - expect(view.verification).toHaveLength(0); - expect(view.otherCount).toBe(15); - expect(view.otherTooltip.split("\n")).toHaveLength(10); - }); -}); diff --git a/app/src/components/EvidenceStrip.tsx b/app/src/components/EvidenceStrip.tsx deleted file mode 100644 index b370870..0000000 --- a/app/src/components/EvidenceStrip.tsx +++ /dev/null @@ -1,519 +0,0 @@ -/** - * Evidence strip: one glanceable row of deterministic review signals rendered - * above the diff (B3). - * - * Surfaces the [`EvidenceSummary`] the backend assembles per review — CI rollup, - * test delta, diff size, risk paths, suspected test-weakening, and the commands - * the agent ran — as compact chips. Each chip carries a status dot AND a text - * label (never color alone) and uses tabular-nums mono for its telemetry. - * Weakening chips are actionable: clicking one jumps the diff to the offending - * hunk via {@link EvidenceStripProps.onJumpTo}. - * - * Renders nothing when `evidence` is null so a failed/absent evidence fetch - * degrades gracefully. - */ - -import { useLayoutEffect, useRef, useState } from "react"; -import type { EvidenceSummary } from "../bindings/EvidenceSummary"; -import type { CiSummary } from "../bindings/CiSummary"; -import type { CiCheck } from "../bindings/CiCheck"; -import type { CommandRun } from "../bindings/CommandRun"; -import type { RiskFlag } from "../bindings/RiskFlag"; -import type { SizeClass } from "../bindings/SizeClass"; -import type { WeakeningKind } from "../bindings/WeakeningKind"; -import type { DiffSide } from "../bindings/DiffSide"; -import type { FindingsBreakdown } from "./FindingsPanel"; -import { checkOutcome } from "@/lib/ci"; -import { cn } from "@/lib/utils"; -import { Gauge, ScanSearch, ShieldAlert, TriangleAlert } from "lucide-react"; - -// --------------------------------------------------------------------------- -// Props -// --------------------------------------------------------------------------- - -interface EvidenceStripProps { - /** The evidence bundle; when null the strip renders nothing. */ - readonly evidence: EvidenceSummary | null; - /** - * The raw CI checks DiffView already fetches, used only to name the failing - * workflow/job on the CI chip. The x/y count comes from `evidence.ci`. - */ - readonly ciChecks?: readonly CiCheck[]; - /** - * Jump the diff to a line, side-aware. Wired for weakening chips so a - * suspected weakening jumps straight to its hunk. - */ - readonly onJumpTo?: (path: string, side: DiffSide, line: number) => void; - /** - * Per-severity counts of the non-dismissed advisory findings. When the total - * is positive the strip shows a "N findings" chip toned by the highest - * severity present; null or all-zero renders no chip. - */ - readonly findingsCount?: FindingsBreakdown | null; - /** Expand + scroll to the findings panel; wired to the findings chip. */ - readonly onShowFindings?: (() => void) | undefined; -} - -// --------------------------------------------------------------------------- -// Helpers -// --------------------------------------------------------------------------- - -function assertNever(x: never): never { - throw new Error(`unreachable: ${String(x)}`); -} - -// --------------------------------------------------------------------------- -// Agent-command classification (chip diet) -// --------------------------------------------------------------------------- - -/** The most verification-command chips shown before the rest fold into a count. */ -const MAX_VERIFICATION_CHIPS = 6; - -/** How many command lines the "N other commands" tooltip enumerates. */ -const OTHER_TOOLTIP_LIMIT = 10; - -/** Collapsed height of the chip rows (~3 rows incl. padding) before "Show all". */ -const COLLAPSED_ROWS_MAX_HEIGHT = "6.5rem"; - -/** - * Patterns that mark a command as a *verification* step — a test run, build, - * lint, or typecheck. These are the commands worth surfacing per-command; the - * long tail (greps, cats, cds, …) folds into a single muted count so the strip - * is not drowned by trajectory noise. - */ -const VERIFICATION_PATTERNS: readonly RegExp[] = [ - // Test runners - /\bcargo\s+(?:test|nextest)\b/, - /\b(?:npm|pnpm|yarn|npx)\s+(?:run\s+)?test\b/, - /\b(?:npx\s+)?(?:vitest|jest)\b/, - /\bpytest\b/, - /\bgo\s+test\b/, - // Builds - /\bcargo\s+(?:build|check)\b/, - /\b(?:npm|pnpm|yarn)\s+run\s+build\b/, - /\bvite\s+build\b/, - /\bmake\b/, - // Linters - /\bcargo\s+clippy\b/, - /\bclippy\b/, - /\beslint\b/, - /\bbiome\b/, - /\bruff\b/, - /\bflake8\b/, - /\bgolangci(?:-lint)?\b/, - // Type checkers (tsc covers both build + --noEmit) - /\btsc\b/, - /\bmypy\b/, - /\bpyright\b/, -]; - -/** - * Whether a command line is a verification step (test / build / lint / - * typecheck). Pure and case-insensitive so it can be unit-tested directly. - */ -export function isVerificationCommand(command: string): boolean { - const normalized = command.toLowerCase(); - return VERIFICATION_PATTERNS.some((re) => re.test(normalized)); -} - -/** The capped verification chips plus the folded "other commands" tail. */ -export interface AgentRanView { - /** Verification runs to render as chips, capped at {@link MAX_VERIFICATION_CHIPS}. */ - readonly verification: readonly CommandRun[]; - /** How many commands folded into the muted count (overflow + non-verification). */ - readonly otherCount: number; - /** Newline-joined command lines for the muted chip's tooltip. */ - readonly otherTooltip: string; -} - -/** - * Split the agent's command runs into a capped list of verification chips and a - * single folded "other" count. Overflow verification commands beyond the cap - * fold into the "other" tail alongside every non-verification command. - */ -export function classifyAgentRan(runs: readonly CommandRun[]): AgentRanView { - const verification = runs.filter((r) => isVerificationCommand(r.command)); - const others = runs.filter((r) => !isVerificationCommand(r.command)); - const shown = verification.slice(0, MAX_VERIFICATION_CHIPS); - const folded = [...verification.slice(MAX_VERIFICATION_CHIPS), ...others]; - const otherTooltip = folded - .slice(0, OTHER_TOOLTIP_LIMIT) - .map((r) => r.command) - .join("\n"); - return { verification: shown, otherCount: folded.length, otherTooltip }; -} - -/** Semantic tone driving a chip's status-dot color. */ -type ChipTone = "pass" | "fail" | "pending" | "warning" | "neutral"; - -function toneDotClass(tone: ChipTone): string { - switch (tone) { - case "pass": - return "bg-success"; - case "fail": - return "bg-danger"; - case "pending": - return "bg-warning"; - case "warning": - return "bg-warning"; - case "neutral": - return "bg-muted-foreground"; - default: - return assertNever(tone); - } -} - -/** Human label for a diff size bucket. */ -function sizeLabel(size: SizeClass): string { - switch (size) { - case "S": - return "S"; - case "M": - return "M"; - case "L": - return "L"; - case "Xl": - return "XL"; - default: - return assertNever(size); - } -} - -/** Larger diffs read as more caution-worthy. */ -function sizeTone(size: SizeClass): ChipTone { - switch (size) { - case "S": - case "M": - return "neutral"; - case "L": - return "warning"; - case "Xl": - return "fail"; - default: - return assertNever(size); - } -} - -/** Human label for a risk-path flag. */ -function riskLabel(flag: RiskFlag): string { - switch (flag) { - case "Migration": - return "Migration"; - case "Lockfile": - return "Lockfile"; - case "CiConfig": - return "CI config"; - case "Auth": - return "Auth"; - case "GithubDir": - return ".github"; - case "Dependency": - return "Dependency"; - default: - return assertNever(flag); - } -} - -/** Human label for a suspected test-weakening. */ -function weakeningLabel(kind: WeakeningKind): string { - switch (kind) { - case "DeletedAssertion": - return "Deleted assertion"; - case "IgnoreAdded": - return "#[ignore] added"; - case "OrTrue": - return "|| true added"; - case "SkipOrTodo": - return "Skip / only"; - case "DeletedTestFn": - return "Deleted test fn"; - case "DeletedTestFile": - return "Deleted test file"; - case "SnapshotRewrite": - return "Snapshot rewrite"; - default: - return assertNever(kind); - } -} - -// --------------------------------------------------------------------------- -// Chip -// --------------------------------------------------------------------------- - -function Chip({ - tone, - title, - onClick, - children, -}: { - readonly tone: ChipTone; - readonly title?: string | undefined; - readonly onClick?: (() => void) | undefined; - readonly children: React.ReactNode; -}) { - const dot = ( -
+ {/* A leading danger dot marks a critical-findings escalation (the only + source of the danger tone); it reads as red-dot + red text. */} + {signal.tone === "danger" && ( +