11import { FileDiff , Virtualizer } from "@pierre/diffs/react" ;
2- import type { NativeApi , PrReviewThread } from "@okcode/contracts" ;
2+ import type { NativeApi , PrAgentReviewResult , PrReviewThread } from "@okcode/contracts" ;
33import { useMemo } from "react" ;
44import { useQuery } from "@tanstack/react-query" ;
55import { Schema } from "effect" ;
@@ -23,6 +23,8 @@ import { useFileViewNavigation } from "~/hooks/useFileViewNavigation";
2323import { joinPath } from "~/components/review/reviewUtils" ;
2424import { projectPathExistsQueryOptions } from "~/lib/projectReactQuery" ;
2525import type { Project } from "~/types" ;
26+ import { PrAgentReviewBanner } from "./PrAgentReviewBanner" ;
27+ import { PrRuleViolationBanner } from "./PrRuleViolationBanner" ;
2628import { PrFileCommentComposer } from "./PrFileCommentComposer" ;
2729import { PrFileTabStrip } from "./PrFileTabStrip" ;
2830import {
@@ -43,24 +45,34 @@ export function PrWorkspace({
4345 project,
4446 patch,
4547 dashboard,
48+ agentResult,
49+ onStartAgentReview,
50+ isStartingAgentReview,
4651 selectedFilePath,
4752 selectedThreadId,
4853 reviewedFiles,
54+ approvalBlockers,
4955 onSelectFilePath,
5056 onSelectThreadId,
5157 onCreateThread,
5258 onToggleFileReviewed,
59+ onOpenConflictDrawer,
5360} : {
5461 project : Project ;
5562 patch : string | null ;
5663 dashboard : Awaited < ReturnType < NativeApi [ "prReview" ] [ "getDashboard" ] > > | null | undefined ;
64+ agentResult : PrAgentReviewResult | null | undefined ;
65+ onStartAgentReview : ( ) => void ;
66+ isStartingAgentReview : boolean ;
5767 selectedFilePath : string | null ;
5868 selectedThreadId : string | null ;
5969 reviewedFiles : readonly string [ ] ;
70+ approvalBlockers : string [ ] ;
6071 onSelectFilePath : ( path : string ) => void ;
6172 onSelectThreadId : ( threadId : string | null ) => void ;
6273 onCreateThread : ( input : { path : string ; line : number ; body : string } ) => Promise < void > ;
6374 onToggleFileReviewed : ( path : string ) => void ;
75+ onOpenConflictDrawer : ( ) => void ;
6476} ) {
6577 const { resolvedTheme } = useTheme ( ) ;
6678 const openFileInCodeViewer = useFileViewNavigation ( ) ;
@@ -91,6 +103,17 @@ export function PrWorkspace({
91103 } , { } ) ;
92104 } , [ dashboard ] ) ;
93105
106+ // Agent findings grouped by file path
107+ const agentFindingsByPath = useMemo < Record < string , typeof agentResult extends { findings : infer F } ? F : never > > ( ( ) => {
108+ if ( ! agentResult ?. findings ) return { } ;
109+ return agentResult . findings . reduce < Record < string , typeof agentResult . findings > > ( ( acc , finding ) => {
110+ if ( ! finding . path ) return acc ;
111+ if ( ! acc [ finding . path ] ) acc [ finding . path ] = [ ] ;
112+ acc [ finding . path ] ! . push ( finding ) ;
113+ return acc ;
114+ } , { } ) ;
115+ } , [ agentResult ?. findings ] ) ;
116+
94117 const patchFiles = useMemo (
95118 ( ) => ( renderablePatch ?. kind === "files" ? renderablePatch . files : [ ] ) ,
96119 [ renderablePatch ] ,
@@ -184,6 +207,24 @@ export function PrWorkspace({
184207 </ Button >
185208 </ div >
186209
210+ { /* Agent review banner */ }
211+ < PrAgentReviewBanner
212+ agentStatus = { agentResult }
213+ onStartReview = { onStartAgentReview }
214+ onSelectFile = { onSelectFilePath }
215+ onOpenFindings = { ( ) => {
216+ // Handled by inspector tab switch via store
217+ } }
218+ isStarting = { isStartingAgentReview }
219+ fileCount = { dashboard . files . length }
220+ />
221+
222+ { /* Rule violation banner */ }
223+ < PrRuleViolationBanner
224+ approvalBlockers = { approvalBlockers }
225+ onOpenConflictDrawer = { onOpenConflictDrawer }
226+ />
227+
187228 { /* File tab strip */ }
188229 { patchFiles . length > 0 ? (
189230 < PrFileTabStrip
@@ -220,6 +261,7 @@ export function PrWorkspace({
220261 const filePath = resolveFileDiffPath ( fileDiff ) ;
221262 const fileKey = `${ buildFileDiffRenderKey ( fileDiff ) } :${ resolvedTheme } ` ;
222263 const fileThreads = threadsByPath [ filePath ] ?? [ ] ;
264+ const fileFindings = agentFindingsByPath [ filePath ] ?? [ ] ;
223265 const isSelected = selectedFilePath === filePath ;
224266 const isReviewed = reviewedFilesSet . has ( filePath ) ;
225267 const firstCommentLine = fileThreads [ 0 ] ?. line ?? 1 ;
@@ -271,6 +313,13 @@ export function PrWorkspace({
271313 +{ fileThreads . length - 2 } more
272314 </ span >
273315 ) : null }
316+ { /* Agent findings indicator for this file */ }
317+ { fileFindings . length > 0 ? (
318+ < span className = "inline-flex items-center gap-1 rounded-full border border-indigo-500/20 bg-indigo-500/8 px-2.5 py-0.5 text-[11px] font-medium text-indigo-400" >
319+ < SparklesIconInline />
320+ { fileFindings . length } finding{ fileFindings . length === 1 ? "" : "s" }
321+ </ span >
322+ ) : null }
274323 < button
275324 className = { cn (
276325 "inline-flex items-center gap-1 rounded-full border px-2.5 py-0.5 text-[11px] font-medium transition-colors" ,
@@ -303,6 +352,53 @@ export function PrWorkspace({
303352 </ Button >
304353 </ div >
305354 </ div >
355+
356+ { /* Inline agent findings for this file */ }
357+ { fileFindings . length > 0 ? (
358+ < div className = "border-b border-indigo-500/15 bg-indigo-500/5 px-4 py-2 space-y-1.5" >
359+ { fileFindings . map ( ( finding ) => (
360+ < div
361+ className = "flex items-start gap-2 text-xs"
362+ key = { finding . id }
363+ >
364+ < span
365+ className = { cn (
366+ "mt-0.5 shrink-0 size-3.5 rounded-full flex items-center justify-center text-[8px] font-bold" ,
367+ finding . severity === "critical"
368+ ? "bg-rose-500/20 text-rose-400"
369+ : finding . severity === "warning"
370+ ? "bg-amber-500/20 text-amber-400"
371+ : "bg-sky-500/20 text-sky-400" ,
372+ ) }
373+ >
374+ { finding . severity === "critical" ? "!" : finding . severity === "warning" ? "!" : "i" }
375+ </ span >
376+ < div className = "min-w-0 flex-1" >
377+ < span className = "font-medium text-foreground" > { finding . title } </ span >
378+ { finding . line ? (
379+ < span className = "ml-1.5 text-muted-foreground" > L{ finding . line } </ span >
380+ ) : null }
381+ </ div >
382+ < button
383+ className = "shrink-0 text-[11px] text-indigo-400 hover:text-indigo-300"
384+ onClick = { ( ) => {
385+ if ( finding . path && finding . line ) {
386+ void onCreateThread ( {
387+ path : finding . path ,
388+ line : finding . line ,
389+ body : `**AI Finding (${ finding . severity } ):** ${ finding . title } \n\n${ finding . detail } ` ,
390+ } ) ;
391+ }
392+ } }
393+ type = "button"
394+ >
395+ Create thread
396+ </ button >
397+ </ div >
398+ ) ) }
399+ </ div >
400+ ) : null }
401+
306402 < div className = "p-3" >
307403 < FileDiff
308404 fileDiff = { fileDiff }
@@ -385,3 +481,24 @@ function PrDiffFileHeaderBadge({ cwd, path }: { cwd: string; path: string }) {
385481 }
386482 return < MissingOnDiskBadge path = { absolutePath } compact /> ;
387483}
484+
485+ /** Tiny inline sparkles icon to avoid importing full lucide for a single use */
486+ function SparklesIconInline ( ) {
487+ return (
488+ < svg
489+ className = "size-3"
490+ fill = "none"
491+ stroke = "currentColor"
492+ strokeLinecap = "round"
493+ strokeLinejoin = "round"
494+ strokeWidth = { 2 }
495+ viewBox = "0 0 24 24"
496+ >
497+ < path d = "M9.937 15.5A2 2 0 0 0 8.5 14.063l-6.135-1.582a.5.5 0 0 1 0-.962L8.5 9.936A2 2 0 0 0 9.937 8.5l1.582-6.135a.5.5 0 0 1 .963 0L14.063 8.5A2 2 0 0 0 15.5 9.937l6.135 1.581a.5.5 0 0 1 0 .964L15.5 14.063a2 2 0 0 0-1.437 1.437l-1.582 6.135a.5.5 0 0 1-.963 0z" />
498+ < path d = "M20 3v4" />
499+ < path d = "M22 5h-4" />
500+ < path d = "M4 17v2" />
501+ < path d = "M5 18H3" />
502+ </ svg >
503+ ) ;
504+ }
0 commit comments