|
| 1 | +/** |
| 2 | + * PendingReviewsBanner - Shows pending code reviews in the chat area |
| 3 | + * Displays as a thin collapsible stripe above the chat input |
| 4 | + * |
| 5 | + * Uses shadcn/ui Button component and semantic Tailwind color classes |
| 6 | + * that map to CSS variables defined in globals.css. |
| 7 | + */ |
| 8 | + |
| 9 | +import React, { useState, useCallback, useMemo } from "react"; |
| 10 | +import { |
| 11 | + ChevronDown, |
| 12 | + ChevronUp, |
| 13 | + Check, |
| 14 | + Undo2, |
| 15 | + Send, |
| 16 | + Trash2, |
| 17 | + MessageSquare, |
| 18 | + Eye, |
| 19 | + EyeOff, |
| 20 | +} from "lucide-react"; |
| 21 | +import { cn } from "@/common/lib/utils"; |
| 22 | +import { Button } from "./ui/button"; |
| 23 | +import { Tooltip, TooltipWrapper } from "./Tooltip"; |
| 24 | +import type { PendingReview } from "@/common/types/review"; |
| 25 | + |
| 26 | +interface PendingReviewsBannerProps { |
| 27 | + /** All reviews (pending and checked) */ |
| 28 | + reviews: PendingReview[]; |
| 29 | + /** Count of pending reviews */ |
| 30 | + pendingCount: number; |
| 31 | + /** Count of checked reviews */ |
| 32 | + checkedCount: number; |
| 33 | + /** Mark a review as checked */ |
| 34 | + onCheck: (reviewId: string) => void; |
| 35 | + /** Uncheck a review */ |
| 36 | + onUncheck: (reviewId: string) => void; |
| 37 | + /** Send review content to chat input */ |
| 38 | + onSendToChat: (content: string) => void; |
| 39 | + /** Remove a review */ |
| 40 | + onRemove: (reviewId: string) => void; |
| 41 | + /** Clear all checked reviews */ |
| 42 | + onClearChecked: () => void; |
| 43 | +} |
| 44 | + |
| 45 | +/** |
| 46 | + * Extract a short summary from review content for display |
| 47 | + */ |
| 48 | +function getReviewSummary(review: PendingReview): string { |
| 49 | + // Extract the user's note from the review content (after the code block) |
| 50 | + const noteMatch = /```\n> (.+?)\n<\/review>/s.exec(review.content); |
| 51 | + if (noteMatch) { |
| 52 | + const note = noteMatch[1].trim(); |
| 53 | + return note.length > 50 ? note.slice(0, 50) + "…" : note; |
| 54 | + } |
| 55 | + return `${review.filePath}:${review.lineRange}`; |
| 56 | +} |
| 57 | + |
| 58 | +/** |
| 59 | + * Single review item in the list |
| 60 | + */ |
| 61 | +const ReviewItem: React.FC<{ |
| 62 | + review: PendingReview; |
| 63 | + onCheck: () => void; |
| 64 | + onUncheck: () => void; |
| 65 | + onSendToChat: () => void; |
| 66 | + onRemove: () => void; |
| 67 | +}> = ({ review, onCheck, onUncheck, onSendToChat, onRemove }) => { |
| 68 | + const isChecked = review.status === "checked"; |
| 69 | + |
| 70 | + return ( |
| 71 | + <div |
| 72 | + className={cn( |
| 73 | + "group flex items-center gap-2 rounded px-2 py-1.5 text-xs transition-colors", |
| 74 | + isChecked ? "bg-hover opacity-60" : "bg-border-medium/30 hover:bg-hover" |
| 75 | + )} |
| 76 | + > |
| 77 | + {/* Check/Uncheck button */} |
| 78 | + <TooltipWrapper inline> |
| 79 | + <Button |
| 80 | + variant="ghost" |
| 81 | + size="icon" |
| 82 | + className={cn("h-5 w-5 shrink-0 [&_svg]:size-3", isChecked && "text-success")} |
| 83 | + onClick={isChecked ? onUncheck : onCheck} |
| 84 | + > |
| 85 | + {isChecked ? <Undo2 /> : <Check />} |
| 86 | + </Button> |
| 87 | + <Tooltip align="center">{isChecked ? "Mark as pending" : "Mark as done"}</Tooltip> |
| 88 | + </TooltipWrapper> |
| 89 | + |
| 90 | + {/* Review info */} |
| 91 | + <div className="min-w-0 flex-1"> |
| 92 | + <div className="flex items-baseline gap-1.5"> |
| 93 | + <span className="font-mono text-[var(--color-review-accent)]"> |
| 94 | + {review.filePath}:{review.lineRange} |
| 95 | + </span> |
| 96 | + </div> |
| 97 | + <div className="text-muted truncate">{getReviewSummary(review)}</div> |
| 98 | + </div> |
| 99 | + |
| 100 | + {/* Actions */} |
| 101 | + <div className="flex shrink-0 items-center gap-0.5 opacity-0 transition-opacity group-hover:opacity-100"> |
| 102 | + <TooltipWrapper inline> |
| 103 | + <Button |
| 104 | + variant="ghost" |
| 105 | + size="icon" |
| 106 | + className="h-5 w-5 [&_svg]:size-3" |
| 107 | + onClick={onSendToChat} |
| 108 | + > |
| 109 | + <Send /> |
| 110 | + </Button> |
| 111 | + <Tooltip align="center">Send to chat</Tooltip> |
| 112 | + </TooltipWrapper> |
| 113 | + |
| 114 | + <TooltipWrapper inline> |
| 115 | + <Button |
| 116 | + variant="ghost" |
| 117 | + size="icon" |
| 118 | + className="text-error h-5 w-5 [&_svg]:size-3" |
| 119 | + onClick={onRemove} |
| 120 | + > |
| 121 | + <Trash2 /> |
| 122 | + </Button> |
| 123 | + <Tooltip align="center">Remove</Tooltip> |
| 124 | + </TooltipWrapper> |
| 125 | + </div> |
| 126 | + </div> |
| 127 | + ); |
| 128 | +}; |
| 129 | + |
| 130 | +export const PendingReviewsBanner: React.FC<PendingReviewsBannerProps> = ({ |
| 131 | + reviews, |
| 132 | + pendingCount, |
| 133 | + checkedCount, |
| 134 | + onCheck, |
| 135 | + onUncheck, |
| 136 | + onSendToChat, |
| 137 | + onRemove, |
| 138 | + onClearChecked, |
| 139 | +}) => { |
| 140 | + const [isExpanded, setIsExpanded] = useState(false); |
| 141 | + const [showChecked, setShowChecked] = useState(false); |
| 142 | + |
| 143 | + // Filter reviews based on view mode |
| 144 | + const displayedReviews = useMemo(() => { |
| 145 | + if (showChecked) { |
| 146 | + return reviews.filter((r) => r.status === "checked"); |
| 147 | + } |
| 148 | + return reviews.filter((r) => r.status === "pending"); |
| 149 | + }, [reviews, showChecked]); |
| 150 | + |
| 151 | + const handleToggle = useCallback(() => { |
| 152 | + setIsExpanded((prev) => !prev); |
| 153 | + }, []); |
| 154 | + |
| 155 | + const handleToggleShowChecked = useCallback(() => { |
| 156 | + setShowChecked((prev) => !prev); |
| 157 | + }, []); |
| 158 | + |
| 159 | + // Don't show anything if no reviews |
| 160 | + if (reviews.length === 0) { |
| 161 | + return null; |
| 162 | + } |
| 163 | + |
| 164 | + return ( |
| 165 | + <div className="border-border bg-dark border-t"> |
| 166 | + {/* Collapsed banner - thin stripe */} |
| 167 | + <button |
| 168 | + type="button" |
| 169 | + onClick={handleToggle} |
| 170 | + className="hover:bg-hover flex w-full items-center gap-2 px-3 py-1.5 text-xs transition-colors" |
| 171 | + > |
| 172 | + <MessageSquare className="h-3.5 w-3.5 text-[var(--color-review-accent)]" /> |
| 173 | + <span className="text-secondary"> |
| 174 | + {pendingCount > 0 ? ( |
| 175 | + <> |
| 176 | + <span className="font-medium text-[var(--color-review-accent)]">{pendingCount}</span> |
| 177 | + {" pending review"} |
| 178 | + {pendingCount !== 1 && "s"} |
| 179 | + </> |
| 180 | + ) : ( |
| 181 | + <span className="text-muted">No pending reviews</span> |
| 182 | + )} |
| 183 | + {checkedCount > 0 && <span className="text-muted"> · {checkedCount} checked</span>} |
| 184 | + </span> |
| 185 | + <div className="ml-auto"> |
| 186 | + {isExpanded ? ( |
| 187 | + <ChevronDown className="text-muted h-3.5 w-3.5" /> |
| 188 | + ) : ( |
| 189 | + <ChevronUp className="text-muted h-3.5 w-3.5" /> |
| 190 | + )} |
| 191 | + </div> |
| 192 | + </button> |
| 193 | + |
| 194 | + {/* Expanded view */} |
| 195 | + {isExpanded && ( |
| 196 | + <div className="border-border border-t px-3 py-2"> |
| 197 | + {/* View toggle and actions */} |
| 198 | + <div className="mb-2 flex items-center justify-between"> |
| 199 | + <div className="flex items-center gap-2"> |
| 200 | + <TooltipWrapper inline> |
| 201 | + <Button |
| 202 | + variant={showChecked ? "secondary" : "ghost"} |
| 203 | + size="sm" |
| 204 | + className="h-6 px-2 text-xs" |
| 205 | + onClick={handleToggleShowChecked} |
| 206 | + > |
| 207 | + {showChecked ? ( |
| 208 | + <Eye className="mr-1 h-3 w-3" /> |
| 209 | + ) : ( |
| 210 | + <EyeOff className="mr-1 h-3 w-3" /> |
| 211 | + )} |
| 212 | + {showChecked ? "Checked" : "Pending"} |
| 213 | + </Button> |
| 214 | + <Tooltip align="center"> |
| 215 | + {showChecked ? "Showing checked reviews" : "Showing pending reviews"} |
| 216 | + </Tooltip> |
| 217 | + </TooltipWrapper> |
| 218 | + </div> |
| 219 | + |
| 220 | + {showChecked && checkedCount > 0 && ( |
| 221 | + <Button |
| 222 | + variant="ghost" |
| 223 | + size="sm" |
| 224 | + className="text-error h-6 px-2 text-xs" |
| 225 | + onClick={onClearChecked} |
| 226 | + > |
| 227 | + <Trash2 className="mr-1 h-3 w-3" /> |
| 228 | + Clear all |
| 229 | + </Button> |
| 230 | + )} |
| 231 | + </div> |
| 232 | + |
| 233 | + {/* Review list */} |
| 234 | + <div className="max-h-48 space-y-1 overflow-y-auto"> |
| 235 | + {displayedReviews.length === 0 ? ( |
| 236 | + <div className="text-muted py-3 text-center text-xs"> |
| 237 | + {showChecked ? "No checked reviews" : "No pending reviews"} |
| 238 | + </div> |
| 239 | + ) : ( |
| 240 | + displayedReviews.map((review) => ( |
| 241 | + <ReviewItem |
| 242 | + key={review.id} |
| 243 | + review={review} |
| 244 | + onCheck={() => onCheck(review.id)} |
| 245 | + onUncheck={() => onUncheck(review.id)} |
| 246 | + onSendToChat={() => onSendToChat(review.content)} |
| 247 | + onRemove={() => onRemove(review.id)} |
| 248 | + /> |
| 249 | + )) |
| 250 | + )} |
| 251 | + </div> |
| 252 | + </div> |
| 253 | + )} |
| 254 | + </div> |
| 255 | + ); |
| 256 | +}; |
0 commit comments