Files
kandev/apps/web/components/task/task-changes-panel.tsx
T
2026-09-18 16:03:18 +01:00

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 };