609 lines
20 KiB
TypeScript
609 lines
20 KiB
TypeScript
"use client";
|
|
|
|
import { memo, useMemo, useCallback, createRef, useState, useEffect, useRef } from "react";
|
|
import { PanelRoot, PanelBody } from "./panel-primitives";
|
|
import { TruncatedFilesBanner, useSelectedFileKey } from "./changes-panel-banner";
|
|
import { useToast } from "@/components/toast-provider";
|
|
import { useAppStore } from "@/components/state-provider";
|
|
import { useReviewSources, type ReviewSource } from "@/hooks/domains/session/use-review-sources";
|
|
import { useGitOperations } from "@/hooks/use-git-operations";
|
|
import { useSessionFileReviews } from "@/hooks/use-session-file-reviews";
|
|
import { useCommentsStore, isReviewComment } from "@/lib/state/slices/comments";
|
|
import { getWebSocketClient } from "@/lib/ws/connection";
|
|
import { generateUUID } from "@/lib/utils";
|
|
import { updateUserSettings } from "@/lib/api";
|
|
import { formatReviewCommentsAsMarkdown } from "@/lib/state/slices/comments/format";
|
|
import { ReviewDiffList } from "@/components/review/review-diff-list";
|
|
import { ReviewPRDiffBoundary } from "@/components/review/review-dialog-pr-state";
|
|
import { DEFAULT_DIFF_WORD_WRAP } from "@/components/diff/diff-defaults";
|
|
import type { ReviewFile } from "@/components/review/types";
|
|
import { reviewFileKey, splitReviewFileKey } from "@/components/review/types";
|
|
import { usePanelActions } from "@/hooks/use-panel-actions";
|
|
import { useRequestChangesWalkthrough } from "@/hooks/domains/session/use-request-changes-walkthrough";
|
|
import { ChangesTopBar } from "./changes-top-bar";
|
|
import {
|
|
computeChangesReviewSets,
|
|
resolveSelectedFileRepositoryName,
|
|
reviewDiffHashForKey,
|
|
shouldBlockChangesForPR,
|
|
shouldDeferReviewStateForPR,
|
|
useAutoCloseWhenEmpty,
|
|
useVisibleDiffState,
|
|
} from "./task-changes-panel-state";
|
|
import type { SelectedDiff } from "./task-layout";
|
|
import { useIsTaskArchived, ArchivedPanelPlaceholder } from "./task-archived-context";
|
|
import { useTranslation } from "react-i18next";
|
|
import type { GitChangeLayer } from "@/lib/state/slices/session-runtime/types";
|
|
import {
|
|
useWorkspaceRestoration,
|
|
type WorkspaceRestorationResult,
|
|
} from "@/hooks/domains/session/use-workspace-restoration";
|
|
import { WorkspaceUnavailable } from "./workspace-unavailable";
|
|
|
|
type TaskChangesPanelProps = {
|
|
mode?: "all" | "file";
|
|
filePath?: string;
|
|
fileRepositoryName?: string;
|
|
changeLayer?: GitChangeLayer;
|
|
prKey?: string;
|
|
selectedDiff: SelectedDiff | null;
|
|
onClearSelected: () => void;
|
|
onBecameEmpty?: () => void;
|
|
/** Callback to open file in editor */
|
|
onOpenFile?: (filePath: string, repo?: string) => void;
|
|
/**
|
|
* Restrict the diff list to a single source. Defaults to `"all"` (no
|
|
* filter). Mobile uses this for source tabs; desktop omits it.
|
|
*/
|
|
sourceFilter?: "all" | ReviewSource;
|
|
/** Force word-wrap on diffs. Defaults to the app diff preference. */
|
|
wordWrap?: boolean;
|
|
};
|
|
|
|
function scrollToFileAndClear(
|
|
path: string,
|
|
fileRefs: Map<string, React.RefObject<HTMLDivElement | null>>,
|
|
onClearSelected: () => void,
|
|
) {
|
|
// Try exact key first (bare path for single-repo, composite if caller provides one).
|
|
let ref = fileRefs.get(path);
|
|
if (!ref) {
|
|
// Fallback: selectedDiff.path is always bare — find the first matching ref.
|
|
for (const [key, r] of fileRefs.entries()) {
|
|
if (splitReviewFileKey(key).path === path) {
|
|
ref = r;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
if (ref?.current) {
|
|
requestAnimationFrame(() => {
|
|
ref!.current?.scrollIntoView({ behavior: "smooth", block: "start" });
|
|
onClearSelected();
|
|
});
|
|
} else {
|
|
onClearSelected();
|
|
}
|
|
}
|
|
|
|
export function discardTargetFromReviewFileKey(key: string): {
|
|
repositoryName: string;
|
|
path: string;
|
|
} {
|
|
return splitReviewFileKey(key);
|
|
}
|
|
|
|
function useChangesView(
|
|
selectedDiff: SelectedDiff | null,
|
|
onClearSelected: () => void,
|
|
sourceFilter: "all" | ReviewSource,
|
|
explicitPRKey?: string,
|
|
) {
|
|
const activeSessionId = useAppStore((state) => state.tasks.activeSessionId);
|
|
const reviewSources = useReviewSources(activeSessionId, explicitPRKey);
|
|
const {
|
|
allFiles,
|
|
cumulativeLoading,
|
|
prDiffLoading,
|
|
prDiffError,
|
|
refreshPRDiff,
|
|
gitStatus,
|
|
rawPRFiles,
|
|
truncatedFilesCount,
|
|
selectedPR: pr,
|
|
} = reviewSources;
|
|
const { reviews } = useSessionFileReviews(activeSessionId);
|
|
const byId = useCommentsStore((s) => s.byId);
|
|
const commentSessionIds = useCommentsStore((s) =>
|
|
activeSessionId ? s.bySession[activeSessionId] : undefined,
|
|
);
|
|
|
|
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 (shouldDeferReviewStateForPR(Boolean(pr), prDiffLoading, sourceFilter)) {
|
|
return { reviewedFiles: reviewed, staleFiles: stale };
|
|
}
|
|
return computeChangesReviewSets(allFiles, reviews);
|
|
}, [allFiles, reviews, pr, prDiffLoading, sourceFilter]);
|
|
|
|
const totalCommentCount = useMemo(() => {
|
|
if (!commentSessionIds || commentSessionIds.length === 0) return 0;
|
|
let count = 0;
|
|
for (const id of commentSessionIds) {
|
|
const comment = byId[id];
|
|
if (comment && isReviewComment(comment)) count++;
|
|
}
|
|
return count;
|
|
}, [byId, commentSessionIds]);
|
|
|
|
// Derive a stable key from composite file keys so refs are only recreated
|
|
// when the file list itself changes, not when diff content updates.
|
|
const filePathsKey = useMemo(() => allFiles.map((f) => reviewFileKey(f)).join("\0"), [allFiles]);
|
|
const fileRefs = useMemo(() => {
|
|
const refs = new Map<string, React.RefObject<HTMLDivElement | null>>();
|
|
for (const file of allFiles) refs.set(reviewFileKey(file), 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;
|
|
scrollToFileAndClear(selectedDiff.path, fileRefs, onClearSelected);
|
|
}, [selectedDiff, fileRefs, onClearSelected]);
|
|
|
|
useEffect(() => {
|
|
if (!selectedDiff) scrolledRef.current = null;
|
|
}, [selectedDiff]);
|
|
|
|
return {
|
|
activeSessionId,
|
|
allFiles,
|
|
rawPRFiles,
|
|
reviewedFiles,
|
|
staleFiles,
|
|
totalCommentCount,
|
|
fileRefs,
|
|
cumulativeLoading,
|
|
prDiffLoading,
|
|
prDiffError,
|
|
refreshPRDiff,
|
|
gitStatus,
|
|
truncatedFilesCount,
|
|
prs: reviewSources.prs,
|
|
selectedPR: reviewSources.selectedPR,
|
|
selectPR: reviewSources.selectPR,
|
|
};
|
|
}
|
|
|
|
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 useWalkthroughRequest(
|
|
activeSessionId: string | null | undefined,
|
|
allFiles: ReviewFile[],
|
|
workspaceBlocked = false,
|
|
) {
|
|
const activeTaskId = useAppStore((s) => s.tasks.activeTaskId);
|
|
return useRequestChangesWalkthrough({
|
|
taskId: activeTaskId,
|
|
sessionId: activeSessionId,
|
|
ready: allFiles.length > 0 && !workspaceBlocked,
|
|
});
|
|
}
|
|
|
|
function useChangesPRPresentation(opts: {
|
|
sourceFilter: "all" | ReviewSource;
|
|
prKey: string | undefined;
|
|
mode: "all" | "file";
|
|
filePath: string | undefined;
|
|
fileRepositoryName: string | undefined;
|
|
changeLayer: GitChangeLayer | undefined;
|
|
visibleFiles: ReviewFile[];
|
|
}) {
|
|
const visibleSelectedFile = opts.visibleFiles.find((file) => file.path === opts.filePath);
|
|
const selectedFileKey = useSelectedFileKey(
|
|
opts.mode,
|
|
opts.filePath,
|
|
resolveSelectedFileRepositoryName(
|
|
opts.sourceFilter,
|
|
opts.prKey,
|
|
opts.fileRepositoryName,
|
|
visibleSelectedFile?.repository_name,
|
|
),
|
|
opts.changeLayer,
|
|
);
|
|
const blockChangesForPR = shouldBlockChangesForPR(opts.sourceFilter, opts.visibleFiles);
|
|
return { selectedFileKey, blockChangesForPR };
|
|
}
|
|
|
|
export function useFixCommentsRequest(
|
|
activeSessionId: string | null | undefined,
|
|
workspaceBlocked: boolean,
|
|
) {
|
|
const { t } = useTranslation();
|
|
const activeTaskId = useAppStore((state) => state.tasks.activeTaskId);
|
|
const getPendingComments = useCommentsStore((s) => s.getPendingComments);
|
|
const markCommentsSent = useCommentsStore((s) => s.markCommentsSent);
|
|
const { toast } = useToast();
|
|
|
|
return useCallback(() => {
|
|
if (workspaceBlocked || !activeSessionId || !activeTaskId) return;
|
|
const comments = getPendingComments()
|
|
.filter(isReviewComment)
|
|
.filter((comment) => comment.sessionId === activeSessionId);
|
|
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,
|
|
client_message_id: generateUUID(),
|
|
content: markdown,
|
|
})
|
|
.catch(() => toast({ title: t("task:failedToSendComments"), variant: "error" }));
|
|
markCommentsSent(comments.map((c) => c.id));
|
|
}, [
|
|
activeSessionId,
|
|
activeTaskId,
|
|
getPendingComments,
|
|
markCommentsSent,
|
|
t,
|
|
toast,
|
|
workspaceBlocked,
|
|
]);
|
|
}
|
|
|
|
function useChangesActions(
|
|
activeSessionId: string | null | undefined,
|
|
allFiles: ReviewFile[],
|
|
defaultWordWrap = DEFAULT_DIFF_WORD_WRAP,
|
|
workspaceBlocked = false,
|
|
) {
|
|
const { t } = useTranslation();
|
|
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 { toast } = useToast();
|
|
|
|
const [splitView, setSplitView] = useState(
|
|
() => typeof window !== "undefined" && localStorage.getItem("diff-view-mode") === "split",
|
|
);
|
|
const [wordWrap, setWordWrap] = useState(defaultWordWrap);
|
|
|
|
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(
|
|
(key: string, reviewed: boolean) => {
|
|
if (reviewed) {
|
|
markReviewed(key, reviewDiffHashForKey(allFiles, key));
|
|
} else {
|
|
markUnreviewed(key);
|
|
}
|
|
},
|
|
[allFiles, markReviewed, markUnreviewed],
|
|
);
|
|
|
|
const handleDiscard = useCallback(
|
|
async (key: string) => {
|
|
if (workspaceBlocked) return;
|
|
const { repositoryName, path } = discardTargetFromReviewFileKey(key);
|
|
try {
|
|
const result = await discard([path], repositoryName || undefined);
|
|
if (result.success) {
|
|
toast({ title: t("task:changesDiscarded"), description: path, variant: "success" });
|
|
} else {
|
|
toast({
|
|
title: t("task:discardFailed"),
|
|
description: result.error || t("task:anErrorOccurred"),
|
|
variant: "error",
|
|
});
|
|
}
|
|
} catch (e) {
|
|
toast({
|
|
title: t("task:discardFailed"),
|
|
description: e instanceof Error ? e.message : t("task:anErrorOccurred"),
|
|
variant: "error",
|
|
});
|
|
}
|
|
},
|
|
[discard, toast, workspaceBlocked],
|
|
);
|
|
|
|
const handleToggleAutoMark = useCallback(
|
|
(checked: boolean) => {
|
|
const next = { ...userSettings, reviewAutoMarkOnScroll: checked };
|
|
setUserSettings(next);
|
|
persistAutoMarkSetting(checked);
|
|
},
|
|
[setUserSettings, userSettings],
|
|
);
|
|
|
|
const handleFixComments = useFixCommentsRequest(activeSessionId, workspaceBlocked);
|
|
|
|
return {
|
|
splitView,
|
|
wordWrap,
|
|
setWordWrap,
|
|
autoMarkOnScroll,
|
|
handleToggleSplitView,
|
|
handleToggleReviewed,
|
|
handleDiscard,
|
|
handleToggleAutoMark,
|
|
handleFixComments,
|
|
};
|
|
}
|
|
|
|
function useTaskChangesWorkspaceRestoration(
|
|
activeSessionId: string | null | undefined,
|
|
): WorkspaceRestorationResult {
|
|
const activeTaskId = useAppStore((state) => state.tasks.activeTaskId);
|
|
return useWorkspaceRestoration(activeTaskId, activeSessionId);
|
|
}
|
|
|
|
function useTaskChangesPanelActions(
|
|
view: ReturnType<typeof useChangesView>,
|
|
wordWrap: boolean,
|
|
workspaceRestoration: WorkspaceRestorationResult,
|
|
) {
|
|
const workspaceBlocked =
|
|
workspaceRestoration.status !== null && workspaceRestoration.status !== "ready";
|
|
const actions = useChangesActions(
|
|
view.activeSessionId,
|
|
view.allFiles,
|
|
wordWrap,
|
|
workspaceBlocked,
|
|
);
|
|
const handleRequestWalkthrough = useWalkthroughRequest(
|
|
view.activeSessionId,
|
|
view.allFiles,
|
|
workspaceBlocked,
|
|
);
|
|
return { actions, handleRequestWalkthrough };
|
|
}
|
|
|
|
function ChangesPanelHeader({
|
|
mode,
|
|
view,
|
|
actions,
|
|
visible,
|
|
onRequestWalkthrough,
|
|
}: {
|
|
mode: "all" | "file";
|
|
view: ReturnType<typeof useChangesView>;
|
|
actions: ReturnType<typeof useChangesActions>;
|
|
visible: ReturnType<typeof useVisibleDiffState>;
|
|
onRequestWalkthrough: ReturnType<typeof useWalkthroughRequest>;
|
|
}) {
|
|
return (
|
|
<ChangesTopBar
|
|
autoMarkOnScroll={actions.autoMarkOnScroll}
|
|
splitView={actions.splitView}
|
|
wordWrap={actions.wordWrap}
|
|
totalCommentCount={view.totalCommentCount}
|
|
reviewedCount={visible.reviewedCount}
|
|
totalCount={visible.totalCount}
|
|
progressPercent={visible.progressPercent}
|
|
setWordWrap={actions.setWordWrap}
|
|
handleToggleSplitView={actions.handleToggleSplitView}
|
|
handleToggleAutoMark={actions.handleToggleAutoMark}
|
|
handleFixComments={actions.handleFixComments}
|
|
handleRequestWalkthrough={onRequestWalkthrough}
|
|
requestWalkthroughDisabled={view.allFiles.length === 0}
|
|
prs={mode === "all" ? view.prs : []}
|
|
selectedPR={mode === "all" ? view.selectedPR : null}
|
|
prDiffLoading={view.prDiffLoading}
|
|
onSelectPR={view.selectPR}
|
|
/>
|
|
);
|
|
}
|
|
|
|
const TaskChangesPanel = memo(function TaskChangesPanel({
|
|
mode = "all",
|
|
filePath,
|
|
fileRepositoryName,
|
|
changeLayer,
|
|
prKey,
|
|
selectedDiff,
|
|
onClearSelected,
|
|
onBecameEmpty,
|
|
onOpenFile: onOpenFileProp,
|
|
sourceFilter = "all",
|
|
wordWrap: wordWrapProp = DEFAULT_DIFF_WORD_WRAP,
|
|
}: TaskChangesPanelProps) {
|
|
const isArchived = useIsTaskArchived();
|
|
const { openFile: panelOpenFile, openFileInMarkdownPreview } = usePanelActions();
|
|
const handleOpenFile = onOpenFileProp ?? panelOpenFile;
|
|
|
|
const view = useChangesView(selectedDiff, onClearSelected, sourceFilter, prKey);
|
|
const workspaceRestoration = useTaskChangesWorkspaceRestoration(view.activeSessionId);
|
|
const { actions, handleRequestWalkthrough } = useTaskChangesPanelActions(
|
|
view,
|
|
wordWrapProp,
|
|
workspaceRestoration,
|
|
);
|
|
const usesPRDiff = sourceFilter === "all" || sourceFilter === "pr";
|
|
const relevantPRLoading = usesPRDiff && view.prDiffLoading;
|
|
const fileTarget = { filePath, fileRepositoryName, prKey, changeLayer };
|
|
const visible = useVisibleDiffState({
|
|
allFiles: view.allFiles,
|
|
rawPRFiles: view.rawPRFiles,
|
|
mode,
|
|
...fileTarget,
|
|
sourceFilter,
|
|
fileRefs: view.fileRefs,
|
|
reviewedFiles: view.reviewedFiles,
|
|
staleFiles: view.staleFiles,
|
|
});
|
|
const { selectedFileKey, blockChangesForPR } = useChangesPRPresentation({
|
|
sourceFilter,
|
|
prKey,
|
|
mode,
|
|
filePath,
|
|
fileRepositoryName,
|
|
changeLayer,
|
|
visibleFiles: visible.visibleFiles,
|
|
});
|
|
useAutoCloseWhenEmpty({
|
|
mode,
|
|
filePath,
|
|
sourceFilter,
|
|
gitStatus: view.gitStatus,
|
|
visibleCount: visible.visibleFiles.length,
|
|
prDiffLoading: relevantPRLoading,
|
|
onBecameEmpty,
|
|
});
|
|
|
|
if (isArchived) return <ArchivedPanelPlaceholder />;
|
|
|
|
return (
|
|
<PanelRoot>
|
|
<ChangesPanelHeader
|
|
mode={mode}
|
|
view={view}
|
|
actions={actions}
|
|
visible={visible}
|
|
onRequestWalkthrough={handleRequestWalkthrough}
|
|
/>
|
|
<PanelBody padding={false} scroll={false} className="overflow-hidden">
|
|
<TruncatedFilesBanner count={view.truncatedFilesCount} />
|
|
<ReviewPRDiffBoundary
|
|
selectedPR={usesPRDiff && blockChangesForPR ? view.selectedPR : null}
|
|
loading={blockChangesForPR && relevantPRLoading}
|
|
error={blockChangesForPR ? view.prDiffError : null}
|
|
onRetry={view.refreshPRDiff}
|
|
>
|
|
<ChangesPanelContent
|
|
isLoading={view.cumulativeLoading || relevantPRLoading}
|
|
files={visible.visibleFiles}
|
|
activeSessionId={view.activeSessionId}
|
|
reviewedFiles={view.reviewedFiles}
|
|
staleFiles={view.staleFiles}
|
|
autoMarkOnScroll={actions.autoMarkOnScroll}
|
|
wordWrap={actions.wordWrap}
|
|
selectedFile={selectedFileKey}
|
|
onToggleReviewed={actions.handleToggleReviewed}
|
|
onDiscard={actions.handleDiscard}
|
|
onOpenFile={handleOpenFile}
|
|
onPreviewMarkdown={openFileInMarkdownPreview}
|
|
fileRefs={visible.visibleFileRefs}
|
|
workspaceRestoration={workspaceRestoration}
|
|
/>
|
|
</ReviewPRDiffBoundary>
|
|
</PanelBody>
|
|
</PanelRoot>
|
|
);
|
|
});
|
|
|
|
function ChangesPanelContent({
|
|
isLoading,
|
|
files,
|
|
activeSessionId,
|
|
reviewedFiles,
|
|
staleFiles,
|
|
autoMarkOnScroll,
|
|
wordWrap,
|
|
selectedFile,
|
|
onToggleReviewed,
|
|
onDiscard,
|
|
onOpenFile,
|
|
onPreviewMarkdown,
|
|
fileRefs,
|
|
workspaceRestoration,
|
|
}: {
|
|
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, repo?: string) => void;
|
|
onPreviewMarkdown?: (path: string, repo?: string) => void;
|
|
fileRefs: Map<string, React.RefObject<HTMLDivElement | null>>;
|
|
workspaceRestoration: WorkspaceRestorationResult;
|
|
}) {
|
|
const { t } = useTranslation();
|
|
const workspaceBlocked = workspaceRestoration.status && workspaceRestoration.status !== "ready";
|
|
if (workspaceBlocked && files.length === 0) {
|
|
return (
|
|
<WorkspaceUnavailable
|
|
restoration={workspaceRestoration.attempt}
|
|
onRetry={() => void workspaceRestoration.restore()}
|
|
retryDisabled={workspaceRestoration.status === "pending"}
|
|
/>
|
|
);
|
|
}
|
|
if (isLoading && files.length === 0) {
|
|
return (
|
|
<div className="flex items-center justify-center h-full text-muted-foreground text-sm">
|
|
{t("task:loadingChanges")}
|
|
</div>
|
|
);
|
|
}
|
|
if (files.length === 0) {
|
|
return (
|
|
<div className="flex items-center justify-center h-full text-muted-foreground text-sm">
|
|
{t("task:noChanges")}
|
|
</div>
|
|
);
|
|
}
|
|
if (!activeSessionId) return null;
|
|
return (
|
|
<>
|
|
{workspaceBlocked && (
|
|
<WorkspaceUnavailable
|
|
restoration={workspaceRestoration.attempt}
|
|
onRetry={() => void workspaceRestoration.restore()}
|
|
retryDisabled={workspaceRestoration.status === "pending"}
|
|
compact
|
|
/>
|
|
)}
|
|
<ReviewDiffList
|
|
files={files}
|
|
reviewedFiles={reviewedFiles}
|
|
staleFiles={staleFiles}
|
|
sessionId={activeSessionId}
|
|
autoMarkOnScroll={autoMarkOnScroll}
|
|
wordWrap={wordWrap}
|
|
enableWalkthroughAnnotations
|
|
selectedFile={selectedFile}
|
|
onToggleReviewed={onToggleReviewed}
|
|
onDiscard={onDiscard}
|
|
onOpenFile={onOpenFile}
|
|
onPreviewMarkdown={onPreviewMarkdown}
|
|
fileRefs={fileRefs}
|
|
/>
|
|
</>
|
|
);
|
|
}
|
|
|
|
export { TaskChangesPanel, scrollToFileAndClear };
|