/ components / SubmitReviewDialog.tsx
SubmitReviewDialog.tsx
  1  import { useState, useEffect } from 'react';
  2  import { AlertTriangle, Check, ExternalLink, MessageSquare, ShieldCheck, ShieldX } from 'lucide-react';
  3  import { Button } from '@/components/ui/button';
  4  import {
  5    Dialog,
  6    DialogContent,
  7    DialogDescription,
  8    DialogFooter,
  9    DialogHeader,
 10    DialogTitle,
 11  } from '@/components/ui/dialog';
 12  import type { PendingReviewComment, ReviewEvent } from '@/lib/types';
 13  
 14  interface Props {
 15    open: boolean;
 16    onOpenChange: (open: boolean) => void;
 17    comments: PendingReviewComment[];
 18    prUrl: string;
 19    headSha?: string;
 20    isOwnPr: boolean;
 21    onSubmit: (event: ReviewEvent, body: string) => Promise<{ reviewUrl: string; droppedCommentCount: number }>;
 22  }
 23  
 24  export function SubmitReviewDialog({ open, onOpenChange, comments, headSha, isOwnPr, onSubmit }: Props) {
 25    const [body, setBody] = useState('');
 26    const [submitting, setSubmitting] = useState(false);
 27    const [error, setError] = useState<string | null>(null);
 28    const [successUrl, setSuccessUrl] = useState<string | null>(null);
 29    const [droppedCount, setDroppedCount] = useState(0);
 30    const [submittedCount, setSubmittedCount] = useState(0);
 31    const [reviewSignature, setReviewSignature] = useState(true);
 32  
 33    useEffect(() => {
 34      if (!open) return;
 35      void window.electronAPI.loadPreferences().then((prefs) => {
 36        setReviewSignature(prefs.reviewSignature);
 37      });
 38    }, [open]);
 39  
 40    // Group comments by file for the summary
 41    const fileGroups = new Map<string, PendingReviewComment[]>();
 42    for (const c of comments) {
 43      const existing = fileGroups.get(c.filePath);
 44      if (existing) {
 45        existing.push(c);
 46      } else {
 47        fileGroups.set(c.filePath, [c]);
 48      }
 49    }
 50  
 51    async function handleSubmit(event: ReviewEvent) {
 52      setSubmitting(true);
 53      setError(null);
 54      try {
 55        setSubmittedCount(comments.length);
 56        const result = await onSubmit(event, body);
 57        setSuccessUrl(result.reviewUrl);
 58        setDroppedCount(result.droppedCommentCount);
 59      } catch (err) {
 60        setError(err instanceof Error ? err.message : 'Failed to submit review');
 61      } finally {
 62        setSubmitting(false);
 63      }
 64    }
 65  
 66    function handleClose() {
 67      if (successUrl) {
 68        setSuccessUrl(null);
 69        setBody('');
 70      }
 71      onOpenChange(false);
 72    }
 73  
 74    // Success state
 75    if (successUrl) {
 76      return (
 77        <Dialog open={open} onOpenChange={handleClose}>
 78          <DialogContent className="sm:max-w-md">
 79            <DialogHeader>
 80              <DialogTitle className="flex items-center gap-2">
 81                <Check className="h-5 w-5 text-green-500" />
 82                Review submitted
 83              </DialogTitle>
 84              <DialogDescription>
 85                Your review with {submittedCount} comment{submittedCount !== 1 ? 's' : ''} has been posted.
 86              </DialogDescription>
 87            </DialogHeader>
 88            {droppedCount > 0 && (
 89              <div className="flex items-start gap-2 rounded-md border border-yellow-800/50 bg-yellow-950/30 p-3 text-sm text-yellow-200">
 90                <AlertTriangle className="h-4 w-4 shrink-0 mt-0.5 text-yellow-500" />
 91                <span>
 92                  {droppedCount} comment{droppedCount !== 1 ? 's were' : ' was'} on lines outside the diff range and{' '}
 93                  {droppedCount !== 1 ? 'were' : 'was'} included in the review body instead.
 94                </span>
 95              </div>
 96            )}
 97            <div className="flex justify-center">
 98              <a
 99                href={successUrl}
100                target="_blank"
101                rel="noopener noreferrer"
102                className="inline-flex items-center gap-1.5 text-sm text-blue-400 hover:text-blue-300 underline underline-offset-2"
103              >
104                View on GitHub <ExternalLink className="h-3 w-3" />
105              </a>
106            </div>
107            <DialogFooter>
108              <Button onClick={handleClose}>Done</Button>
109            </DialogFooter>
110          </DialogContent>
111        </Dialog>
112      );
113    }
114  
115    return (
116      <Dialog open={open} onOpenChange={handleClose}>
117        <DialogContent className="sm:max-w-xl max-h-[80vh] flex flex-col">
118          <DialogHeader>
119            <DialogTitle>Submit review</DialogTitle>
120            <DialogDescription>
121              {comments.length} comment{comments.length !== 1 ? 's' : ''} across {fileGroups.size} file
122              {fileGroups.size !== 1 ? 's' : ''}
123            </DialogDescription>
124          </DialogHeader>
125  
126          {!headSha && (
127            <div className="flex items-start gap-2 rounded-md border border-yellow-800/50 bg-yellow-950/30 p-3 text-sm text-yellow-200">
128              <AlertTriangle className="h-4 w-4 shrink-0 mt-0.5 text-yellow-500" />
129              <span>
130                This review was loaded from history without a commit reference. Line comments may land on wrong lines if
131                the PR has been updated.
132              </span>
133            </div>
134          )}
135  
136          {/* Comment list */}
137          {comments.length > 0 && (
138            <div className="flex-1 overflow-y-auto space-y-3 min-h-0">
139              {Array.from(fileGroups.entries()).map(([filePath, fileComments]) => (
140                <details key={filePath} className="group" open>
141                  <summary className="cursor-pointer text-xs font-mono text-muted-foreground hover:text-foreground select-none flex items-center gap-1">
142                    <span className="group-open:rotate-90 inline-block transition-transform">▶</span>
143                    {filePath}
144                    <span className="text-blue-400 ml-1">({fileComments.length})</span>
145                  </summary>
146                  <div className="mt-1 ml-3 space-y-1">
147                    {fileComments.map((c) => (
148                      <div key={c.id} className="text-xs border rounded p-2 bg-muted/20">
149                        <div className="flex items-center gap-2 text-muted-foreground mb-1">
150                          <span>Line {c.line}</span>
151                          <code className="text-[10px] bg-muted/50 px-1 rounded truncate max-w-[200px]">
152                            {c.codeSnippet}
153                          </code>
154                        </div>
155                        <pre className="text-xs whitespace-pre-wrap break-words font-mono">{c.body}</pre>
156                      </div>
157                    ))}
158                  </div>
159                </details>
160              ))}
161            </div>
162          )}
163  
164          {/* Review body */}
165          <div className="flex flex-col gap-2">
166            <div>
167              <label className="text-xs text-muted-foreground mb-1 block">Review summary (optional)</label>
168              <textarea
169                value={body}
170                onChange={(e) => setBody(e.target.value)}
171                placeholder="Overall feedback..."
172                className="w-full min-h-[80px] bg-transparent text-sm resize-y border rounded p-2 focus:outline-none focus:ring-1 focus:ring-ring"
173              />
174            </div>
175            <label className="flex items-center gap-2 cursor-pointer select-none">
176              <input
177                type="checkbox"
178                checked={reviewSignature}
179                onChange={(e) => {
180                  setReviewSignature(e.target.checked);
181                  void window.electronAPI
182                    .loadPreferences()
183                    .then((prefs) => window.electronAPI.savePreferences({ ...prefs, reviewSignature: e.target.checked }));
184                }}
185                className="rounded border"
186              />
187              <span className="text-xs text-muted-foreground">Add &ldquo;Reviewed using gnosis.to&rdquo; signature</span>
188            </label>
189          </div>
190  
191          {error && <p className="text-sm text-destructive">{error}</p>}
192  
193          <DialogFooter className="flex-col gap-2 sm:flex-col sm:gap-2">
194            {isOwnPr && (
195              <p className="text-xs text-muted-foreground text-center">
196                You can't approve or request changes on your own PR.
197              </p>
198            )}
199            <div className="flex flex-row justify-end gap-2">
200              <Button
201                variant="outline"
202                size="sm"
203                onClick={() => handleSubmit('COMMENT')}
204                disabled={submitting || (comments.length === 0 && !body.trim())}
205              >
206                <MessageSquare className="h-3.5 w-3.5 mr-1.5" />
207                Comment
208              </Button>
209              <Button
210                variant="outline"
211                size="sm"
212                className="border-green-800 text-green-400 hover:bg-green-950/50"
213                onClick={() => handleSubmit('APPROVE')}
214                disabled={submitting || isOwnPr}
215              >
216                <ShieldCheck className="h-3.5 w-3.5 mr-1.5" />
217                Approve
218              </Button>
219              <Button
220                variant="outline"
221                size="sm"
222                className="border-red-800 text-red-400 hover:bg-red-950/50"
223                onClick={() => handleSubmit('REQUEST_CHANGES')}
224                disabled={submitting || isOwnPr || (comments.length === 0 && !body.trim())}
225              >
226                <ShieldX className="h-3.5 w-3.5 mr-1.5" />
227                Request changes
228              </Button>
229            </div>
230          </DialogFooter>
231        </DialogContent>
232      </Dialog>
233    );
234  }