Files
kandev/apps/web/components/review/review-diff-list.tsx
T
Kandev Agent 9e8079de69 feat(multi-repo): per-repo git ops, unified branch endpoint, chip-style task create
Bundles a string of multi-repo fixes and refactors that came up while
testing the feature end-to-end:

- workspace_path stored on multi-repo TaskEnvironment was one level too
  high, so agentctl booted with the wrong WorkDir on cold start; fixed
  in resolveTaskEnvWorkspacePath + GetWorkspaceInfoForSession.
- Stage/Unstage/Discard/Commit/Revert/Reset/Amend now thread `repo`
  through agentctl client → WS handler → frontend hook → SessionGit
  fan-out, so the right git repo is targeted (was running at the task
  root, which isn't a git repo for multi-repo tasks).
- agentctl GitLog is multi-repo aware: when no `repo` is specified and
  per-repo trackers exist, fans out one log per repo and merges sorted
  newest-first with RepositoryName stamped on each commit.
- Per-repo grouping for files (Changes panel) and commits, with
  collapsible group headers; commits section defaults to collapsed.
  Algorithm extracted into one generic groupByRepositoryName helper
  (was copy-pasted three times).
- Task creation dialog rewritten around a unified `fs.repositories[]`
  list — no more "primary vs extras" split. Chip-style row with one
  pill per repo + branch, "+" to add, x to remove, GitHub URL toggle
  inline. Discovered (on-machine) repos appear in any chip's dropdown.
  Removes ~6 fields and 3 helpers from the dialog state.
- Branch listing endpoints collapsed to one:
  GET /workspaces/:id/branches?repository_id=X | path=Y
  Frontend collapses to a single useBranches hook with a tagged-union
  source. Drops two HTTP routes, two service methods, two API client
  funcs, and one whole hook file.

Tests added across the chain: workspace_path resolver, sort-commits-
by-date, manager poll-mode propagation, RepoSubpaths, chip grouping
+ collapse + per-repo isLatest, group-by-repo helper, dialog payload
builder + chip wiring + discovered-repo flow.
2026-04-26 21:23:48 +01:00

621 lines
19 KiB
TypeScript

"use client";
import { memo, useEffect, useMemo, useRef, useState, useCallback } from "react";
import {
IconAlertTriangle,
IconArrowBackUp,
IconChevronDown,
IconChevronRight,
IconCopy,
IconFold,
IconFoldDown,
IconLayoutColumns,
IconLayoutRows,
IconPencil,
IconTextWrap,
IconEye,
} from "@tabler/icons-react";
import { Checkbox } from "@kandev/ui/checkbox";
import { Button } from "@kandev/ui/button";
import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip";
import { FileDiffViewer } from "@/components/diff";
import type { RevertBlockInfo } from "@/components/diff";
import { FileActionsDropdown } from "@/components/editors/file-actions-dropdown";
import { getWebSocketClient } from "@/lib/ws/connection";
import { requestFileContent, updateFileContent } from "@/lib/ws/workspace-files";
import { generateUnifiedDiff, calculateHash } from "@/lib/utils/file-diff";
import { useGlobalViewMode } from "@/hooks/use-global-view-mode";
import { useAppStore } from "@/components/state-provider";
import { useToast } from "@/components/toast-provider";
import { useRunComment } from "@/hooks/domains/comments/use-run-comment";
import type { DiffComment } from "@/lib/diff/types";
import { diffSkipReasonLabel } from "./types";
import type { ReviewFile } from "./types";
import { RepoGroupHeader } from "./review-diff-list-groups";
import { groupByRepositoryName } from "@/lib/group-by-repo";
function isMarkdownPath(filePath: string): boolean {
const ext = filePath.split(".").pop()?.toLowerCase();
return ext === "md" || ext === "mdx";
}
type ReviewDiffListProps = {
files: ReviewFile[];
reviewedFiles: Set<string>;
staleFiles: Set<string>;
sessionId: string;
autoMarkOnScroll: boolean;
wordWrap: boolean;
selectedFile?: string | null;
onToggleReviewed: (path: string, reviewed: boolean) => void;
onDiscard: (path: string) => void;
onOpenFile?: (filePath: string) => void;
onPreviewMarkdown?: (filePath: string) => void;
fileRefs: Map<string, React.RefObject<HTMLDivElement | null>>;
};
export const ReviewDiffList = memo(function ReviewDiffList({
files,
reviewedFiles,
staleFiles,
sessionId,
autoMarkOnScroll,
wordWrap,
selectedFile,
onToggleReviewed,
onDiscard,
onOpenFile,
onPreviewMarkdown,
fileRefs,
}: ReviewDiffListProps) {
const scrollContainerRef = useRef<HTMLDivElement | null>(null);
// Find index of selected file - we need to force-load all files up to it
const selectedIndex = selectedFile ? files.findIndex((f) => f.path === selectedFile) : -1;
const groups = useMemo(
() => groupByRepositoryName(files, (f) => f.repository_name),
[files],
);
const showRepoHeaders = groups.length > 1 || (groups[0]?.repositoryName ?? "") !== "";
return (
<div ref={scrollContainerRef} className="overflow-y-auto h-full">
{groups.map((group) => (
<div
key={group.repositoryName || "__no_repo__"}
data-testid="changes-repo-group"
data-repository-name={group.repositoryName || ""}
>
{showRepoHeaders && (
<RepoGroupHeader name={group.repositoryName} fileCount={group.items.length} />
)}
{group.items.map((file) => (
<FileDiffSection
key={`${group.repositoryName}:${file.path}`}
file={file}
isReviewed={reviewedFiles.has(file.path) && !staleFiles.has(file.path)}
isStale={staleFiles.has(file.path)}
sessionId={sessionId}
autoMarkOnScroll={autoMarkOnScroll}
wordWrap={wordWrap}
isSelected={selectedFile === file.path}
forceLoad={
selectedIndex >= 0 && files.findIndex((f) => f.path === file.path) <= selectedIndex
}
onToggleReviewed={onToggleReviewed}
onDiscard={onDiscard}
onOpenFile={onOpenFile}
onPreviewMarkdown={onPreviewMarkdown}
sectionRef={fileRefs.get(file.path)}
scrollContainer={scrollContainerRef}
/>
))}
</div>
))}
</div>
);
});
// Per-repo grouping helpers live in ./review-diff-list-groups so this file
// stays under the 600-line lint cap.
type FileDiffSectionProps = {
file: ReviewFile;
isReviewed: boolean;
isStale: boolean;
sessionId: string;
autoMarkOnScroll: boolean;
wordWrap: boolean;
isSelected?: boolean;
forceLoad?: boolean;
onToggleReviewed: (path: string, reviewed: boolean) => void;
onDiscard: (path: string) => void;
onOpenFile?: (filePath: string) => void;
onPreviewMarkdown?: (filePath: string) => void;
sectionRef?: React.RefObject<HTMLDivElement | null>;
scrollContainer: React.RefObject<HTMLDivElement | null>;
};
function useLazyVisible(scrollContainer: React.RefObject<HTMLDivElement | null>) {
const [isVisible, setIsVisible] = useState(false);
const sentinelRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const sentinel = sentinelRef.current;
if (!sentinel) return;
const observer = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) {
setIsVisible(true);
observer.disconnect();
}
},
{ rootMargin: "200px 0px", root: scrollContainer.current },
);
observer.observe(sentinel);
return () => observer.disconnect();
}, [scrollContainer]);
return { isVisible, sentinelRef };
}
type AutoMarkArgs = {
autoMarkOnScroll: boolean;
isReviewed: boolean;
isStale: boolean;
filePath: string;
onToggleReviewed: (path: string, reviewed: boolean) => void;
scrollContainer: React.RefObject<HTMLDivElement | null>;
};
function useAutoMarkOnScroll({
autoMarkOnScroll,
isReviewed,
isStale,
filePath,
onToggleReviewed,
scrollContainer,
}: AutoMarkArgs) {
const scrollSentinelRef = useRef<HTMLDivElement | null>(null);
const autoMarkedRef = useRef(false);
useEffect(() => {
if (!autoMarkOnScroll || isReviewed || isStale) {
autoMarkedRef.current = false;
return;
}
const sentinel = scrollSentinelRef.current;
const root = scrollContainer.current;
if (!sentinel || !root) return;
const observer = new IntersectionObserver(
([entry]) => {
if (
!entry.isIntersecting &&
entry.boundingClientRect.top < root.getBoundingClientRect().top &&
!autoMarkedRef.current
) {
autoMarkedRef.current = true;
console.debug("[review] auto-mark reviewed:", filePath);
onToggleReviewed(filePath, true);
}
},
{ threshold: 0, root },
);
observer.observe(sentinel);
return () => observer.disconnect();
}, [autoMarkOnScroll, filePath, isReviewed, isStale, onToggleReviewed, scrollContainer]);
return scrollSentinelRef;
}
const iconBtn = "h-6 w-6 p-0 cursor-pointer opacity-60 hover:opacity-100";
const iconBtnActive = "h-6 w-6 p-0 cursor-pointer opacity-100 bg-muted";
type FileDiffToolbarProps = {
diff: string;
filePath: string;
sessionId: string;
source: string;
wordWrap: boolean;
expandUnchanged: boolean;
onDiscard: () => void;
onOpenFile?: (filePath: string) => void;
onPreviewMarkdown?: (filePath: string) => void;
onToggleExpandUnchanged: () => void;
onToggleWordWrap: () => void;
};
function ToolbarIconBtn({
onClick,
tooltip,
active,
children,
className,
}: {
onClick: () => void;
tooltip: string;
active?: boolean;
children: React.ReactNode;
className?: string;
}) {
return (
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
aria-label={tooltip}
aria-pressed={active}
variant="ghost"
size="sm"
className={className ?? (active ? iconBtnActive : iconBtn)}
onClick={onClick}
>
{children}
</Button>
</TooltipTrigger>
<TooltipContent>{tooltip}</TooltipContent>
</Tooltip>
);
}
function FileDiffToolbar(props: FileDiffToolbarProps) {
const {
diff,
filePath,
sessionId,
source,
wordWrap,
expandUnchanged,
onDiscard,
onOpenFile,
onPreviewMarkdown,
onToggleExpandUnchanged,
onToggleWordWrap,
} = props;
const [globalViewMode, setGlobalViewMode] = useGlobalViewMode();
const handleCopyDiff = useCallback(() => {
navigator.clipboard.writeText(diff || "");
}, [diff]);
const handleToggleViewMode = useCallback(
() => setGlobalViewMode(globalViewMode === "split" ? "unified" : "split"),
[globalViewMode, setGlobalViewMode],
);
return (
<div className="flex items-center gap-0.5">
<ToolbarIconBtn onClick={handleCopyDiff} tooltip="Copy diff">
<IconCopy className="h-3.5 w-3.5" />
</ToolbarIconBtn>
<ToolbarIconBtn
onClick={onToggleExpandUnchanged}
tooltip={expandUnchanged ? "Collapse unchanged" : "Expand all"}
active={expandUnchanged}
>
{expandUnchanged ? (
<IconFold className="h-3.5 w-3.5" />
) : (
<IconFoldDown className="h-3.5 w-3.5" />
)}
</ToolbarIconBtn>
<ToolbarIconBtn onClick={onToggleWordWrap} tooltip="Toggle word wrap" active={wordWrap}>
<IconTextWrap className="h-3.5 w-3.5" />
</ToolbarIconBtn>
<ToolbarIconBtn
onClick={handleToggleViewMode}
tooltip={globalViewMode === "split" ? "Switch to unified view" : "Switch to split view"}
>
{globalViewMode === "split" ? (
<IconLayoutRows className="h-3.5 w-3.5" />
) : (
<IconLayoutColumns className="h-3.5 w-3.5" />
)}
</ToolbarIconBtn>
{onPreviewMarkdown && isMarkdownPath(filePath) && (
<ToolbarIconBtn onClick={() => onPreviewMarkdown(filePath)} tooltip="Preview markdown">
<IconEye className="h-3.5 w-3.5" />
</ToolbarIconBtn>
)}
{onOpenFile && (
<ToolbarIconBtn onClick={() => onOpenFile(filePath)} tooltip="Edit">
<IconPencil className="h-3.5 w-3.5" />
</ToolbarIconBtn>
)}
<FileActionsDropdown filePath={filePath} sessionId={sessionId} size="xs" />
{source === "uncommitted" && (
<ToolbarIconBtn
onClick={onDiscard}
tooltip="Revert changes"
className="h-6 w-6 p-0 cursor-pointer opacity-60 hover:opacity-100 hover:text-destructive"
>
<IconArrowBackUp className="h-3.5 w-3.5" />
</ToolbarIconBtn>
)}
</div>
);
}
type FileDiffHeaderProps = {
file: ReviewFile;
isReviewed: boolean;
isStale: boolean;
sessionId: string;
collapsed: boolean;
wordWrap: boolean;
expandUnchanged: boolean;
onCheckboxChange: (checked: boolean | "indeterminate") => void;
onDiscard: () => void;
onOpenFile?: (filePath: string) => void;
onPreviewMarkdown?: (filePath: string) => void;
onToggleCollapse: () => void;
onToggleExpandUnchanged: () => void;
onToggleWordWrap: () => void;
};
function FileDiffHeader({
file,
isReviewed,
isStale,
collapsed,
wordWrap,
expandUnchanged,
sessionId,
onCheckboxChange,
onDiscard,
onOpenFile,
onPreviewMarkdown,
onToggleCollapse,
onToggleExpandUnchanged,
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">
<Checkbox
checked={isReviewed}
onCheckedChange={onCheckboxChange}
className="h-4 w-4 cursor-pointer"
/>
<button
onClick={onToggleCollapse}
className="flex items-center gap-1.5 flex-1 min-w-0 cursor-pointer text-left hover:text-foreground"
>
{collapsed ? (
<IconChevronRight className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
) : (
<IconChevronDown className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
)}
<span className="text-[13px] font-medium truncate">{file.path}</span>
</button>
{isStale && (
<span className="flex items-center gap-1 text-xs text-yellow-500">
<IconAlertTriangle className="h-3.5 w-3.5" />
changed
</span>
)}
<span className="text-xs text-muted-foreground">
{file.additions > 0 && <span className="text-emerald-500">+{file.additions}</span>}
{file.additions > 0 && file.deletions > 0 && " / "}
{file.deletions > 0 && <span className="text-rose-500">-{file.deletions}</span>}
</span>
<FileDiffToolbar
diff={file.diff}
filePath={file.path}
sessionId={sessionId}
source={file.source}
wordWrap={wordWrap}
expandUnchanged={expandUnchanged}
onDiscard={onDiscard}
onOpenFile={onOpenFile}
onPreviewMarkdown={onPreviewMarkdown}
onToggleExpandUnchanged={onToggleExpandUnchanged}
onToggleWordWrap={onToggleWordWrap}
/>
</div>
);
}
function useCommentRunHandler(sessionId: string) {
const activeTaskId = useAppStore((state) => state.tasks.activeTaskId);
const { toast } = useToast();
const { runComment } = useRunComment({
sessionId,
taskId: activeTaskId ?? null,
});
return useCallback(
async (comment: DiffComment) => {
try {
const { queued } = await runComment(comment);
toast({
title: "Comment sent",
description: queued ? "Queued for the agent." : "Sent to the agent.",
});
} catch (err) {
console.error("Failed to run diff comment:", err);
toast({
title: "Failed to send comment",
description: "Please try again.",
variant: "error",
});
}
},
[runComment, toast],
);
}
async function revertBlock(sessionId: string, filePath: string, info: RevertBlockInfo) {
const client = getWebSocketClient();
if (!client) return;
try {
const response = await requestFileContent(client, sessionId, filePath);
if (response.error) return;
const currentContent = response.content;
const hash = await calculateHash(currentContent);
const lines = currentContent.split("\n");
lines.splice(info.addStart - 1, info.addCount, ...info.oldLines);
const nextContent = lines.join("\n");
if (nextContent === currentContent) return;
const patch = generateUnifiedDiff(currentContent, nextContent, filePath);
if (!patch || !/^@@/m.test(patch)) return;
await updateFileContent(client, sessionId, {
path: filePath,
diff: patch,
originalHash: hash,
});
} catch (err) {
console.error("Failed to revert change block:", err);
}
}
function useScrollIntoViewOnSelect(
isSelected: boolean | undefined,
sectionRef: React.RefObject<HTMLDivElement | null> | undefined,
setCollapsed: React.Dispatch<React.SetStateAction<boolean>>,
) {
useEffect(() => {
if (isSelected) {
setCollapsed(false);
requestAnimationFrame(() => {
requestAnimationFrame(() => {
sectionRef?.current?.scrollIntoView({ behavior: "smooth", block: "start" });
});
});
}
}, [isSelected, sectionRef, setCollapsed]);
}
function renderDiffContent(opts: {
shouldRender: boolean;
file: ReviewFile;
sessionId: string;
wordWrap: boolean;
expandUnchanged: boolean;
onRevertBlock: (filePath: string, info: RevertBlockInfo) => void;
onCommentRun: (comment: DiffComment) => void;
onToggleExpandUnchanged: () => void;
}) {
const {
shouldRender,
file,
sessionId,
wordWrap,
expandUnchanged,
onRevertBlock,
onCommentRun,
onToggleExpandUnchanged,
} = opts;
if (shouldRender && file.diff) {
return (
<>
<FileDiffViewer
filePath={file.path}
diff={file.diff}
status={file.status}
enableComments
enableAcceptReject
onRevertBlock={onRevertBlock}
onCommentRun={onCommentRun}
sessionId={sessionId}
wordWrap={wordWrap}
enableExpansion={true}
baseRef="HEAD"
hideHeader
expandUnchanged={expandUnchanged}
onToggleExpandUnchanged={onToggleExpandUnchanged}
/>
{file.diff_skip_reason === "truncated" && (
<div className="py-1 text-center text-xs text-muted-foreground border-t">
Diff truncated — showing first 256 KB
</div>
)}
</>
);
}
return (
<div className="flex items-center justify-center py-12 text-muted-foreground text-sm">
{diffSkipReasonLabel(file.diff_skip_reason)}
</div>
);
}
function FileDiffSection({
file,
isReviewed,
isStale,
sessionId,
autoMarkOnScroll,
wordWrap,
isSelected,
forceLoad,
onToggleReviewed,
onDiscard,
onOpenFile,
onPreviewMarkdown,
sectionRef,
scrollContainer,
}: FileDiffSectionProps) {
const [collapsed, setCollapsed] = useState(false);
const [expandUnchanged, setExpandUnchanged] = useState(false);
const [localWordWrap, setLocalWordWrap] = useState<boolean | undefined>(undefined);
const effectiveWordWrap = localWordWrap ?? wordWrap;
const handleToggleCollapse = useCallback(() => setCollapsed((v) => !v), []);
const handleToggleExpandUnchanged = useCallback(() => setExpandUnchanged((v) => !v), []);
const handleToggleWordWrap = useCallback(
() => setLocalWordWrap((v) => !(v ?? wordWrap)),
[wordWrap],
);
const { isVisible, sentinelRef } = useLazyVisible(scrollContainer);
// Force load when visible via intersection observer, or forceLoad is true
const shouldRenderContent = isVisible || !!forceLoad;
useScrollIntoViewOnSelect(isSelected, sectionRef, setCollapsed);
const scrollSentinelRef = useAutoMarkOnScroll({
autoMarkOnScroll,
isReviewed,
isStale,
filePath: file.path,
onToggleReviewed,
scrollContainer,
});
const handleCheckboxChange = useCallback(
(checked: boolean | "indeterminate") => {
onToggleReviewed(file.path, checked === true);
},
[file.path, onToggleReviewed],
);
const handleDiscard = useCallback(() => {
onDiscard(file.path);
}, [file.path, onDiscard]);
const handleRevertBlock = useCallback(
(filePath: string, info: RevertBlockInfo) => revertBlock(sessionId, filePath, info),
[sessionId],
);
const handleCommentRun = useCommentRunHandler(sessionId);
return (
<div ref={sectionRef} className="border-b border-border">
<div ref={scrollSentinelRef} className="h-0" />
<FileDiffHeader
file={file}
isReviewed={isReviewed}
isStale={isStale}
sessionId={sessionId}
collapsed={collapsed}
wordWrap={effectiveWordWrap}
expandUnchanged={expandUnchanged}
onCheckboxChange={handleCheckboxChange}
onDiscard={handleDiscard}
onOpenFile={onOpenFile}
onPreviewMarkdown={onPreviewMarkdown}
onToggleCollapse={handleToggleCollapse}
onToggleExpandUnchanged={handleToggleExpandUnchanged}
onToggleWordWrap={handleToggleWordWrap}
/>
<div ref={sentinelRef} />
{!collapsed &&
renderDiffContent({
shouldRender: shouldRenderContent,
file,
sessionId,
wordWrap: effectiveWordWrap,
expandUnchanged,
onRevertBlock: handleRevertBlock,
onCommentRun: handleCommentRun,
onToggleExpandUnchanged: handleToggleExpandUnchanged,
})}
</div>
);
}