import { useCallback, useEffect, useMemo, useRef, useState, } from "react"; import { Check, ChevronDown, ChevronLeft, ChevronRight, ChevronUp, MessageCircleQuestion, Sparkles, X, type LucideIcon, } from "lucide-react"; import { cn } from "@/openpress/core/cn"; import type { ChangeProposal, ChangeProposalDecision, ChangeProposalFeedback, } from "./changePreviewModel"; import { computeChangeReviewProgress, getAdjacentProposalIndex, } from "./changeReviewUtils"; const CHANGE_FEEDBACK_AUTOSAVE_DELAY_MS = 450; const MAX_STEP_PILLS_COUNT = 6; const DECISION_BUTTON_CONFIG: Array<{ decision: ChangeProposalDecision; label: string; keyHint: string; Icon: LucideIcon; baseClass: string; activeClass: string; }> = [ { decision: "accept", label: "接受", keyHint: "A", Icon: Check, baseClass: "bg-white/[0.04] text-[rgb(125_214_166)] hover:bg-[rgb(35_128_88_/_0.22)] hover:text-white", activeClass: "!bg-[rgb(35_128_88)] !text-white !shadow-[0_2px_10px_rgb(35_128_88_/_0.4)]", }, { decision: "reject", label: "拒絕", keyHint: "R", Icon: X, baseClass: "bg-white/[0.04] text-[rgb(236_143_135)] hover:bg-[rgb(184_62_55_/_0.22)] hover:text-white", activeClass: "!bg-[rgb(184_62_55)] !text-white !shadow-[0_2px_10px_rgb(184_62_55_/_0.4)]", }, { decision: "more-info", label: "討論", keyHint: "M", Icon: MessageCircleQuestion, baseClass: "bg-white/[0.04] text-[rgb(238_197_119)] hover:bg-[rgb(177_121_33_/_0.22)] hover:text-white", activeClass: "!bg-[rgb(177_121_33)] !text-white !shadow-[0_2px_10px_rgb(177_121_33_/_0.4)]", }, ]; export interface ChangeReviewDockProps { proposals: ChangeProposal[]; activeProposalIndex: number; onSelectProposal: (index: number) => void; onFeedbackChange: (proposal: ChangeProposal, feedback?: ChangeProposalFeedback) => Promise; } export function ChangeReviewDock({ proposals, activeProposalIndex, onSelectProposal, onFeedbackChange, }: ChangeReviewDockProps) { // Default to expanded so the full modification reason and feedback are immediately visible const [expanded, setExpanded] = useState(true); const [jumpMenuOpen, setJumpMenuOpen] = useState(false); const total = proposals.length; const activeProposal = proposals[activeProposalIndex] ?? proposals[0]; const stats = useMemo(() => computeChangeReviewProgress(proposals), [proposals]); const [decision, setDecision] = useState( activeProposal?.feedback?.decision, ); const [comment, setComment] = useState(activeProposal?.feedback?.comment ?? ""); const [saveState, setSaveState] = useState<"idle" | "pending" | "saving" | "saved" | "error">("idle"); const [saveError, setSaveError] = useState(""); const commentSaveTimerRef = useRef | null>(null); const saveQueueRef = useRef>(Promise.resolve()); const saveVersionRef = useRef(0); const commentRef = useRef(comment); const decisionRef = useRef(decision); const storedFeedbackRef = useRef(activeProposal?.feedback); const localFeedbackPendingRef = useRef(false); const jumpMenuRef = useRef(null); storedFeedbackRef.current = activeProposal?.feedback; useEffect(() => { if (localFeedbackPendingRef.current) return; setDecision(activeProposal?.feedback?.decision); setComment(activeProposal?.feedback?.comment ?? ""); decisionRef.current = activeProposal?.feedback?.decision; commentRef.current = activeProposal?.feedback?.comment ?? ""; setSaveState( activeProposal?.feedback?.decision || activeProposal?.feedback?.comment ? "saved" : "idle", ); setSaveError(""); }, [activeProposal?.feedback?.comment, activeProposal?.feedback?.decision, activeProposalIndex]); useEffect(() => () => { if (commentSaveTimerRef.current) clearTimeout(commentSaveTimerRef.current); }, []); // Close jump menu on outside click useEffect(() => { if (!jumpMenuOpen) return; const handleClickOutside = (e: MouseEvent) => { if (jumpMenuRef.current && !jumpMenuRef.current.contains(e.target as Node)) { setJumpMenuOpen(false); } }; window.addEventListener("mousedown", handleClickOutside); return () => window.removeEventListener("mousedown", handleClickOutside); }, [jumpMenuOpen]); const clearPendingCommentSave = () => { if (!commentSaveTimerRef.current) return; clearTimeout(commentSaveTimerRef.current); commentSaveTimerRef.current = null; }; const persistFeedback = useCallback(( targetProposal: ChangeProposal, nextDecision: ChangeProposalDecision | undefined, nextComment: string, ) => { const saveVersion = ++saveVersionRef.current; localFeedbackPendingRef.current = true; setSaveState("saving"); setSaveError(""); const request = saveQueueRef.current .catch(() => undefined) .then(() => onFeedbackChange(targetProposal, { ...(nextDecision ? { decision: nextDecision } : {}), ...(nextComment.trim() ? { comment: nextComment.trim() } : {}), })); saveQueueRef.current = request; void request.then(() => { if ( saveVersionRef.current === saveVersion && commentRef.current === nextComment && decisionRef.current === nextDecision ) { localFeedbackPendingRef.current = false; setSaveState("saved"); } }, (error) => { if ( saveVersionRef.current === saveVersion && commentRef.current === nextComment && decisionRef.current === nextDecision ) { localFeedbackPendingRef.current = false; setDecision(storedFeedbackRef.current?.decision); decisionRef.current = storedFeedbackRef.current?.decision; setSaveState("error"); setSaveError(error instanceof Error ? error.message : String(error)); } }); }, [onFeedbackChange]); const chooseDecision = useCallback((nextDecision: ChangeProposalDecision) => { if (!activeProposal) return; clearPendingCommentSave(); const value = decision === nextDecision ? undefined : nextDecision; setDecision(value); decisionRef.current = value; persistFeedback(activeProposal, value, commentRef.current); }, [activeProposal, decision, persistFeedback]); const scheduleCommentSave = (nextComment: string) => { if (!activeProposal) return; clearPendingCommentSave(); setComment(nextComment); commentRef.current = nextComment; localFeedbackPendingRef.current = true; setSaveState("pending"); setSaveError(""); commentSaveTimerRef.current = setTimeout(() => { commentSaveTimerRef.current = null; persistFeedback(activeProposal, decisionRef.current, nextComment); }, CHANGE_FEEDBACK_AUTOSAVE_DELAY_MS); }; const flushCommentSave = () => { if (!activeProposal || !commentSaveTimerRef.current) return; clearPendingCommentSave(); persistFeedback(activeProposal, decisionRef.current, commentRef.current); }; const handlePrev = useCallback(() => { if (total <= 0) return; const prevIndex = getAdjacentProposalIndex(activeProposalIndex, total, "prev"); onSelectProposal(prevIndex); }, [activeProposalIndex, onSelectProposal, total]); const handleNext = useCallback(() => { if (total <= 0) return; const nextIndex = getAdjacentProposalIndex(activeProposalIndex, total, "next"); onSelectProposal(nextIndex); }, [activeProposalIndex, onSelectProposal, total]); // Global keyboard shortcuts for review dock useEffect(() => { const handleKeyDown = (event: KeyboardEvent) => { const target = event.target as HTMLElement | null; const isEditable = target && ( target.tagName === "INPUT" || target.tagName === "TEXTAREA" || target.isContentEditable ); if (event.key === "Escape") { if (jumpMenuOpen) { event.preventDefault(); setJumpMenuOpen(false); return; } if (expanded) { event.preventDefault(); setExpanded(false); } return; } if (isEditable) return; if (event.key === "j" || event.key === "J" || event.key === "ArrowDown" || event.key === "]") { event.preventDefault(); handleNext(); } else if (event.key === "k" || event.key === "K" || event.key === "ArrowUp" || event.key === "[") { event.preventDefault(); handlePrev(); } else if (event.key === "a" || event.key === "A") { event.preventDefault(); chooseDecision("accept"); } else if (event.key === "r" || event.key === "R") { event.preventDefault(); chooseDecision("reject"); } else if (event.key === "m" || event.key === "M") { event.preventDefault(); chooseDecision("more-info"); } else if (event.key === "c" || event.key === "C" || event.key === "Enter") { event.preventDefault(); setExpanded((prev) => !prev); } }; window.addEventListener("keydown", handleKeyDown); return () => window.removeEventListener("keydown", handleKeyDown); }, [chooseDecision, expanded, handleNext, handlePrev, jumpMenuOpen]); if (total === 0 || !activeProposal) return null; const currentNumber = activeProposalIndex + 1; const cleanNote = activeProposal.note?.trim() || "未提供改動說明。"; const displayPath = activeProposal.path.split("/").slice(-2).join("/"); const useStepPills = total <= MAX_STEP_PILLS_COUNT; return (
{/* Top Navigation & Action Header */}
{/* Left: Prev / Next + Adaptive Navigation (Step Pills for <=6, Dropdown Selector for >6) */}
{useStepPills ? ( /* Case 1: <= 6 proposals: discrete, beautiful step pills */
{proposals.map((proposal, idx) => { const isSelected = idx === activeProposalIndex; const itemDecision = proposal.feedback?.decision; const hasComment = Boolean(proposal.feedback?.comment?.trim()); return ( ); })}
) : ( /* Case 2: > 6 proposals: Compact Dropdown Selector with Search / List */
{jumpMenuOpen && (
跳至變更 ({total})
{proposals.map((proposal, idx) => { const isSelected = idx === activeProposalIndex; const itemDecision = proposal.feedback?.decision; return ( ); })}
)}
)} {/* Overall Progress Badge */} {stats.reviewed}/{total} 已審
{/* Right: Quick Action Buttons & Expand Toggle */}
{DECISION_BUTTON_CONFIG.map((btn) => { const active = decision === btn.decision; return ( ); })}
{/* Detailed Modification Reason & Feedback Area */} {expanded && (
{/* Full Modification Reason (Clearly displayed in full with high legibility) */}
變更 #{currentNumber} 改動意圖 {displayPath}

{cleanNote}

{/* Comment Input for Agent */}