@@ -57,6 +57,7 @@ import {
5757 usePRReviewStore ,
5858 getTimeAgo ,
5959} from "../contexts/pr-review" ;
60+ import { parseDiffCached , type ParsedDiff } from "../lib/diff" ;
6061import {
6162 useGitHub ,
6263 useCurrentUser ,
@@ -145,13 +146,16 @@ export const PROverview = memo(function PROverview() {
145146
146147 // Repo permissions - use GraphQL viewerPermission as primary source
147148 const isArchived = pr . base ?. repo ?. archived ?? false ;
148- // WRITE, MAINTAIN, or ADMIN permissions allow merging
149- const canPush =
149+ // WRITE, MAINTAIN, or ADMIN permissions allow merging and resolving threads
150+ const hasWritePermission =
150151 viewerPermission === "ADMIN" ||
151152 viewerPermission === "MAINTAIN" ||
152- viewerPermission === "WRITE" ||
153- pr . base ?. repo ?. permissions ?. push === true ;
153+ viewerPermission === "WRITE" ;
154+ const canPush =
155+ hasWritePermission || pr . base ?. repo ?. permissions ?. push === true ;
154156 const canMergeRepo = canWrite && canPush && ! isArchived ;
157+ // Resolving threads requires write permission to the repo
158+ const canResolveThread = canWrite && hasWritePermission ;
155159
156160 // Reviewers and Assignees state
157161 const [ collaborators , setCollaborators ] = useState <
@@ -571,6 +575,52 @@ export const PROverview = memo(function PROverview() {
571575 [ github , owner , repo ]
572576 ) ;
573577
578+ // Review comment reactions (different from issue comments)
579+ const handleAddReviewCommentReaction = useCallback (
580+ async ( commentId : number , content : ReactionContent ) => {
581+ try {
582+ const newReaction = await github . addReviewCommentReaction (
583+ owner ,
584+ repo ,
585+ commentId ,
586+ content
587+ ) ;
588+ setReactions ( ( prev ) => ( {
589+ ...prev ,
590+ [ `review-comment-${ commentId } ` ] : [
591+ ...( prev [ `review-comment-${ commentId } ` ] || [ ] ) ,
592+ newReaction ,
593+ ] ,
594+ } ) ) ;
595+ } catch ( error ) {
596+ console . error ( "Failed to add review comment reaction:" , error ) ;
597+ }
598+ } ,
599+ [ github , owner , repo ]
600+ ) ;
601+
602+ const handleRemoveReviewCommentReaction = useCallback (
603+ async ( commentId : number , reactionId : number ) => {
604+ try {
605+ await github . deleteReviewCommentReaction (
606+ owner ,
607+ repo ,
608+ commentId ,
609+ reactionId
610+ ) ;
611+ setReactions ( ( prev ) => ( {
612+ ...prev ,
613+ [ `review-comment-${ commentId } ` ] : (
614+ prev [ `review-comment-${ commentId } ` ] || [ ]
615+ ) . filter ( ( r ) => r . id !== reactionId ) ,
616+ } ) ) ;
617+ } catch ( error ) {
618+ console . error ( "Failed to remove review comment reaction:" , error ) ;
619+ }
620+ } ,
621+ [ github , owner , repo ]
622+ ) ;
623+
574624 // Thread actions (reply, resolve, unresolve)
575625 const handleReplyToThread = useCallback (
576626 async ( threadId : string , commentId : number , body : string ) => {
@@ -734,7 +784,7 @@ export const PROverview = memo(function PROverview() {
734784 />
735785
736786 { /* Timeline - merge comments, reviews, and events by date */ }
737- < div className = "relative" >
787+ < div className = "relative space-y-6 " >
738788 { /* Vertical timeline line - z-0 so content appears above it */ }
739789 < div className = "absolute left-4 top-0 bottom-0 w-0.5 bg-muted-foreground/30 -translate-x-1/2 z-0" />
740790
@@ -893,7 +943,10 @@ export const PROverview = memo(function PROverview() {
893943 }
894944 if ( entry . type === "review" ) {
895945 return (
896- < div key = { `review-${ entry . data . id } ` } >
946+ < div
947+ key = { `review-${ entry . data . id } ` }
948+ className = "space-y-3"
949+ >
897950 < ReviewBox review = { entry . data } />
898951 { /* Render associated threads under the review */ }
899952 { entry . threads . map ( ( thread ) => (
@@ -907,6 +960,18 @@ export const PROverview = memo(function PROverview() {
907960 onUnresolve = { handleUnresolveThread }
908961 canWrite = { canWrite }
909962 currentUser = { currentUser }
963+ onAddReaction = { handleAddReviewCommentReaction }
964+ onRemoveReaction = {
965+ handleRemoveReviewCommentReaction
966+ }
967+ reactions = { Object . fromEntries (
968+ thread . comments . nodes . map ( ( c ) => [
969+ c . databaseId ,
970+ reactions [
971+ `review-comment-${ c . databaseId } `
972+ ] || [ ] ,
973+ ] )
974+ ) }
910975 />
911976 ) ) }
912977 </ div >
@@ -944,7 +1009,17 @@ export const PROverview = memo(function PROverview() {
9441009 onResolve = { handleResolveThread }
9451010 onUnresolve = { handleUnresolveThread }
9461011 canWrite = { canWrite }
1012+ canResolveThread = { canResolveThread }
9471013 currentUser = { currentUser }
1014+ onAddReaction = { handleAddReviewCommentReaction }
1015+ onRemoveReaction = { handleRemoveReviewCommentReaction }
1016+ reactions = { Object . fromEntries (
1017+ entry . data . comments . nodes . map ( ( c ) => [
1018+ c . databaseId ,
1019+ reactions [ `review-comment-${ c . databaseId } ` ] ||
1020+ [ ] ,
1021+ ] )
1022+ ) }
9481023 />
9491024 ) ;
9501025 }
@@ -2126,7 +2201,11 @@ function ReviewThreadBox({
21262201 onResolve,
21272202 onUnresolve,
21282203 canWrite,
2204+ canResolveThread,
21292205 currentUser,
2206+ onAddReaction,
2207+ onRemoveReaction,
2208+ reactions,
21302209} : {
21312210 thread : ReviewThread ;
21322211 owner : string ;
@@ -2139,20 +2218,39 @@ function ReviewThreadBox({
21392218 onResolve ?: ( threadId : string ) => Promise < void > ;
21402219 onUnresolve ?: ( threadId : string ) => Promise < void > ;
21412220 canWrite ?: boolean ;
2221+ /** Whether user can resolve/unresolve threads (requires WRITE, MAINTAIN, or ADMIN permission) */
2222+ canResolveThread ?: boolean ;
21422223 currentUser ?: string | null ;
2224+ onAddReaction ?: ( commentId : number , content : ReactionContent ) => void ;
2225+ onRemoveReaction ?: ( commentId : number , reactionId : number ) => void ;
2226+ reactions ?: Record < number , Reaction [ ] > ;
21432227} ) {
21442228 const [ replyText , setReplyText ] = useState ( "" ) ;
21452229 const [ submitting , setSubmitting ] = useState ( false ) ;
21462230 const [ resolving , setResolving ] = useState ( false ) ;
21472231 const [ showReplyBox , setShowReplyBox ] = useState ( false ) ;
21482232 const [ showResolved , setShowResolved ] = useState ( false ) ;
2233+ const [ parsedDiff , setParsedDiff ] = useState < ParsedDiff | null > ( null ) ;
21492234 const comments = thread . comments . nodes ;
21502235 if ( comments . length === 0 ) return null ;
21512236
21522237 const firstComment = comments [ 0 ] ;
21532238 const filePath = firstComment . path ;
21542239 const diffHunk = firstComment . diffHunk ;
21552240
2241+ // Parse diff hunk with syntax highlighting using the worker
2242+ // Note: The worker already adds git diff headers, so we pass diffHunk directly
2243+ useEffect ( ( ) => {
2244+ if ( ! diffHunk ) {
2245+ setParsedDiff ( null ) ;
2246+ return ;
2247+ }
2248+
2249+ parseDiffCached ( diffHunk , filePath )
2250+ . then ( setParsedDiff )
2251+ . catch ( console . error ) ;
2252+ } , [ diffHunk , filePath ] ) ;
2253+
21562254 // If resolved and not expanded, show collapsed view
21572255 if ( thread . isResolved && ! showResolved ) {
21582256 return (
@@ -2202,47 +2300,8 @@ function ReviewThreadBox({
22022300 }
22032301 } ;
22042302
2205- // Parse diff hunk to get line numbers and content
2206- const parseDiffHunk = ( hunk : string | null ) => {
2207- if ( ! hunk ) return [ ] ;
2208-
2209- const lines = hunk . split ( "\n" ) ;
2210- const result : Array < {
2211- type : "header" | "context" | "addition" | "deletion" ;
2212- content : string ;
2213- oldLine ?: number ;
2214- newLine ?: number ;
2215- } > = [ ] ;
2216-
2217- let oldLine = 0 ;
2218- let newLine = 0 ;
2219-
2220- for ( const line of lines ) {
2221- if ( line . startsWith ( "@@" ) ) {
2222- const match = line . match ( / @ @ - ( \d + ) (?: , \d + ) ? \+ ( \d + ) (?: , \d + ) ? @ @ / ) ;
2223- if ( match ) {
2224- oldLine = parseInt ( match [ 1 ] , 10 ) ;
2225- newLine = parseInt ( match [ 2 ] , 10 ) ;
2226- }
2227- result . push ( { type : "header" , content : line } ) ;
2228- } else if ( line . startsWith ( "+" ) ) {
2229- result . push ( { type : "addition" , content : line . slice ( 1 ) , newLine } ) ;
2230- newLine ++ ;
2231- } else if ( line . startsWith ( "-" ) ) {
2232- result . push ( { type : "deletion" , content : line . slice ( 1 ) , oldLine } ) ;
2233- oldLine ++ ;
2234- } else {
2235- const content = line . startsWith ( " " ) ? line . slice ( 1 ) : line ;
2236- result . push ( { type : "context" , content, oldLine, newLine } ) ;
2237- oldLine ++ ;
2238- newLine ++ ;
2239- }
2240- }
2241-
2242- return result ;
2243- } ;
2244-
2245- const diffLines = parseDiffHunk ( diffHunk ) ;
2303+ // Get diff lines from parsed diff (first hunk)
2304+ const diffHunkData = parsedDiff ?. hunks . find ( ( h ) => h . type === "hunk" ) ?? null ;
22462305
22472306 return (
22482307 < div
@@ -2274,53 +2333,46 @@ function ReviewThreadBox({
22742333 ) }
22752334 </ div >
22762335
2277- { /* Code context (diff hunk) */ }
2278- { diffLines . length > 0 && (
2336+ { /* Code context (diff hunk) with syntax highlighting */ }
2337+ { diffHunkData && diffHunkData . type === "hunk" && (
22792338 < div className = "bg-[#0d1117] border-b border-border overflow-x-auto" >
22802339 < table className = "w-full text-xs font-mono" >
22812340 < tbody >
2282- { diffLines . map ( ( line , i ) => (
2341+ { diffHunkData . lines . map ( ( line , i ) => (
22832342 < tr
22842343 key = { i }
22852344 className = { cn (
2286- line . type === "addition" && "bg-green-500/15" ,
2287- line . type === "deletion" && "bg-red-500/15" ,
2288- line . type === "header" && "bg-blue-500/10 text-blue-400"
2345+ line . type === "insert" && "bg-green-500/15" ,
2346+ line . type === "delete" && "bg-red-500/15"
22892347 ) }
22902348 >
2291- { line . type === "header" ? (
2292- < td
2293- colSpan = { 3 }
2294- className = "px-2 py-0.5 text-muted-foreground"
2349+ < td className = "w-10 text-right px-2 py-0.5 text-muted-foreground select-none border-r border-border/50" >
2350+ { line . type !== "insert" ? line . oldLineNumber : "" }
2351+ </ td >
2352+ < td className = "w-10 text-right px-2 py-0.5 text-muted-foreground select-none border-r border-border/50" >
2353+ { line . type !== "delete" ? line . newLineNumber : "" }
2354+ </ td >
2355+ < td className = "px-2 py-0.5 whitespace-pre" >
2356+ < span
2357+ className = { cn (
2358+ "select-none mr-1" ,
2359+ line . type === "insert" && "text-green-400" ,
2360+ line . type === "delete" && "text-red-400"
2361+ ) }
22952362 >
2296- { line . content }
2297- </ td >
2298- ) : (
2299- < >
2300- < td className = "w-10 text-right px-2 py-0.5 text-muted-foreground select-none border-r border-border/50" >
2301- { line . type !== "addition" ? line . oldLine : "" }
2302- </ td >
2303- < td className = "w-10 text-right px-2 py-0.5 text-muted-foreground select-none border-r border-border/50" >
2304- { line . type !== "deletion" ? line . newLine : "" }
2305- </ td >
2306- < td className = "px-2 py-0.5 whitespace-pre" >
2307- < span
2308- className = { cn (
2309- "select-none mr-1" ,
2310- line . type === "addition" && "text-green-400" ,
2311- line . type === "deletion" && "text-red-400"
2312- ) }
2313- >
2314- { line . type === "addition"
2315- ? "+"
2316- : line . type === "deletion"
2317- ? "-"
2318- : " " }
2319- </ span >
2320- { line . content }
2321- </ td >
2322- </ >
2323- ) }
2363+ { line . type === "insert"
2364+ ? "+"
2365+ : line . type === "delete"
2366+ ? "-"
2367+ : " " }
2368+ </ span >
2369+ { line . content . map ( ( seg , j ) => (
2370+ < span
2371+ key = { j }
2372+ dangerouslySetInnerHTML = { { __html : seg . html } }
2373+ />
2374+ ) ) }
2375+ </ td >
23242376 </ tr >
23252377 ) ) }
23262378 </ tbody >
@@ -2362,11 +2414,23 @@ function ReviewThreadBox({
23622414 < div className = "mt-2" >
23632415 < Markdown > { comment . body } </ Markdown >
23642416 </ div >
2365- { /* Emoji reactions placeholder */ }
2417+ { /* Emoji reactions */ }
23662418 < div className = "mt-3" >
2367- < button className = "inline-flex items-center justify-center w-7 h-7 text-xs rounded-full border border-border hover:border-blue-500/50 text-muted-foreground hover:text-foreground hover:bg-muted/50 transition-colors" >
2368- < Smile className = "w-4 h-4" />
2369- </ button >
2419+ < EmojiReactions
2420+ reactions = { reactions ?. [ comment . databaseId ] || [ ] }
2421+ onAddReaction = {
2422+ canWrite && onAddReaction
2423+ ? ( content ) => onAddReaction ( comment . databaseId , content )
2424+ : undefined
2425+ }
2426+ onRemoveReaction = {
2427+ canWrite && onRemoveReaction
2428+ ? ( reactionId ) =>
2429+ onRemoveReaction ( comment . databaseId , reactionId )
2430+ : undefined
2431+ }
2432+ currentUser = { currentUser }
2433+ />
23702434 </ div >
23712435 </ div >
23722436 ) ) }
0 commit comments