/ src / pages / ReviewPage.tsx
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  }