"use client";
import * as React from "react";
import { CheckIcon, CodeBracketIcon, SparklesIcon, XMarkIcon } from "@heroicons-animated/react";
import { cn } from "@/lib/utils";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { DiffHeader, DiffHunkView, DiffStats, DiffView } from "@/components/pandacoderz-ui/diff-view";
import { Message, MessageAvatar, MessageContent, MessageMarkdown, MessageStack } from "@/components/pandacoderz-ui/message";
import { computeDiff, diffStats, type DiffHunk } from "@/lib/diff";
type FileChange = { path: string; before: string; after: string; note: string };
const files: FileChange[] = [
{
path: "src/components/prompt-input.tsx",
note: "Reads the live textarea value so Enter never submits a stale prop.",
before: `function handleKeyDown(e: React.KeyboardEvent<HTMLTextAreaElement>) {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
onSubmit?.(value);
}
}
function focusTextarea() {
textareaRef.current?.focus();
}`,
after: `function handleKeyDown(e: React.KeyboardEvent<HTMLTextAreaElement>) {
if (e.key === "Enter" && !e.shiftKey && !e.nativeEvent.isComposing) {
e.preventDefault();
const next = e.currentTarget.value.trim();
if (next) onSubmit?.(next);
}
}
function focusTextarea() {
textareaRef.current?.focus();
}`,
},
{
path: "src/components/prompt-input.test.tsx",
note: "Covers the IME composition case that caused the flake.",
before: `it("submits on Enter", async () => {
const onSubmit = vi.fn();
render(<PromptInput onSubmit={onSubmit} />);
await user.type(screen.getByRole("textbox"), "hello{Enter}");
expect(onSubmit).toHaveBeenCalledWith("hello");
});`,
after: `it("submits on Enter", async () => {
const onSubmit = vi.fn();
render(<PromptInput onSubmit={onSubmit} />);
await user.type(screen.getByRole("textbox"), "hello{Enter}");
expect(onSubmit).toHaveBeenCalledWith("hello");
});
it("does not submit while composing", async () => {
const onSubmit = vi.fn();
render(<PromptInput onSubmit={onSubmit} />);
const box = screen.getByRole("textbox");
fireEvent.keyDown(box, { key: "Enter", isComposing: true });
expect(onSubmit).not.toHaveBeenCalled();
});`,
},
{
path: "CHANGELOG.md",
note: "Release note.",
before: `## Unreleased
- Add Suggestions panel`,
after: `## Unreleased
- Add Suggestions panel
- Fix Enter submitting a stale value in PromptInput; ignore IME composition`,
},
];
const summary = `I traced the flake to \`handleKeyDown\` reading the \`value\` prop, which lags one render behind the textarea during fast typing. The fix reads \`e.currentTarget.value\` and also skips Enter while an IME composition is active, which was a separate source of duplicate submits on CJK keyboards.
**Risk:** low. Behaviour only changes on Enter. **Tests:** 13 passing, 1 new.`;
type Decision = "accepted" | "rejected";
export type CodeReviewProps = { className?: string; title?: string; branch?: string };
export default function CodeReview({ className, title = "Fix Enter submitting a stale value", branch = "fix/enter-key → main" }: CodeReviewProps) {
const diffs = React.useMemo(() => files.map((f) => ({ ...f, hunks: computeDiff(f.before, f.after, 2) })), []);
const [selected, setSelected] = React.useState(0);
const [decisions, setDecisions] = React.useState<Record<string, Decision>>({});
const [applied, setApplied] = React.useState(false);
const key = (fileIdx: number, hunk: DiffHunk) => `${fileIdx}:${hunk.id}`;
const decide = (k: string, d: Decision) => setDecisions((prev) => ({ ...prev, [k]: d }));
const decideAll = (d: Decision) => setDecisions(Object.fromEntries(diffs.flatMap((f, fi) => f.hunks.map((h) => [key(fi, h), d]))));
const total = diffs.reduce((n, f) => n + f.hunks.length, 0);
const accepted = Object.values(decisions).filter((d) => d === "accepted").length;
const rejected = Object.values(decisions).filter((d) => d === "rejected").length;
const totals = diffs.reduce((acc, f) => { const s = diffStats(f.hunks); return { additions: acc.additions + s.additions, deletions: acc.deletions + s.deletions }; }, { additions: 0, deletions: 0 });
const file = diffs[selected];
const fileStats = diffStats(file.hunks);
return (
<div data-slot="code-review" className={cn("flex h-full min-h-0 w-full flex-col overflow-hidden rounded-3xl border bg-background shadow-xs", className)}>
<header className="flex shrink-0 flex-wrap items-center gap-3 border-b px-4 py-3">
<div className="flex min-w-0 flex-1 flex-col">
<div className="flex items-center gap-2">
<span className="truncate text-sm font-medium">{title}</span>
<Badge variant="outline" className="font-mono text-[10px]">{branch}</Badge>
</div>
<span className="flex items-center gap-2 text-xs text-muted-foreground">{diffs.length} files · <DiffStats additions={totals.additions} deletions={totals.deletions} /> · {accepted}/{total} hunks accepted</span>
</div>
<div className="flex items-center gap-1">
<Button size="sm" variant="ghost" className="rounded-full" onClick={() => decideAll("rejected")}>Reject all</Button>
<Button size="sm" variant="outline" className="rounded-full" onClick={() => decideAll("accepted")}>Accept all</Button>
<Button size="sm" className="rounded-full" disabled={accepted === 0 || applied} onClick={() => setApplied(true)}>
{applied ? <><CheckIcon size={14} className="flex" /> Applied</> : `Apply ${accepted} hunk${accepted === 1 ? "" : "s"}`}
</Button>
</div>
</header>
<div className="grid min-h-0 flex-1 md:grid-cols-[minmax(0,16rem)_minmax(0,1fr)]">
<aside className="flex min-h-0 flex-col border-b bg-surface md:border-r md:border-b-0">
<div className="flex h-9 shrink-0 items-center px-4 text-[11px] font-medium uppercase tracking-wide text-muted-foreground">Files changed</div>
<ul className="min-h-0 flex-1 overflow-y-auto px-2 pb-2">
{diffs.map((f, i) => {
const s = diffStats(f.hunks);
const fileDecisions = f.hunks.map((h) => decisions[key(i, h)]);
const allDone = fileDecisions.every(Boolean);
return (
<li key={f.path}>
<button type="button" onClick={() => setSelected(i)} className={cn("flex h-9 w-full items-center gap-2 rounded-lg px-2 text-left text-xs transition-colors", selected === i ? "bg-brand-soft/60 text-brand dark:bg-brand-soft/50" : "hover:bg-accent")}>
<CodeBracketIcon size={12} className="flex shrink-0 opacity-60" />
<span className="min-w-0 flex-1 truncate font-mono">{f.path.split("/").pop()}</span>
{allDone ? <CheckIcon size={12} className="flex shrink-0 text-emerald-600 dark:text-emerald-400" /> : <DiffStats additions={s.additions} deletions={s.deletions} className="text-[10px]" />}
</button>
</li>
);
})}
</ul>
</aside>
<div className="min-h-0 overflow-y-auto p-4">
<Message from="assistant" className="mb-4 max-w-none">
<MessageAvatar fallback={<SparklesIcon size={14} className="flex" />} />
<MessageStack>
<MessageContent><MessageMarkdown>{summary}</MessageMarkdown></MessageContent>
</MessageStack>
</Message>
<p className="mb-2 px-1 text-xs text-muted-foreground">{file.note}</p>
<DiffView>
<DiffHeader path={file.path} additions={fileStats.additions} deletions={fileStats.deletions}>
<Button variant="ghost" size="xs" onClick={() => file.hunks.forEach((h) => decide(key(selected, h), "accepted"))}>Accept file</Button>
</DiffHeader>
{file.hunks.map((hunk) => {
const k = key(selected, hunk);
return (
<DiffHunkView
key={hunk.id}
hunk={hunk}
decision={decisions[k]}
actions={
<>
<Button variant="ghost" size="xs" className="text-red-600 dark:text-red-400" onClick={() => decide(k, "rejected")}><XMarkIcon size={12} className="flex" /> Reject</Button>
<Button variant="ghost" size="xs" className="text-emerald-600 dark:text-emerald-400" onClick={() => decide(k, "accepted")}><CheckIcon size={12} className="flex" /> Accept</Button>
</>
}
/>
);
})}
</DiffView>
{rejected > 0 ? <p className="mt-3 px-1 text-xs text-muted-foreground">{rejected} hunk{rejected === 1 ? "" : "s"} rejected. The agent will see your decisions on the next turn.</p> : null}
</div>
</div>
</div>
);
}What’s inside
- Diff View renders each file’s hunks with accept and reject actions and a decision badge afterwards.
- Message shows the agent’s summary above the diff.
- File list tracks which files are fully decided and the header applies accepted hunks.
Installation
npx shadcn@latest add https://ui.spencerwueste.com/r/code-review.jsonpnpm dlx shadcn@latest add https://ui.spencerwueste.com/r/code-review.jsonyarn dlx shadcn@latest add https://ui.spencerwueste.com/r/code-review.jsonbunx --bun shadcn@latest add https://ui.spencerwueste.com/r/code-review.jsonInstall the dependencies:
npm install @heroicons-animated/react motionAdd the shadcn primitives it builds on:
npx shadcn@latest add badge buttonCopy the source into your project:
"use client";
import * as React from "react";
import { CheckIcon, CodeBracketIcon, SparklesIcon, XMarkIcon } from "@heroicons-animated/react";
import { cn } from "@/lib/utils";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { DiffHeader, DiffHunkView, DiffStats, DiffView } from "@/components/pandacoderz-ui/diff-view";
import { Message, MessageAvatar, MessageContent, MessageMarkdown, MessageStack } from "@/components/pandacoderz-ui/message";
import { computeDiff, diffStats, type DiffHunk } from "@/lib/diff";
type FileChange = { path: string; before: string; after: string; note: string };
const files: FileChange[] = [
{
path: "src/components/prompt-input.tsx",
note: "Reads the live textarea value so Enter never submits a stale prop.",
before: `function handleKeyDown(e: React.KeyboardEvent<HTMLTextAreaElement>) {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
onSubmit?.(value);
}
}
function focusTextarea() {
textareaRef.current?.focus();
}`,
after: `function handleKeyDown(e: React.KeyboardEvent<HTMLTextAreaElement>) {
if (e.key === "Enter" && !e.shiftKey && !e.nativeEvent.isComposing) {
e.preventDefault();
const next = e.currentTarget.value.trim();
if (next) onSubmit?.(next);
}
}
function focusTextarea() {
textareaRef.current?.focus();
}`,
},
{
path: "src/components/prompt-input.test.tsx",
note: "Covers the IME composition case that caused the flake.",
before: `it("submits on Enter", async () => {
const onSubmit = vi.fn();
render(<PromptInput onSubmit={onSubmit} />);
await user.type(screen.getByRole("textbox"), "hello{Enter}");
expect(onSubmit).toHaveBeenCalledWith("hello");
});`,
after: `it("submits on Enter", async () => {
const onSubmit = vi.fn();
render(<PromptInput onSubmit={onSubmit} />);
await user.type(screen.getByRole("textbox"), "hello{Enter}");
expect(onSubmit).toHaveBeenCalledWith("hello");
});
it("does not submit while composing", async () => {
const onSubmit = vi.fn();
render(<PromptInput onSubmit={onSubmit} />);
const box = screen.getByRole("textbox");
fireEvent.keyDown(box, { key: "Enter", isComposing: true });
expect(onSubmit).not.toHaveBeenCalled();
});`,
},
{
path: "CHANGELOG.md",
note: "Release note.",
before: `## Unreleased
- Add Suggestions panel`,
after: `## Unreleased
- Add Suggestions panel
- Fix Enter submitting a stale value in PromptInput; ignore IME composition`,
},
];
const summary = `I traced the flake to \`handleKeyDown\` reading the \`value\` prop, which lags one render behind the textarea during fast typing. The fix reads \`e.currentTarget.value\` and also skips Enter while an IME composition is active, which was a separate source of duplicate submits on CJK keyboards.
**Risk:** low. Behaviour only changes on Enter. **Tests:** 13 passing, 1 new.`;
type Decision = "accepted" | "rejected";
export type CodeReviewProps = { className?: string; title?: string; branch?: string };
export default function CodeReview({ className, title = "Fix Enter submitting a stale value", branch = "fix/enter-key → main" }: CodeReviewProps) {
const diffs = React.useMemo(() => files.map((f) => ({ ...f, hunks: computeDiff(f.before, f.after, 2) })), []);
const [selected, setSelected] = React.useState(0);
const [decisions, setDecisions] = React.useState<Record<string, Decision>>({});
const [applied, setApplied] = React.useState(false);
const key = (fileIdx: number, hunk: DiffHunk) => `${fileIdx}:${hunk.id}`;
const decide = (k: string, d: Decision) => setDecisions((prev) => ({ ...prev, [k]: d }));
const decideAll = (d: Decision) => setDecisions(Object.fromEntries(diffs.flatMap((f, fi) => f.hunks.map((h) => [key(fi, h), d]))));
const total = diffs.reduce((n, f) => n + f.hunks.length, 0);
const accepted = Object.values(decisions).filter((d) => d === "accepted").length;
const rejected = Object.values(decisions).filter((d) => d === "rejected").length;
const totals = diffs.reduce((acc, f) => { const s = diffStats(f.hunks); return { additions: acc.additions + s.additions, deletions: acc.deletions + s.deletions }; }, { additions: 0, deletions: 0 });
const file = diffs[selected];
const fileStats = diffStats(file.hunks);
return (
<div data-slot="code-review" className={cn("flex h-full min-h-0 w-full flex-col overflow-hidden rounded-3xl border bg-background shadow-xs", className)}>
<header className="flex shrink-0 flex-wrap items-center gap-3 border-b px-4 py-3">
<div className="flex min-w-0 flex-1 flex-col">
<div className="flex items-center gap-2">
<span className="truncate text-sm font-medium">{title}</span>
<Badge variant="outline" className="font-mono text-[10px]">{branch}</Badge>
</div>
<span className="flex items-center gap-2 text-xs text-muted-foreground">{diffs.length} files · <DiffStats additions={totals.additions} deletions={totals.deletions} /> · {accepted}/{total} hunks accepted</span>
</div>
<div className="flex items-center gap-1">
<Button size="sm" variant="ghost" className="rounded-full" onClick={() => decideAll("rejected")}>Reject all</Button>
<Button size="sm" variant="outline" className="rounded-full" onClick={() => decideAll("accepted")}>Accept all</Button>
<Button size="sm" className="rounded-full" disabled={accepted === 0 || applied} onClick={() => setApplied(true)}>
{applied ? <><CheckIcon size={14} className="flex" /> Applied</> : `Apply ${accepted} hunk${accepted === 1 ? "" : "s"}`}
</Button>
</div>
</header>
<div className="grid min-h-0 flex-1 md:grid-cols-[minmax(0,16rem)_minmax(0,1fr)]">
<aside className="flex min-h-0 flex-col border-b bg-surface md:border-r md:border-b-0">
<div className="flex h-9 shrink-0 items-center px-4 text-[11px] font-medium uppercase tracking-wide text-muted-foreground">Files changed</div>
<ul className="min-h-0 flex-1 overflow-y-auto px-2 pb-2">
{diffs.map((f, i) => {
const s = diffStats(f.hunks);
const fileDecisions = f.hunks.map((h) => decisions[key(i, h)]);
const allDone = fileDecisions.every(Boolean);
return (
<li key={f.path}>
<button type="button" onClick={() => setSelected(i)} className={cn("flex h-9 w-full items-center gap-2 rounded-lg px-2 text-left text-xs transition-colors", selected === i ? "bg-brand-soft/60 text-brand dark:bg-brand-soft/50" : "hover:bg-accent")}>
<CodeBracketIcon size={12} className="flex shrink-0 opacity-60" />
<span className="min-w-0 flex-1 truncate font-mono">{f.path.split("/").pop()}</span>
{allDone ? <CheckIcon size={12} className="flex shrink-0 text-emerald-600 dark:text-emerald-400" /> : <DiffStats additions={s.additions} deletions={s.deletions} className="text-[10px]" />}
</button>
</li>
);
})}
</ul>
</aside>
<div className="min-h-0 overflow-y-auto p-4">
<Message from="assistant" className="mb-4 max-w-none">
<MessageAvatar fallback={<SparklesIcon size={14} className="flex" />} />
<MessageStack>
<MessageContent><MessageMarkdown>{summary}</MessageMarkdown></MessageContent>
</MessageStack>
</Message>
<p className="mb-2 px-1 text-xs text-muted-foreground">{file.note}</p>
<DiffView>
<DiffHeader path={file.path} additions={fileStats.additions} deletions={fileStats.deletions}>
<Button variant="ghost" size="xs" onClick={() => file.hunks.forEach((h) => decide(key(selected, h), "accepted"))}>Accept file</Button>
</DiffHeader>
{file.hunks.map((hunk) => {
const k = key(selected, hunk);
return (
<DiffHunkView
key={hunk.id}
hunk={hunk}
decision={decisions[k]}
actions={
<>
<Button variant="ghost" size="xs" className="text-red-600 dark:text-red-400" onClick={() => decide(k, "rejected")}><XMarkIcon size={12} className="flex" /> Reject</Button>
<Button variant="ghost" size="xs" className="text-emerald-600 dark:text-emerald-400" onClick={() => decide(k, "accepted")}><CheckIcon size={12} className="flex" /> Accept</Button>
</>
}
/>
);
})}
</DiffView>
{rejected > 0 ? <p className="mt-3 px-1 text-xs text-muted-foreground">{rejected} hunk{rejected === 1 ? "" : "s"} rejected. The agent will see your decisions on the next turn.</p> : null}
</div>
</div>
</div>
);
}The registry item pulls in every component it depends on.
Usage
import CodeReview from "@/components/blocks/code-review/code-review";
export default function Page() {
return (
<div className="h-dvh p-4">
<CodeReview />
</div>
);
}Give the parent a bounded height. The block fills it and scrolls internally.
Going live
Feed files from your agent’s proposed changes as before and after strings, or pass hunks straight from a unified diff. Post the decisions map back to the agent so it can apply accepted hunks and revise rejected ones.