fc9aabcda0
The agent's workspace tracker is now split per-repository for multi-repo
task roots. Each repo gets its own tracker that polls git independently
and stamps its repository name onto every emitted event. The Changes
panel aggregates files from every repo and groups them under per-repo
headers using the file-tree machinery from Phase 6.
Backend (agentctl):
- GitStatusUpdate / FileListUpdate gain a RepositoryName field. Empty
for single-repo workspaces (legacy clients see no change).
- WorkspaceTracker carries repositoryName and stamps it onto every
emitted status/file-list. Two constructors: NewWorkspaceTracker for
the legacy single-repo path (with the bare-root fallback) and
NewWorkspaceTrackerForRepo for explicit per-repo trackers.
- Manager scans direct child directories for git repos at construction.
When 2+ repos are found, builds one tracker per repo subdir on top of
the root tracker (which stays bound to the bare task root and emits
nothing useful). Single-repo workspaces are unchanged.
- Manager.SubscribeWorkspaceStream / UnsubscribeWorkspaceStream fan out
one client subscription across the root + all per-repo trackers via
AttachWorkspaceStreamSubscriber / DetachWorkspaceStreamSubscriber on
the tracker. The WS handler switched from the single-tracker subscribe
to the manager fan-out.
- Tests cover: per-repo trackers spawn for multi-repo task roots,
single-repo doesn't, both repos emit GitStatusUpdates tagged with
RepositoryName.
Frontend:
- GitStatusEntry / GitStatusData carry repository_name (optional).
- session-runtime gitStatus.byEnvironmentRepo new map keyed by env id
then repository name; setGitStatus routes per-repo updates into it
while still updating the legacy byEnvironmentId for single-repo
consumers. clearGitStatus drops both maps.
- migrateEnvKeyedData migrates the new per-repo map alongside the
existing keys when sessionId resolves to an environmentId.
- WS git-status handler forwards repository_name from event.status.
- New useSessionGitStatusByRepo hook returns sorted [{repo, status}]
pairs for the active session.
- task-changes-panel mergeReviewFiles iterates statusByRepo when
available, stamping repository_name onto every uncommitted file so
the Changes panel groups them under per-repo headers (using the
ReviewFile.repository_name plumbing from Phase 6).
- Tests: per-repo routing, no overwrite on sibling repo update,
legacy single-repo fallback, clear-on-cleanup.
Verified: pnpm exec vitest run 623/623, eslint --max-warnings 0 clean,
tsc --noEmit clean, make test (backend) clean, make lint 0 issues.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
584 lines
19 KiB
TypeScript
584 lines
19 KiB
TypeScript
"use client";
|
|
|
|
import { memo, useMemo, useCallback, createRef, useState, useEffect, useRef } from "react";
|
|
import { PanelRoot, PanelBody } from "./panel-primitives";
|
|
import { useToast } from "@/components/toast-provider";
|
|
import { useAppStore } from "@/components/state-provider";
|
|
import {
|
|
useSessionGitStatus,
|
|
useSessionGitStatusByRepo,
|
|
} from "@/hooks/domains/session/use-session-git-status";
|
|
import { useCumulativeDiff } from "@/hooks/domains/session/use-cumulative-diff";
|
|
import { useActiveTaskPR } from "@/hooks/domains/github/use-task-pr";
|
|
import { usePRDiff } from "@/hooks/domains/github/use-pr-diff";
|
|
import { useGitOperations } from "@/hooks/use-git-operations";
|
|
import { useSessionFileReviews } from "@/hooks/use-session-file-reviews";
|
|
import { useCommentsStore, isDiffComment } from "@/lib/state/slices/comments";
|
|
import { getWebSocketClient } from "@/lib/ws/connection";
|
|
import { updateUserSettings } from "@/lib/api";
|
|
import { formatReviewCommentsAsMarkdown } from "@/lib/state/slices/comments/format";
|
|
import { ReviewDiffList } from "@/components/review/review-diff-list";
|
|
import type { ReviewFile } from "@/components/review/types";
|
|
import { hashDiff, normalizeDiffContent } from "@/components/review/types";
|
|
import type { PRDiffFile } from "@/lib/types/github";
|
|
import { usePanelActions } from "@/hooks/use-panel-actions";
|
|
import { ChangesTopBar } from "./changes-top-bar";
|
|
import type { SelectedDiff } from "./task-layout";
|
|
import { useIsTaskArchived, ArchivedPanelPlaceholder } from "./task-archived-context";
|
|
|
|
type TaskChangesPanelProps = {
|
|
mode?: "all" | "file";
|
|
filePath?: string;
|
|
selectedDiff: SelectedDiff | null;
|
|
onClearSelected: () => void;
|
|
onBecameEmpty?: () => void;
|
|
/** Callback to open file in editor */
|
|
onOpenFile?: (filePath: string) => void;
|
|
};
|
|
|
|
type UncommittedFile = {
|
|
diff?: string;
|
|
diff_skip_reason?: ReviewFile["diff_skip_reason"];
|
|
status?: string;
|
|
additions?: number;
|
|
deletions?: number;
|
|
staged?: boolean;
|
|
};
|
|
type CumulativeFile = { diff?: string; status?: string; additions?: number; deletions?: number };
|
|
|
|
function addUncommittedFiles(
|
|
fileMap: Map<string, ReviewFile>,
|
|
files: Record<string, UncommittedFile>,
|
|
repositoryName?: string,
|
|
) {
|
|
for (const [path, file] of Object.entries(files)) {
|
|
const diff = file.diff ? normalizeDiffContent(file.diff) : "";
|
|
const skipReason = file.diff_skip_reason;
|
|
if (diff || skipReason) {
|
|
// For multi-repo workspaces the file tree groups by repository_name
|
|
// (see components/review/types.ts buildMultiRepoTree). Key the map by
|
|
// "repo:path" so two repos with the same filename don't collide.
|
|
const key = repositoryName ? `${repositoryName}:${path}` : path;
|
|
fileMap.set(key, {
|
|
path,
|
|
diff,
|
|
status: file.status ?? "modified",
|
|
additions: file.additions ?? 0,
|
|
deletions: file.deletions ?? 0,
|
|
staged: file.staged ?? false,
|
|
source: "uncommitted",
|
|
diff_skip_reason: skipReason,
|
|
repository_name: repositoryName,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
function addCumulativeFiles(
|
|
fileMap: Map<string, ReviewFile>,
|
|
files: Record<string, CumulativeFile>,
|
|
) {
|
|
for (const [path, file] of Object.entries(files)) {
|
|
if (fileMap.has(path)) continue;
|
|
const diff = file.diff ? normalizeDiffContent(file.diff) : "";
|
|
if (diff) {
|
|
fileMap.set(path, {
|
|
path,
|
|
diff,
|
|
status: file.status || "modified",
|
|
additions: file.additions ?? 0,
|
|
deletions: file.deletions ?? 0,
|
|
staged: false,
|
|
source: "committed",
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
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[]) {
|
|
for (const file of files) {
|
|
if (fileMap.has(file.filename)) 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",
|
|
});
|
|
}
|
|
}
|
|
|
|
// Returns true only after gitStatus loads and the file's uncommitted diff is gone.
|
|
export function shouldCloseFileDiffPanel(
|
|
gitStatus: { files?: Record<string, { diff?: string }> } | undefined,
|
|
filePath: string,
|
|
): boolean {
|
|
if (!gitStatus) return false;
|
|
const entry = gitStatus.files?.[filePath];
|
|
return !entry?.diff;
|
|
}
|
|
|
|
/** Merge PR + uncommitted + committed files into a single sorted list.
|
|
*
|
|
* For multi-repo workspaces statusByRepo carries one entry per repo, each
|
|
* tagged with repository_name; the merge stamps that name onto every
|
|
* uncommitted file so the file tree's per-repo grouping (see
|
|
* components/review/types.ts buildMultiRepoTree) kicks in automatically.
|
|
*
|
|
* Falls back to the single-repo gitStatus when statusByRepo is empty so
|
|
* legacy single-repo tasks keep their pre-multi-repo behavior.
|
|
*/
|
|
function mergeReviewFiles(
|
|
gitStatus: ReturnType<typeof useSessionGitStatus>,
|
|
cumulativeDiff: { files?: Record<string, CumulativeFile> } | null,
|
|
prDiffFiles?: PRDiffFile[],
|
|
statusByRepo?: ReturnType<typeof useSessionGitStatusByRepo>,
|
|
): ReviewFile[] {
|
|
const fileMap = new Map<string, ReviewFile>();
|
|
if (prDiffFiles) addPRFiles(fileMap, prDiffFiles);
|
|
if (statusByRepo && statusByRepo.length > 0) {
|
|
for (const { repository_name, status } of statusByRepo) {
|
|
if (status?.files) {
|
|
addUncommittedFiles(
|
|
fileMap,
|
|
status.files as Record<string, UncommittedFile>,
|
|
repository_name || undefined,
|
|
);
|
|
}
|
|
}
|
|
} else if (gitStatus?.files) {
|
|
addUncommittedFiles(fileMap, gitStatus.files as Record<string, UncommittedFile>);
|
|
}
|
|
if (cumulativeDiff?.files) addCumulativeFiles(fileMap, cumulativeDiff.files);
|
|
return Array.from(fileMap.values()).sort((a, b) => {
|
|
// Sort by repo then path so files cluster within their repo group.
|
|
const repoCmp = (a.repository_name ?? "").localeCompare(b.repository_name ?? "");
|
|
if (repoCmp !== 0) return repoCmp;
|
|
return a.path.localeCompare(b.path);
|
|
});
|
|
}
|
|
|
|
function useChangesData(selectedDiff: SelectedDiff | null, onClearSelected: () => void) {
|
|
const activeSessionId = useAppStore((state) => state.tasks.activeSessionId);
|
|
const gitStatus = useSessionGitStatus(activeSessionId);
|
|
const statusByRepo = useSessionGitStatusByRepo(activeSessionId);
|
|
const { diff: cumulativeDiff, loading: cumulativeLoading } = useCumulativeDiff(activeSessionId);
|
|
const pr = useActiveTaskPR();
|
|
const { files: prDiffFiles, loading: prDiffLoading } = usePRDiff(
|
|
pr?.owner ?? null,
|
|
pr?.repo ?? null,
|
|
pr?.pr_number ?? null,
|
|
);
|
|
const { reviews } = useSessionFileReviews(activeSessionId);
|
|
const byId = useCommentsStore((s) => s.byId);
|
|
const commentSessionIds = useCommentsStore((s) =>
|
|
activeSessionId ? s.bySession[activeSessionId] : undefined,
|
|
);
|
|
|
|
const allFiles = useMemo<ReviewFile[]>(
|
|
() =>
|
|
mergeReviewFiles(
|
|
gitStatus,
|
|
cumulativeDiff,
|
|
prDiffFiles.length > 0 ? prDiffFiles : undefined,
|
|
statusByRepo,
|
|
),
|
|
[gitStatus, statusByRepo, cumulativeDiff, prDiffFiles],
|
|
);
|
|
|
|
const { reviewedFiles, staleFiles } = useMemo(() => {
|
|
const reviewed = new Set<string>();
|
|
const stale = new Set<string>();
|
|
// When a PR exists but its diff files are still loading, the file list
|
|
// temporarily uses cumulative diff content which has a different hash.
|
|
// Skip review computation until PR diffs arrive to avoid a 1/1 -> 0/1 flash.
|
|
if (pr && prDiffLoading) {
|
|
return { reviewedFiles: reviewed, staleFiles: stale };
|
|
}
|
|
for (const file of allFiles) {
|
|
const reviewState = reviews.get(file.path);
|
|
if (!reviewState?.reviewed) continue;
|
|
const currentHash = hashDiff(file.diff);
|
|
if (reviewState.diffHash && reviewState.diffHash !== currentHash) {
|
|
stale.add(file.path);
|
|
} else {
|
|
reviewed.add(file.path);
|
|
}
|
|
}
|
|
return { reviewedFiles: reviewed, staleFiles: stale };
|
|
}, [allFiles, reviews, pr, prDiffLoading]);
|
|
|
|
const totalCommentCount = useMemo(() => {
|
|
if (!commentSessionIds || commentSessionIds.length === 0) return 0;
|
|
let count = 0;
|
|
for (const id of commentSessionIds) {
|
|
const comment = byId[id];
|
|
if (comment && isDiffComment(comment)) count++;
|
|
}
|
|
return count;
|
|
}, [byId, commentSessionIds]);
|
|
|
|
// Derive a stable key from file paths so refs are only recreated when
|
|
// the file list itself changes, not when diff content updates.
|
|
const filePathsKey = useMemo(() => allFiles.map((f) => f.path).join("\0"), [allFiles]);
|
|
const fileRefs = useMemo(() => {
|
|
const refs = new Map<string, React.RefObject<HTMLDivElement | null>>();
|
|
for (const file of allFiles) refs.set(file.path, createRef<HTMLDivElement>());
|
|
return refs;
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps -- keyed on stable path list, not allFiles reference
|
|
}, [filePathsKey]);
|
|
|
|
const scrolledRef = useRef<string | null>(null);
|
|
useEffect(() => {
|
|
if (!selectedDiff?.path || scrolledRef.current === selectedDiff.path) return;
|
|
scrolledRef.current = selectedDiff.path;
|
|
const ref = fileRefs.get(selectedDiff.path);
|
|
if (ref?.current)
|
|
requestAnimationFrame(() => {
|
|
ref.current?.scrollIntoView({ behavior: "smooth", block: "start" });
|
|
});
|
|
onClearSelected();
|
|
}, [selectedDiff, fileRefs, onClearSelected]);
|
|
|
|
useEffect(() => {
|
|
if (!selectedDiff) scrolledRef.current = null;
|
|
}, [selectedDiff]);
|
|
|
|
return {
|
|
activeSessionId,
|
|
allFiles,
|
|
reviewedFiles,
|
|
staleFiles,
|
|
totalCommentCount,
|
|
fileRefs,
|
|
cumulativeLoading,
|
|
gitStatus,
|
|
};
|
|
}
|
|
|
|
function persistAutoMarkSetting(checked: boolean) {
|
|
const client = getWebSocketClient();
|
|
const payload = { review_auto_mark_on_scroll: checked };
|
|
if (client) {
|
|
client.request("user.settings.update", payload).catch(() => {
|
|
updateUserSettings(payload, { cache: "no-store" }).catch(() => {});
|
|
});
|
|
return;
|
|
}
|
|
updateUserSettings(payload, { cache: "no-store" }).catch(() => {});
|
|
}
|
|
|
|
function useChangesActions(activeSessionId: string | null | undefined, allFiles: ReviewFile[]) {
|
|
const activeTaskId = useAppStore((state) => state.tasks.activeTaskId);
|
|
const autoMarkOnScroll = useAppStore((s) => s.userSettings.reviewAutoMarkOnScroll);
|
|
const setUserSettings = useAppStore((state) => state.setUserSettings);
|
|
const userSettings = useAppStore((state) => state.userSettings);
|
|
const { discard } = useGitOperations(activeSessionId ?? null);
|
|
const { markReviewed, markUnreviewed } = useSessionFileReviews(activeSessionId ?? null);
|
|
const getPendingComments = useCommentsStore((s) => s.getPendingComments);
|
|
const markCommentsSent = useCommentsStore((s) => s.markCommentsSent);
|
|
const { toast } = useToast();
|
|
|
|
const [splitView, setSplitView] = useState(
|
|
() => typeof window !== "undefined" && localStorage.getItem("diff-view-mode") === "split",
|
|
);
|
|
const [wordWrap, setWordWrap] = useState(false);
|
|
|
|
const handleToggleSplitView = useCallback((split: boolean) => {
|
|
setSplitView(split);
|
|
const mode = split ? "split" : "unified";
|
|
localStorage.setItem("diff-view-mode", mode);
|
|
window.dispatchEvent(new CustomEvent("diff-view-mode-change", { detail: mode }));
|
|
}, []);
|
|
|
|
const handleToggleReviewed = useCallback(
|
|
(path: string, reviewed: boolean) => {
|
|
if (reviewed) {
|
|
const file = allFiles.find((f) => f.path === path);
|
|
markReviewed(path, file ? hashDiff(file.diff) : "");
|
|
} else {
|
|
markUnreviewed(path);
|
|
}
|
|
},
|
|
[allFiles, markReviewed, markUnreviewed],
|
|
);
|
|
|
|
const handleDiscard = useCallback(
|
|
async (path: string) => {
|
|
try {
|
|
const result = await discard([path]);
|
|
if (result.success) {
|
|
toast({ title: "Changes discarded", description: path, variant: "success" });
|
|
} else {
|
|
toast({
|
|
title: "Discard failed",
|
|
description: result.error || "An error occurred",
|
|
variant: "error",
|
|
});
|
|
}
|
|
} catch (e) {
|
|
toast({
|
|
title: "Discard failed",
|
|
description: e instanceof Error ? e.message : "An error occurred",
|
|
variant: "error",
|
|
});
|
|
}
|
|
},
|
|
[discard, toast],
|
|
);
|
|
|
|
const handleToggleAutoMark = useCallback(
|
|
(checked: boolean) => {
|
|
const next = { ...userSettings, reviewAutoMarkOnScroll: checked };
|
|
setUserSettings(next);
|
|
persistAutoMarkSetting(checked);
|
|
},
|
|
[setUserSettings, userSettings],
|
|
);
|
|
|
|
const handleFixComments = useCallback(() => {
|
|
if (!activeSessionId || !activeTaskId) return;
|
|
const allPending = getPendingComments();
|
|
const comments = allPending.filter(isDiffComment);
|
|
if (comments.length === 0) return;
|
|
const markdown = formatReviewCommentsAsMarkdown(comments);
|
|
if (!markdown) return;
|
|
const client = getWebSocketClient();
|
|
if (client) {
|
|
client
|
|
.request("message.add", {
|
|
task_id: activeTaskId,
|
|
session_id: activeSessionId,
|
|
content: markdown,
|
|
})
|
|
.catch(() => {
|
|
toast({ title: "Failed to send comments", variant: "error" });
|
|
});
|
|
}
|
|
markCommentsSent(comments.map((c) => c.id));
|
|
}, [activeSessionId, activeTaskId, getPendingComments, markCommentsSent, toast]);
|
|
|
|
return {
|
|
splitView,
|
|
wordWrap,
|
|
setWordWrap,
|
|
autoMarkOnScroll,
|
|
handleToggleSplitView,
|
|
handleToggleReviewed,
|
|
handleDiscard,
|
|
handleToggleAutoMark,
|
|
handleFixComments,
|
|
};
|
|
}
|
|
|
|
function useAutoCloseWhenEmpty(opts: {
|
|
mode: "all" | "file";
|
|
filePath: string | undefined;
|
|
gitStatus: { files?: Record<string, { diff?: string }> } | undefined;
|
|
visibleCount: number;
|
|
onBecameEmpty: (() => void) | undefined;
|
|
}) {
|
|
const { mode, filePath, gitStatus, visibleCount, onBecameEmpty } = opts;
|
|
const prevVisibleCountRef = useRef<number | null>(null);
|
|
const prevFileSeenRef = useRef<boolean>(false);
|
|
|
|
useEffect(() => {
|
|
if (!onBecameEmpty) return;
|
|
|
|
if (mode === "file" && filePath) {
|
|
// File-mode: close when the file no longer has an uncommitted diff,
|
|
// regardless of whether it still appears in PR/cumulative diff sources.
|
|
const shouldClose = shouldCloseFileDiffPanel(gitStatus, filePath);
|
|
if (prevFileSeenRef.current && shouldClose) {
|
|
onBecameEmpty();
|
|
return;
|
|
}
|
|
if (!shouldClose) prevFileSeenRef.current = true;
|
|
return;
|
|
}
|
|
|
|
const prevCount = prevVisibleCountRef.current;
|
|
if (prevCount !== null && prevCount > 0 && visibleCount === 0) {
|
|
onBecameEmpty();
|
|
}
|
|
prevVisibleCountRef.current = visibleCount;
|
|
}, [mode, filePath, gitStatus, onBecameEmpty, visibleCount]);
|
|
}
|
|
|
|
const TaskChangesPanel = memo(function TaskChangesPanel({
|
|
mode = "all",
|
|
filePath,
|
|
selectedDiff,
|
|
onClearSelected,
|
|
onBecameEmpty,
|
|
onOpenFile: onOpenFileProp,
|
|
}: TaskChangesPanelProps) {
|
|
const isArchived = useIsTaskArchived();
|
|
const { openFile: panelOpenFile, openFileInMarkdownPreview } = usePanelActions();
|
|
const handleOpenFile = onOpenFileProp ?? panelOpenFile;
|
|
|
|
const {
|
|
activeSessionId,
|
|
allFiles,
|
|
reviewedFiles,
|
|
staleFiles,
|
|
totalCommentCount,
|
|
fileRefs,
|
|
cumulativeLoading,
|
|
gitStatus,
|
|
} = useChangesData(selectedDiff, onClearSelected);
|
|
const visibleFiles = useMemo(() => {
|
|
if (mode === "file" && filePath) {
|
|
return allFiles.filter((file) => file.path === filePath);
|
|
}
|
|
return allFiles;
|
|
}, [allFiles, mode, filePath]);
|
|
const visibleFileRefs = useMemo(() => {
|
|
if (mode !== "file" || !filePath) return fileRefs;
|
|
const refs = new Map<string, React.RefObject<HTMLDivElement | null>>();
|
|
const fileRef = fileRefs.get(filePath);
|
|
if (fileRef) refs.set(filePath, fileRef);
|
|
return refs;
|
|
}, [mode, filePath, fileRefs]);
|
|
const {
|
|
splitView,
|
|
wordWrap,
|
|
setWordWrap,
|
|
autoMarkOnScroll,
|
|
handleToggleSplitView,
|
|
handleToggleReviewed,
|
|
handleDiscard,
|
|
handleToggleAutoMark,
|
|
handleFixComments,
|
|
} = useChangesActions(activeSessionId, allFiles);
|
|
|
|
const reviewedCount = useMemo(
|
|
() =>
|
|
visibleFiles.reduce((count, file) => {
|
|
if (!staleFiles.has(file.path) && reviewedFiles.has(file.path)) return count + 1;
|
|
return count;
|
|
}, 0),
|
|
[visibleFiles, reviewedFiles, staleFiles],
|
|
);
|
|
const totalCount = visibleFiles.length;
|
|
const progressPercent = totalCount > 0 ? (reviewedCount / totalCount) * 100 : 0;
|
|
useAutoCloseWhenEmpty({
|
|
mode,
|
|
filePath,
|
|
gitStatus,
|
|
visibleCount: visibleFiles.length,
|
|
onBecameEmpty,
|
|
});
|
|
|
|
if (isArchived) return <ArchivedPanelPlaceholder />;
|
|
|
|
return (
|
|
<PanelRoot>
|
|
<ChangesTopBar
|
|
autoMarkOnScroll={autoMarkOnScroll}
|
|
splitView={splitView}
|
|
wordWrap={wordWrap}
|
|
totalCommentCount={totalCommentCount}
|
|
reviewedCount={reviewedCount}
|
|
totalCount={totalCount}
|
|
progressPercent={progressPercent}
|
|
setWordWrap={setWordWrap}
|
|
handleToggleSplitView={handleToggleSplitView}
|
|
handleToggleAutoMark={handleToggleAutoMark}
|
|
handleFixComments={handleFixComments}
|
|
/>
|
|
<PanelBody padding={false} scroll={false} className="overflow-hidden">
|
|
<ChangesPanelContent
|
|
isLoading={cumulativeLoading}
|
|
files={visibleFiles}
|
|
activeSessionId={activeSessionId}
|
|
reviewedFiles={reviewedFiles}
|
|
staleFiles={staleFiles}
|
|
autoMarkOnScroll={autoMarkOnScroll}
|
|
wordWrap={wordWrap}
|
|
selectedFile={mode === "file" ? filePath : undefined}
|
|
onToggleReviewed={handleToggleReviewed}
|
|
onDiscard={handleDiscard}
|
|
onOpenFile={handleOpenFile}
|
|
onPreviewMarkdown={openFileInMarkdownPreview}
|
|
fileRefs={visibleFileRefs}
|
|
/>
|
|
</PanelBody>
|
|
</PanelRoot>
|
|
);
|
|
});
|
|
|
|
function ChangesPanelContent({
|
|
isLoading,
|
|
files,
|
|
activeSessionId,
|
|
reviewedFiles,
|
|
staleFiles,
|
|
autoMarkOnScroll,
|
|
wordWrap,
|
|
selectedFile,
|
|
onToggleReviewed,
|
|
onDiscard,
|
|
onOpenFile,
|
|
onPreviewMarkdown,
|
|
fileRefs,
|
|
}: {
|
|
isLoading: boolean;
|
|
files: ReviewFile[];
|
|
activeSessionId: string | null | undefined;
|
|
reviewedFiles: Set<string>;
|
|
staleFiles: Set<string>;
|
|
autoMarkOnScroll: boolean;
|
|
wordWrap: boolean;
|
|
selectedFile?: string | null;
|
|
onToggleReviewed: (path: string, reviewed: boolean) => void;
|
|
onDiscard: (path: string) => Promise<void>;
|
|
onOpenFile: (path: string) => void;
|
|
onPreviewMarkdown?: (path: string) => void;
|
|
fileRefs: Map<string, React.RefObject<HTMLDivElement | null>>;
|
|
}) {
|
|
if (isLoading && files.length === 0) {
|
|
return (
|
|
<div className="flex items-center justify-center h-full text-muted-foreground text-sm">
|
|
Loading changes...
|
|
</div>
|
|
);
|
|
}
|
|
if (files.length === 0) {
|
|
return (
|
|
<div className="flex items-center justify-center h-full text-muted-foreground text-sm">
|
|
No changes
|
|
</div>
|
|
);
|
|
}
|
|
if (!activeSessionId) return null;
|
|
return (
|
|
<ReviewDiffList
|
|
files={files}
|
|
reviewedFiles={reviewedFiles}
|
|
staleFiles={staleFiles}
|
|
sessionId={activeSessionId}
|
|
autoMarkOnScroll={autoMarkOnScroll}
|
|
wordWrap={wordWrap}
|
|
selectedFile={selectedFile}
|
|
onToggleReviewed={onToggleReviewed}
|
|
onDiscard={onDiscard}
|
|
onOpenFile={onOpenFile}
|
|
onPreviewMarkdown={onPreviewMarkdown}
|
|
fileRefs={fileRefs}
|
|
/>
|
|
);
|
|
}
|
|
|
|
export { TaskChangesPanel };
|