"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 { useActiveTaskPR } from "@/hooks/domains/github/use-task-pr"; 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 { DEFAULT_DIFF_WORD_WRAP } from "@/components/diff/diff-defaults"; import type { ReviewFile } from "@/components/review/types"; import { hashDiff, 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 type { SelectedDiff } from "./task-layout"; import { useIsTaskArchived, ArchivedPanelPlaceholder } from "./task-archived-context"; type TaskChangesPanelProps = { mode?: "all" | "file"; filePath?: string; fileRepositoryName?: 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; }; // Returns true only after gitStatus loads and the file's uncommitted diff is gone. export function shouldCloseFileDiffPanel( gitStatus: { files?: Record } | undefined, filePath: string, ): boolean { if (!gitStatus) return false; const entry = gitStatus.files?.[filePath]; return !entry?.diff; } function scrollToFileAndClear( path: string, fileRefs: Map>, 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(); } } function useChangesView(selectedDiff: SelectedDiff | null, onClearSelected: () => void) { const activeSessionId = useAppStore((state) => state.tasks.activeSessionId); const { allFiles, cumulativeLoading, prDiffLoading, gitStatus, rawPRFiles, truncatedFilesCount } = useReviewSources(activeSessionId); const pr = useActiveTaskPR(); 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(); const stale = new Set(); // 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 key = reviewFileKey(file); const reviewState = reviews.get(key); if (!reviewState?.reviewed) continue; const currentHash = hashDiff(file.diff); if (reviewState.diffHash && reviewState.diffHash !== currentHash) { stale.add(key); } else { reviewed.add(key); } } 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 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>(); for (const file of allFiles) refs.set(reviewFileKey(file), createRef()); return refs; // eslint-disable-next-line react-hooks/exhaustive-deps -- keyed on stable path list, not allFiles reference }, [filePathsKey]); const scrolledRef = useRef(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, gitStatus, truncatedFilesCount, }; } 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[]) { const activeTaskId = useAppStore((s) => s.tasks.activeTaskId); return useRequestChangesWalkthrough({ taskId: activeTaskId, sessionId: activeSessionId, ready: allFiles.length > 0, }); } function useChangesActions( activeSessionId: string | null | undefined, allFiles: ReviewFile[], defaultWordWrap = DEFAULT_DIFF_WORD_WRAP, ) { 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(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) { const file = allFiles.find((f) => reviewFileKey(f) === key); markReviewed(key, file ? hashDiff(file.diff) : ""); } else { markUnreviewed(key); } }, [allFiles, markReviewed, markUnreviewed], ); const handleDiscard = useCallback( async (key: string) => { const { path } = splitReviewFileKey(key); 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 shouldCloseFileDiffPanelAggregate( prevFileSeenRef: React.MutableRefObject, gitStatus: { files?: Record } | undefined, filePath: string, onBecameEmpty: (() => void) | undefined, ): boolean { const shouldClose = shouldCloseFileDiffPanel(gitStatus, filePath); if (prevFileSeenRef.current && shouldClose) { onBecameEmpty?.(); return true; } if (!shouldClose) prevFileSeenRef.current = true; return false; } function useAutoCloseWhenEmpty(opts: { mode: "all" | "file"; filePath: string | undefined; sourceFilter: "all" | ReviewSource; gitStatus: { files?: Record } | undefined; visibleCount: number; onBecameEmpty: (() => void) | undefined; }) { const { mode, filePath, sourceFilter, gitStatus, visibleCount, onBecameEmpty } = opts; const prevVisibleCountRef = useRef(null); const prevFileSeenRef = useRef(false); const prevSourceFilterRef = useRef(null); useEffect(() => { if (!onBecameEmpty) return; if (mode === "file" && filePath) { if (sourceFilter === "all") { shouldCloseFileDiffPanelAggregate(prevFileSeenRef, gitStatus, filePath, onBecameEmpty); return; } // File-mode with explicit source (uncommitted/committed/pr): close when // that source-specific view becomes empty for the opened file. // Reset tracking when the filtered source changes so a tab switch // doesn't fire onBecameEmpty just because the new tab starts empty. if (prevSourceFilterRef.current !== sourceFilter) { prevSourceFilterRef.current = sourceFilter; prevVisibleCountRef.current = null; } const prevCount = prevVisibleCountRef.current; prevVisibleCountRef.current = visibleCount; if (prevCount !== null && prevCount > 0 && visibleCount === 0) { onBecameEmpty(); } return; } // In list mode, a filtered tab going empty is not a "panel is empty" // signal — other sources may still have content. if (sourceFilter !== "all") return; const prevCount = prevVisibleCountRef.current; if (prevCount !== null && prevCount > 0 && visibleCount === 0) { onBecameEmpty(); } prevVisibleCountRef.current = visibleCount; }, [mode, filePath, sourceFilter, gitStatus, onBecameEmpty, visibleCount]); } type FilterVisibleFilesOpts = { mode: "all" | "file"; filePath: string | undefined; fileRepositoryName: string | undefined; sourceFilter: "all" | ReviewSource; rawPRFiles?: ReviewFile[]; }; function filterVisibleFiles(allFiles: ReviewFile[], opts: FilterVisibleFilesOpts): ReviewFile[] { const { mode, filePath, fileRepositoryName, sourceFilter, rawPRFiles } = opts; // In file mode with an explicit PR source, bypass the deduplicated allFiles and // read from rawPRFiles. This is necessary because allFiles deduplicates with // priority uncommitted > committed > PR: a file that also has local changes // will not appear with source "pr" in allFiles, causing "No changes" in PR rows. if (mode === "file" && filePath && sourceFilter === "pr" && rawPRFiles && rawPRFiles.length > 0) { let prFiles = rawPRFiles.filter((f) => f.path === filePath && f.source === "pr"); if (fileRepositoryName !== undefined) { prFiles = prFiles.filter((f) => (f.repository_name ?? "") === fileRepositoryName); } if (prFiles.length > 0) return prFiles; } let files = allFiles; if (mode === "file" && filePath) { files = files.filter((file) => file.path === filePath); if (fileRepositoryName !== undefined) { files = files.filter((file) => (file.repository_name ?? "") === fileRepositoryName); } } if (sourceFilter !== "all") { files = files.filter((file) => file.source === sourceFilter); } return files; } function useVisibleDiffState(opts: { allFiles: ReviewFile[]; rawPRFiles: ReviewFile[]; mode: "all" | "file"; filePath: string | undefined; fileRepositoryName: string | undefined; sourceFilter: "all" | ReviewSource; fileRefs: Map>; reviewedFiles: Set; staleFiles: Set; }) { const { allFiles, rawPRFiles, mode, filePath, fileRepositoryName, sourceFilter, fileRefs, reviewedFiles, staleFiles, } = opts; const visibleFiles = useMemo( () => filterVisibleFiles(allFiles, { mode, filePath, fileRepositoryName, sourceFilter, rawPRFiles, }), [allFiles, rawPRFiles, mode, filePath, fileRepositoryName, sourceFilter], ); const visibleFileRefs = useMemo(() => { if (mode !== "file" || !filePath) return fileRefs; const refs = new Map>(); for (const [key, ref] of fileRefs.entries()) { const split = splitReviewFileKey(key); if (split.path !== filePath) continue; if (fileRepositoryName !== undefined && (split.repositoryName || "") !== fileRepositoryName) { continue; } refs.set(key, ref); } return refs; }, [mode, filePath, fileRepositoryName, fileRefs]); const reviewedCount = useMemo( () => visibleFiles.reduce((count, file) => { const key = reviewFileKey(file); if (!staleFiles.has(key) && reviewedFiles.has(key)) return count + 1; return count; }, 0), [visibleFiles, reviewedFiles, staleFiles], ); const totalCount = visibleFiles.length; const progressPercent = totalCount > 0 ? (reviewedCount / totalCount) * 100 : 0; return { visibleFiles, visibleFileRefs, reviewedCount, totalCount, progressPercent }; } const TaskChangesPanel = memo(function TaskChangesPanel({ mode = "all", filePath, fileRepositoryName, 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 { activeSessionId, allFiles, rawPRFiles, reviewedFiles, staleFiles, totalCommentCount, fileRefs, cumulativeLoading, prDiffLoading, gitStatus, truncatedFilesCount, } = useChangesView(selectedDiff, onClearSelected); const { splitView, wordWrap, setWordWrap, autoMarkOnScroll, handleToggleSplitView, handleToggleReviewed, handleDiscard, handleToggleAutoMark, handleFixComments, } = useChangesActions(activeSessionId, allFiles, wordWrapProp); const handleRequestWalkthrough = useWalkthroughRequest(activeSessionId, allFiles); const { visibleFiles, visibleFileRefs, reviewedCount, totalCount, progressPercent } = useVisibleDiffState({ allFiles, rawPRFiles, mode, filePath, fileRepositoryName, sourceFilter, fileRefs, reviewedFiles, staleFiles, }); const selectedFileKey = useSelectedFileKey(mode, filePath, fileRepositoryName); useAutoCloseWhenEmpty({ mode, filePath, sourceFilter, gitStatus, visibleCount: visibleFiles.length, onBecameEmpty, }); if (isArchived) return ; return ( ); }); 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; staleFiles: Set; autoMarkOnScroll: boolean; wordWrap: boolean; selectedFile?: string | null; onToggleReviewed: (path: string, reviewed: boolean) => void; onDiscard: (path: string) => Promise; onOpenFile: (path: string, repo?: string) => void; onPreviewMarkdown?: (path: string) => void; fileRefs: Map>; }) { if (isLoading && files.length === 0) { return (
Loading changes...
); } if (files.length === 0) { return (
No changes
); } if (!activeSessionId) return null; return ( ); } export { TaskChangesPanel, filterVisibleFiles, scrollToFileAndClear };