ReviewPage.tsx
1 import { useState, useCallback, useEffect, useMemo } from 'react'; 2 import { ArrowLeft } from 'lucide-react'; 3 import { PRSummaryBanner } from '../../components/PRSummaryBanner'; 4 import { StaleBanner } from '../../components/StaleBanner'; 5 import { OverviewSlide } from '../../components/OverviewSlide'; 6 import { SlideView } from '../../components/SlideView'; 7 import { SlideNav } from '../../components/SlideNav'; 8 import { SlideChatSheet } from '../../components/SlideChatSheet'; 9 import { SubmitReviewDialog } from '../../components/SubmitReviewDialog'; 10 import { SettingsDialog } from '../../components/SettingsDialog'; 11 import { useReviewComments } from '../../lib/use-review-comments'; 12 import { useSlideChat } from '../../lib/use-slide-chat'; 13 import { buildFileUrlBase } from '../../lib/github-url'; 14 import type { 15 ReviewGuide, 16 ReviewEvent, 17 FreshnessResult, 18 Preferences, 19 PrStatus, 20 Provider, 21 ModelId, 22 } from '../../lib/types'; 23 24 interface Props { 25 review: ReviewGuide; 26 onBack: () => void; 27 onReReview: (prUrl: string) => void; 28 } 29 30 export function ReviewPage({ review: initialReview, onBack, onReReview }: Props) { 31 const [review, setReview] = useState(initialReview); 32 const [currentSlide, setCurrentSlide] = useState(0); 33 const [showSubmitDialog, setShowSubmitDialog] = useState(false); 34 const [settingsOpen, setSettingsOpen] = useState(false); 35 const [currentLogin, setCurrentLogin] = useState<string | null>(null); 36 const [freshness, setFreshness] = useState<FreshnessResult | null>(null); 37 const [prStatus, setPrStatus] = useState<PrStatus | null>(null); 38 const [chatOpen, setChatOpen] = useState(false); 39 const [chatProvider, setChatProvider] = useState<Provider>('claude'); 40 const [chatModel, setChatModel] = useState<ModelId>('claude-sonnet-4-6'); 41 const [diffLayout, setDiffLayout] = useState<Preferences['diffLayout']>('unified'); 42 const [prefs, setPrefs] = useState<Preferences | null>(null); 43 const { comments, addComment, removeComment, editComment, clearAll } = useReviewComments(); 44 const slideChat = useSlideChat(review, chatProvider, chatModel); 45 const gitFileUrlBase = useMemo(() => buildFileUrlBase(review.prUrl, review.headSha), [review.prUrl, review.headSha]); 46 const excludedFilesSet = useMemo(() => new Set(review.excludedFiles ?? []), [review.excludedFiles]); 47 48 useEffect(() => { 49 void window.electronAPI.loadPreferences().then((p) => { 50 setPrefs(p); 51 setChatProvider(p.provider); 52 setChatModel(p.model); 53 setDiffLayout(p.diffLayout); 54 }); 55 }, []); 56 57 useEffect(() => { 58 void window.electronAPI.getAuthState().then((state) => setCurrentLogin(state.login)); 59 }, []); 60 61 useEffect(() => { 62 let cancelled = false; 63 void window.electronAPI.checkPrFreshness(review.prUrl, review.headSha).then((result) => { 64 if (!cancelled) setFreshness(result); 65 }); 66 void window.electronAPI 67 .getPrStatus(review.prUrl) 68 .then((status) => { 69 if (!cancelled) setPrStatus(status); 70 }) 71 .catch(() => { 72 /* token may be missing for loaded reviews */ 73 }); 74 return () => { 75 cancelled = true; 76 }; 77 }, [review.prUrl, review.headSha]); 78 79 const handlePrev = useCallback(() => { 80 setCurrentSlide((n) => Math.max(0, n - 1)); 81 }, []); 82 83 const handleNext = useCallback(() => { 84 setCurrentSlide((n) => Math.min(review.slides.length, n + 1)); 85 }, [review.slides.length]); 86 87 useEffect(() => { 88 function handleKeyDown(e: KeyboardEvent) { 89 // Don't navigate when typing in a textarea or input 90 const tag = (e.target as HTMLElement).tagName; 91 if (tag === 'TEXTAREA' || tag === 'INPUT') return; 92 93 if (e.key === 'ArrowLeft') handlePrev(); 94 if (e.key === 'ArrowRight') handleNext(); 95 } 96 window.addEventListener('keydown', handleKeyDown); 97 return () => window.removeEventListener('keydown', handleKeyDown); 98 }, [handlePrev, handleNext]); 99 100 const commentCallbacks = useMemo( 101 () => ({ onAddComment: addComment, onRemoveComment: removeComment, onEditComment: editComment }), 102 [addComment, removeComment, editComment] 103 ); 104 105 const handleDiffLayoutChange = useCallback( 106 (layout: Preferences['diffLayout']) => { 107 setDiffLayout(layout); 108 if (prefs) { 109 const updated = { ...prefs, diffLayout: layout }; 110 setPrefs(updated); 111 void window.electronAPI.savePreferences(updated); 112 } 113 }, 114 [prefs] 115 ); 116 117 async function handleSubmitReview(event: ReviewEvent, body: string) { 118 const result = await window.electronAPI.submitReview({ 119 prUrl: review.prUrl, 120 headSha: review.headSha ?? '', 121 event, 122 body, 123 comments: comments.map((c) => ({ 124 path: c.filePath, 125 line: c.line, 126 side: c.side, 127 body: c.body, 128 })), 129 }); 130 clearAll(); 131 return result; 132 } 133 134 if (review.slides.length === 0) { 135 return ( 136 <main className="flex min-h-screen items-center justify-center p-8"> 137 <div className="flex flex-col items-center gap-4"> 138 <p className="text-muted-foreground">No slides were generated for this PR.</p> 139 <button 140 onClick={onBack} 141 className="text-sm text-muted-foreground hover:text-foreground flex items-center gap-1" 142 > 143 <ArrowLeft className="h-3.5 w-3.5" /> 144 Back 145 </button> 146 </div> 147 </main> 148 ); 149 } 150 151 return ( 152 <div className="flex flex-col h-screen overflow-hidden"> 153 <PRSummaryBanner review={review} onBack={onBack} onOpenSettings={() => setSettingsOpen(true)} /> 154 155 {freshness && <StaleBanner freshness={freshness} onReReview={() => onReReview(review.prUrl)} />} 156 157 <div key={currentSlide} className="slide-enter flex-1 overflow-hidden flex flex-row"> 158 <div className="flex-1 min-w-0 overflow-hidden flex flex-col"> 159 {currentSlide === 0 ? ( 160 <OverviewSlide review={review} prStatus={prStatus} onNavigate={(n) => setCurrentSlide(n)} /> 161 ) : ( 162 <SlideView 163 slide={review.slides[currentSlide - 1]} 164 slideNumber={currentSlide} 165 totalSlides={review.slides.length} 166 pendingComments={comments} 167 commentCallbacks={commentCallbacks} 168 diffLayout={diffLayout} 169 onDiffLayoutChange={handleDiffLayoutChange} 170 onAskQuestion={() => setChatOpen(true)} 171 gitFileUrlBase={gitFileUrlBase} 172 excludedFiles={excludedFilesSet} 173 /> 174 )} 175 </div> 176 177 {currentSlide > 0 && ( 178 <SlideChatSheet 179 open={chatOpen} 180 onOpenChange={setChatOpen} 181 slideTitle={review.slides[currentSlide - 1].title} 182 reviewFocus={review.slides[currentSlide - 1].reviewFocus} 183 messages={slideChat.getMessages(currentSlide)} 184 isStreaming={slideChat.isStreaming} 185 onSend={(text) => void slideChat.send(currentSlide, text)} 186 /> 187 )} 188 </div> 189 190 <SlideNav 191 current={currentSlide} 192 total={review.slides.length} 193 onPrev={handlePrev} 194 onNext={handleNext} 195 commentCount={comments.length} 196 onSubmitReview={() => setShowSubmitDialog(true)} 197 /> 198 199 <SubmitReviewDialog 200 open={showSubmitDialog} 201 onOpenChange={setShowSubmitDialog} 202 comments={comments} 203 prUrl={review.prUrl} 204 headSha={review.headSha} 205 isOwnPr={currentLogin !== null && currentLogin === review.author} 206 onSubmit={handleSubmitReview} 207 /> 208 209 <SettingsDialog 210 open={settingsOpen} 211 onOpenChange={setSettingsOpen} 212 onThemeChange={async () => { 213 const updated = await window.electronAPI.reRenderHunks(review); 214 setReview(updated); 215 }} 216 /> 217 </div> 218 ); 219 }