Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
105 changes: 105 additions & 0 deletions app/src/components/AgentStrip.test.tsx
Original file line number Diff line number Diff line change
@@ -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(
<AgentStrip intent={null} findings={[]} onOpenBriefing={vi.fn()} />,
);
expect(container.firstChild).toBeNull();
});

it("renders with intent only", () => {
render(
<AgentStrip
intent="Streams the parser instead of buffering it."
findings={[]}
onOpenBriefing={vi.fn()}
/>,
);
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(
<AgentStrip
intent={null}
findings={[makeFinding({ id: "a", severity: "Critical" })]}
onOpenBriefing={vi.fn()}
/>,
);
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(
<AgentStrip
intent="**Bold** intent line"
findings={[]}
onOpenBriefing={vi.fn()}
/>,
);
expect(screen.getByText("Bold intent line")).toBeInTheDocument();
});
});

describe("AgentStrip — severity chips", () => {
it("shows a chip per non-zero severity, Info included", () => {
render(
<AgentStrip
intent="does things"
findings={[
makeFinding({ id: "c1", severity: "Critical" }),
makeFinding({ id: "c2", severity: "Critical" }),
makeFinding({ id: "w1", severity: "Warning" }),
makeFinding({ id: "i1", severity: "Info" }),
]}
onOpenBriefing={vi.fn()}
/>,
);
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(
<AgentStrip
intent="does things"
findings={[makeFinding({ id: "w1", severity: "Warning" })]}
onOpenBriefing={vi.fn()}
/>,
);
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(
<AgentStrip
intent="does things"
findings={[]}
onOpenBriefing={onOpenBriefing}
/>,
);
await user.click(screen.getByRole("button", { name: /Briefing/ }));
expect(onOpenBriefing).toHaveBeenCalledTimes(1);
});
});
100 changes: 100 additions & 0 deletions app/src/components/AgentStrip.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className="flex shrink-0 items-center gap-3 border-b border-border border-l-2 border-l-brand bg-brand/5 px-4 py-1.5">
<span className="inline-flex shrink-0 items-center gap-1.5">
<AgentMark className="h-3 w-3 text-brand" />
<span className="font-mono text-[10px] font-semibold uppercase tracking-wide text-brand">
{"AGENT'S READ"}
</span>
</span>

{intent !== null ? (
<span
className="min-w-0 flex-1 truncate text-xs text-muted-foreground"
title={stripMarkdown(intent)}
>
{previewText(intent)}
</span>
) : (
// No intent yet — keep the flex spacer so the chips + link stay right-aligned.
<span className="min-w-0 flex-1" />
)}

{chips.length > 0 && (
<span className="flex shrink-0 items-center gap-2.5 font-mono text-[10px] tabular-nums">
{chips.map((c) => (
<span
key={c.meta.short}
className="inline-flex items-center gap-1"
title={`${String(c.count)} ${c.meta.label} finding${
c.count === 1 ? "" : "s"
}`}
>
<span
className={cn("h-1.5 w-1.5 shrink-0 rounded-full", c.meta.dot)}
aria-hidden="true"
/>
<span className="text-foreground">{String(c.count)}</span>
<span className={c.meta.text}>{c.meta.short}</span>
</span>
))}
</span>
)}

<button
type="button"
onClick={onOpenBriefing}
className="inline-flex shrink-0 cursor-pointer items-center gap-0.5 border-none bg-transparent p-0 font-mono text-[10px] font-semibold uppercase tracking-wide text-brand hover:underline"
title="Open the Briefing tab"
>
Briefing
<ArrowUpRight className="h-3 w-3" />
</button>
</div>
);
}
Loading
Loading