feat(web): show file statuses in review (#1683)

* feat(web): show file statuses in review

* fix(web): address review status feedback

* fix(web): deduplicate review sources

* fix(web): align review progress identities

* fix(web): guard review status labels

* fix(web): enforce file status exhaustiveness

* perf(web): index review progress sources

* fix(web): preserve files during status hydration

* fix(web): align multi-repo review progress
This commit is contained in:
José Almeida
2026-07-15 12:54:43 +01:00
committed by GitHub
parent abe4147d07
commit e067e7a1f6
39 changed files with 2593 additions and 260 deletions
@@ -1,8 +1,15 @@
import { describe, it, expect } from "vitest";
import { buildAllFiles, filterPendingDiffCommentsForSession } from "./review-dialog";
import { reviewFileKey } from "./types";
import type { CumulativeDiff } from "@/lib/state/slices/session-runtime/types";
import type { Comment } from "@/lib/state/slices/comments";
const RENAMED_PATH = "src/new-name.ts";
const PREVIOUS_PATH = "src/old-name.ts";
const README_PATH = "README.md";
const FRONTEND_REPO = "frontend";
const BACKEND_REPO = "backend";
function pendingDiffComment(overrides: Partial<Comment>): Comment {
return {
id: "c1",
@@ -139,6 +146,278 @@ describe("buildAllFiles (review dialog)", () => {
});
});
describe("buildAllFiles source collisions", () => {
it("deduplicates a repo-stamped cumulative file against repo-unaware git status", () => {
const path = "src/shared.ts";
const gitStatusFiles = {
[path]: {
path,
status: "modified" as const,
staged: false,
additions: 1,
deletions: 1,
diff: "@@ -1 +1 @@\n-local\n+fresh\n",
},
};
const cumulativeDiff = {
session_id: "s1",
base_commit: "abc",
head_commit: "def",
total_commits: 1,
files: {
[`${FRONTEND_REPO}\u0000${path}`]: {
path,
repository_name: FRONTEND_REPO,
status: "modified",
staged: false,
additions: 1,
deletions: 1,
diff: "@@ -1 +1 @@\n-stale\n+snapshot\n",
},
},
} as unknown as CumulativeDiff;
const result = buildAllFiles(gitStatusFiles, cumulativeDiff);
expect(result).toHaveLength(1);
expect(result[0]).toMatchObject({ path, source: "uncommitted" });
});
it("uses a bare cumulative key in single-repository review mode", () => {
const path = "src/committed.ts";
const cumulativeDiff = {
session_id: "s1",
base_commit: "abc",
head_commit: "def",
total_commits: 1,
files: {
[`${FRONTEND_REPO}\u0000${path}`]: {
path,
repository_name: FRONTEND_REPO,
status: "modified",
staged: false,
diff: "@@committed@@",
},
},
} as CumulativeDiff;
const result = buildAllFiles(null, cumulativeDiff, undefined, undefined, false);
expect(result).toHaveLength(1);
expect(reviewFileKey(result[0])).toBe(path);
expect(result[0].repository_name).toBeUndefined();
});
});
describe("buildAllFiles sort order", () => {
it("sorts multi-repo files by repository name and then path", () => {
const gitStatusFiles = {
"frontend\u0000src/a.ts": {
path: "src/a.ts",
status: "modified" as const,
staged: false,
repository_name: FRONTEND_REPO,
},
"backend\u0000src/b.ts": {
path: "src/b.ts",
status: "modified" as const,
staged: false,
repository_name: BACKEND_REPO,
},
};
const result = buildAllFiles(gitStatusFiles, null);
expect(result.map((file) => `${file.repository_name}:${file.path}`)).toEqual([
"backend:src/b.ts",
"frontend:src/a.ts",
]);
});
});
describe("buildAllFiles patchless status files", () => {
it("keeps a patchless PR rename with its previous path", () => {
const result = buildAllFiles(null, null, [
{
filename: RENAMED_PATH,
old_path: PREVIOUS_PATH,
status: "renamed",
patch: "",
additions: 0,
deletions: 0,
},
]);
expect(result).toEqual([
expect.objectContaining({
path: RENAMED_PATH,
old_path: PREVIOUS_PATH,
status: "renamed",
diff: "",
source: "pr",
}),
]);
expect(reviewFileKey(result[0])).toBe(RENAMED_PATH);
expect(result[0].repository_name).toBeUndefined();
});
it("keeps patchless uncommitted and cumulative files with metadata", () => {
const gitStatusFiles = {
"assets/logo.png": {
path: "assets/logo.png",
status: "modified" as const,
staged: false,
diff_skip_reason: "binary" as const,
},
};
const cumulativeDiff = {
session_id: "s1",
base_commit: "abc",
head_commit: "def",
total_commits: 1,
files: {
[RENAMED_PATH]: {
path: RENAMED_PATH,
old_path: PREVIOUS_PATH,
status: "renamed",
staged: false,
additions: 0,
deletions: 0,
diff: "",
},
},
} as CumulativeDiff;
const result = buildAllFiles(gitStatusFiles, cumulativeDiff);
expect(result).toEqual(
expect.arrayContaining([
expect.objectContaining({
path: "assets/logo.png",
diff_skip_reason: "binary",
diff: "",
source: "uncommitted",
}),
expect.objectContaining({
path: RENAMED_PATH,
old_path: PREVIOUS_PATH,
status: "renamed",
diff: "",
source: "committed",
}),
]),
);
expect(result).toHaveLength(2);
});
});
describe("buildAllFiles patchless source identity", () => {
it("keeps patchless uncommitted precedence over a same-repo PR file", () => {
const gitStatusFiles = {
[`${FRONTEND_REPO}\u0000${README_PATH}`]: {
path: README_PATH,
status: "modified" as const,
staged: false,
repository_name: FRONTEND_REPO,
},
};
const result = buildAllFiles(
gitStatusFiles,
null,
[
{
filename: README_PATH,
status: "modified",
patch: "",
additions: 0,
deletions: 0,
},
],
FRONTEND_REPO,
);
expect(result).toEqual([
expect.objectContaining({
path: README_PATH,
source: "uncommitted",
repository_name: FRONTEND_REPO,
}),
]);
});
it("keeps patchless cumulative precedence over a same-repo PR file", () => {
const cumulativeDiff = {
session_id: "s1",
base_commit: "abc",
head_commit: "def",
total_commits: 1,
files: {
[`${FRONTEND_REPO}\u0000${README_PATH}`]: {
path: README_PATH,
status: "modified",
staged: false,
repository_name: FRONTEND_REPO,
},
},
} as CumulativeDiff;
const result = buildAllFiles(
null,
cumulativeDiff,
[
{
filename: README_PATH,
status: "modified",
patch: "",
additions: 0,
deletions: 0,
},
],
FRONTEND_REPO,
);
expect(result).toEqual([
expect.objectContaining({
path: README_PATH,
source: "committed",
repository_name: FRONTEND_REPO,
}),
]);
});
it("keeps same-path files from different repositories distinct", () => {
const gitStatusFiles = {
[`${BACKEND_REPO}\u0000${README_PATH}`]: {
path: README_PATH,
status: "modified" as const,
staged: false,
repository_name: BACKEND_REPO,
},
};
const result = buildAllFiles(
gitStatusFiles,
null,
[
{
filename: README_PATH,
status: "modified",
patch: "",
additions: 0,
deletions: 0,
},
],
FRONTEND_REPO,
);
expect(result.map(reviewFileKey).sort()).toEqual([
"backend\u0000README.md",
"frontend\u0000README.md",
]);
});
});
describe("filterPendingDiffCommentsForSession", () => {
it("keeps only diff comments from the active session", () => {
const comments = [
+67 -71
View File
@@ -14,6 +14,7 @@ import { useReviewSidebarResize } from "@/hooks/use-review-sidebar-resize";
import { useAppStore } from "@/components/state-provider";
import { useToast } from "@/components/toast-provider";
import { DEFAULT_DIFF_WORD_WRAP } from "@/components/diff/diff-defaults";
import { normalizeFileChangeStatus } from "@/lib/utils/file-change-status";
import { useRequestChangesWalkthrough } from "@/hooks/domains/session/use-request-changes-walkthrough";
import { ReviewTopBar } from "./review-top-bar";
import { ReviewFileTree } from "./review-file-tree";
@@ -42,7 +43,7 @@ function fileMapKey(path: string, repositoryName?: string): string {
function addCumulativeDiffFiles(
fileMap: Map<string, ReviewFile>,
files: CumulativeDiff["files"],
gitStatusFiles: Record<string, FileInfo> | null,
useRepositoryKeys: boolean,
) {
// Multi-repo: backend stamps each per-file payload with `repository_name`
// + the repo-relative `path`, and uses a NUL-composite `<repo>\x00<path>`
@@ -51,45 +52,28 @@ function addCumulativeDiffFiles(
// value so the composite key doesn't bleed into the displayed path, and
// fall back to the map key so single-repo files aren't silently dropped.
for (const [mapKey, file] of Object.entries(files)) {
const repoName = file.repository_name;
const path = file.path ?? mapKey;
const repoName = useRepositoryKeys ? file.repository_name : undefined;
const path = file.path ?? splitFileKey(mapKey).path;
if (!path) continue;
const key = fileMapKey(path, repoName);
if (fileMap.has(key)) continue;
const hasRepoUnawareCollision = key !== path && fileMap.has(path);
if (fileMap.has(key) || hasRepoUnawareCollision) continue;
const diff = file.diff ? normalizeDiffContent(file.diff) : "";
if (diff) {
const matchingUncommitted = findUncommittedByPathAndRepo(gitStatusFiles, path, repoName);
fileMap.set(key, {
path,
diff,
status: file.status || "modified",
additions: file.additions ?? 0,
deletions: file.deletions ?? 0,
staged: matchingUncommitted?.staged ?? false,
source: matchingUncommitted ? "uncommitted" : "committed",
repository_name: repoName,
});
}
fileMap.set(key, {
path,
diff,
status: normalizeFileChangeStatus(file.status),
additions: file.additions ?? 0,
deletions: file.deletions ?? 0,
staged: false,
source: "committed",
old_path: file.old_path,
diff_skip_reason: file.diff_skip_reason,
repository_name: repoName,
});
}
}
/** Looks up a FileInfo in the (possibly composite-keyed) gitStatus map by
* (path, repository_name). When repo is undefined or empty (single-repo),
* returns the first match by path. */
function findUncommittedByPathAndRepo(
gitStatusFiles: Record<string, FileInfo> | null,
path: string,
repositoryName: string | undefined,
): FileInfo | undefined {
if (!gitStatusFiles) return undefined;
for (const file of Object.values(gitStatusFiles)) {
if (file.path !== path) continue;
if (!repositoryName) return file;
if (file.repository_name === repositoryName) return file;
}
return undefined;
}
function addUncommittedFiles(
fileMap: Map<string, ReviewFile>,
gitStatusFiles: Record<string, FileInfo>,
@@ -98,40 +82,39 @@ function addUncommittedFiles(
const key = fileMapKey(file.path, file.repository_name);
if (fileMap.has(key)) continue;
const diff = file.diff ? normalizeDiffContent(file.diff) : "";
if (diff)
fileMap.set(key, {
path: file.path,
diff,
status: file.status,
additions: file.additions ?? 0,
deletions: file.deletions ?? 0,
staged: file.staged,
source: "uncommitted",
repository_name: file.repository_name,
});
fileMap.set(key, {
path: file.path,
diff,
status: normalizeFileChangeStatus(file.status),
additions: file.additions ?? 0,
deletions: file.deletions ?? 0,
staged: file.staged,
source: "uncommitted",
old_path: file.old_path,
diff_skip_reason: file.diff_skip_reason,
repository_name: file.repository_name,
});
}
}
function prFileStatus(status: string): "added" | "deleted" | "modified" {
if (status === "added") return "added";
if (status === "removed") return "deleted";
return "modified";
}
function addPRFiles(fileMap: Map<string, ReviewFile>, files: PRDiffFile[]) {
function addPRFiles(fileMap: Map<string, ReviewFile>, files: PRDiffFile[], repoName?: string) {
const repositoryName = repoName || undefined;
for (const file of files) {
if (fileMap.has(file.filename)) continue;
const key = fileMapKey(file.filename, repositoryName);
const hasRepoUnawareCollision = !!repositoryName && fileMap.has(file.filename);
if (fileMap.has(key) || hasRepoUnawareCollision) continue;
const diff = file.patch ? normalizeDiffContent(file.patch) : "";
if (diff)
fileMap.set(file.filename, {
path: file.filename,
diff,
status: prFileStatus(file.status),
additions: file.additions ?? 0,
deletions: file.deletions ?? 0,
staged: false,
source: "pr",
});
fileMap.set(key, {
path: file.filename,
diff,
status: normalizeFileChangeStatus(file.status),
additions: file.additions ?? 0,
deletions: file.deletions ?? 0,
staged: false,
source: "pr",
old_path: file.old_path,
repository_name: repositoryName,
});
}
}
@@ -139,19 +122,28 @@ export function buildAllFiles(
gitStatusFiles: Record<string, FileInfo> | null,
cumulativeDiff: CumulativeDiff | null,
prDiffFiles?: PRDiffFile[],
prRepoName?: string,
useRepositoryKeys = true,
): ReviewFile[] {
const fileMap = new Map<string, ReviewFile>();
// Order matters and must match `buildReviewSources` (the Changes panel
// builder): uncommitted FIRST so its always-fresh WS-pushed diff content
// wins over the polled cumulative diff for files that exist in both. Before
// Keep priority, dedup keys, and sorting aligned with `buildReviewSources`
// in `hooks/domains/session/use-review-sources.ts`. Uncommitted goes first,
// so its always-fresh WS-pushed diff content wins over the polled cumulative
// diff for files that exist in both. Before
// this, the dialog and the panel disagreed on `datasource.ts`-style files
// (panel: fresh worktree content from `git-status`, dialog: stale cumulative
// diff snapshot from the last fetch) — the dialog appeared to show outdated
// content even though the cumulative-diff hook was successfully refetching.
if (gitStatusFiles) addUncommittedFiles(fileMap, gitStatusFiles);
if (cumulativeDiff?.files) addCumulativeDiffFiles(fileMap, cumulativeDiff.files, gitStatusFiles);
if (prDiffFiles) addPRFiles(fileMap, prDiffFiles);
return Array.from(fileMap.values()).sort((a, b) => a.path.localeCompare(b.path));
if (cumulativeDiff?.files) {
addCumulativeDiffFiles(fileMap, cumulativeDiff.files, useRepositoryKeys);
}
if (prDiffFiles) addPRFiles(fileMap, prDiffFiles, prRepoName);
return Array.from(fileMap.values()).sort((a, b) => {
const repoCmp = (a.repository_name ?? "").localeCompare(b.repository_name ?? "");
if (repoCmp !== 0) return repoCmp;
return a.path.localeCompare(b.path);
});
}
export function filterPendingDiffCommentsForSession(
@@ -173,6 +165,8 @@ type ReviewDialogProps = {
gitStatusFiles: Record<string, FileInfo> | null;
cumulativeDiff: CumulativeDiff | null;
prDiffFiles?: PRDiffFile[];
prRepoName?: string;
useRepositoryKeys?: boolean;
};
function computeReviewSets(
@@ -343,6 +337,8 @@ function useReviewDialogState(props: ReviewDialogProps) {
gitStatusFiles,
cumulativeDiff,
prDiffFiles,
prRepoName,
useRepositoryKeys = true,
} = props;
const [selectedFile, setSelectedFile] = useState<string | null>(null);
const [splitView, setSplitView] = useState(() =>
@@ -361,8 +357,8 @@ function useReviewDialogState(props: ReviewDialogProps) {
const [filter, setFilter] = useState("");
const allFiles = useMemo<ReviewFile[]>(
() => buildAllFiles(gitStatusFiles, cumulativeDiff, prDiffFiles),
[gitStatusFiles, cumulativeDiff, prDiffFiles],
() => buildAllFiles(gitStatusFiles, cumulativeDiff, prDiffFiles, prRepoName, useRepositoryKeys),
[gitStatusFiles, cumulativeDiff, prDiffFiles, prRepoName, useRepositoryKeys],
);
const filteredFiles = useFilteredReviewFiles(allFiles, filter);
const { reviewedFiles, staleFiles } = useMemo(
@@ -68,6 +68,20 @@ function withTooltips(node: ReactNode) {
return <TooltipProvider>{node}</TooltipProvider>;
}
function renderSingleFile(reviewFile: ReviewFile, selected = true) {
const refs = new Map([[reviewFile.path, createRef<HTMLDivElement>()]]);
render(
withTooltips(
<ReviewDiffList
{...baseProps}
files={[reviewFile]}
selectedFile={selected ? reviewFile.path : null}
fileRefs={refs}
/>,
),
);
}
describe("ReviewDiffList — multi-repo grouping", () => {
it("renders no repo header when all files lack a repository_name", () => {
const refs = new Map([["a.ts", createRef<HTMLDivElement>()]]);
@@ -121,3 +135,68 @@ describe("ReviewDiffList — multi-repo grouping", () => {
expect(yGroup?.textContent).not.toContain("a.ts");
});
});
describe("ReviewDiffList — file status rendering", () => {
it("shows moved status in the mobile header and honest copy for a patchless rename", () => {
const movedFile = {
...file("new-name.ts"),
diff: "",
status: "renamed",
old_path: "old-name.ts",
} as ReviewFile;
renderSingleFile(movedFile);
const marker = screen.getByRole("img", { name: "Moved from old-name.ts" });
expect(marker.className).toContain("sm:hidden");
expect(screen.getByText("Moved from old-name.ts; no textual changes")).toBeTruthy();
});
it("treats a nonempty 100%-rename metadata diff as patchless", () => {
const movedFile = {
...file("new-name.ts"),
diff: [
"diff --git a/old-name.ts b/new-name.ts",
"similarity index 100%",
"rename from old-name.ts",
"rename to new-name.ts",
].join("\n"),
status: "renamed",
old_path: "old-name.ts",
} as ReviewFile;
renderSingleFile(movedFile);
expect(screen.getByText("Moved from old-name.ts; no textual changes")).toBeTruthy();
expect(screen.queryByTestId("diff-stub")).toBeNull();
});
it("treats a synthetic added-file hunk for a zero-stat rename as patchless", () => {
const movedFile = {
...file("new-name.ts"),
diff: [
"diff --git a/new-name.ts b/new-name.ts",
"new file mode 100644",
"--- /dev/null",
"+++ b/new-name.ts",
"@@ -0,0 +1,2 @@",
"+first line",
"+second line",
].join("\n"),
status: "renamed",
additions: 0,
deletions: 0,
old_path: "old-name.ts",
} as ReviewFile;
renderSingleFile(movedFile);
expect(screen.getByText("Moved from old-name.ts; no textual changes")).toBeTruthy();
expect(screen.queryByTestId("diff-stub")).toBeNull();
});
it("keeps the loading state for a nonempty diff deferred by lazy rendering", () => {
const deferredFile = file("deferred.ts");
renderSingleFile(deferredFile, false);
expect(screen.getByText("Loading diff...")).toBeTruthy();
expect(screen.queryByText("No textual diff available")).toBeNull();
});
});
@@ -4,6 +4,7 @@ import { memo, useEffect, useMemo, useRef, useState, useCallback } from "react";
import { IconAlertTriangle, IconChevronDown, IconChevronRight } from "@tabler/icons-react";
import { Checkbox } from "@kandev/ui/checkbox";
import { FileDiffViewer, DiffErrorBoundary } from "@/components/diff";
import { FileStatusIcon } from "@/components/shared/file-status-icon";
import type { RevertBlockInfo } from "@/components/diff";
import { getWebSocketClient } from "@/lib/ws/connection";
import { requestFileContent, updateFileContent } from "@/lib/ws/workspace-files";
@@ -13,7 +14,12 @@ import { useToast } from "@/components/toast-provider";
import { useRunComment } from "@/hooks/domains/comments/use-run-comment";
import { useBaseBranchByRepo } from "@/hooks/domains/session/use-base-branch-by-repo";
import type { DiffComment } from "@/lib/diff/types";
import { diffSkipReasonLabel, reviewFileKey } from "./types";
import {
diffSkipReasonLabel,
hasTextualDiff,
reviewDiffUnavailableLabel,
reviewFileKey,
} from "./types";
import type { ReviewFile } from "./types";
import { RepoGroupHeader } from "./review-diff-list-groups";
import { FileDiffToolbar } from "./review-diff-toolbar";
@@ -245,7 +251,11 @@ function FileDiffHeader({
onToggleWordWrap,
}: FileDiffHeaderProps) {
return (
<div className="sticky top-0 z-10 flex items-center gap-2 px-4 py-2 bg-card/95 backdrop-blur-sm border-b border-border/50">
<div
data-testid="review-file-header"
data-file-path={file.path}
className="sticky top-0 z-10 flex items-center gap-2 px-4 py-2 bg-card/95 backdrop-blur-sm border-b border-border/50"
>
<Checkbox
checked={isReviewed}
onCheckedChange={onCheckboxChange}
@@ -262,6 +272,7 @@ function FileDiffHeader({
)}
<span className="text-[13px] font-medium truncate">{file.path}</span>
</button>
<FileStatusIcon status={file.status} oldPath={file.old_path} className="sm:hidden" />
{isStale && (
<span className="flex items-center gap-1 text-xs text-yellow-500">
<IconAlertTriangle className="h-3.5 w-3.5" />
@@ -438,7 +449,8 @@ function renderDiffContent(opts: {
onCommentRun,
onToggleExpandUnchanged,
} = opts;
if (shouldRender && file.diff) {
const hasText = hasTextualDiff(file);
if (shouldRender && hasText) {
return (
<>
<DiffErrorBoundary filePath={file.path}>
@@ -468,9 +480,12 @@ function renderDiffContent(opts: {
</>
);
}
const message = hasText
? diffSkipReasonLabel(file.diff_skip_reason)
: reviewDiffUnavailableLabel(file);
return (
<div className="flex items-center justify-center py-12 text-muted-foreground text-sm">
{diffSkipReasonLabel(file.diff_skip_reason)}
{message}
</div>
);
}
@@ -43,6 +43,23 @@ function renderTree(files: ReviewFile[], overrides: RenderOpts = {}) {
return { onFilterChange, onSelectFile, onToggleReviewed };
}
describe("ReviewFileTree status markers", () => {
it("renders each file status as the final fixed row item", () => {
renderTree([
file({ path: "src/added.ts", status: "added" }),
file({ path: "src/modified.ts", status: "modified" }),
file({ path: "src/deleted.ts", status: "deleted" }),
file({ path: "src/moved.ts", status: "renamed" }),
]);
for (const label of ["Added", "Modified", "Deleted", "Moved"]) {
const marker = screen.getByRole("img", { name: label });
expect(marker.parentElement?.lastElementChild).toBe(marker);
expect(marker.className).toContain("shrink-0");
}
});
});
describe("ReviewFileTree", () => {
it("renders one row per file", () => {
renderTree([file({ path: "src/a.ts" }), file({ path: "src/b.ts" })]);
@@ -153,7 +170,9 @@ describe("ReviewFileTree", () => {
// backend's child is still visible.
expect(screen.getByText("task.go")).toBeTruthy();
});
});
describe("ReviewFileTree multi-repo identity", () => {
it("disambiguates same-named files in different repos via composite key", () => {
const fA = file({ path: "README.md", repository_name: "frontend", repository_id: "f" });
const fB = file({ path: "README.md", repository_name: "backend", repository_id: "b" });
@@ -169,4 +188,17 @@ describe("ReviewFileTree", () => {
expect(calls).toContain(reviewFileKey(fA));
expect(calls).toContain(reviewFileKey(fB));
});
it("exposes a stable repository discriminator for same-path rows", () => {
renderTree([
file({ path: "README.md", repository_name: "frontend", repository_id: "f" }),
file({ path: "README.md", repository_name: "backend", repository_id: "b" }),
]);
const identities = screen
.getAllByTestId("review-file-row")
.map((row) => `${row.dataset.repositoryName}:${row.dataset.filePath}`)
.sort();
expect(identities).toEqual(["backend:README.md", "frontend:README.md"]);
});
});
@@ -11,6 +11,7 @@ import {
} from "@tabler/icons-react";
import { Checkbox } from "@kandev/ui/checkbox";
import { cn } from "@kandev/ui/lib/utils";
import { FileStatusIcon } from "@/components/shared/file-status-icon";
import { FileIcon } from "@/components/ui/file-icon";
import { useTree, type VisibleRow } from "@/hooks/use-tree";
import type { ReviewFile, FileTreeNode } from "./types";
@@ -182,6 +183,9 @@ function ReviewFileRow({
const isSelected = selectedFile === key;
return (
<div
data-testid="review-file-row"
data-file-path={file.path}
data-repository-name={file.repository_name}
className={cn(
"flex items-center gap-1.5 px-2 py-1 cursor-pointer transition-colors group",
isSelected ? "bg-accent/50" : "hover:bg-muted/50",
@@ -196,14 +200,17 @@ function ReviewFileRow({
className="h-3.5 w-3.5"
/>
<FileIcon fileName={row.node.name} className="h-4 w-4 shrink-0" />
<span className="text-[13px] truncate flex-1">{row.node.name}</span>
<span data-review-file-name className="text-[13px] truncate flex-1 min-w-0">
{row.node.name}
</span>
{isStale && <IconAlertTriangle className="h-3 w-3 text-yellow-500 shrink-0" />}
{commentCount > 0 && (
<span className="flex items-center gap-0.5 text-[10px] text-blue-500">
<span className="flex items-center gap-0.5 text-[10px] text-blue-500 shrink-0">
<IconMessage className="h-3 w-3" />
{commentCount}
</span>
)}
<FileStatusIcon status={file.status} oldPath={file.old_path} />
</div>
);
}
@@ -1,5 +1,14 @@
import { describe, it, expect } from "vitest";
import { buildFileTree, reviewFileKey, splitReviewFileKey, type ReviewFile } from "./types";
import {
buildFileTree,
getCumulativeReviewRepositoryNames,
isReviewMultiRepo,
resolvePRReviewRepositoryName,
reviewFileKey,
sanitizeReviewRepositoryName,
splitReviewFileKey,
type ReviewFile,
} from "./types";
const SEP = "\u0000";
@@ -105,6 +114,62 @@ describe("reviewFileKey", () => {
});
});
describe("resolvePRReviewRepositoryName", () => {
it("uses the sanitized workspace repository name instead of the provider repo name", () => {
expect(
resolvePRReviewRepositoryName({ repository_id: "repo-1", repo: "widgets" }, "acme/widgets"),
).toBe("acme-widgets");
});
it("falls back to the provider repo name for legacy or unhydrated workspace data", () => {
expect(resolvePRReviewRepositoryName({ repo: "widgets" }, "acme/widgets")).toBe("widgets");
expect(resolvePRReviewRepositoryName({ repository_id: "repo-1", repo: "widgets" })).toBe(
"widgets",
);
});
});
describe("sanitizeReviewRepositoryName", () => {
it.each([
["acme/widgets", "acme-widgets"],
["owner\\repo", "owner-repo"],
["weird:name space", "weird-name-space"],
["with..dots", "with..dots"],
["--a///b..", "a-b"],
["修复/登录", ""],
])("mirrors the backend repository directory sanitizer for %s", (input, expected) => {
expect(sanitizeReviewRepositoryName(input)).toBe(expected);
});
});
describe("isReviewMultiRepo", () => {
it("detects multiple named git-status repositories before task links hydrate", () => {
expect(isReviewMultiRepo(0, ["frontend", "backend"])).toBe(true);
});
it("uses task links during partial status hydration", () => {
expect(isReviewMultiRepo(2, ["frontend"])).toBe(true);
});
it("keeps one named or legacy repository in single-repo mode", () => {
expect(isReviewMultiRepo(1, ["frontend"])).toBe(false);
expect(isReviewMultiRepo(1, [""])).toBe(false);
});
});
describe("getCumulativeReviewRepositoryNames", () => {
it("returns distinct stamped repository names", () => {
expect(
getCumulativeReviewRepositoryNames({
a: { repository_name: "frontend" },
b: { repository_name: "backend" },
c: { repository_name: "frontend" },
d: {},
}),
).toEqual(["frontend", "backend"]);
});
});
describe("splitReviewFileKey", () => {
it("returns empty repositoryName for legacy bare-path keys", () => {
expect(splitReviewFileKey("README.md")).toEqual({ repositoryName: "", path: "README.md" });
+122
View File
@@ -0,0 +1,122 @@
import { describe, expect, it } from "vitest";
import { hasTextualDiff, reviewDiffUnavailableLabel, type ReviewFile } from "./types";
function patchlessFile(overrides: Partial<ReviewFile>): ReviewFile {
return {
path: "src/new-name.ts",
diff: "",
status: "renamed",
additions: 0,
deletions: 0,
staged: false,
source: "pr",
...overrides,
};
}
describe("reviewDiffUnavailableLabel", () => {
it("describes a pure move using its previous path", () => {
const file = patchlessFile({ old_path: "src/old-name.ts" });
expect(reviewDiffUnavailableLabel(file)).toBe("Moved from src/old-name.ts; no textual changes");
});
it("prefers an explicit diff skip reason over move text", () => {
const file = patchlessFile({
old_path: "src/old-name.ts",
diff_skip_reason: "binary",
});
expect(reviewDiffUnavailableLabel(file)).toBe("Binary file — not diffable");
});
it.each([
["added", "Added file has no textual diff"],
["untracked", "Untracked file has no textual diff"],
["deleted", "Deleted file has no textual diff"],
] as const)("describes a patchless %s file", (status, expected) => {
const file = patchlessFile({ status, old_path: undefined });
expect(reviewDiffUnavailableLabel(file)).toBe(expected);
});
});
describe("hasTextualDiff", () => {
it.each<[string, Partial<ReviewFile>]>([
["empty input", { diff: "", status: "modified" }],
[
"pure rename metadata",
{
diff: [
"diff --git a/src/old.ts b/src/new.ts",
"similarity index 100%",
"rename from src/old.ts",
"rename to src/new.ts",
].join("\n"),
status: "renamed",
},
],
[
"empty added-file metadata",
{
diff: [
"diff --git a/empty.txt b/empty.txt",
"new file mode 100644",
"index 0000000..e69de29",
].join("\n"),
status: "added",
},
],
[
"zero-stat rename with a synthetic added-file hunk",
{
diff: [
"diff --git a/src/new.ts b/src/new.ts",
"new file mode 100644",
"--- /dev/null",
"+++ b/src/new.ts",
"@@ -0,0 +1,2 @@",
"+first line",
"+second line",
].join("\n"),
status: "renamed",
additions: 0,
deletions: 0,
},
],
[
"empty untracked zero-count hunk",
{
diff: [
"diff --git a/empty.txt b/empty.txt",
"new file mode 100644",
"--- /dev/null",
"+++ b/empty.txt",
"@@ -0,0 +1,0 @@",
].join("\n"),
status: "untracked",
additions: 0,
deletions: 0,
},
],
])("returns false for %s", (_name, overrides) => {
expect(hasTextualDiff(patchlessFile(overrides))).toBe(false);
});
it.each([
["modified file", "modified", 1, 1],
["renamed file with a real delta", "renamed", 1, 1],
] as const)("returns true for a %s hunk", (_name, status, additions, deletions) => {
const file = patchlessFile({
diff: [
"diff --git a/src/app.ts b/src/app.ts",
"--- a/src/app.ts",
"+++ b/src/app.ts",
"@@ -1 +1 @@",
"-before",
"+after",
].join("\n"),
status,
additions,
deletions,
});
expect(hasTextualDiff(file)).toBe(true);
});
});
+96 -1
View File
@@ -1,13 +1,15 @@
import { djb2Hash } from "@/lib/utils/hash";
import type { FileChangeStatus } from "@/lib/utils/file-change-status";
export type ReviewFile = {
path: string;
diff: string;
status: string;
status: FileChangeStatus;
additions: number;
deletions: number;
staged: boolean;
source: "uncommitted" | "committed" | "pr";
old_path?: string;
diff_skip_reason?: "too_large" | "binary" | "truncated" | "budget_exceeded";
/**
* Repository this file belongs to. Set on multi-repo task changes so the
@@ -34,6 +36,57 @@ export function reviewFileKey(file: { path: string; repository_name?: string }):
return file.repository_name ? `${file.repository_name}${FILE_KEY_SEP}${file.path}` : file.path;
}
/** Mirrors backend `worktree.SanitizeRepoDirName`, which defines the
* repository_name stamped on multi-repo agentctl events. */
export function sanitizeReviewRepositoryName(name: string): string {
let sanitized = "";
for (const character of name) {
const code = character.charCodeAt(0);
const isASCIIAlphaNumeric =
(code >= 48 && code <= 57) || (code >= 65 && code <= 90) || (code >= 97 && code <= 122);
sanitized +=
isASCIIAlphaNumeric || character === "_" || character === "." || character === "-"
? character
: "-";
}
return sanitized.replace(/-+/g, "-").replace(/^[-.]+|[-.]+$/g, "");
}
export function resolvePRReviewRepositoryName(
pr: { repository_id?: string; repo: string } | null | undefined,
workspaceRepositoryName?: string | null,
): string | undefined {
if (!pr) return undefined;
if (pr.repository_id && workspaceRepositoryName) {
const canonicalName = sanitizeReviewRepositoryName(workspaceRepositoryName);
if (canonicalName) return canonicalName;
}
return pr.repo || undefined;
}
export function isReviewMultiRepo(
taskRepositoryCount: number,
repositoryNames: Iterable<string>,
): boolean {
if (taskRepositoryCount > 1) return true;
const namedRepositories = new Set<string>();
for (const name of repositoryNames) {
if (name) namedRepositories.add(name);
if (namedRepositories.size > 1) return true;
}
return false;
}
export function getCumulativeReviewRepositoryNames(
files: Record<string, { repository_name?: string }> | null | undefined,
): string[] {
const names = new Set<string>();
for (const file of Object.values(files ?? {})) {
if (file.repository_name) names.add(file.repository_name);
}
return Array.from(names);
}
export function splitReviewFileKey(key: string): { repositoryName: string; path: string } {
const sep = key.indexOf(FILE_KEY_SEP);
if (sep < 0) return { repositoryName: "", path: key };
@@ -55,6 +108,48 @@ export function diffSkipReasonLabel(reason?: string): string {
}
}
const TEXTUAL_HUNK_HEADER = /^@@+ (.+?) @@+/;
const HUNK_RANGE = /[+-]\d+(?:,(\d+))?/g;
function hunkHeaderHasLines(line: string): boolean {
const header = line.match(TEXTUAL_HUNK_HEADER);
if (!header) return false;
return [...header[1].matchAll(HUNK_RANGE)].some((range) => Number(range[1] ?? "1") > 0);
}
/** True when a file has a real textual delta, excluding git's synthetic rename/empty hunks. */
export function hasTextualDiff(
file: Pick<ReviewFile, "diff" | "status" | "additions" | "deletions">,
): boolean {
// GitHub sends 0+0 stats for pure renames even when the diff contains a
// synthetic new-file hunk. Trust the stats because that hunk represents the
// same file at a new path with no textual change.
if (file.status === "renamed" && file.additions === 0 && file.deletions === 0) return false;
return file.diff.split("\n").some(hunkHeaderHasLines);
}
export function reviewDiffUnavailableLabel(file: ReviewFile): string {
if (file.diff_skip_reason) return diffSkipReasonLabel(file.diff_skip_reason);
switch (file.status) {
case "added":
return "Added file has no textual diff";
case "untracked":
return "Untracked file has no textual diff";
case "deleted":
return "Deleted file has no textual diff";
case "renamed":
return file.old_path
? `Moved from ${file.old_path}; no textual changes`
: "File moved; no textual changes";
case "modified":
return "No textual diff available";
default: {
const exhaustiveStatus: never = file.status;
return exhaustiveStatus;
}
}
}
export type FileTreeNode = {
name: string;
path: string;
@@ -0,0 +1,38 @@
import { cleanup, render, screen } from "@testing-library/react";
import { afterEach, describe, expect, it } from "vitest";
import type { FileChangeStatus } from "@/lib/utils/file-change-status";
import { FileStatusIcon } from "./file-status-icon";
afterEach(cleanup);
describe("FileStatusIcon", () => {
it.each<[FileChangeStatus, string, string]>([
["added", "Added", "tabler-icon-plus"],
["untracked", "Untracked", "tabler-icon-plus"],
["modified", "Modified", "tabler-icon-circle-filled"],
["deleted", "Deleted", "tabler-icon-minus"],
["renamed", "Moved", "tabler-icon-arrow-right"],
])("renders an accessible %s marker", (status, label, glyphClass) => {
render(<FileStatusIcon status={status} />);
const marker = screen.getByRole("img", { name: label });
expect(marker.getAttribute("data-file-status")).toBe(status);
expect(marker.getAttribute("title")).toBe(label);
expect(marker.querySelector(`.${glyphClass}`)).not.toBeNull();
});
it("exposes previous-path context for a moved file", () => {
render(<FileStatusIcon status="renamed" oldPath="src/old-name.ts" />);
const marker = screen.getByRole("img", { name: "Moved from src/old-name.ts" });
expect(marker.getAttribute("title")).toBe("Moved from src/old-name.ts");
});
it("merges caller classes into the fixed-size marker", () => {
render(<FileStatusIcon status="modified" className="custom-marker" />);
const marker = screen.getByRole("img", { name: "Modified" });
expect(marker.className).toContain("shrink-0");
expect(marker.className).toContain("custom-marker");
});
});
@@ -0,0 +1,57 @@
"use client";
import { IconArrowRight, IconCircleFilled, IconMinus, IconPlus } from "@tabler/icons-react";
import { cn } from "@/lib/utils";
import { fileChangeStatusLabel, type FileChangeStatus } from "@/lib/utils/file-change-status";
type FileStatusIconProps = {
status: FileChangeStatus;
oldPath?: string;
className?: string;
};
const markerClassName: Record<FileChangeStatus, string> = {
added: "border-emerald-600 text-emerald-600",
untracked: "border-emerald-600 text-emerald-600",
modified: "border-amber-600 text-amber-600",
deleted: "border-rose-600 text-rose-600",
renamed: "border-purple-600 text-purple-600",
};
function StatusGlyph({ status }: { status: FileChangeStatus }) {
switch (status) {
case "added":
case "untracked":
return <IconPlus aria-hidden="true" className="h-2 w-2" />;
case "modified":
return <IconCircleFilled aria-hidden="true" className="h-1 w-1" />;
case "deleted":
return <IconMinus aria-hidden="true" className="h-2 w-2" />;
case "renamed":
return <IconArrowRight aria-hidden="true" className="h-2 w-2" />;
default: {
const exhaustiveStatus: never = status;
return exhaustiveStatus;
}
}
}
export function FileStatusIcon({ status, oldPath, className }: FileStatusIconProps) {
const label = fileChangeStatusLabel(status, oldPath);
return (
<span
role="img"
aria-label={label}
title={label}
data-file-status={status}
className={cn(
"flex h-3 w-3 shrink-0 items-center justify-center rounded border",
markerClassName[status],
className,
)}
>
<StatusGlyph status={status} />
</span>
);
}
+47 -13
View File
@@ -8,13 +8,17 @@ import { useEnvironmentSessionId } from "@/hooks/use-environment-session-id";
import { useToast } from "@/components/toast-provider";
import { useVcsDialogs } from "@/components/vcs/vcs-dialogs";
import type { PRChangedFile } from "./changes-panel-timeline";
import type { PRDiffFile } from "@/lib/types/github";
import { useChangesGitHandlers, useChangesDialogHandlers } from "./changes-panel-hooks";
import { useRepoDisplayName } from "@/hooks/domains/session/use-repo-display-name";
import { useBaseBranchByRepo } from "@/hooks/domains/session/use-base-branch-by-repo";
import { useActiveTaskPR } from "@/hooks/domains/github/use-task-pr";
import { useActiveTaskPRsWithFiles } from "@/hooks/domains/github/use-active-task-pr-files";
import { usePRCommits } from "@/hooks/domains/github/use-pr-commits";
import {
getCumulativeReviewRepositoryNames,
isReviewMultiRepo,
resolvePRReviewRepositoryName,
} from "@/components/review/types";
import {
type ChangedFile,
computePRGroupStamp,
@@ -25,6 +29,8 @@ import {
mapToChangedFiles,
buildPrByRepoMap,
buildRepoNameById,
selectPRFilesForReviewProgress,
type ReviewProgressPRSource,
} from "./changes-panel-helpers";
import type { OpenDiffOptions } from "./changes-diff-target";
@@ -148,16 +154,18 @@ function usePerRepoCallbacks(
);
}
function useChangesPanelPRData() {
function useChangesPanelPRData(repositoryNames: string[]) {
const { prs, filesByPRKey } = useActiveTaskPRsWithFiles();
const reposByWorkspace = useAppStore((s) => s.repositories.itemsByWorkspaceId);
const repoNameById = useMemo(() => buildRepoNameById(reposByWorkspace), [reposByWorkspace]);
const taskHasMultipleRepos = useAppStore((s) => {
const taskRepositoryCount = useAppStore((s) => {
const taskId = s.tasks.activeTaskId;
if (!taskId) return false;
if (!taskId) return 0;
const task = s.kanban.tasks.find((t: { id: string }) => t.id === taskId);
return (task?.repositories?.length ?? 0) > 1;
return task?.repositories?.length ?? 0;
});
const taskHasMultipleRepos = taskRepositoryCount > 1;
const useRepositoryKeys = isReviewMultiRepo(taskRepositoryCount, repositoryNames);
const taskPR = useActiveTaskPR();
const refreshKey = taskPR?.last_synced_at ?? null;
const { commits: prCommitsList } = usePRCommits(
@@ -168,7 +176,7 @@ function useChangesPanelPRData() {
);
const { prFiles, prDiffFiles } = useMemo(() => {
const merged: PRChangedFile[] = [];
const flat: PRDiffFile[] = [];
const progressSources = new Map<string, ReviewProgressPRSource>();
// Multi-branch tasks open multiple PRs on the same repo — files must
// split by PR so the agent's two branches don't get conflated under one
// "kandev" header. We stamp each row with a label unique per PR.
@@ -200,13 +208,28 @@ function useChangesPanelPRData() {
prNumber: pr.pr_number,
});
merged.push(...mapPRFilesToChangedFiles(files, stamp));
flat.push(...files);
progressSources.set(pr.id, {
repositoryName: resolvePRReviewRepositoryName(pr, repoName || undefined) ?? pr.repo,
files,
});
}
return { prFiles: merged, prDiffFiles: flat };
}, [prs, filesByPRKey, repoNameById, taskHasMultipleRepos]);
const progressFiles = selectPRFilesForReviewProgress(
progressSources,
taskPR?.id,
useRepositoryKeys,
);
return { prFiles: merged, prDiffFiles: progressFiles };
}, [prs, filesByPRKey, repoNameById, taskHasMultipleRepos, taskPR?.id, useRepositoryKeys]);
const hasPRFiles = prFiles.length > 0;
const hasPRCommits = prCommitsList.length > 0;
return { prDiffFiles, prCommitsList, hasPRFiles, hasPRCommits, prFiles };
return {
prDiffFiles,
prCommitsList,
hasPRFiles,
hasPRCommits,
prFiles,
useRepositoryKeys,
};
}
function hasCumulativeFiles(files: Record<string, unknown> | null | undefined): boolean {
@@ -219,14 +242,25 @@ export function useChangesPanelData() {
const git = useSessionGit(activeSessionId);
const { toast } = useToast();
const { reviews } = useSessionFileReviews(activeSessionId);
const prData = useChangesPanelPRData();
const reviewRepositoryNames = useMemo(
() => [...git.repoNames, ...getCumulativeReviewRepositoryNames(git.cumulativeDiff?.files)],
[git.repoNames, git.cumulativeDiff],
);
const prData = useChangesPanelPRData(reviewRepositoryNames);
const vcsDialogs = useVcsDialogs();
const baseBranchDisplay = useMemo(() => getBaseBranchDisplay(baseBranch), [baseBranch]);
const unstagedFiles = useMemo(() => mapToChangedFiles(git.unstagedFiles), [git.unstagedFiles]);
const stagedFiles = useMemo(() => mapToChangedFiles(git.stagedFiles), [git.stagedFiles]);
const { reviewedCount, totalFileCount } = useMemo(
() => computeReviewProgress(git.allFiles, git.cumulativeDiff, reviews, prData.prDiffFiles),
[git.allFiles, git.cumulativeDiff, reviews, prData.prDiffFiles],
() =>
computeReviewProgress(
git.allFiles,
git.cumulativeDiff,
reviews,
prData.prDiffFiles,
prData.useRepositoryKeys,
),
[git.allFiles, git.cumulativeDiff, reviews, prData.prDiffFiles, prData.useRepositoryKeys],
);
const staged = useMemo(() => computeStagedStats(git.stagedFiles), [git.stagedFiles]);
const gitHandlers = useChangesGitHandlers(git, toast, baseBranch);
@@ -143,6 +143,35 @@ describe("FileRow truncation (regression: path overlaps diff stats in narrow pan
});
});
describe("FileRow status marker", () => {
it("shows previous-path context for a moved file", () => {
const { container } = render(
<TooltipProvider>
<ul>
<FileRow
file={{
...baseFile,
path: "src/new-name.ts",
status: "renamed",
oldPath: "src/old-name.ts",
}}
isPending={false}
onSelect={noopSelect}
onOpenDiff={noop}
onStage={noop}
onUnstage={noop}
onDiscard={noop}
onEditFile={noop}
/>
</ul>
</TooltipProvider>,
);
const marker = container.querySelector("[data-file-status='renamed']");
expect(marker?.getAttribute("aria-label")).toBe("Moved from src/old-name.ts");
});
});
describe("FileRow hover swap (stats <-> actions occupy same cell)", () => {
// Goal: hovering must not shift the file name. The stats group and the
// hover-actions group live in the same CSS grid cell — stats fade out and
@@ -13,9 +13,9 @@ import { Button } from "@kandev/ui/button";
import { cn } from "@/lib/utils";
import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip";
import { LineStat } from "@/components/diff-stat";
import { FileStatusIcon } from "@/components/shared/file-status-icon";
import { FileIcon } from "@/components/ui/file-icon";
import { getFileCategory } from "@/lib/utils/file-types";
import { FileStatusIcon } from "./file-status-icon";
import type { ChangedFile } from "./changes-panel-helpers";
import type { OpenDiffOptions } from "./changes-diff-target";
@@ -139,7 +139,7 @@ export function FileRow({
<div className="grid items-center shrink-0 [&>*]:col-start-1 [&>*]:row-start-1">
<div className="flex items-center gap-2 justify-end transition-opacity group-hover:opacity-0 pointer-events-none">
<LineStat added={file.plus} removed={file.minus} />
<FileStatusIcon status={file.status} />
<FileStatusIcon status={file.status} oldPath={file.oldPath} />
</div>
<FileRowActions
path={file.path}
@@ -1,7 +1,17 @@
import { describe, it, expect } from "vitest";
import { buildPrByRepoMap, mapPRFilesToChangedFiles } from "./changes-panel-helpers";
import {
buildPrByRepoMap,
computeReviewProgress,
mapPRFilesToChangedFiles,
selectPRFilesForReviewProgress,
type ReviewProgressPRFile,
} from "./changes-panel-helpers";
import { hashDiff, resolvePRReviewRepositoryName, reviewFileKey } from "@/components/review/types";
import type { PRDiffFile } from "@/lib/types/github";
const PRIMARY_PR_ID = "primary-pr";
const RENAMED_PATH = "src/renamed.ts";
function diffFile(overrides: Partial<PRDiffFile>): PRDiffFile {
return {
filename: "src/app.ts",
@@ -13,6 +23,28 @@ function diffFile(overrides: Partial<PRDiffFile>): PRDiffFile {
} as PRDiffFile;
}
function progressPRFile(
overrides: Partial<PRDiffFile> & { repository_name?: string },
): ReviewProgressPRFile {
return {
...diffFile(overrides),
repository_name: overrides.repository_name,
};
}
function progressPRSource(repositoryName: string, filename: string) {
return { repositoryName, files: [diffFile({ filename })] };
}
type ProgressLocalFile = Parameters<typeof computeReviewProgress>[0][number];
function progressLocalFile(
path: string,
overrides: Partial<ProgressLocalFile> = {},
): ProgressLocalFile {
return { path, status: "modified", staged: false, diff: "", ...overrides };
}
describe("mapPRFilesToChangedFiles", () => {
it("maps GitHub status strings to FileInfo statuses", () => {
const out = mapPRFilesToChangedFiles([
@@ -20,8 +52,10 @@ describe("mapPRFilesToChangedFiles", () => {
diffFile({ filename: "b.ts", status: "removed" }),
diffFile({ filename: "c.ts", status: "renamed", old_path: "old/c.ts" }),
diffFile({ filename: "d.ts", status: "modified" }),
// Anything not in the explicit set should fall through to "modified".
diffFile({ filename: "e.ts", status: "weird" as PRDiffFile["status"] }),
diffFile({ filename: "e.ts", status: "copied" }),
diffFile({ filename: "f.ts", status: "changed" }),
diffFile({ filename: "g.ts", status: "unchanged" }),
diffFile({ filename: "h.ts", status: "weird" as PRDiffFile["status"] }),
]);
expect(out.map((f) => f.status)).toEqual([
"added",
@@ -29,6 +63,9 @@ describe("mapPRFilesToChangedFiles", () => {
"renamed",
"modified",
"modified",
"modified",
"modified",
"modified",
]);
});
@@ -69,6 +106,285 @@ describe("mapPRFilesToChangedFiles", () => {
});
});
describe("selectPRFilesForReviewProgress", () => {
it("selects only the primary PR and qualifies it in a multi-repo task", () => {
const repositoryName = resolvePRReviewRepositoryName(
{ repository_id: "repo-frontend", repo: "widgets" },
"acme/widgets",
);
const files = selectPRFilesForReviewProgress(
new Map([
[PRIMARY_PR_ID, progressPRSource(repositoryName ?? "widgets", "README.md")],
["secondary-pr", progressPRSource("backend", "README.md")],
]),
PRIMARY_PR_ID,
true,
);
expect(files.map((file) => reviewFileKey({ path: file.filename, ...file }))).toEqual([
"acme-widgets\u0000README.md",
]);
});
it("keeps the selected primary PR bare in a legacy single-repo task", () => {
const files = selectPRFilesForReviewProgress(
new Map([[PRIMARY_PR_ID, progressPRSource("kandev", "README.md")]]),
PRIMARY_PR_ID,
false,
);
expect(files.map((file) => reviewFileKey({ path: file.filename, ...file }))).toEqual([
"README.md",
]);
});
});
describe("computeReviewProgress PR files", () => {
it("counts same-path PR reviews per repository using each patch hash", () => {
const path = "README.md";
const frontendPatch = "@@ -1 +1 @@\n-frontend old\n+frontend new";
const backendPatch = "@@ -1 +1 @@\n-backend old\n+backend new";
const frontendKey = reviewFileKey({ path, repository_name: "frontend" });
const backendKey = reviewFileKey({ path, repository_name: "backend" });
const progress = computeReviewProgress(
[],
null,
new Map([
[frontendKey, { reviewed: true, diffHash: hashDiff(frontendPatch) }],
[backendKey, { reviewed: true, diffHash: hashDiff(backendPatch) }],
]),
[
progressPRFile({ filename: path, patch: frontendPatch, repository_name: "frontend" }),
progressPRFile({ filename: path, patch: backendPatch, repository_name: "backend" }),
],
);
expect(progress).toEqual({ reviewedCount: 2, totalFileCount: 2 });
});
it("keeps a same-repo uncommitted file over PR data without dropping another repo", () => {
const path = "README.md";
const frontendDiff = "@@ -1 +1 @@\n-local old\n+local new";
const backendPatch = "@@ -1 +1 @@\n-backend old\n+backend new";
const frontendKey = reviewFileKey({ path, repository_name: "frontend" });
const backendKey = reviewFileKey({ path, repository_name: "backend" });
const progress = computeReviewProgress(
[progressLocalFile(path, { diff: frontendDiff, repository_name: "frontend" })],
null,
new Map([
[frontendKey, { reviewed: true, diffHash: hashDiff(frontendDiff) }],
[backendKey, { reviewed: true, diffHash: hashDiff(backendPatch) }],
]),
[
progressPRFile({
filename: path,
patch: "@@ -1 +1 @@\n-pr old\n+pr new",
repository_name: "frontend",
}),
progressPRFile({ filename: path, patch: backendPatch, repository_name: "backend" }),
],
);
expect(progress).toEqual({ reviewedCount: 2, totalFileCount: 2 });
});
});
describe("computeReviewProgress rejected source precedence", () => {
const uncommittedWinner: Parameters<typeof computeReviewProgress>[0] = [
progressLocalFile(RENAMED_PATH, { status: "renamed", old_path: "src/old.ts" }),
];
const cumulativeWinner: Parameters<typeof computeReviewProgress>[1] = {
files: { [RENAMED_PATH]: { path: RENAMED_PATH, status: "renamed", diff: "" } },
};
it.each([
{ name: "uncommitted", uncommittedFiles: uncommittedWinner, cumulativeDiff: null },
{ name: "cumulative", uncommittedFiles: [], cumulativeDiff: cumulativeWinner },
])("does not hash a rejected PR patch past a patchless $name winner", (winner) => {
const progress = computeReviewProgress(
winner.uncommittedFiles,
winner.cumulativeDiff,
new Map([[RENAMED_PATH, { reviewed: true, diffHash: hashDiff("") }]]),
[diffFile({ filename: RENAMED_PATH, patch: "@@rejected@@" })],
);
expect(progress).toEqual({ reviewedCount: 1, totalFileCount: 1 });
});
});
describe("computeReviewProgress source precedence", () => {
it("keeps bare uncommitted precedence over a stamped cumulative file at the same path", () => {
const path = RENAMED_PATH;
const progress = computeReviewProgress(
[
{
path,
status: "renamed",
staged: false,
additions: 0,
deletions: 0,
old_path: "src/old.ts",
diff: "",
},
],
{
files: {
[`frontend\u0000${path}`]: {
path,
repository_name: "frontend",
diff: "@@ -1 +1 @@\n-old\n+new",
},
},
},
new Map([[path, { reviewed: true }]]),
);
expect(progress).toEqual({ reviewedCount: 1, totalFileCount: 1 });
});
it("keeps same-path composite keys from different repositories distinct", () => {
const path = "README.md";
const progress = computeReviewProgress(
[
{
path,
status: "modified",
staged: false,
diff: "",
repository_name: "frontend",
},
],
{
files: {
[`backend\u0000${path}`]: {
path,
repository_name: "backend",
diff: "",
},
},
},
new Map(),
);
expect(progress.totalFileCount).toBe(2);
});
it("counts a reviewed patchless file under its repository-qualified review key", () => {
const file = {
path: RENAMED_PATH,
status: "renamed" as const,
staged: false,
additions: 0,
deletions: 0,
old_path: "src/old.ts",
diff: "",
repository_name: "frontend",
};
const key = reviewFileKey(file);
expect(computeReviewProgress([file], null, new Map([[key, { reviewed: true }]]))).toEqual({
reviewedCount: 1,
totalFileCount: 1,
});
});
it("counts patchless-only status and PR files", () => {
const progress = computeReviewProgress(
[
{
path: "src/local-renamed.ts",
status: "renamed",
staged: false,
additions: 0,
deletions: 0,
old_path: "src/local-old.ts",
diff: "",
},
],
null,
new Map(),
[
diffFile({
filename: "src/pr-renamed.ts",
status: "renamed",
additions: 0,
deletions: 0,
old_path: "src/pr-old.ts",
patch: "",
}),
],
);
expect(progress.totalFileCount).toBe(2);
});
});
describe("computeReviewProgress repository key mode", () => {
it("uses bare local, cumulative, and PR keys for a single named repository", () => {
const localPath = "src/local.ts";
const cumulativePath = "src/cumulative.ts";
const prPath = "src/pr.ts";
const progress = computeReviewProgress(
[progressLocalFile(localPath, { repository_name: "frontend", diff: "@@local@@" })],
{
files: {
[`frontend\u0000${cumulativePath}`]: {
path: cumulativePath,
repository_name: "frontend",
diff: "@@cumulative@@",
},
},
},
new Map([
[localPath, { reviewed: true }],
[cumulativePath, { reviewed: true }],
[prPath, { reviewed: true }],
]),
[progressPRFile({ filename: prPath, patch: "@@pr@@" })],
false,
);
expect(progress).toEqual({ reviewedCount: 3, totalFileCount: 3 });
});
});
describe("computeReviewProgress scalability", () => {
it("indexes cumulative file identities once for large review sets", () => {
const fileCount = 80;
let pathReads = 0;
const files = Object.fromEntries(
Array.from({ length: fileCount }, (_, index) => {
const path = `src/file-${index}.ts`;
return [
`frontend\u0000${path}`,
{
get path() {
pathReads++;
return path;
},
repository_name: "frontend",
diff: `@@ -1 +1 @@\n-old-${index}\n+new-${index}`,
},
];
}),
);
const reviews = new Map(
Array.from({ length: fileCount }, (_, index) => [
`frontend\u0000src/file-${index}.ts`,
{ reviewed: true },
]),
);
expect(computeReviewProgress([], { files }, reviews)).toEqual({
reviewedCount: fileCount,
totalFileCount: fileCount,
});
expect(pathReads).toBeLessThanOrEqual(fileCount * 4);
});
});
describe("buildPrByRepoMap", () => {
it("does not copy a multi-repo TaskPR URL into the empty-key fallback", () => {
const map = buildPrByRepoMap(
+120 -58
View File
@@ -1,13 +1,19 @@
"use client";
import { hashDiff, normalizeDiffContent } from "@/components/review/types";
import {
hashDiff,
normalizeDiffContent,
reviewFileKey,
splitReviewFileKey,
} from "@/components/review/types";
import type { FileInfo } from "@/lib/state/store";
import type { PRDiffFile } from "@/lib/types/github";
import { normalizeFileChangeStatus, type FileChangeStatus } from "@/lib/utils/file-change-status";
import type { PRChangedFile } from "./changes-panel-timeline";
export type ChangedFile = {
path: string;
status: FileInfo["status"];
status: FileChangeStatus;
staged: boolean;
plus: number | undefined;
minus: number | undefined;
@@ -59,65 +65,137 @@ export function mapToChangedFiles(files: FileInfo[]): ChangedFile[] {
type CumulativeDiffFiles = Record<
string,
{ diff?: string; status?: string; additions?: number; deletions?: number }
{
path?: string;
repository_name?: string;
diff?: string;
status?: string;
additions?: number;
deletions?: number;
}
>;
function collectReviewPaths(
function cumulativeFileIdentity(
mapKey: string,
file: CumulativeDiffFiles[string],
useRepositoryKeys: boolean,
) {
const path = file.path ?? splitReviewFileKey(mapKey).path;
const repositoryName = useRepositoryKeys ? file.repository_name : undefined;
return {
key: reviewFileKey({ path, repository_name: repositoryName }),
path,
repositoryName,
};
}
export type ReviewProgressPRFile = PRDiffFile & { repository_name?: string };
export type ReviewProgressPRSource = {
repositoryName: string;
files: PRDiffFile[];
};
export function selectPRFilesForReviewProgress(
sourcesByPRID: ReadonlyMap<string, ReviewProgressPRSource>,
primaryPRID: string | undefined,
useRepositoryKeys: boolean,
): ReviewProgressPRFile[] {
if (!primaryPRID) return [];
const source = sourcesByPRID.get(primaryPRID);
if (!source) return [];
return source.files.map((file) => ({
...file,
repository_name: useRepositoryKeys ? source.repositoryName : undefined,
}));
}
function addReviewSource(
winningDiffs: Map<string, string | undefined>,
paths: Set<string>,
source: {
key: string;
path: string;
repositoryName?: string;
diff?: string;
},
): void {
const collidesWithHigherPriority = source.repositoryName
? winningDiffs.has(source.path)
: paths.has(source.path);
if (winningDiffs.has(source.key) || collidesWithHigherPriority) return;
winningDiffs.set(source.key, source.diff);
paths.add(source.path);
}
function buildReviewProgressIndex(
uncommittedFiles: FileInfo[],
cumulativeDiffFiles: CumulativeDiffFiles | undefined,
prFiles?: PRDiffFile[],
): Set<string> {
prFiles?: ReviewProgressPRFile[],
useRepositoryKeys = true,
): Map<string, string | undefined> {
const winningDiffs = new Map<string, string | undefined>();
const paths = new Set<string>();
for (const file of uncommittedFiles) {
if (file.diff && normalizeDiffContent(file.diff)) paths.add(file.path);
const repositoryName = useRepositoryKeys ? file.repository_name : undefined;
const key = reviewFileKey({ path: file.path, repository_name: repositoryName });
addReviewSource(winningDiffs, paths, {
key,
path: file.path,
repositoryName,
diff: file.diff,
});
}
if (cumulativeDiffFiles) {
for (const [path, file] of Object.entries(cumulativeDiffFiles)) {
if (!paths.has(path) && file.diff && normalizeDiffContent(file.diff)) paths.add(path);
for (const [mapKey, file] of Object.entries(cumulativeDiffFiles)) {
const identity = cumulativeFileIdentity(mapKey, file, useRepositoryKeys);
addReviewSource(winningDiffs, paths, {
...identity,
diff: file.diff,
});
}
}
if (prFiles) {
for (const file of prFiles) {
if (!paths.has(file.filename) && file.patch) paths.add(file.filename);
const repositoryName = useRepositoryKeys ? file.repository_name : undefined;
const key = reviewFileKey({
path: file.filename,
repository_name: repositoryName,
});
addReviewSource(winningDiffs, paths, {
key,
path: file.filename,
repositoryName,
diff: file.patch,
});
}
}
return paths;
}
function getDiffForPath(
path: string,
uncommittedFiles: FileInfo[],
cumulativeDiffFiles: CumulativeDiffFiles | undefined,
prFiles?: PRDiffFile[],
): string {
const uncommitted = uncommittedFiles.find((f) => f.path === path);
if (uncommitted?.diff) return normalizeDiffContent(uncommitted.diff);
const cumDiff = cumulativeDiffFiles?.[path]?.diff;
if (cumDiff) return normalizeDiffContent(cumDiff);
if (prFiles) {
const prFile = prFiles.find((f) => f.filename === path);
if (prFile?.patch) return normalizeDiffContent(prFile.patch);
}
return "";
return winningDiffs;
}
export function computeReviewProgress(
uncommittedFiles: FileInfo[],
cumulativeDiff: { files?: CumulativeDiffFiles } | null,
reviews: Map<string, { reviewed: boolean; diffHash?: string }>,
prFiles?: PRDiffFile[],
prFiles?: ReviewProgressPRFile[],
useRepositoryKeys = true,
) {
const cumulativeDiffFiles = cumulativeDiff?.files;
const paths = collectReviewPaths(uncommittedFiles, cumulativeDiffFiles, prFiles);
const winningDiffs = buildReviewProgressIndex(
uncommittedFiles,
cumulativeDiffFiles,
prFiles,
useRepositoryKeys,
);
let reviewed = 0;
for (const path of paths) {
const state = reviews.get(path);
for (const [key, diff] of winningDiffs) {
const state = reviews.get(key);
if (!state?.reviewed) continue;
const diffContent = getDiffForPath(path, uncommittedFiles, cumulativeDiffFiles, prFiles);
const diffContent = normalizeDiffContent(diff ?? "");
if (diffContent && state.diffHash && state.diffHash !== hashDiff(diffContent)) continue;
reviewed++;
}
return { reviewedCount: reviewed, totalFileCount: paths.size };
return { reviewedCount: reviewed, totalFileCount: winningDiffs.size };
}
export function computeStagedStats(stagedFiles: FileInfo[]) {
@@ -130,30 +208,14 @@ export function mapPRFilesToChangedFiles(
files: PRDiffFile[],
repositoryName?: string,
): PRChangedFile[] {
return files.map((file) => {
let status: FileInfo["status"];
switch (file.status) {
case "added":
status = "added";
break;
case "removed":
status = "deleted";
break;
case "renamed":
status = "renamed";
break;
default:
status = "modified";
}
return {
path: file.filename,
status,
plus: file.additions,
minus: file.deletions,
oldPath: file.old_path,
repository_name: repositoryName ?? "",
};
});
return files.map((file) => ({
path: file.filename,
status: normalizeFileChangeStatus(file.status),
plus: file.additions,
minus: file.deletions,
oldPath: file.old_path,
repository_name: repositoryName ?? "",
}));
}
export function filterUnpushedCommits<T extends { commit_sha: string }>(
@@ -54,4 +54,24 @@ describe("PRFilesGroupedList", () => {
repositoryName: undefined,
});
});
it("shows previous-path context for a moved PR file", () => {
render(
<PRFilesGroupedList
files={[
{
path: "src/new-name.ts",
status: "renamed",
plus: 0,
minus: 0,
oldPath: "src/old-name.ts",
repository_name: "",
},
]}
onOpenDiff={vi.fn()}
/>,
);
expect(screen.getByRole("img", { name: "Moved from src/old-name.ts" })).toBeTruthy();
});
});
@@ -3,9 +3,9 @@
import { useState } from "react";
import { IconChevronDown, IconChevronRight, IconGitPullRequest } from "@tabler/icons-react";
import { LineStat } from "@/components/diff-stat";
import { FileStatusIcon } from "@/components/shared/file-status-icon";
import { cn } from "@/lib/utils";
import { useDockviewStore } from "@/lib/state/dockview-store";
import { FileStatusIcon } from "./file-status-icon";
import { groupByRepositoryName } from "@/lib/group-by-repo";
import type { PRChangedFile } from "./changes-panel-timeline";
import type { OpenDiffOptions } from "./changes-diff-target";
@@ -145,7 +145,7 @@ function PRFileRow({
</div>
<div className="flex items-center gap-2">
<LineStat added={file.plus} removed={file.minus} />
<FileStatusIcon status={file.status} />
<FileStatusIcon status={file.status} oldPath={file.oldPath} />
</div>
</li>
);
+1
View File
@@ -116,6 +116,7 @@ export function ChangesTab(props: IDockviewPanelHeaderProps) {
<ContextMenu>
<ContextMenuTrigger
className="flex h-full items-center cursor-pointer select-none"
data-testid="dockview-tab-changes"
onDoubleClick={onDoubleClick}
>
<div className={cn("relative", isFlashing && "animate-changes-flash")}>
@@ -150,6 +150,7 @@ function ChangesTopBarRight({
size="sm"
variant="ghost"
className="px-1.5 h-5 cursor-pointer"
aria-label="Expand review"
onClick={() => window.dispatchEvent(new CustomEvent("open-review-dialog"))}
>
<IconArrowsMaximize className="h-3.5 w-3.5" />
@@ -24,6 +24,8 @@ export function DockviewReviewDialog({ sessionId, review }: DockviewReviewDialog
gitStatusFiles={review.reviewGitStatusFiles}
cumulativeDiff={review.reviewCumulativeDiff}
prDiffFiles={review.reviewPRDiffFiles}
prRepoName={review.reviewPRRepoName}
useRepositoryKeys={review.reviewUseRepositoryKeys}
/>
);
}
@@ -1,44 +0,0 @@
"use client";
import { IconPlus, IconCircleFilled, IconMinus } from "@tabler/icons-react";
import type { FileInfo } from "@/lib/state/store";
type FileStatusIconProps = {
status: FileInfo["status"];
};
export function FileStatusIcon({ status }: FileStatusIconProps) {
switch (status) {
case "added":
case "untracked":
return (
<div className="flex items-center justify-center h-3 w-3 rounded border border-emerald-600">
<IconPlus className="h-2 w-2 text-emerald-600" />
</div>
);
case "modified":
return (
<div className="flex items-center justify-center h-3 w-3 rounded border border-yellow-600">
<IconCircleFilled className="h-1 w-1 text-yellow-600" />
</div>
);
case "deleted":
return (
<div className="flex items-center justify-center h-3 w-3 rounded border border-rose-600">
<IconMinus className="h-2 w-2 text-rose-600" />
</div>
);
case "renamed":
return (
<div className="flex items-center justify-center h-3 w-3 rounded border border-purple-600">
<IconCircleFilled className="h-1 w-1 text-purple-600" />
</div>
);
default:
return (
<div className="flex items-center justify-center h-3 w-3 rounded border border-muted-foreground">
<IconCircleFilled className="h-1 w-1 text-muted-foreground" />
</div>
);
}
}
@@ -255,6 +255,8 @@ function MobileReviewDialogMount({
gitStatusFiles={review.reviewGitStatusFiles}
cumulativeDiff={review.reviewCumulativeDiff}
prDiffFiles={review.reviewPRDiffFiles}
prRepoName={review.reviewPRRepoName}
useRepositoryKeys={review.reviewUseRepositoryKeys}
/>
<WalkthroughOverlay
taskId={activeTaskId}
@@ -0,0 +1,109 @@
import { describe, expect, it } from "vitest";
import { buildReviewGitStatusFiles } from "./use-review-dialog";
import type { FileInfo, GitStatusEntry } from "@/lib/state/slices/session-runtime/types";
type GitStatusByRepo = Array<{ repository_name: string; status: GitStatusEntry }>;
const README_PATH = "README.md";
function file(path = README_PATH): FileInfo {
return { path, status: "modified", staged: false };
}
function status(files: Record<string, FileInfo>): GitStatusEntry {
return { files } as GitStatusEntry;
}
describe("buildReviewGitStatusFiles", () => {
it("keeps legacy bare files for a single-repository task", () => {
const legacyFiles = { [README_PATH]: file() };
const perRepoFiles = { [README_PATH]: file() };
const result = buildReviewGitStatusFiles(
status(legacyFiles),
[{ repository_name: "frontend", status: status(perRepoFiles) }],
1,
);
expect(result).toEqual({ files: legacyFiles, isMultiRepo: false });
expect(result.files?.[README_PATH]?.repository_name).toBeUndefined();
});
it("uses one named status as a bare fallback while legacy status hydrates", () => {
const perRepoFiles = { [README_PATH]: file() };
const result = buildReviewGitStatusFiles(
undefined,
[{ repository_name: "frontend", status: status(perRepoFiles) }],
1,
);
expect(result).toEqual({ files: perRepoFiles, isMultiRepo: false });
expect(result.files?.[README_PATH]?.repository_name).toBeUndefined();
});
it("uses stable composite keys while multi-repo statuses hydrate", () => {
const frontend = { [README_PATH]: file() };
const backend = { [README_PATH]: file() };
const partiallyHydrated = buildReviewGitStatusFiles(
status(frontend),
[{ repository_name: "frontend", status: status(frontend) }],
2,
);
const fullyHydrated = buildReviewGitStatusFiles(
status(backend),
[
{ repository_name: "frontend", status: status(frontend) },
{ repository_name: "backend", status: status(backend) },
],
2,
);
expect(partiallyHydrated.isMultiRepo).toBe(true);
expect(Object.keys(partiallyHydrated.files ?? {})).toEqual(["frontend\u0000README.md"]);
expect(Object.keys(fullyHydrated.files ?? {}).sort()).toEqual([
"backend\u0000README.md",
"frontend\u0000README.md",
]);
});
it("keeps legacy files while multi-repo statuses have not hydrated", () => {
const legacyFiles = { [README_PATH]: file() };
const result = buildReviewGitStatusFiles(status(legacyFiles), [], 2);
expect(result).toEqual({ files: legacyFiles, isMultiRepo: true });
});
it("falls back to per-repo status count when task repositories are not hydrated", () => {
const frontend = { "src/app.ts": file("src/app.ts") };
const backend = { "cmd/main.go": file("cmd/main.go") };
const statuses: GitStatusByRepo = [
{ repository_name: "frontend", status: status(frontend) },
{ repository_name: "backend", status: status(backend) },
];
const result = buildReviewGitStatusFiles(status(frontend), statuses, 0);
expect(result.isMultiRepo).toBe(true);
expect(Object.keys(result.files ?? {}).sort()).toEqual([
"backend\u0000cmd/main.go",
"frontend\u0000src/app.ts",
]);
});
it("uses cumulative repositories while task and status metadata hydrate", () => {
const frontend = { [README_PATH]: file() };
const result = buildReviewGitStatusFiles(
undefined,
[{ repository_name: "frontend", status: status(frontend) }],
0,
["frontend", "backend"],
);
expect(result.isMultiRepo).toBe(true);
expect(Object.keys(result.files ?? {})).toEqual(["frontend\u0000README.md"]);
});
});
+83 -16
View File
@@ -10,11 +10,60 @@ import { useCumulativeDiff } from "@/hooks/domains/session/use-cumulative-diff";
import { useFileEditors } from "@/hooks/use-file-editors";
import { useActiveTaskPR } from "@/hooks/domains/github/use-task-pr";
import { usePRDiff } from "@/hooks/domains/github/use-pr-diff";
import { useTaskRepositories } from "@/hooks/domains/kanban/use-task-repositories";
import { useRepository } from "@/hooks/domains/workspace/use-repository";
import { formatReviewCommentsAsMarkdown } from "@/components/task/chat/messages/review-comments-attachment";
import {
getCumulativeReviewRepositoryNames,
isReviewMultiRepo,
resolvePRReviewRepositoryName,
} from "@/components/review/types";
import { getWebSocketClient } from "@/lib/ws/connection";
import { useToast } from "@/components/toast-provider";
import type { DiffComment } from "@/lib/diff/types";
import type { FileInfo } from "@/lib/state/slices/session-runtime/types";
import type { FileInfo, GitStatusEntry } from "@/lib/state/slices/session-runtime/types";
type ReviewGitStatusFiles = {
files: Record<string, FileInfo> | null;
isMultiRepo: boolean;
};
export function buildReviewGitStatusFiles(
reviewGitStatus: GitStatusEntry | undefined,
statusByRepo: Array<{ repository_name: string; status: GitStatusEntry }>,
taskRepositoryCount: number,
cumulativeRepositoryNames: Iterable<string> = [],
): ReviewGitStatusFiles {
const named = statusByRepo.filter((entry) => entry.repository_name !== "");
const isMultiRepo = isReviewMultiRepo(
taskRepositoryCount,
named.map((entry) => entry.repository_name).concat(Array.from(cumulativeRepositoryNames)),
);
if (!isMultiRepo) {
return {
files: reviewGitStatus?.files ?? named[0]?.status.files ?? null,
isMultiRepo: false,
};
}
if (named.length === 0) {
return {
files: reviewGitStatus?.files ?? null,
isMultiRepo: true,
};
}
const files: Record<string, FileInfo> = {};
for (const { repository_name, status } of named) {
if (!status?.files) continue;
for (const [path, file] of Object.entries(status.files)) {
files[`${repository_name}\u0000${path}`] = { ...file, repository_name };
}
}
return {
files: Object.keys(files).length > 0 ? files : null,
isMultiRepo: true,
};
}
/**
* Builds the unified gitStatus.files map fed into the ReviewDialog. Multi-repo
@@ -23,35 +72,51 @@ import type { FileInfo } from "@/lib/state/slices/session-runtime/types";
* every FileInfo is stamped with its `repository_name`. Single-repo tasks keep
* the legacy path-only keying.
*/
function useReviewGitStatusFiles(sessionId: string | null): Record<string, FileInfo> | null {
function useReviewGitStatusFiles(
sessionId: string | null,
taskRepositoryCount: number,
cumulativeRepositoryNames: string[],
): ReviewGitStatusFiles {
const reviewGitStatus = useSessionGitStatus(sessionId);
const statusByRepo = useSessionGitStatusByRepo(sessionId);
return useMemo(() => {
const named = statusByRepo.filter((s) => s.repository_name !== "");
if (named.length === 0) return reviewGitStatus?.files ?? null;
const out: Record<string, FileInfo> = {};
for (const { repository_name, status } of named) {
if (!status?.files) continue;
for (const [path, file] of Object.entries(status.files)) {
out[`${repository_name}\u0000${path}`] = { ...file, repository_name };
}
}
return out;
}, [reviewGitStatus, statusByRepo]);
return useMemo(
() =>
buildReviewGitStatusFiles(
reviewGitStatus,
statusByRepo,
taskRepositoryCount,
cumulativeRepositoryNames,
),
[reviewGitStatus, statusByRepo, taskRepositoryCount, cumulativeRepositoryNames],
);
}
export function useReviewDialog(effectiveSessionId: string | null) {
const [reviewDialogOpen, setReviewDialogOpen] = useState(false);
const { toast } = useToast();
const activeTaskId = useAppStore((state) => state.tasks.activeTaskId);
const taskRepositories = useTaskRepositories(activeTaskId);
const baseBranch = useAppStore((state) => {
if (!effectiveSessionId) return undefined;
return state.taskSessions.items[effectiveSessionId]?.base_branch;
});
const reviewGitStatusFiles = useReviewGitStatusFiles(effectiveSessionId);
const { diff: reviewCumulativeDiff } = useCumulativeDiff(effectiveSessionId);
const cumulativeRepositoryNames = useMemo(
() => getCumulativeReviewRepositoryNames(reviewCumulativeDiff?.files),
[reviewCumulativeDiff],
);
const reviewGitStatus = useReviewGitStatusFiles(
effectiveSessionId,
taskRepositories.length,
cumulativeRepositoryNames,
);
const { openFile: reviewOpenFile } = useFileEditors();
const reviewTaskPR = useActiveTaskPR();
const reviewPRRepository = useRepository(reviewTaskPR?.repository_id ?? null);
const reviewPRRepositoryName = resolvePRReviewRepositoryName(
reviewTaskPR,
reviewPRRepository?.name,
);
const { files: reviewPRDiffFiles } = usePRDiff(
reviewTaskPR?.owner ?? null,
reviewTaskPR?.repo ?? null,
@@ -88,9 +153,11 @@ export function useReviewDialog(effectiveSessionId: string | null) {
reviewDialogOpen,
setReviewDialogOpen,
baseBranch,
reviewGitStatusFiles,
reviewGitStatusFiles: reviewGitStatus.files,
reviewCumulativeDiff,
reviewPRDiffFiles,
reviewPRRepoName: reviewGitStatus.isMultiRepo ? reviewPRRepositoryName : undefined,
reviewUseRepositoryKeys: reviewGitStatus.isMultiRepo,
reviewOpenFile,
handleReviewSendComments,
};
+1
View File
@@ -1003,6 +1003,7 @@ export class ApiClient {
additions: number;
deletions: number;
patch?: string;
old_path?: string;
}>,
): Promise<void> {
await this.request("POST", "/api/v1/github/mock/files", {
@@ -0,0 +1,135 @@
import { test, expect } from "../../fixtures/test-base";
import { GitHelper, makeGitEnv } from "../../helpers/git-helper";
import { SessionPage } from "../../pages/session-page";
import path from "node:path";
const MOBILE_FILE = "mobile-review-status-added.ts";
const MOBILE_MOVED_FROM_FILE = "mobile-review-status-old-name.ts";
const MOBILE_MOVED_FILE = "mobile-review-status-new-name.ts";
test.describe("Review file status on mobile", () => {
test.describe.configure({ timeout: 120_000 });
test("shows status in the sticky header without horizontal overflow", async ({
testPage,
apiClient,
seedData,
backend,
}) => {
await apiClient.mockGitHubReset();
await apiClient.mockGitHubSetUser("reviewer");
await apiClient.mockGitHubAddPRs([
{
number: 72,
title: "Mobile review status cues",
state: "open",
head_branch: "feat/mobile-review-status",
base_branch: "main",
author_login: "reviewer",
repo_owner: "testorg",
repo_name: "testrepo",
additions: 0,
deletions: 0,
},
]);
await apiClient.mockGitHubAddPRFiles("testorg", "testrepo", 72, [
{
filename: MOBILE_MOVED_FILE,
status: "renamed",
additions: 0,
deletions: 0,
old_path: MOBILE_MOVED_FROM_FILE,
},
]);
const task = await apiClient.createTaskWithAgent(
seedData.workspaceId,
"Mobile Review File Status E2E",
seedData.agentProfileId,
{
description: "/e2e:simple-message",
workflow_id: seedData.workflowId,
workflow_step_id: seedData.startStepId,
repository_ids: [seedData.repositoryId],
},
);
await apiClient.mockGitHubAssociateTaskPR({
task_id: task.id,
owner: "testorg",
repo: "testrepo",
pr_number: 72,
pr_url: "https://github.com/testorg/testrepo/pull/72",
pr_title: "Mobile review status cues",
head_branch: "feat/mobile-review-status",
base_branch: "main",
author_login: "reviewer",
additions: 0,
deletions: 0,
});
await testPage.goto(`/t/${task.id}`);
const session = new SessionPage(testPage);
await session.waitForLoad();
await session.waitForChatIdle();
const git = new GitHelper(
path.join(backend.tmpDir, "repos", "e2e-repo"),
makeGitEnv(backend.tmpDir),
);
git.createFile(MOBILE_FILE, "mobile added file\n");
git.stageFile(MOBILE_FILE);
await testPage.getByRole("button", { name: "Changes" }).tap();
const changesPanel = testPage.getByTestId("mobile-changes-panel");
await expect(changesPanel).toBeVisible();
await expect(testPage.getByTestId(`file-row-${MOBILE_FILE}`)).toBeVisible({ timeout: 20_000 });
await changesPanel.getByRole("button", { name: "Review", exact: true }).tap();
const dialog = testPage.getByRole("dialog", { name: "Review Changes" });
await expect(dialog).toBeVisible();
const header = dialog.getByTestId("review-file-header").filter({ hasText: MOBILE_FILE });
await expect(header).toBeVisible();
const marker = header.getByRole("img", { name: "Added" });
await expect(marker).toBeVisible();
const movedHeader = dialog
.getByTestId("review-file-header")
.filter({ hasText: MOBILE_MOVED_FILE });
await expect(movedHeader).toBeVisible();
await expect(
movedHeader.getByRole("img", { name: `Moved from ${MOBILE_MOVED_FROM_FILE}` }),
).toBeVisible();
await expect(
dialog.getByText(`Moved from ${MOBILE_MOVED_FROM_FILE}; no textual changes`),
).toBeVisible();
const headerGeometry = await header.evaluate((element) => {
const markerElement = element.querySelector<HTMLElement>('[data-file-status="added"]');
if (!markerElement) return null;
const headerBounds = element.getBoundingClientRect();
const markerBounds = markerElement.getBoundingClientRect();
return {
scrollWidth: element.scrollWidth,
clientWidth: element.clientWidth,
headerLeft: headerBounds.left,
headerRight: headerBounds.right,
markerLeft: markerBounds.left,
markerRight: markerBounds.right,
};
});
if (!headerGeometry) throw new Error("Mobile Review header geometry is unavailable");
expect(headerGeometry.scrollWidth).toBeLessThanOrEqual(headerGeometry.clientWidth);
expect(headerGeometry.markerLeft).toBeGreaterThanOrEqual(headerGeometry.headerLeft);
expect(headerGeometry.markerRight).toBeLessThanOrEqual(headerGeometry.headerRight);
const checkbox = header.getByRole("checkbox");
await checkbox.tap();
await expect(checkbox).toHaveAttribute("aria-checked", "true");
const overflow = await testPage.evaluate(() => ({
viewport: window.innerWidth,
document: document.documentElement.scrollWidth,
}));
expect(overflow.document).toBeLessThanOrEqual(overflow.viewport + 1);
});
});
@@ -0,0 +1,162 @@
import { test, expect } from "../../fixtures/test-base";
import { GitHelper, makeGitEnv } from "../../helpers/git-helper";
import { SessionPage } from "../../pages/session-page";
import { REVIEW_SIDEBAR_LIMITS } from "../../../hooks/use-review-sidebar-resize";
import path from "node:path";
const ADDED_PATH = "review-status-added.ts";
const MODIFIED_PATH = "review-status-modified.ts";
const DELETED_PATH = "review-status-deleted.ts";
const MOVED_FROM_PATH = "review-status-old-name.ts";
const MOVED_PATH = "review-status-a-very-long-new-name-that-must-truncate.ts";
test.describe("Review file status", () => {
test.describe.configure({ timeout: 120_000 });
test("shows every status, keeps the marker visible at minimum width, and explains a pure move", async ({
testPage,
apiClient,
seedData,
backend,
}) => {
await testPage.addInitScript(({ key, width }) => sessionStorage.setItem(key, width), {
key: REVIEW_SIDEBAR_LIMITS.storageKey,
width: String(REVIEW_SIDEBAR_LIMITS.minWidth),
});
await apiClient.mockGitHubReset();
await apiClient.mockGitHubSetUser("reviewer");
await apiClient.mockGitHubAddPRs([
{
number: 71,
title: "Review status cues",
state: "open",
head_branch: "feat/review-status",
base_branch: "main",
author_login: "reviewer",
repo_owner: "testorg",
repo_name: "testrepo",
additions: 0,
deletions: 0,
},
]);
const movedFiles = [
{
filename: MOVED_PATH,
status: "renamed",
additions: 0,
deletions: 0,
old_path: MOVED_FROM_PATH,
},
];
await apiClient.mockGitHubAddPRFiles("testorg", "testrepo", 71, movedFiles);
const task = await apiClient.createTaskWithAgent(
seedData.workspaceId,
"Review File Status E2E",
seedData.agentProfileId,
{
description: "/e2e:simple-message",
workflow_id: seedData.workflowId,
workflow_step_id: seedData.startStepId,
repository_ids: [seedData.repositoryId],
},
);
await apiClient.mockGitHubAssociateTaskPR({
task_id: task.id,
owner: "testorg",
repo: "testrepo",
pr_number: 71,
pr_url: "https://github.com/testorg/testrepo/pull/71",
pr_title: "Review status cues",
head_branch: "feat/review-status",
base_branch: "main",
author_login: "reviewer",
additions: 0,
deletions: 0,
});
await testPage.goto(`/t/${task.id}`);
const session = new SessionPage(testPage);
await session.waitForLoad();
await session.waitForChatIdle();
const git = new GitHelper(
path.join(backend.tmpDir, "repos", "e2e-repo"),
makeGitEnv(backend.tmpDir),
);
git.createFile(MODIFIED_PATH, "before\n");
git.createFile(DELETED_PATH, "remove me\n");
git.stageAll();
git.commit("seed review status files");
git.createFile(ADDED_PATH, "added\n");
git.stageFile(ADDED_PATH);
git.modifyFile(MODIFIED_PATH, "after\n");
git.deleteFile(DELETED_PATH);
const changesTab = testPage.getByTestId("dockview-tab-changes");
await expect(changesTab).toBeVisible();
await changesTab.click();
for (const filePath of [ADDED_PATH, MODIFIED_PATH, DELETED_PATH]) {
await expect(testPage.getByTestId(`file-row-${filePath}`)).toBeVisible({ timeout: 20_000 });
}
await testPage
.getByTestId("changes-panel")
.getByRole("button", { name: "Diff", exact: true })
.click();
await testPage.getByRole("button", { name: "Expand review" }).click();
const dialog = testPage.getByRole("dialog", { name: "Review Changes" });
await expect(dialog).toBeVisible();
const sidebar = dialog.getByTestId("review-dialog-sidebar");
await expect(sidebar).toBeVisible();
for (const name of ["Added", "Modified", "Deleted"]) {
await expect(sidebar.getByRole("img", { name })).toBeVisible();
}
await expect(sidebar.getByRole("img", { name: `Moved from ${MOVED_FROM_PATH}` })).toBeVisible({
timeout: 20_000,
});
const movedRow = sidebar.locator(
`[data-testid="review-file-row"][data-file-path="${MOVED_PATH}"]`,
);
const movedMarker = movedRow.locator('[data-file-status="renamed"]');
await expect(movedMarker).toBeVisible();
await expect
.poll(async () => Math.round((await sidebar.boundingBox())?.width ?? 0))
.toBeGreaterThanOrEqual(REVIEW_SIDEBAR_LIMITS.minWidth - 2);
const sidebarBox = await sidebar.boundingBox();
if (!sidebarBox) throw new Error("Review sidebar has no bounding box");
expect(sidebarBox.width).toBeLessThanOrEqual(REVIEW_SIDEBAR_LIMITS.minWidth + 2);
const geometry = await movedRow.evaluate((row) => {
const name = row.querySelector<HTMLElement>("[data-review-file-name]");
const marker = row.querySelector<HTMLElement>('[data-file-status="renamed"]');
const rowBounds = row.getBoundingClientRect();
const sidebarBounds = row
.closest<HTMLElement>('[data-testid="review-dialog-sidebar"]')
?.getBoundingClientRect();
if (!name || !marker || !sidebarBounds) return null;
const nameBounds = name.getBoundingClientRect();
const markerBounds = marker.getBoundingClientRect();
return {
nameRight: nameBounds.right,
markerLeft: markerBounds.left,
markerRight: markerBounds.right,
rowRight: rowBounds.right,
sidebarRight: sidebarBounds.right,
};
});
if (!geometry) throw new Error("Moved row geometry is unavailable");
expect(geometry.nameRight).toBeLessThanOrEqual(geometry.markerLeft);
expect(geometry.markerRight).toBeLessThanOrEqual(geometry.rowRight);
expect(geometry.markerRight).toBeLessThanOrEqual(geometry.sidebarRight);
await movedRow.click();
await expect(
dialog.getByText(`Moved from ${MOVED_FROM_PATH}; no textual changes`),
).toBeVisible();
await expect(dialog.getByText("Loading diff...")).toHaveCount(0);
});
});
@@ -0,0 +1,99 @@
import { describe, expect, it } from "vitest";
import { reviewFileKey } from "@/components/review/types";
import type { PRDiffFile } from "@/lib/types/github";
import { buildReviewSources, normalizeReviewStatusSources } from "./use-review-sources";
const namedStatus = {
repository_name: "frontend",
status: {
files: {
"src/local.ts": { diff: "@@local@@", status: "modified", additions: 1, deletions: 0 },
},
},
};
const prDiffFiles = [
{ filename: "src/pr.ts", status: "modified", patch: "@@pr@@" },
] as PRDiffFile[];
const cumulativeDiff = {
files: {
["frontend\u0000src/committed.ts"]: {
path: "src/committed.ts",
repository_name: "frontend",
diff: "@@committed@@",
},
},
};
function reviewKeysFor(taskRepositoryCount: number) {
const normalized = normalizeReviewStatusSources({
gitStatus: undefined,
statusByRepo: [namedStatus],
taskRepositoryCount,
resolvedPRRepoName: "frontend",
});
const result = buildReviewSources({
gitStatus: normalized.normalizedGitStatus,
statusByRepo: normalized.normalizedStatusByRepo,
cumulativeDiff,
prDiffFiles,
prRepoName: normalized.prRepoName,
useRepositoryKeys: normalized.useRepositoryKeys,
});
return result.allFiles.map(reviewFileKey).sort();
}
describe("review source key mode", () => {
it.each([
{
name: "bare for one named status in a single-repo task",
taskRepositoryCount: 1,
expected: ["src/committed.ts", "src/local.ts", "src/pr.ts"],
},
{
name: "composite for a true multi-repo task",
taskRepositoryCount: 2,
expected: [
"frontend\u0000src/committed.ts",
"frontend\u0000src/local.ts",
"frontend\u0000src/pr.ts",
],
},
])("keeps local, cumulative, and PR keys $name", ({ taskRepositoryCount, expected }) => {
expect(reviewKeysFor(taskRepositoryCount)).toEqual(expected);
});
it("preserves same-path cumulative files before task and status metadata hydrate", () => {
const normalized = normalizeReviewStatusSources({
gitStatus: undefined,
statusByRepo: [namedStatus],
taskRepositoryCount: 0,
resolvedPRRepoName: "frontend",
cumulativeRepositoryNames: ["frontend", "backend"],
});
const samePathCumulativeDiff = {
files: Object.fromEntries(
["frontend", "backend"].map((repositoryName) => [
`${repositoryName}\u0000README.md`,
{ path: "README.md", repository_name: repositoryName, diff: `@@${repositoryName}@@` },
]),
),
};
const result = buildReviewSources({
gitStatus: normalized.normalizedGitStatus,
statusByRepo: normalized.normalizedStatusByRepo,
cumulativeDiff: samePathCumulativeDiff,
prDiffFiles: undefined,
prRepoName: normalized.prRepoName,
useRepositoryKeys: normalized.useRepositoryKeys,
});
expect(result.allFiles.map(reviewFileKey).sort()).toEqual([
"backend\u0000README.md",
"frontend\u0000README.md",
"frontend\u0000src/local.ts",
]);
});
});
@@ -1,8 +1,12 @@
import { describe, it, expect } from "vitest";
import { buildReviewSources } from "./use-review-sources";
import { resolvePRReviewRepositoryName } from "@/components/review/types";
import type { PRDiffFile } from "@/lib/types/github";
/* eslint-disable max-lines-per-function -- multi-case describe block */
const RENAMED_PATH = "src/new-name.ts";
const PREVIOUS_PATH = "src/old-name.ts";
/* eslint-disable max-lines-per-function -- comprehensive source merge cases */
describe("buildReviewSources", () => {
it("returns empty result when no inputs", () => {
const result = buildReviewSources({
@@ -76,9 +80,98 @@ describe("buildReviewSources", () => {
});
expect(result.allFiles).toHaveLength(1);
expect(result.allFiles[0].source).toBe("pr");
expect(result.allFiles[0].repository_name).toBeUndefined();
expect(result.sourceCounts).toEqual({ uncommitted: 0, committed: 0, pr: 1 });
});
it("keeps a patchless PR rename with its previous path", () => {
const result = buildReviewSources({
gitStatus: undefined,
statusByRepo: undefined,
cumulativeDiff: null,
prDiffFiles: [
{
filename: RENAMED_PATH,
old_path: PREVIOUS_PATH,
status: "renamed",
patch: "",
additions: 0,
deletions: 0,
},
],
});
expect(result.allFiles).toEqual([
expect.objectContaining({
path: RENAMED_PATH,
old_path: PREVIOUS_PATH,
status: "renamed",
diff: "",
source: "pr",
}),
]);
expect(result.sourceCounts).toEqual({ uncommitted: 0, committed: 0, pr: 1 });
});
it("normalizes removed and unknown PR statuses", () => {
const result = buildReviewSources({
gitStatus: undefined,
statusByRepo: undefined,
cumulativeDiff: null,
prDiffFiles: [
{ filename: "gone.ts", status: "removed", patch: "", additions: 0, deletions: 1 },
{ filename: "copied.ts", status: "copied", patch: "", additions: 1, deletions: 0 },
],
});
expect(Object.fromEntries(result.allFiles.map((file) => [file.path, file.status]))).toEqual({
"copied.ts": "modified",
"gone.ts": "deleted",
});
});
it("keeps patchless uncommitted and cumulative files with metadata", () => {
const result = buildReviewSources({
gitStatus: {
files: {
"assets/logo.png": {
status: "modified",
diff_skip_reason: "binary",
},
},
},
statusByRepo: undefined,
cumulativeDiff: {
files: {
[RENAMED_PATH]: {
status: "renamed",
old_path: PREVIOUS_PATH,
},
},
},
prDiffFiles: undefined,
});
expect(result.allFiles).toEqual(
expect.arrayContaining([
expect.objectContaining({
path: "assets/logo.png",
diff_skip_reason: "binary",
diff: "",
source: "uncommitted",
}),
expect.objectContaining({
path: RENAMED_PATH,
old_path: PREVIOUS_PATH,
status: "renamed",
diff: "",
source: "committed",
}),
]),
);
expect(result.sourceCounts).toEqual({ uncommitted: 1, committed: 1, pr: 0 });
});
it("dedupes by path: uncommitted wins over committed wins over PR", () => {
const prFiles: PRDiffFile[] = [
{
@@ -217,12 +310,16 @@ describe("buildReviewSources", () => {
expect(result.sourceCounts).toEqual({ uncommitted: 1, committed: 0, pr: 0 });
});
it("multi-repo uncommitted + PR overlap: file appears once as uncommitted (same repo)", () => {
it("dedupes PR files using the canonical workspace repo rather than provider repo name", () => {
const prRepoName = resolvePRReviewRepositoryName(
{ repository_id: "repo-1", repo: "widgets" },
"acme/widgets",
);
const result = buildReviewSources({
gitStatus: undefined,
statusByRepo: [
{
repository_name: "frontend",
repository_name: "acme-widgets",
status: {
files: {
"src/shared.ts": {
@@ -245,7 +342,7 @@ describe("buildReviewSources", () => {
deletions: 0,
},
],
prRepoName: "frontend",
prRepoName,
});
expect(result.allFiles).toHaveLength(1);
expect(result.allFiles[0].source).toBe("uncommitted");
@@ -413,6 +510,42 @@ describe("buildReviewSources", () => {
expect(result.sourceCounts).toEqual({ uncommitted: 2, committed: 0, pr: 0 });
});
it("keeps repo and path combinations containing colons distinct", () => {
const result = buildReviewSources({
gitStatus: undefined,
statusByRepo: [
{
repository_name: "frontend:src",
status: {
files: {
"app.ts": { diff: "@@first@@", status: "modified", additions: 1, deletions: 0 },
},
},
},
{
repository_name: "frontend",
status: {
files: {
"src:app.ts": {
diff: "@@second@@",
status: "modified",
additions: 1,
deletions: 0,
},
},
},
},
],
cumulativeDiff: null,
prDiffFiles: undefined,
});
expect(result.allFiles.map((file) => [file.repository_name, file.path])).toEqual([
["frontend", "src:app.ts"],
["frontend:src", "app.ts"],
]);
});
// Regression for the multi-repo cumulative-diff bug: backend's
// `mergeCumulativeFiles` uses a `<repo>\x00<path>` map key and stamps the
// clean repo-relative path on `file.path`. The old code read the path from
@@ -1,12 +1,23 @@
import { useEffect, useMemo } from "react";
import { useAppStore } from "@/components/state-provider";
import { useSessionGitStatus, useSessionGitStatusByRepo } from "./use-session-git-status";
import { useCumulativeDiff } from "./use-cumulative-diff";
import { useActiveTaskPR } from "@/hooks/domains/github/use-task-pr";
import { usePRDiff } from "@/hooks/domains/github/use-pr-diff";
import { normalizeDiffContent } from "@/components/review/types";
import { useTaskRepositories } from "@/hooks/domains/kanban/use-task-repositories";
import { useRepository } from "@/hooks/domains/workspace/use-repository";
import {
getCumulativeReviewRepositoryNames,
isReviewMultiRepo,
normalizeDiffContent,
resolvePRReviewRepositoryName,
reviewFileKey,
splitReviewFileKey,
} from "@/components/review/types";
import { createDebugLogger } from "@/lib/debug/log";
import type { ReviewFile } from "@/components/review/types";
import type { PRDiffFile } from "@/lib/types/github";
import { normalizeFileChangeStatus } from "@/lib/utils/file-change-status";
const debug = createDebugLogger("review:sources");
@@ -18,6 +29,7 @@ type UncommittedFile = {
diff?: string;
diff_skip_reason?: ReviewFile["diff_skip_reason"];
status?: string;
old_path?: string;
additions?: number;
deletions?: number;
staged?: boolean;
@@ -25,7 +37,9 @@ type UncommittedFile = {
type CumulativeFile = {
diff?: string;
diff_skip_reason?: ReviewFile["diff_skip_reason"];
status?: string;
old_path?: string;
additions?: number;
deletions?: number;
repository_name?: string;
@@ -45,16 +59,16 @@ function addUncommittedFiles(
for (const [path, file] of Object.entries(files)) {
const diff = file.diff ? normalizeDiffContent(file.diff) : "";
const skipReason = file.diff_skip_reason;
if (!diff && !skipReason) continue;
const key = repositoryName ? `${repositoryName}:${path}` : path;
const key = reviewFileKey({ path, repository_name: repositoryName });
fileMap.set(key, {
path,
diff,
status: file.status ?? "modified",
status: normalizeFileChangeStatus(file.status),
additions: file.additions ?? 0,
deletions: file.deletions ?? 0,
staged: file.staged ?? false,
source: "uncommitted",
old_path: file.old_path,
diff_skip_reason: skipReason,
repository_name: repositoryName,
});
@@ -65,6 +79,7 @@ function addCumulativeFiles(
fileMap: Map<string, ReviewFile>,
files: Record<string, CumulativeFile>,
uncommittedPaths: Set<string>,
useRepositoryKeys: boolean,
) {
for (const [mapKey, file] of Object.entries(files)) {
// Multi-repo cumulative payloads use a NUL-composite `<repo>\x00<path>`
@@ -72,31 +87,27 @@ function addCumulativeFiles(
// Single-repo keeps the bare path on the map key with no `file.path`.
// Prefer the stamped value so the composite key doesn't bleed into the
// displayed path; fall back to the map key for single-repo.
const path = file.path ?? mapKey;
const key = file.repository_name ? `${file.repository_name}:${path}` : path;
const path = file.path ?? splitReviewFileKey(mapKey).path;
const repositoryName = useRepositoryKeys ? file.repository_name : undefined;
const key = reviewFileKey({ path, repository_name: repositoryName });
const hasRepoUnawareCollision = key !== path && fileMap.has(path);
if (fileMap.has(key) || uncommittedPaths.has(key) || hasRepoUnawareCollision) continue;
const diff = file.diff ? normalizeDiffContent(file.diff) : "";
if (!diff) continue;
fileMap.set(key, {
path,
diff,
status: file.status || "modified",
status: normalizeFileChangeStatus(file.status),
additions: file.additions ?? 0,
deletions: file.deletions ?? 0,
staged: false,
source: "committed",
repository_name: file.repository_name,
old_path: file.old_path,
diff_skip_reason: file.diff_skip_reason,
repository_name: repositoryName,
});
}
}
function prFileStatus(status: string): "added" | "deleted" | "modified" {
if (status === "added") return "added";
if (status === "removed") return "deleted";
return "modified";
}
function addPRFiles(
fileMap: Map<string, ReviewFile>,
files: PRDiffFile[],
@@ -104,19 +115,19 @@ function addPRFiles(
repoName?: string,
) {
for (const file of files) {
const key = repoName ? `${repoName}:${file.filename}` : file.filename;
const key = reviewFileKey({ path: file.filename, repository_name: repoName });
const hasRepoUnawareCollision = key !== file.filename && fileMap.has(file.filename);
if (fileMap.has(key) || uncommittedPaths.has(key) || hasRepoUnawareCollision) continue;
const diff = file.patch ? normalizeDiffContent(file.patch) : "";
if (!diff) continue;
fileMap.set(key, {
path: file.filename,
diff,
status: prFileStatus(file.status),
status: normalizeFileChangeStatus(file.status),
additions: file.additions ?? 0,
deletions: file.deletions ?? 0,
staged: false,
source: "pr",
old_path: file.old_path,
repository_name: repoName,
});
}
@@ -127,15 +138,12 @@ function collectPathsFromFiles(
files: Record<string, UncommittedFile>,
repositoryName?: string,
): void {
for (const [path, file] of Object.entries(files)) {
const diff = file.diff ? normalizeDiffContent(file.diff) : "";
if (diff || file.diff_skip_reason) {
// Always add bare path (for deduping repo-unaware sources like cumulative
// diffs that may not carry repository_name).
paths.add(path);
// Also add composite key (for repo-aware dedup when sources carry repo info).
if (repositoryName) paths.add(`${repositoryName}:${path}`);
}
for (const path of Object.keys(files)) {
// Always add bare path (for deduping repo-unaware sources like cumulative
// diffs that may not carry repository_name).
paths.add(path);
// Also add composite key (for repo-aware dedup when sources carry repo info).
if (repositoryName) paths.add(reviewFileKey({ path, repository_name: repositoryName }));
}
}
@@ -169,6 +177,9 @@ export type BuildReviewSourcesInput = {
/** Repository name for the primary PR — used to composite-key PR files so
* same-named files in different repos are not incorrectly deduped. */
prRepoName?: string;
/** Whether review identities include repository_name. False keeps every
* source bare during legacy/single-repository status hydration. */
useRepositoryKeys?: boolean;
};
export type BuildReviewSourcesResult = {
@@ -176,16 +187,57 @@ export type BuildReviewSourcesResult = {
sourceCounts: SourceCounts;
};
type NormalizeReviewStatusSourcesInput = {
gitStatus: BuildReviewSourcesInput["gitStatus"];
statusByRepo: NonNullable<BuildReviewSourcesInput["statusByRepo"]>;
taskRepositoryCount: number;
resolvedPRRepoName?: string;
cumulativeRepositoryNames?: Iterable<string>;
};
export function normalizeReviewStatusSources(input: NormalizeReviewStatusSourcesInput) {
const namedStatuses = input.statusByRepo.filter((entry) => entry.repository_name !== "");
const useRepositoryKeys = isReviewMultiRepo(
input.taskRepositoryCount,
namedStatuses
.map((entry) => entry.repository_name)
.concat(Array.from(input.cumulativeRepositoryNames ?? [])),
);
if (useRepositoryKeys) {
return {
useRepositoryKeys: true,
prRepoName: input.resolvedPRRepoName,
normalizedGitStatus: input.gitStatus,
normalizedStatusByRepo: namedStatuses.length > 0 ? namedStatuses : undefined,
};
}
return {
useRepositoryKeys: false,
prRepoName: undefined,
normalizedGitStatus: input.gitStatus?.files ? input.gitStatus : namedStatuses[0]?.status,
normalizedStatusByRepo: undefined,
};
}
/**
* Pure helper that merges the three diff sources into one sorted, deduped
* list and computes per-source counts. Uncommitted files write first under
* composite `repo:path` keys (multi-repo) or simple `path` keys (single-repo).
* Committed and PR files write next under simple `path` keys but skip any
* path that already appears in the uncommitted set — preserving dedup
* priority (uncommitted > committed > PR) across the key-shape mismatch.
* shared `reviewFileKey` composite keys (multi-repo) or bare paths
* (single-repo). Committed and PR files write next under the same key shape
* but skip any path already present in the uncommitted set — preserving
* dedup priority (uncommitted > committed > PR).
* Keep priority, dedup keys, and sorting aligned with `buildAllFiles` in
* `components/review/review-dialog.tsx`.
*/
export function buildReviewSources(input: BuildReviewSourcesInput): BuildReviewSourcesResult {
const { gitStatus, statusByRepo, cumulativeDiff, prDiffFiles, prRepoName } = input;
const {
gitStatus,
statusByRepo,
cumulativeDiff,
prDiffFiles,
prRepoName,
useRepositoryKeys = true,
} = input;
const fileMap = new Map<string, ReviewFile>();
const uncommittedPaths = collectUncommittedPaths(statusByRepo, gitStatus);
@@ -204,7 +256,9 @@ export function buildReviewSources(input: BuildReviewSourcesInput): BuildReviewS
addUncommittedFiles(fileMap, gitStatus.files as Record<string, UncommittedFile>);
}
if (cumulativeDiff?.files) addCumulativeFiles(fileMap, cumulativeDiff.files, uncommittedPaths);
if (cumulativeDiff?.files) {
addCumulativeFiles(fileMap, cumulativeDiff.files, uncommittedPaths, useRepositoryKeys);
}
if (prDiffFiles && prDiffFiles.length > 0)
addPRFiles(fileMap, prDiffFiles, uncommittedPaths, prRepoName);
@@ -244,6 +298,29 @@ export type UseReviewSourcesResult = {
rawPRFiles: ReviewFile[];
};
function useReviewRepositoryContext(
pr: ReturnType<typeof useActiveTaskPR>,
gitStatus: ReturnType<typeof useSessionGitStatus>,
statusByRepo: ReturnType<typeof useSessionGitStatusByRepo>,
cumulativeDiff: ReturnType<typeof useCumulativeDiff>["diff"],
) {
const activeTaskId = useAppStore((state) => state.tasks.activeTaskId);
const taskRepositories = useTaskRepositories(activeTaskId);
const prRepository = useRepository(pr?.repository_id ?? null);
const resolvedPRRepoName = resolvePRReviewRepositoryName(pr, prRepository?.name);
return useMemo(
() =>
normalizeReviewStatusSources({
gitStatus,
statusByRepo,
taskRepositoryCount: taskRepositories.length,
resolvedPRRepoName,
cumulativeRepositoryNames: getCumulativeReviewRepositoryNames(cumulativeDiff?.files),
}),
[gitStatus, statusByRepo, taskRepositories.length, resolvedPRRepoName, cumulativeDiff],
);
}
/**
* Multi-source merge hook. Aggregates uncommitted / committed / PR diffs
* into one sorted ReviewFile[] tagged with `.source`. Shared by
@@ -257,6 +334,8 @@ export function useReviewSources(sessionId: string | null | undefined): UseRevie
const statusByRepo = useSessionGitStatusByRepo(sessionId ?? null);
const { diff: cumulativeDiff, loading: cumulativeLoading } = useCumulativeDiff(sessionId ?? null);
const pr = useActiveTaskPR();
const { normalizedGitStatus, normalizedStatusByRepo, prRepoName, useRepositoryKeys } =
useReviewRepositoryContext(pr, gitStatus, statusByRepo, cumulativeDiff);
const { files: prDiffFiles, loading: prDiffLoading } = usePRDiff(
pr?.owner ?? null,
pr?.repo ?? null,
@@ -266,21 +345,29 @@ export function useReviewSources(sessionId: string | null | undefined): UseRevie
const { allFiles, sourceCounts } = useMemo(
() =>
buildReviewSources({
gitStatus,
statusByRepo,
gitStatus: normalizedGitStatus,
statusByRepo: normalizedStatusByRepo,
cumulativeDiff: cumulativeDiff as { files?: Record<string, CumulativeFile> } | null,
prDiffFiles: prDiffFiles.length > 0 ? prDiffFiles : undefined,
prRepoName: pr?.repo || undefined,
prRepoName,
useRepositoryKeys,
}),
[gitStatus, statusByRepo, cumulativeDiff, prDiffFiles, pr?.repo],
[
normalizedGitStatus,
normalizedStatusByRepo,
cumulativeDiff,
prDiffFiles,
prRepoName,
useRepositoryKeys,
],
);
const rawPRFiles = useMemo(() => {
if (!prDiffFiles || prDiffFiles.length === 0) return [] as ReviewFile[];
const fileMap = new Map<string, ReviewFile>();
addPRFiles(fileMap, prDiffFiles, new Set(), pr?.repo || undefined);
addPRFiles(fileMap, prDiffFiles, new Set(), prRepoName);
return Array.from(fileMap.values());
}, [prDiffFiles, pr?.repo]);
}, [prDiffFiles, prRepoName]);
// Keep `hasPR` keyed on the existence of a TaskPR row, not on whether the
// PR diff files have loaded yet — avoids the tab bar reflowing the moment
@@ -0,0 +1,42 @@
import { describe, expect, it } from "vitest";
import {
fileChangeStatusLabel,
normalizeFileChangeStatus,
type FileChangeStatus,
} from "./file-change-status";
describe("normalizeFileChangeStatus", () => {
it.each<FileChangeStatus>(["added", "modified", "deleted", "untracked", "renamed"])(
"preserves local status %s",
(status) => {
expect(normalizeFileChangeStatus(status)).toBe(status);
},
);
it("maps GitHub removed status to deleted", () => {
expect(normalizeFileChangeStatus("removed")).toBe("deleted");
});
it.each([undefined, "copied", "changed", "unexpected"])(
"falls back to modified for %s",
(status) => {
expect(normalizeFileChangeStatus(status)).toBe("modified");
},
);
});
describe("fileChangeStatusLabel", () => {
it.each<[FileChangeStatus, string]>([
["added", "Added"],
["untracked", "Untracked"],
["modified", "Modified"],
["deleted", "Deleted"],
["renamed", "Moved"],
])("labels %s as %s", (status, label) => {
expect(fileChangeStatusLabel(status)).toBe(label);
});
it("includes previous path for moved files", () => {
expect(fileChangeStatusLabel("renamed", "src/old-name.ts")).toBe("Moved from src/old-name.ts");
});
});
+35
View File
@@ -0,0 +1,35 @@
export type FileChangeStatus = "modified" | "added" | "deleted" | "untracked" | "renamed";
export function normalizeFileChangeStatus(status?: string): FileChangeStatus {
switch (status) {
case "added":
case "modified":
case "deleted":
case "untracked":
case "renamed":
return status;
case "removed":
return "deleted";
default:
return "modified";
}
}
export function fileChangeStatusLabel(status: FileChangeStatus, oldPath?: string): string {
switch (status) {
case "added":
return "Added";
case "untracked":
return "Untracked";
case "modified":
return "Modified";
case "deleted":
return "Deleted";
case "renamed":
return oldPath ? `Moved from ${oldPath}` : "Moved";
default: {
const exhaustiveStatus: never = status;
return exhaustiveStatus;
}
}
}
+72
View File
@@ -0,0 +1,72 @@
---
spec: docs/specs/ui/review-file-status.md
created: 2026-07-14
status: building
---
# Implementation Plan: Review File Status Cues
## Overview
First extract the Changes panel's status mapping and marker into shared frontend primitives. Then preserve status-only files through Review's source builders and render the marker on desktop and mobile with honest patchless states. Finish with focused production-build Playwright coverage. No backend or API changes are required: `FileInfo` and `PRDiffFile` already carry status and previous-path metadata.
## Frontend
### Shared status model and marker
- Add `normalizeFileChangeStatus` and the canonical `FileChangeStatus`/label mapping in `apps/web/lib/utils/file-change-status.ts`; map GitHub `removed` to `deleted`, preserve `renamed`, and fall back to `modified`.
- Move `FileStatusIcon` from `apps/web/components/task/file-status-icon.tsx` to `apps/web/components/shared/file-status-icon.tsx`. Preserve the Changes palette, use a distinct arrow for `renamed`, and expose `aria-label`, `title`, `data-file-status`, `oldPath`, and shrink-safe styling.
- Update `changes-panel-file-row.tsx`, `changes-panel-pr-files.tsx`, and `mapPRFilesToChangedFiles` in `changes-panel-helpers.ts` to use the shared primitive and normalizer without changing current Changes interactions.
### Review source completeness and UI
- Narrow `ReviewFile.status` to the shared status union and add `old_path` in `apps/web/components/review/types.ts`; add status-aware no-diff copy while keeping `diff_skip_reason` authoritative.
- Update `buildAllFiles` helpers in `apps/web/components/review/review-dialog.tsx` and `buildReviewSources` helpers in `apps/web/hooks/domains/session/use-review-sources.ts` to normalize every source, preserve `old_path`/`diff_skip_reason`, and retain status-only entries. Keep uncommitted > cumulative > PR precedence and `reviewFileKey` identity.
- Render the shared marker as the final `shrink-0` item in `review-file-tree.tsx`, after existing stale/comment metadata.
- Render a mobile-only marker in `FileDiffHeader` and status-specific patchless content in `review-diff-list.tsx`.
## Tests
- `apps/web/components/shared/file-status-icon.test.tsx`: table-driven visual and accessible contracts for added, untracked, modified, deleted, renamed, and unknown input.
- `apps/web/components/task/changes-panel-helpers.test.ts`: shared normalization and previous-path regression coverage.
- `apps/web/components/review/review-dialog.build-files.test.ts`: all PR statuses, pure rename inclusion, metadata propagation, and source precedence.
- `apps/web/hooks/domains/session/use-review-sources.test.ts`: status-only inclusion and matching normalization across Review/Changes sources.
- `apps/web/components/review/review-file-tree.test.tsx` and a focused status-empty-state test beside `types.ts`: marker semantics/layout and skip-reason precedence.
## E2E Tests
- `apps/web/e2e/tests/review/review-file-status.spec.ts`: create added, modified, deleted, and renamed files with `GitHelper`, hydrate Changes, open Review, and verify markers, narrow-sidebar layout, and the pure-rename empty state.
- `apps/web/e2e/tests/review/mobile-review-file-status.spec.ts`: open Review through the mobile Changes surface and verify the sticky-header marker remains usable without horizontal overflow.
## Implementation Waves
Wave 1:
- [x] [task-01-status-model](task-01-status-model.md) — done
Wave 2 (depends on Wave 1):
- [x] [task-02-review-ui](task-02-review-ui.md) — done
Wave 3 (depends on Wave 2):
- [x] [task-03-e2e](task-03-e2e.md) — done
## Verification
```bash
make fmt
make typecheck test lint
(cd apps && pnpm --filter @kandev/web test components/shared/file-status-icon.test.tsx components/task/changes-panel-helpers.test.ts components/review/review-file-tree.test.tsx components/review/types.test.ts components/review/review-dialog.build-files.test.ts hooks/domains/session/use-review-sources.test.ts)
(cd apps && pnpm --filter @kandev/web e2e:run tests/review/review-file-status.spec.ts)
(cd apps && pnpm --filter @kandev/web e2e:run --project mobile-chrome tests/review/mobile-review-file-status.spec.ts)
(cd apps/web && pnpm run typecheck)
```
## Risks
- Including patchless files changes review totals and empty-diff hashes; regression tests must cover marking them reviewed.
- Unknown provider statuses must degrade to modified rather than inventing categories.
- `old_path` is display metadata only; current path plus repository remains the review identity.
- Binary/large/truncated/budget messages must outrank generic patchless copy.
- The shared extraction must not change Changes rows' existing hover actions or status/stat visibility.
@@ -0,0 +1,49 @@
---
id: "01-status-model"
title: "Shared file status model"
status: done
wave: 1
depends_on: []
plan: "plan.md"
spec: "../../specs/ui/review-file-status.md"
---
# Task 01: Shared file status model
Extract the existing Changes status language into a domain-neutral, accessible primitive.
## Acceptance
- `normalizeFileChangeStatus` preserves local statuses, maps GitHub `removed` to `deleted`, preserves `renamed`, and maps unknown values to `modified`.
- `FileStatusIcon` renders plus/dot/minus/arrow markers with semantic labels and optional previous-path context; untracked remains semantically distinct from added.
- Both Changes file-row variants use the shared component without changing row actions or layout.
## Verification
- `cd apps && pnpm --filter @kandev/web test components/shared/file-status-icon.test.tsx components/task/changes-panel-helpers.test.ts`
- `cd apps/web && pnpm run typecheck`
## Files likely touched
- `apps/web/lib/utils/file-change-status.ts`
- `apps/web/components/shared/file-status-icon.tsx`
- `apps/web/components/shared/file-status-icon.test.tsx`
- `apps/web/components/task/changes-panel-file-row.tsx`
- `apps/web/components/task/changes-panel-pr-files.tsx`
- `apps/web/components/task/changes-panel-helpers.ts`
- `apps/web/components/task/changes-panel-helpers.test.ts`
- remove `apps/web/components/task/file-status-icon.tsx`
## Inputs
- Spec: `What`, marker semantics and accessibility.
- Pattern: current `FileStatusIcon` and its call sites in the Changes panel.
- Constraint: preserve Changes hover/action behavior.
## Dependencies
None.
## Output contract
Report the files changed, focused tests/typecheck run, blockers, and residual risks; set this task to `done` and tick it in `plan.md`.
@@ -0,0 +1,50 @@
---
id: "02-review-ui"
title: "Review status UI"
status: done
wave: 2
depends_on: ["01-status-model"]
plan: "plan.md"
spec: "../../specs/ui/review-file-status.md"
---
# Task 02: Review status UI
Carry canonical status metadata through Review and render it on desktop, mobile, and patchless files.
## Acceptance
- Review retains and normalizes every changed file, including patchless pure renames, while preserving source precedence, multi-repository identity, previous path, and explicit skip reason.
- Desktop tree rows show a fixed trailing marker without displacing reviewed, stale, or comment metadata; mobile sticky headers expose the same status without hover.
- Patchless files show status-specific explanatory text, while explicit binary/size/truncation/budget reasons take precedence.
## Verification
- `cd apps && pnpm --filter @kandev/web test components/review/review-file-tree.test.tsx components/review/types.test.ts components/review/review-dialog.build-files.test.ts hooks/domains/session/use-review-sources.test.ts`
- `cd apps/web && pnpm run typecheck`
## Files likely touched
- `apps/web/components/review/types.ts`
- `apps/web/components/review/types.test.ts`
- `apps/web/components/review/review-dialog.tsx`
- `apps/web/components/review/review-dialog.build-files.test.ts`
- `apps/web/hooks/domains/session/use-review-sources.ts`
- `apps/web/hooks/domains/session/use-review-sources.test.ts`
- `apps/web/components/review/review-file-tree.tsx`
- `apps/web/components/review/review-file-tree.test.tsx`
- `apps/web/components/review/review-diff-list.tsx`
## Inputs
- Spec: all scenarios.
- Task 01: `normalizeFileChangeStatus`, `FileChangeStatus`, and shared `FileStatusIcon`.
- Preserve `reviewFileKey`, uncommitted > cumulative > PR precedence, and current `diff_skip_reason` copy.
## Dependencies
Task 01.
## Output contract
Report the files changed, focused tests/typecheck run, blockers, and residual risks; set this task to `done` and tick it in `plan.md`.
@@ -0,0 +1,43 @@
---
id: "03-e2e"
title: "Review status E2E coverage"
status: done
wave: 3
depends_on: ["02-review-ui"]
plan: "plan.md"
spec: "../../specs/ui/review-file-status.md"
---
# Task 03: Review status E2E coverage
Verify the complete Review workflow against a production Vite build on desktop and mobile.
## Acceptance
- Desktop Review visibly distinguishes added, modified, deleted, and renamed files and keeps the marker visible at the 160 px sidebar minimum.
- A pure rename with no textual patch remains in Review and shows the moved-without-textual-changes state.
- Mobile Review exposes the status in the sticky diff header, keeps controls usable, and introduces no document-level horizontal overflow.
## Verification
- `cd apps && pnpm --filter @kandev/web e2e:run tests/review/review-file-status.spec.ts`
- `cd apps && pnpm --filter @kandev/web e2e:run --project mobile-chrome tests/review/mobile-review-file-status.spec.ts`
## Files likely touched
- `apps/web/e2e/tests/review/review-file-status.spec.ts`
- `apps/web/e2e/tests/review/mobile-review-file-status.spec.ts`
## Inputs
- Spec: desktop, patchless, narrow-sidebar, and mobile scenarios.
- Task 02 DOM/accessibility contracts; select markers by semantic label or stable data attribute, never by color class.
- Patterns: `GitHelper` in `apps/web/e2e/helpers/git-helper.ts`, Review opening in `review-cumulative-diff.spec.ts`, sidebar sizing in `review-sidebar-resize.spec.ts`, and mobile Review entry in `task/mobile-changes-panel.spec.ts`.
## Dependencies
Task 02.
## Output contract
Report the files changed, exact E2E command/result, failure artifacts or blockers, and residual risks; set this task to `done` and tick it in `plan.md`.
+1
View File
@@ -98,6 +98,7 @@ Subscription quota tracking and per-agent cheap-model profile routing.
| [comment-markdown](ui/comment-markdown.md) | shipped |
| [empty-turn-notice](ui/empty-turn-notice.md) | shipped |
| [acp-shell-command-output](ui/acp-shell-command-output.md) | shipped |
| [review-file-status](ui/review-file-status.md) | building |
| [slash-command-composer](ui/slash-command-composer.md) | shipped |
## system-page/ — operational diagnostics & maintenance UI
+40
View File
@@ -0,0 +1,40 @@
---
status: building
created: 2026-07-14
owner: kandev
---
# Review File Status Cues
## Why
Reviewers can see changed filenames in Review's file tree, but cannot tell whether a file was added, modified, deleted, or moved without opening its diff. This slows navigation and makes patchless changes, especially pure renames, easy to miss.
## What
- Every changed file in Review shows a persistent trailing status marker: green plus for added, amber dot for modified, rose minus for deleted, and purple arrow for moved/renamed.
- Untracked files use the added visual language while retaining the accessible label `Untracked`.
- The marker does not rely on color alone. Its accessible name and native title describe the status; a moved file may include its previous path when available.
- The desktop marker occupies a fixed trailing column. At narrow sidebar widths, the filename truncates before the marker, stale warning, or comment count overlap.
- Below the desktop breakpoint, where the file tree is hidden, the same status marker appears in each sticky diff header. No required cue depends on hover.
- Review includes changed files even when they have no textual patch, including pure renames. Their diff body shows status-specific explanatory text instead of an indefinite loading message.
- Existing explicit skip reasons for binary, oversized, truncated, or budget-exceeded diffs take precedence over generic status-specific text.
- Existing file icons, tree hierarchy, filtering, review checkboxes, stale indicators, comment counts, source precedence, and multi-repository identity remain unchanged.
## Scenarios
- **GIVEN** added, modified, deleted, and renamed files, **WHEN** Review opens, **THEN** its desktop tree shows four distinct markers aligned at the trailing edge of their rows.
- **GIVEN** an untracked file, **WHEN** Review opens, **THEN** it uses the added plus marker and its accessible name says `Untracked`.
- **GIVEN** a pure rename with no textual patch, **WHEN** Review opens, **THEN** the file remains reviewable, uses the moved marker, and its body explains that the move has no textual changes.
- **GIVEN** another changed file with no textual patch, **WHEN** its diff body renders, **THEN** Review shows status-appropriate empty-state text rather than `Loading diff...`.
- **GIVEN** a file with an explicit diff skip reason, **WHEN** its body renders, **THEN** the skip-reason message is shown instead of the generic status empty state.
- **GIVEN** a 160 px Review sidebar and a long filename, **WHEN** the row renders, **THEN** the filename truncates while the status marker, stale warning, and comment count remain visible without overlap.
- **GIVEN** a mobile viewport, **WHEN** the user reviews a changed file, **THEN** its sticky diff header exposes the status marker without horizontal page overflow.
## Out of scope
- Grouping or filtering Review files by status.
- Replacing compact markers with visible `A`/`M`/`D`/`R` badges or filename tinting.
- A new mobile file navigator.
- Status animations.
- Backend or API changes; Review consumes existing git and pull-request status metadata.