@@ -24,6 +24,48 @@ type ReviewCommentFilters = {
2424 blockerOnly ?: boolean
2525}
2626
27+ type ReviewCommentSectionKey = 'stale' | 'unresolved' | 'informational' | 'fixed'
28+
29+ type ReviewCommentSection = {
30+ key : ReviewCommentSectionKey
31+ title : string
32+ description : string
33+ badgeClassName : string
34+ files : Array < {
35+ path : string
36+ comments : Comment [ ]
37+ } >
38+ }
39+
40+ const REVIEW_COMMENT_SECTION_ORDER : ReviewCommentSectionKey [ ] = [ 'stale' , 'unresolved' , 'informational' , 'fixed' ]
41+
42+ const REVIEW_COMMENT_SECTION_META : Record < ReviewCommentSectionKey , Omit < ReviewCommentSection , 'files' > > = {
43+ stale : {
44+ key : 'stale' ,
45+ title : 'Stale' ,
46+ description : 'Open findings from a review that predates newer commits.' ,
47+ badgeClassName : 'bg-accent/10 text-accent border border-accent/20' ,
48+ } ,
49+ unresolved : {
50+ key : 'unresolved' ,
51+ title : 'Unresolved' ,
52+ description : 'Open blocking findings that still need action.' ,
53+ badgeClassName : 'bg-sev-warning/10 text-sev-warning border border-sev-warning/20' ,
54+ } ,
55+ informational : {
56+ key : 'informational' ,
57+ title : 'Informational' ,
58+ description : 'Open non-blocking findings worth keeping in view.' ,
59+ badgeClassName : 'bg-surface-2 text-text-muted border border-border' ,
60+ } ,
61+ fixed : {
62+ key : 'fixed' ,
63+ title : 'Fixed' ,
64+ description : 'Resolved and dismissed findings retained for audit history.' ,
65+ badgeClassName : 'bg-sev-suggestion/10 text-sev-suggestion border border-sev-suggestion/20' ,
66+ } ,
67+ }
68+
2769function normalizeCommentFilePath ( filePath : string ) : string {
2870 return filePath . replace ( / ^ \. \/ / , '' )
2971}
@@ -32,6 +74,57 @@ function isBlockingComment(comment: Pick<Comment, 'severity' | 'status'>): boole
3274 return ( comment . status ?? 'Open' ) === 'Open' && BLOCKING_SEVERITIES . has ( comment . severity )
3375}
3476
77+ function classifyReviewCommentSection (
78+ comment : Comment ,
79+ mergeReadiness ?: MergeReadiness ,
80+ ) : ReviewCommentSectionKey {
81+ const status = comment . status ?? 'Open'
82+ if ( status === 'Resolved' || status === 'Dismissed' ) {
83+ return 'fixed'
84+ }
85+
86+ if ( mergeReadiness === 'NeedsReReview' ) {
87+ return 'stale'
88+ }
89+
90+ return BLOCKING_SEVERITIES . has ( comment . severity ) ? 'unresolved' : 'informational'
91+ }
92+
93+ function buildReviewCommentSections (
94+ comments : Comment [ ] ,
95+ mergeReadiness ?: MergeReadiness ,
96+ ) : ReviewCommentSection [ ] {
97+ const groupedSections = new Map < ReviewCommentSectionKey , Map < string , Comment [ ] > > ( )
98+
99+ for ( const comment of comments ) {
100+ const sectionKey = classifyReviewCommentSection ( comment , mergeReadiness )
101+ if ( ! groupedSections . has ( sectionKey ) ) {
102+ groupedSections . set ( sectionKey , new Map ( ) )
103+ }
104+
105+ const sectionFiles = groupedSections . get ( sectionKey ) !
106+ if ( ! sectionFiles . has ( comment . file_path ) ) {
107+ sectionFiles . set ( comment . file_path , [ ] )
108+ }
109+ sectionFiles . get ( comment . file_path ) ! . push ( comment )
110+ }
111+
112+ return REVIEW_COMMENT_SECTION_ORDER . flatMap ( ( sectionKey ) => {
113+ const sectionFiles = groupedSections . get ( sectionKey )
114+ if ( ! sectionFiles || sectionFiles . size === 0 ) {
115+ return [ ]
116+ }
117+
118+ return [ {
119+ ...REVIEW_COMMENT_SECTION_META [ sectionKey ] ,
120+ files : [ ...sectionFiles . entries ( ) ] . map ( ( [ path , sectionComments ] ) => ( {
121+ path,
122+ comments : sectionComments ,
123+ } ) ) ,
124+ } ]
125+ } )
126+ }
127+
35128function filterReviewComments (
36129 comments : Comment [ ] ,
37130 {
@@ -105,6 +198,11 @@ export function ReviewView() {
105198 blockerOnly : showOnlyBlockers ,
106199 } ) , [ comments , severityFilter , activeSelectedFile , categoryFilter , lifecycleFilter , showOnlyBlockers ] )
107200
201+ const commentSections = useMemo (
202+ ( ) => buildReviewCommentSections ( filteredComments , review ?. summary ?. merge_readiness ) ,
203+ [ filteredComments , review ?. summary ?. merge_readiness ] ,
204+ )
205+
108206 // All hooks MUST be above this line — no hooks after early returns
109207
110208 if ( isLoading || ! review ) {
@@ -188,14 +286,6 @@ export function ReviewView() {
188286 lifecycle . mutate ( { commentId, status } )
189287 }
190288
191- // Group comments by file for list view (no useMemo — filteredComments changes every render)
192- const groupedByFile = new Map < string , typeof filteredComments > ( )
193- for ( const c of filteredComments ) {
194- const key = c . file_path
195- if ( ! groupedByFile . has ( key ) ) groupedByFile . set ( key , [ ] )
196- groupedByFile . get ( key ) ! . push ( c )
197- }
198-
199289 const visibleDiffFiles = activeSelectedFile
200290 ? filteredDiffFiles . filter ( f => f . path === activeSelectedFile )
201291 : filteredDiffFiles
@@ -491,26 +581,44 @@ export function ReviewView() {
491581 ) : (
492582 /* List view / fallback when no diff content */
493583 < div className = "space-y-4 max-w-3xl" >
494- { [ ...groupedByFile . entries ( ) ] . map ( ( [ file , fileComments ] ) => (
495- < div key = { file } >
496- < div className = "flex items-center gap-2 mb-2" >
497- < FileCode size = { 13 } className = "text-text-muted" />
498- < span className = "font-code text-[12px] text-text-muted" > { file . split ( '/' ) . slice ( 0 , - 1 ) . join ( '/' ) } /</ span >
499- < span className = "font-code text-[12px] text-text-primary font-medium" > { file . split ( '/' ) . pop ( ) } </ span >
584+ { commentSections . map ( ( section ) => (
585+ < section key = { section . key } className = "border border-border rounded-lg overflow-hidden bg-surface-1/60" >
586+ < div className = "px-4 py-3 border-b border-border bg-surface-2/70 flex items-start justify-between gap-3" >
587+ < div className = "min-w-0" >
588+ < div className = "flex items-center gap-2" >
589+ < h2 className = "text-[12px] font-semibold text-text-primary" > { section . title } </ h2 >
590+ < span className = { `text-[10px] px-2 py-0.5 rounded font-code ${ section . badgeClassName } ` } >
591+ { section . files . reduce ( ( total , file ) => total + file . comments . length , 0 ) }
592+ </ span >
593+ </ div >
594+ < p className = "mt-1 text-[11px] text-text-muted" > { section . description } </ p >
595+ </ div >
500596 </ div >
501- < div className = "space-y-2 ml-5" >
502- { fileComments . map ( comment => (
503- < div key = { comment . id } >
504- < span className = "text-[10px] text-text-muted font-code" > L{ comment . line_number } </ span >
505- < CommentCard
506- comment = { comment }
507- onFeedback = { action => handleFeedback ( comment . id , action ) }
508- onLifecycleChange = { status => handleLifecycleChange ( comment . id , status ) }
509- />
597+
598+ < div className = "p-4 space-y-4" >
599+ { section . files . map ( ( { path, comments : fileComments } ) => (
600+ < div key = { `${ section . key } -${ path } ` } >
601+ < div className = "flex items-center gap-2 mb-2" >
602+ < FileCode size = { 13 } className = "text-text-muted" />
603+ < span className = "font-code text-[12px] text-text-muted" > { path . split ( '/' ) . slice ( 0 , - 1 ) . join ( '/' ) } /</ span >
604+ < span className = "font-code text-[12px] text-text-primary font-medium" > { path . split ( '/' ) . pop ( ) } </ span >
605+ </ div >
606+ < div className = "space-y-2 ml-5" >
607+ { fileComments . map ( comment => (
608+ < div key = { comment . id } >
609+ < span className = "text-[10px] text-text-muted font-code" > L{ comment . line_number } </ span >
610+ < CommentCard
611+ comment = { comment }
612+ onFeedback = { action => handleFeedback ( comment . id , action ) }
613+ onLifecycleChange = { status => handleLifecycleChange ( comment . id , status ) }
614+ />
615+ </ div >
616+ ) ) }
617+ </ div >
510618 </ div >
511619 ) ) }
512620 </ div >
513- </ div >
621+ </ section >
514622 ) ) }
515623
516624 { filteredComments . length === 0 && (
0 commit comments