"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>, 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(); 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 (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>(); 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, 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, 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; actions: ReturnType; visible: ReturnType; onRequestWalkthrough: ReturnType; }) { return ( ); } 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 ; return ( ); }); 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; 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, repo?: string) => void; fileRefs: Map>; workspaceRestoration: WorkspaceRestorationResult; }) { const { t } = useTranslation(); const workspaceBlocked = workspaceRestoration.status && workspaceRestoration.status !== "ready"; if (workspaceBlocked && files.length === 0) { return ( void workspaceRestoration.restore()} retryDisabled={workspaceRestoration.status === "pending"} /> ); } if (isLoading && files.length === 0) { return (
{t("task:loadingChanges")}
); } if (files.length === 0) { return (
{t("task:noChanges")}
); } if (!activeSessionId) return null; return ( <> {workspaceBlocked && ( void workspaceRestoration.restore()} retryDisabled={workspaceRestoration.status === "pending"} compact /> )} ); } export { TaskChangesPanel, scrollToFileAndClear };