feat: switch ReviewTab phase diff from rawDiff to metadata file list
- Split FileDiff into FileDiff (metadata-only) and FileDiffDetail (with hunks) so the phase branch diff no longer requires client-side diff parsing - ReviewTab now reads diffQuery.data.files (FileStatEntry[]) and maps path → newPath for sidebar and DiffViewer; no parseUnifiedDiff call in the phase branch view path - Commit view still parses rawDiff via parseUnifiedDiff → FileDiffDetail[] - Pass phaseId, commitMode, comments, expandAll to DiffViewer - Pass totalAdditions/totalDeletions from server to ReviewHeader stats bar - Wire Expand all button in ReviewHeader to expandAll toggle state - Update FileCard to use FileDiffDetail and status (replaces changeType) - Update ReviewSidebar to use file.status instead of file.changeType Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,4 +1,4 @@
|
|||||||
import type { FileDiff, DiffLine, ReviewComment } from "./types";
|
import type { FileDiff, FileDiffDetail, DiffLine, ReviewComment } from "./types";
|
||||||
import { FileCard } from "./FileCard";
|
import { FileCard } from "./FileCard";
|
||||||
|
|
||||||
function getFileCommentMap(
|
function getFileCommentMap(
|
||||||
@@ -13,7 +13,10 @@ function getFileCommentMap(
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface DiffViewerProps {
|
interface DiffViewerProps {
|
||||||
files: FileDiff[];
|
files: FileDiff[] | FileDiffDetail[];
|
||||||
|
phaseId?: string;
|
||||||
|
commitMode?: boolean;
|
||||||
|
comments?: ReviewComment[];
|
||||||
commentsByLine: Map<string, ReviewComment[]>;
|
commentsByLine: Map<string, ReviewComment[]>;
|
||||||
onAddComment: (
|
onAddComment: (
|
||||||
filePath: string,
|
filePath: string,
|
||||||
@@ -28,6 +31,7 @@ interface DiffViewerProps {
|
|||||||
viewedFiles?: Set<string>;
|
viewedFiles?: Set<string>;
|
||||||
onToggleViewed?: (filePath: string) => void;
|
onToggleViewed?: (filePath: string) => void;
|
||||||
onRegisterRef?: (filePath: string, el: HTMLDivElement | null) => void;
|
onRegisterRef?: (filePath: string, el: HTMLDivElement | null) => void;
|
||||||
|
expandAll?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function DiffViewer({
|
export function DiffViewer({
|
||||||
@@ -44,7 +48,7 @@ export function DiffViewer({
|
|||||||
}: DiffViewerProps) {
|
}: DiffViewerProps) {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{files.map((file) => (
|
{(files as FileDiffDetail[]).map((file) => (
|
||||||
<div key={file.newPath} ref={(el) => onRegisterRef?.(file.newPath, el)}>
|
<div key={file.newPath} ref={(el) => onRegisterRef?.(file.newPath, el)}>
|
||||||
<FileCard
|
<FileCard
|
||||||
file={file}
|
file={file}
|
||||||
|
|||||||
@@ -8,12 +8,14 @@ import {
|
|||||||
Circle,
|
Circle,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import type { FileDiff, FileChangeType, DiffLine, ReviewComment } from "./types";
|
import type { FileDiffDetail, DiffLine, ReviewComment } from "./types";
|
||||||
import { HunkRows } from "./HunkRows";
|
import { HunkRows } from "./HunkRows";
|
||||||
import { useHighlightedFile } from "./use-syntax-highlight";
|
import { useHighlightedFile } from "./use-syntax-highlight";
|
||||||
|
|
||||||
|
type FileStatus = FileDiffDetail['status'];
|
||||||
|
|
||||||
const changeTypeBadge: Record<
|
const changeTypeBadge: Record<
|
||||||
FileChangeType,
|
FileStatus,
|
||||||
{ label: string; classes: string } | null
|
{ label: string; classes: string } | null
|
||||||
> = {
|
> = {
|
||||||
added: {
|
added: {
|
||||||
@@ -32,17 +34,19 @@ const changeTypeBadge: Record<
|
|||||||
"bg-status-active-bg text-status-active-fg border-status-active-border",
|
"bg-status-active-bg text-status-active-fg border-status-active-border",
|
||||||
},
|
},
|
||||||
modified: null,
|
modified: null,
|
||||||
|
binary: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
const leftBorderClass: Record<FileChangeType, string> = {
|
const leftBorderClass: Record<FileStatus, string> = {
|
||||||
added: "border-l-2 border-l-status-success-fg",
|
added: "border-l-2 border-l-status-success-fg",
|
||||||
deleted: "border-l-2 border-l-status-error-fg",
|
deleted: "border-l-2 border-l-status-error-fg",
|
||||||
renamed: "border-l-2 border-l-status-active-fg",
|
renamed: "border-l-2 border-l-status-active-fg",
|
||||||
modified: "border-l-2 border-l-primary/40",
|
modified: "border-l-2 border-l-primary/40",
|
||||||
|
binary: "border-l-2 border-l-muted-foreground/40",
|
||||||
};
|
};
|
||||||
|
|
||||||
interface FileCardProps {
|
interface FileCardProps {
|
||||||
file: FileDiff;
|
file: FileDiffDetail;
|
||||||
commentsByLine: Map<string, ReviewComment[]>;
|
commentsByLine: Map<string, ReviewComment[]>;
|
||||||
onAddComment: (
|
onAddComment: (
|
||||||
filePath: string,
|
filePath: string,
|
||||||
@@ -80,7 +84,7 @@ export function FileCard({
|
|||||||
[commentsByLine],
|
[commentsByLine],
|
||||||
);
|
);
|
||||||
|
|
||||||
const badge = changeTypeBadge[file.changeType];
|
const badge = changeTypeBadge[file.status];
|
||||||
|
|
||||||
// Flatten all hunk lines for syntax highlighting
|
// Flatten all hunk lines for syntax highlighting
|
||||||
const allLines = useMemo(
|
const allLines = useMemo(
|
||||||
@@ -93,7 +97,7 @@ export function FileCard({
|
|||||||
<div className="rounded-lg border border-border overflow-clip">
|
<div className="rounded-lg border border-border overflow-clip">
|
||||||
{/* File header — sticky so it stays visible when scrolling */}
|
{/* File header — sticky so it stays visible when scrolling */}
|
||||||
<button
|
<button
|
||||||
className={`sticky z-10 flex w-full items-center gap-2 px-3 py-2 bg-muted hover:bg-muted/90 text-left text-sm font-mono transition-colors ${leftBorderClass[file.changeType]}`}
|
className={`sticky z-10 flex w-full items-center gap-2 px-3 py-2 bg-muted hover:bg-muted/90 text-left text-sm font-mono transition-colors ${leftBorderClass[file.status]}`}
|
||||||
style={{ top: 'var(--review-header-h, 0px)' }}
|
style={{ top: 'var(--review-header-h, 0px)' }}
|
||||||
onClick={() => setExpanded(!expanded)}
|
onClick={() => setExpanded(!expanded)}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -42,6 +42,9 @@ interface ReviewHeaderProps {
|
|||||||
preview: PreviewState | null;
|
preview: PreviewState | null;
|
||||||
viewedCount?: number;
|
viewedCount?: number;
|
||||||
totalCount?: number;
|
totalCount?: number;
|
||||||
|
totalAdditions?: number;
|
||||||
|
totalDeletions?: number;
|
||||||
|
onExpandAll?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ReviewHeader({
|
export function ReviewHeader({
|
||||||
@@ -62,9 +65,12 @@ export function ReviewHeader({
|
|||||||
preview,
|
preview,
|
||||||
viewedCount,
|
viewedCount,
|
||||||
totalCount,
|
totalCount,
|
||||||
|
totalAdditions: totalAdditionsProp,
|
||||||
|
totalDeletions: totalDeletionsProp,
|
||||||
|
onExpandAll,
|
||||||
}: ReviewHeaderProps) {
|
}: ReviewHeaderProps) {
|
||||||
const totalAdditions = files.reduce((s, f) => s + f.additions, 0);
|
const totalAdditions = totalAdditionsProp ?? files.reduce((s, f) => s + f.additions, 0);
|
||||||
const totalDeletions = files.reduce((s, f) => s + f.deletions, 0);
|
const totalDeletions = totalDeletionsProp ?? files.reduce((s, f) => s + f.deletions, 0);
|
||||||
const [showConfirmation, setShowConfirmation] = useState(false);
|
const [showConfirmation, setShowConfirmation] = useState(false);
|
||||||
const [showRequestConfirm, setShowRequestConfirm] = useState(false);
|
const [showRequestConfirm, setShowRequestConfirm] = useState(false);
|
||||||
const confirmRef = useRef<HTMLDivElement>(null);
|
const confirmRef = useRef<HTMLDivElement>(null);
|
||||||
@@ -186,6 +192,16 @@ export function ReviewHeader({
|
|||||||
|
|
||||||
{/* Right: preview + actions */}
|
{/* Right: preview + actions */}
|
||||||
<div className="flex items-center gap-3 shrink-0">
|
<div className="flex items-center gap-3 shrink-0">
|
||||||
|
{onExpandAll && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={onExpandAll}
|
||||||
|
className="h-7 text-xs px-2 text-muted-foreground"
|
||||||
|
>
|
||||||
|
Expand all
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
{/* Preview controls */}
|
{/* Preview controls */}
|
||||||
{preview && <PreviewControls preview={preview} />}
|
{preview && <PreviewControls preview={preview} />}
|
||||||
|
|
||||||
|
|||||||
@@ -310,7 +310,7 @@ function FilesView({
|
|||||||
const isInView = activeFilePaths.has(file.newPath);
|
const isInView = activeFilePaths.has(file.newPath);
|
||||||
const dimmed = selectedCommit && !isInView;
|
const dimmed = selectedCommit && !isInView;
|
||||||
const isViewed = viewedFiles.has(file.newPath);
|
const isViewed = viewedFiles.has(file.newPath);
|
||||||
const dotColor = changeTypeDotColor[file.changeType];
|
const dotColor = changeTypeDotColor[file.status];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
|
|||||||
226
apps/web/src/components/review/ReviewTab.test.tsx
Normal file
226
apps/web/src/components/review/ReviewTab.test.tsx
Normal file
@@ -0,0 +1,226 @@
|
|||||||
|
// @vitest-environment happy-dom
|
||||||
|
import "@testing-library/jest-dom/vitest";
|
||||||
|
import { render, screen, act } from "@testing-library/react";
|
||||||
|
import { vi, describe, it, expect, beforeEach } from "vitest";
|
||||||
|
|
||||||
|
// ── Capture props passed to stubs ─────────────────────────────────────────────
|
||||||
|
// These are module-level so the vi.mock factories (which are hoisted) can close over them.
|
||||||
|
let diffViewerProps: Record<string, unknown> = {};
|
||||||
|
let reviewSidebarProps: Record<string, unknown> = {};
|
||||||
|
|
||||||
|
vi.mock("./DiffViewer", () => ({
|
||||||
|
DiffViewer: (props: Record<string, unknown>) => {
|
||||||
|
diffViewerProps = props;
|
||||||
|
return <div data-testid="diff-viewer" />;
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("./ReviewSidebar", () => ({
|
||||||
|
ReviewSidebar: (props: Record<string, unknown>) => {
|
||||||
|
reviewSidebarProps = props;
|
||||||
|
return <div data-testid="review-sidebar" />;
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("./ReviewHeader", () => ({
|
||||||
|
ReviewHeader: (props: Record<string, unknown>) => (
|
||||||
|
<div data-testid="review-header">
|
||||||
|
{props.onExpandAll && (
|
||||||
|
<button
|
||||||
|
data-testid="expand-all-btn"
|
||||||
|
onClick={props.onExpandAll as () => void}
|
||||||
|
>
|
||||||
|
Expand all
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("./InitiativeReview", () => ({
|
||||||
|
InitiativeReview: () => <div data-testid="initiative-review" />,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("./comment-index", () => ({
|
||||||
|
buildCommentIndex: vi.fn(() => new Map()),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("sonner", () => ({
|
||||||
|
toast: { success: vi.fn(), error: vi.fn() },
|
||||||
|
}));
|
||||||
|
|
||||||
|
// ── parseUnifiedDiff spy ───────────────────────────────────────────────────────
|
||||||
|
const mockParseUnifiedDiff = vi.fn((_raw: string) => [
|
||||||
|
{
|
||||||
|
oldPath: "a.ts",
|
||||||
|
newPath: "a.ts",
|
||||||
|
status: "modified" as const,
|
||||||
|
additions: 3,
|
||||||
|
deletions: 1,
|
||||||
|
hunks: [],
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
vi.mock("./parse-diff", () => ({
|
||||||
|
get parseUnifiedDiff() {
|
||||||
|
return mockParseUnifiedDiff;
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
// ── tRPC mock factory ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const noopMutation = () => ({
|
||||||
|
mutate: vi.fn(),
|
||||||
|
isPending: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const noopQuery = (data: unknown = undefined) => ({
|
||||||
|
data,
|
||||||
|
isLoading: false,
|
||||||
|
isError: false,
|
||||||
|
refetch: vi.fn(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const mockUtils = {
|
||||||
|
listReviewComments: { invalidate: vi.fn() },
|
||||||
|
};
|
||||||
|
|
||||||
|
// Server format (FileStatEntry): uses `path` not `newPath`
|
||||||
|
const PHASE_FILES = [
|
||||||
|
{
|
||||||
|
path: "a.ts",
|
||||||
|
status: "modified" as const,
|
||||||
|
additions: 5,
|
||||||
|
deletions: 2,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// trpcMock is a let so tests can override it. The getter in the mock reads the current value.
|
||||||
|
let trpcMock = buildTrpcMock();
|
||||||
|
|
||||||
|
function buildTrpcMock(overrides: Record<string, unknown> = {}) {
|
||||||
|
return {
|
||||||
|
getInitiative: { useQuery: vi.fn(() => noopQuery({ status: "in_progress" })) },
|
||||||
|
listPhases: {
|
||||||
|
useQuery: vi.fn(() =>
|
||||||
|
noopQuery([{ id: "phase-1", name: "Phase 1", status: "pending_review" }])
|
||||||
|
),
|
||||||
|
},
|
||||||
|
getInitiativeProjects: { useQuery: vi.fn(() => noopQuery([{ id: "proj-1" }])) },
|
||||||
|
getPhaseReviewDiff: {
|
||||||
|
useQuery: vi.fn(() =>
|
||||||
|
noopQuery({
|
||||||
|
phaseName: "Phase 1",
|
||||||
|
sourceBranch: "cw/phase-1",
|
||||||
|
targetBranch: "main",
|
||||||
|
files: PHASE_FILES,
|
||||||
|
totalAdditions: 5,
|
||||||
|
totalDeletions: 2,
|
||||||
|
})
|
||||||
|
),
|
||||||
|
},
|
||||||
|
getPhaseReviewCommits: {
|
||||||
|
useQuery: vi.fn(() =>
|
||||||
|
noopQuery({ commits: [], sourceBranch: "cw/phase-1", targetBranch: "main" })
|
||||||
|
),
|
||||||
|
},
|
||||||
|
getCommitDiff: {
|
||||||
|
useQuery: vi.fn(() => noopQuery({ rawDiff: "" })),
|
||||||
|
},
|
||||||
|
listPreviews: { useQuery: vi.fn(() => noopQuery([])) },
|
||||||
|
getPreviewStatus: { useQuery: vi.fn(() => noopQuery(null)) },
|
||||||
|
listReviewComments: { useQuery: vi.fn(() => noopQuery([])) },
|
||||||
|
startPreview: { useMutation: vi.fn(() => noopMutation()) },
|
||||||
|
stopPreview: { useMutation: vi.fn(() => noopMutation()) },
|
||||||
|
createReviewComment: { useMutation: vi.fn(() => noopMutation()) },
|
||||||
|
resolveReviewComment: { useMutation: vi.fn(() => noopMutation()) },
|
||||||
|
unresolveReviewComment: { useMutation: vi.fn(() => noopMutation()) },
|
||||||
|
replyToReviewComment: { useMutation: vi.fn(() => noopMutation()) },
|
||||||
|
updateReviewComment: { useMutation: vi.fn(() => noopMutation()) },
|
||||||
|
approvePhaseReview: { useMutation: vi.fn(() => noopMutation()) },
|
||||||
|
requestPhaseChanges: { useMutation: vi.fn(() => noopMutation()) },
|
||||||
|
useUtils: vi.fn(() => mockUtils),
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
vi.mock("@/lib/trpc", () => ({
|
||||||
|
get trpc() {
|
||||||
|
return trpcMock;
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
// ── Import component after mocks ──────────────────────────────────────────────
|
||||||
|
import { ReviewTab } from "./ReviewTab";
|
||||||
|
|
||||||
|
// ── Tests ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe("ReviewTab", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
diffViewerProps = {};
|
||||||
|
reviewSidebarProps = {};
|
||||||
|
mockParseUnifiedDiff.mockClear();
|
||||||
|
trpcMock = buildTrpcMock();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("1. phase diff loads metadata: DiffViewer receives files array and commitMode=false", () => {
|
||||||
|
render(<ReviewTab initiativeId="init-1" />);
|
||||||
|
|
||||||
|
expect(screen.getByTestId("diff-viewer")).toBeInTheDocument();
|
||||||
|
const files = diffViewerProps.files as unknown[];
|
||||||
|
expect(files).toHaveLength(1);
|
||||||
|
expect(diffViewerProps.commitMode).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("2. no rawDiff parsing in phase mode: parseUnifiedDiff is NOT called", () => {
|
||||||
|
render(<ReviewTab initiativeId="init-1" />);
|
||||||
|
|
||||||
|
expect(mockParseUnifiedDiff).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("3. commit view parses rawDiff: parseUnifiedDiff called and DiffViewer gets commitMode=true", async () => {
|
||||||
|
trpcMock = buildTrpcMock({
|
||||||
|
getCommitDiff: {
|
||||||
|
useQuery: vi.fn(() =>
|
||||||
|
noopQuery({ rawDiff: "diff --git a/a.ts b/a.ts\nindex 000..111 100644\n--- a/a.ts\n+++ b/a.ts\n@@ -1,1 +1,1 @@\n-old\n+new\n" })
|
||||||
|
),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<ReviewTab initiativeId="init-1" />);
|
||||||
|
|
||||||
|
// Select a commit via the sidebar stub's onSelectCommit prop
|
||||||
|
const { onSelectCommit } = reviewSidebarProps as {
|
||||||
|
onSelectCommit: (hash: string | null) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
onSelectCommit("abc123");
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(diffViewerProps.commitMode).toBe(true);
|
||||||
|
expect(mockParseUnifiedDiff).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("4. allFiles uses metadata for sidebar: ReviewSidebar receives files from diffQuery.data.files", () => {
|
||||||
|
render(<ReviewTab initiativeId="init-1" />);
|
||||||
|
|
||||||
|
const sidebarFiles = reviewSidebarProps.files as Array<{ newPath: string }>;
|
||||||
|
expect(sidebarFiles).toHaveLength(1);
|
||||||
|
expect(sidebarFiles[0].newPath).toBe("a.ts");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("5. expandAll prop passed: clicking Expand all button causes DiffViewer to receive expandAll=true", async () => {
|
||||||
|
render(<ReviewTab initiativeId="init-1" />);
|
||||||
|
|
||||||
|
// Before clicking, expandAll should be false
|
||||||
|
expect(diffViewerProps.expandAll).toBe(false);
|
||||||
|
|
||||||
|
const expandBtn = screen.getByTestId("expand-all-btn");
|
||||||
|
await act(async () => {
|
||||||
|
expandBtn.click();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(diffViewerProps.expandAll).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -8,7 +8,7 @@ import { ReviewSidebar } from "./ReviewSidebar";
|
|||||||
import { ReviewHeader } from "./ReviewHeader";
|
import { ReviewHeader } from "./ReviewHeader";
|
||||||
import { InitiativeReview } from "./InitiativeReview";
|
import { InitiativeReview } from "./InitiativeReview";
|
||||||
import { buildCommentIndex } from "./comment-index";
|
import { buildCommentIndex } from "./comment-index";
|
||||||
import type { ReviewStatus, DiffLine } from "./types";
|
import type { ReviewStatus, DiffLine, FileDiff, FileDiffDetail } from "./types";
|
||||||
|
|
||||||
interface ReviewTabProps {
|
interface ReviewTabProps {
|
||||||
initiativeId: string;
|
initiativeId: string;
|
||||||
@@ -18,6 +18,7 @@ export function ReviewTab({ initiativeId }: ReviewTabProps) {
|
|||||||
const [status, setStatus] = useState<ReviewStatus>("pending");
|
const [status, setStatus] = useState<ReviewStatus>("pending");
|
||||||
const [selectedCommit, setSelectedCommit] = useState<string | null>(null);
|
const [selectedCommit, setSelectedCommit] = useState<string | null>(null);
|
||||||
const [viewedFiles, setViewedFiles] = useState<Set<string>>(new Set());
|
const [viewedFiles, setViewedFiles] = useState<Set<string>>(new Set());
|
||||||
|
const [expandAll, setExpandAll] = useState(false);
|
||||||
const fileRefs = useRef<Map<string, HTMLDivElement>>(new Map());
|
const fileRefs = useRef<Map<string, HTMLDivElement>>(new Map());
|
||||||
const headerRef = useRef<HTMLDivElement>(null);
|
const headerRef = useRef<HTMLDivElement>(null);
|
||||||
const [headerHeight, setHeaderHeight] = useState(0);
|
const [headerHeight, setHeaderHeight] = useState(0);
|
||||||
@@ -74,7 +75,7 @@ export function ReviewTab({ initiativeId }: ReviewTabProps) {
|
|||||||
const projectsQuery = trpc.getInitiativeProjects.useQuery({ initiativeId });
|
const projectsQuery = trpc.getInitiativeProjects.useQuery({ initiativeId });
|
||||||
const firstProjectId = projectsQuery.data?.[0]?.id ?? null;
|
const firstProjectId = projectsQuery.data?.[0]?.id ?? null;
|
||||||
|
|
||||||
// Fetch full branch diff for active phase
|
// Fetch full branch diff for active phase (metadata only, no rawDiff)
|
||||||
const diffQuery = trpc.getPhaseReviewDiff.useQuery(
|
const diffQuery = trpc.getPhaseReviewDiff.useQuery(
|
||||||
{ phaseId: activePhaseId! },
|
{ phaseId: activePhaseId! },
|
||||||
{ enabled: !!activePhaseId && !isInitiativePendingReview },
|
{ enabled: !!activePhaseId && !isInitiativePendingReview },
|
||||||
@@ -96,7 +97,7 @@ export function ReviewTab({ initiativeId }: ReviewTabProps) {
|
|||||||
// Preview state
|
// Preview state
|
||||||
const previewsQuery = trpc.listPreviews.useQuery({ initiativeId });
|
const previewsQuery = trpc.listPreviews.useQuery({ initiativeId });
|
||||||
const existingPreview = previewsQuery.data?.find(
|
const existingPreview = previewsQuery.data?.find(
|
||||||
(p) => p.phaseId === activePhaseId || p.initiativeId === initiativeId,
|
(p: { phaseId?: string; initiativeId?: string }) => p.phaseId === activePhaseId || p.initiativeId === initiativeId,
|
||||||
);
|
);
|
||||||
const [activePreviewId, setActivePreviewId] = useState<string | null>(null);
|
const [activePreviewId, setActivePreviewId] = useState<string | null>(null);
|
||||||
const previewStatusQuery = trpc.getPreviewStatus.useQuery(
|
const previewStatusQuery = trpc.getPreviewStatus.useQuery(
|
||||||
@@ -107,12 +108,12 @@ export function ReviewTab({ initiativeId }: ReviewTabProps) {
|
|||||||
const sourceBranch = diffQuery.data?.sourceBranch ?? commitsQuery.data?.sourceBranch ?? "";
|
const sourceBranch = diffQuery.data?.sourceBranch ?? commitsQuery.data?.sourceBranch ?? "";
|
||||||
|
|
||||||
const startPreview = trpc.startPreview.useMutation({
|
const startPreview = trpc.startPreview.useMutation({
|
||||||
onSuccess: (data) => {
|
onSuccess: (data: { id: string; url: string }) => {
|
||||||
setActivePreviewId(data.id);
|
setActivePreviewId(data.id);
|
||||||
previewsQuery.refetch();
|
previewsQuery.refetch();
|
||||||
toast.success(`Preview running at ${data.url}`);
|
toast.success(`Preview running at ${data.url}`);
|
||||||
},
|
},
|
||||||
onError: (err) => toast.error(`Preview failed: ${err.message}`),
|
onError: (err: { message: string }) => toast.error(`Preview failed: ${err.message}`),
|
||||||
});
|
});
|
||||||
|
|
||||||
const stopPreview = trpc.stopPreview.useMutation({
|
const stopPreview = trpc.stopPreview.useMutation({
|
||||||
@@ -121,7 +122,7 @@ export function ReviewTab({ initiativeId }: ReviewTabProps) {
|
|||||||
toast.success("Preview stopped");
|
toast.success("Preview stopped");
|
||||||
previewsQuery.refetch();
|
previewsQuery.refetch();
|
||||||
},
|
},
|
||||||
onError: (err) => toast.error(`Failed to stop: ${err.message}`),
|
onError: (err: { message: string }) => toast.error(`Failed to stop: ${err.message}`),
|
||||||
});
|
});
|
||||||
|
|
||||||
const previewState = firstProjectId && sourceBranch
|
const previewState = firstProjectId && sourceBranch
|
||||||
@@ -157,7 +158,17 @@ export function ReviewTab({ initiativeId }: ReviewTabProps) {
|
|||||||
{ enabled: !!activePhaseId && !isInitiativePendingReview },
|
{ enabled: !!activePhaseId && !isInitiativePendingReview },
|
||||||
);
|
);
|
||||||
const comments = useMemo(() => {
|
const comments = useMemo(() => {
|
||||||
return (commentsQuery.data ?? []).map((c) => ({
|
return (commentsQuery.data ?? []).map((c: {
|
||||||
|
id: string;
|
||||||
|
filePath: string;
|
||||||
|
lineNumber: number | null;
|
||||||
|
lineType: string;
|
||||||
|
body: string;
|
||||||
|
author: string;
|
||||||
|
createdAt: string | number;
|
||||||
|
resolved: boolean;
|
||||||
|
parentCommentId?: string | null;
|
||||||
|
}) => ({
|
||||||
id: c.id,
|
id: c.id,
|
||||||
filePath: c.filePath,
|
filePath: c.filePath,
|
||||||
lineNumber: c.lineNumber,
|
lineNumber: c.lineNumber,
|
||||||
@@ -179,7 +190,7 @@ export function ReviewTab({ initiativeId }: ReviewTabProps) {
|
|||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
utils.listReviewComments.invalidate({ phaseId: activePhaseId! });
|
utils.listReviewComments.invalidate({ phaseId: activePhaseId! });
|
||||||
},
|
},
|
||||||
onError: (err) => toast.error(`Failed to save comment: ${err.message}`),
|
onError: (err: { message: string }) => toast.error(`Failed to save comment: ${err.message}`),
|
||||||
});
|
});
|
||||||
|
|
||||||
const resolveCommentMutation = trpc.resolveReviewComment.useMutation({
|
const resolveCommentMutation = trpc.resolveReviewComment.useMutation({
|
||||||
@@ -198,14 +209,14 @@ export function ReviewTab({ initiativeId }: ReviewTabProps) {
|
|||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
utils.listReviewComments.invalidate({ phaseId: activePhaseId! });
|
utils.listReviewComments.invalidate({ phaseId: activePhaseId! });
|
||||||
},
|
},
|
||||||
onError: (err) => toast.error(`Failed to post reply: ${err.message}`),
|
onError: (err: { message: string }) => toast.error(`Failed to post reply: ${err.message}`),
|
||||||
});
|
});
|
||||||
|
|
||||||
const editCommentMutation = trpc.updateReviewComment.useMutation({
|
const editCommentMutation = trpc.updateReviewComment.useMutation({
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
utils.listReviewComments.invalidate({ phaseId: activePhaseId! });
|
utils.listReviewComments.invalidate({ phaseId: activePhaseId! });
|
||||||
},
|
},
|
||||||
onError: (err) => toast.error(`Failed to update comment: ${err.message}`),
|
onError: (err: { message: string }) => toast.error(`Failed to update comment: ${err.message}`),
|
||||||
});
|
});
|
||||||
|
|
||||||
const approveMutation = trpc.approvePhaseReview.useMutation({
|
const approveMutation = trpc.approvePhaseReview.useMutation({
|
||||||
@@ -214,23 +225,48 @@ export function ReviewTab({ initiativeId }: ReviewTabProps) {
|
|||||||
toast.success("Phase approved and merged");
|
toast.success("Phase approved and merged");
|
||||||
phasesQuery.refetch();
|
phasesQuery.refetch();
|
||||||
},
|
},
|
||||||
onError: (err) => toast.error(err.message),
|
onError: (err: { message: string }) => toast.error(err.message),
|
||||||
});
|
});
|
||||||
|
|
||||||
// Determine which diff to display
|
// Phase branch diff — metadata only, no parsing
|
||||||
const activeDiffRaw = selectedCommit
|
const phaseFiles: FileDiff[] = useMemo(
|
||||||
? commitDiffQuery.data?.rawDiff
|
() => {
|
||||||
: diffQuery.data?.rawDiff;
|
const serverFiles = diffQuery.data?.files ?? [];
|
||||||
|
// Map server FileStatEntry (path) to frontend FileDiff (newPath)
|
||||||
|
return serverFiles.map((f: {
|
||||||
|
path: string;
|
||||||
|
oldPath?: string;
|
||||||
|
status: FileDiff['status'];
|
||||||
|
additions: number;
|
||||||
|
deletions: number;
|
||||||
|
projectId?: string;
|
||||||
|
}) => ({
|
||||||
|
newPath: f.path,
|
||||||
|
oldPath: f.oldPath ?? f.path,
|
||||||
|
status: f.status,
|
||||||
|
additions: f.additions,
|
||||||
|
deletions: f.deletions,
|
||||||
|
projectId: f.projectId,
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
[diffQuery.data?.files],
|
||||||
|
);
|
||||||
|
|
||||||
const files = useMemo(() => {
|
// Commit diff — still raw, parse client-side
|
||||||
if (!activeDiffRaw) return [];
|
const commitFiles: FileDiffDetail[] = useMemo(() => {
|
||||||
return parseUnifiedDiff(activeDiffRaw);
|
if (!commitDiffQuery.data?.rawDiff) return [];
|
||||||
}, [activeDiffRaw]);
|
return parseUnifiedDiff(commitDiffQuery.data.rawDiff);
|
||||||
|
}, [commitDiffQuery.data?.rawDiff]);
|
||||||
|
|
||||||
const isDiffLoading = selectedCommit
|
const isDiffLoading = selectedCommit
|
||||||
? commitDiffQuery.isLoading
|
? commitDiffQuery.isLoading
|
||||||
: diffQuery.isLoading;
|
: diffQuery.isLoading;
|
||||||
|
|
||||||
|
// All files for sidebar — always from phase metadata
|
||||||
|
const allFiles = phaseFiles;
|
||||||
|
|
||||||
|
const activeFiles: FileDiff[] | FileDiffDetail[] = selectedCommit ? commitFiles : phaseFiles;
|
||||||
|
|
||||||
const handleAddComment = useCallback(
|
const handleAddComment = useCallback(
|
||||||
(filePath: string, lineNumber: number, lineType: DiffLine["type"], body: string) => {
|
(filePath: string, lineNumber: number, lineType: DiffLine["type"], body: string) => {
|
||||||
if (!activePhaseId) return;
|
if (!activePhaseId) return;
|
||||||
@@ -273,7 +309,7 @@ export function ReviewTab({ initiativeId }: ReviewTabProps) {
|
|||||||
toast.success("Changes requested — revision task dispatched");
|
toast.success("Changes requested — revision task dispatched");
|
||||||
phasesQuery.refetch();
|
phasesQuery.refetch();
|
||||||
},
|
},
|
||||||
onError: (err) => toast.error(err.message),
|
onError: (err: { message: string }) => toast.error(err.message),
|
||||||
});
|
});
|
||||||
|
|
||||||
const handleRequestChanges = useCallback(() => {
|
const handleRequestChanges = useCallback(() => {
|
||||||
@@ -303,6 +339,11 @@ export function ReviewTab({ initiativeId }: ReviewTabProps) {
|
|||||||
setSelectedCommit(null);
|
setSelectedCommit(null);
|
||||||
setStatus("pending");
|
setStatus("pending");
|
||||||
setViewedFiles(new Set());
|
setViewedFiles(new Set());
|
||||||
|
setExpandAll(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleExpandAll = useCallback(() => {
|
||||||
|
setExpandAll(v => !v);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const unresolvedCount = comments.filter((c) => !c.resolved && !c.parentCommentId).length;
|
const unresolvedCount = comments.filter((c) => !c.resolved && !c.parentCommentId).length;
|
||||||
@@ -312,12 +353,6 @@ export function ReviewTab({ initiativeId }: ReviewTabProps) {
|
|||||||
reviewablePhases.find((p) => p.id === activePhaseId)?.name ??
|
reviewablePhases.find((p) => p.id === activePhaseId)?.name ??
|
||||||
"Phase";
|
"Phase";
|
||||||
|
|
||||||
// All files from the full branch diff (for sidebar file list)
|
|
||||||
const allFiles = useMemo(() => {
|
|
||||||
if (!diffQuery.data?.rawDiff) return [];
|
|
||||||
return parseUnifiedDiff(diffQuery.data.rawDiff);
|
|
||||||
}, [diffQuery.data?.rawDiff]);
|
|
||||||
|
|
||||||
// Initiative-level review takes priority
|
// Initiative-level review takes priority
|
||||||
if (isInitiativePendingReview) {
|
if (isInitiativePendingReview) {
|
||||||
return (
|
return (
|
||||||
@@ -363,6 +398,9 @@ export function ReviewTab({ initiativeId }: ReviewTabProps) {
|
|||||||
preview={previewState}
|
preview={previewState}
|
||||||
viewedCount={viewedFiles.size}
|
viewedCount={viewedFiles.size}
|
||||||
totalCount={allFiles.length}
|
totalCount={allFiles.length}
|
||||||
|
totalAdditions={selectedCommit ? undefined : diffQuery.data?.totalAdditions}
|
||||||
|
totalDeletions={selectedCommit ? undefined : diffQuery.data?.totalDeletions}
|
||||||
|
onExpandAll={handleExpandAll}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Main content area — sidebar always rendered to preserve state */}
|
{/* Main content area — sidebar always rendered to preserve state */}
|
||||||
@@ -382,7 +420,7 @@ export function ReviewTab({ initiativeId }: ReviewTabProps) {
|
|||||||
onFileClick={handleFileClick}
|
onFileClick={handleFileClick}
|
||||||
onCommentClick={handleCommentClick}
|
onCommentClick={handleCommentClick}
|
||||||
selectedCommit={selectedCommit}
|
selectedCommit={selectedCommit}
|
||||||
activeFiles={files}
|
activeFiles={activeFiles}
|
||||||
commits={commits}
|
commits={commits}
|
||||||
onSelectCommit={setSelectedCommit}
|
onSelectCommit={setSelectedCommit}
|
||||||
viewedFiles={viewedFiles}
|
viewedFiles={viewedFiles}
|
||||||
@@ -397,7 +435,7 @@ export function ReviewTab({ initiativeId }: ReviewTabProps) {
|
|||||||
<Loader2 className="h-4 w-4 animate-spin" />
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
Loading diff...
|
Loading diff...
|
||||||
</div>
|
</div>
|
||||||
) : files.length === 0 ? (
|
) : activeFiles.length === 0 ? (
|
||||||
<div className="flex h-32 items-center justify-center text-muted-foreground text-sm">
|
<div className="flex h-32 items-center justify-center text-muted-foreground text-sm">
|
||||||
{selectedCommit
|
{selectedCommit
|
||||||
? "No changes in this commit"
|
? "No changes in this commit"
|
||||||
@@ -405,7 +443,10 @@ export function ReviewTab({ initiativeId }: ReviewTabProps) {
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<DiffViewer
|
<DiffViewer
|
||||||
files={files}
|
files={activeFiles}
|
||||||
|
phaseId={activePhaseId!}
|
||||||
|
commitMode={!!selectedCommit}
|
||||||
|
comments={comments}
|
||||||
commentsByLine={commentsByLine}
|
commentsByLine={commentsByLine}
|
||||||
onAddComment={handleAddComment}
|
onAddComment={handleAddComment}
|
||||||
onResolveComment={handleResolveComment}
|
onResolveComment={handleResolveComment}
|
||||||
@@ -415,6 +456,7 @@ export function ReviewTab({ initiativeId }: ReviewTabProps) {
|
|||||||
viewedFiles={viewedFiles}
|
viewedFiles={viewedFiles}
|
||||||
onToggleViewed={toggleViewed}
|
onToggleViewed={toggleViewed}
|
||||||
onRegisterRef={registerFileRef}
|
onRegisterRef={registerFileRef}
|
||||||
|
expandAll={expandAll}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import type { FileDiff, FileChangeType, DiffHunk, DiffLine } from "./types";
|
import type { FileDiffDetail, DiffHunk, DiffLine } from "./types";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Parse a unified diff string into structured FileDiff objects.
|
* Parse a unified diff string into structured FileDiffDetail objects.
|
||||||
*/
|
*/
|
||||||
export function parseUnifiedDiff(raw: string): FileDiff[] {
|
export function parseUnifiedDiff(raw: string): FileDiffDetail[] {
|
||||||
const files: FileDiff[] = [];
|
const files: FileDiffDetail[] = [];
|
||||||
const fileChunks = raw.split(/^diff --git /m).filter(Boolean);
|
const fileChunks = raw.split(/^diff --git /m).filter(Boolean);
|
||||||
|
|
||||||
for (const chunk of fileChunks) {
|
for (const chunk of fileChunks) {
|
||||||
@@ -90,19 +90,19 @@ export function parseUnifiedDiff(raw: string): FileDiff[] {
|
|||||||
hunks.push({ header, oldStart, oldCount, newStart, newCount, lines: hunkLines });
|
hunks.push({ header, oldStart, oldCount, newStart, newCount, lines: hunkLines });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Derive changeType from header markers and path comparison
|
// Derive status from header markers and path comparison
|
||||||
let changeType: FileChangeType;
|
let status: FileDiffDetail['status'];
|
||||||
if (hasOldDevNull) {
|
if (hasOldDevNull) {
|
||||||
changeType = "added";
|
status = "added";
|
||||||
} else if (hasNewDevNull) {
|
} else if (hasNewDevNull) {
|
||||||
changeType = "deleted";
|
status = "deleted";
|
||||||
} else if (oldPath !== newPath) {
|
} else if (oldPath !== newPath) {
|
||||||
changeType = "renamed";
|
status = "renamed";
|
||||||
} else {
|
} else {
|
||||||
changeType = "modified";
|
status = "modified";
|
||||||
}
|
}
|
||||||
|
|
||||||
files.push({ oldPath, newPath, hunks, additions, deletions, changeType });
|
files.push({ oldPath, newPath, hunks, additions, deletions, status });
|
||||||
}
|
}
|
||||||
|
|
||||||
return files;
|
return files;
|
||||||
|
|||||||
@@ -14,15 +14,22 @@ export interface DiffLine {
|
|||||||
newLineNumber: number | null;
|
newLineNumber: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** @deprecated Use FileDiff.status instead */
|
||||||
export type FileChangeType = 'added' | 'modified' | 'deleted' | 'renamed';
|
export type FileChangeType = 'added' | 'modified' | 'deleted' | 'renamed';
|
||||||
|
|
||||||
|
/** Metadata returned by getPhaseReviewDiff — no hunk content */
|
||||||
export interface FileDiff {
|
export interface FileDiff {
|
||||||
oldPath: string;
|
oldPath: string;
|
||||||
newPath: string;
|
newPath: string;
|
||||||
hunks: DiffHunk[];
|
status: 'added' | 'modified' | 'deleted' | 'renamed' | 'binary';
|
||||||
additions: number;
|
additions: number;
|
||||||
deletions: number;
|
deletions: number;
|
||||||
changeType: FileChangeType;
|
projectId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Full diff with parsed hunks — returned by getFileDiff and parsed client-side */
|
||||||
|
export interface FileDiffDetail extends FileDiff {
|
||||||
|
hunks: DiffHunk[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ReviewComment {
|
export interface ReviewComment {
|
||||||
|
|||||||
@@ -113,10 +113,10 @@ The initiative detail page has three tabs managed via local state (not URL param
|
|||||||
### Review Components (`src/components/review/`)
|
### Review Components (`src/components/review/`)
|
||||||
| Component | Purpose |
|
| Component | Purpose |
|
||||||
|-----------|---------|
|
|-----------|---------|
|
||||||
| `ReviewTab` | Review tab container — orchestrates header, diff, sidebar, and preview. Phase-level review has threaded inline comments (with reply support) + Request Changes; initiative-level review has Request Changes (summary prompt) + Push Branch / Merge & Push |
|
| `ReviewTab` | Review tab container — orchestrates header, diff, sidebar, and preview. Phase diff uses metadata-only `FileDiff[]` from `getPhaseReviewDiff` (no rawDiff parsing). Commit diff parses `rawDiff` via `parseUnifiedDiff` → `FileDiffDetail[]`. Passes `commitMode`, `phaseId`, `expandAll` to DiffViewer |
|
||||||
| `ReviewHeader` | Consolidated toolbar: phase selector pills, branch info, stats, preview controls, approve/reject actions |
|
| `ReviewHeader` | Consolidated toolbar: phase selector pills, branch info, stats (uses `totalAdditions`/`totalDeletions` props when available, falls back to summing files), preview controls, Expand all button, approve/reject actions |
|
||||||
| `ReviewSidebar` | VSCode-style icon strip (Files/Commits views) with file list, root-only comment counts, and commit navigation |
|
| `ReviewSidebar` | VSCode-style icon strip (Files/Commits views) with file list, root-only comment counts, and commit navigation |
|
||||||
| `DiffViewer` | Unified diff renderer with threaded inline comments (root + reply threads) |
|
| `DiffViewer` | Unified diff renderer with threaded inline comments (root + reply threads). Accepts `FileDiff[] | FileDiffDetail[]`, `phaseId`, `commitMode`, `expandAll` props |
|
||||||
| `CommentThread` | Renders root comment with resolve/reopen + nested reply threads (agent replies styled with primary border). Inline reply form |
|
| `CommentThread` | Renders root comment with resolve/reopen + nested reply threads (agent replies styled with primary border). Inline reply form |
|
||||||
| `ConflictResolutionPanel` | Merge conflict detection + agent resolution in initiative review. Shows conflict files, spawns conflict agent, inline questions, re-check on completion |
|
| `ConflictResolutionPanel` | Merge conflict detection + agent resolution in initiative review. Shows conflict files, spawns conflict agent, inline questions, re-check on completion |
|
||||||
| `PreviewPanel` | Docker preview status: building/running/failed with start/stop (legacy, now integrated into ReviewHeader) |
|
| `PreviewPanel` | Docker preview status: building/running/failed with start/stop (legacy, now integrated into ReviewHeader) |
|
||||||
|
|||||||
Reference in New Issue
Block a user