2026-10-16 10:44:27 -07:00
import { get , post , put } from "./api.js" ;
2026-10-15 18:34:12 -07:00
import { ago , errorText , h , replace , showAlert } from "./dom.js" ;
2026-10-15 16:33:17 -07:00
import { markdown } from "./markdown.js" ;
2026-10-16 10:44:27 -07:00
import { onEach , pickReview , state } from "./state.js" ;
2026-10-15 16:33:17 -07:00
const severities = [ "critical" , "high" , "medium" , "low" , "info" ];
export function isReviewEvent ( ev ) {
return ev . kind . startsWith ( "review." );
}
2026-10-15 18:34:12 -07:00
function findingsOf ( r ) {
2026-10-15 16:33:17 -07:00
const res = ( r . result ?? {});
2026-10-15 18:34:12 -07:00
return Array . isArray ( res [ "findings" ]) ? res [ "findings" ] : [];
}
export function findingCount ( r ) {
2026-10-16 10:44:27 -07:00
return r . state === "done" ? findingsOf ( r ). filter (( _ , i ) => ! r . finding_states [ String ( i )]). length : 0 ;
}
export function unignoredFindings ( r ) {
return r . state === "done" ? findingsOf ( r ). filter (( _ , i ) => r . finding_states [ String ( i )] !== "ignored" ) : [];
2026-10-15 18:34:12 -07:00
}
2026-10-16 09:53:34 -07:00
export function latestReview ( events ) {
const all = events . filter ( isReviewEvent ). map (( ev ) => ev . payload );
return all . reduce (( best , r ) => ( ! best || r . snapshot_n >= best . snapshot_n ? r : best ), undefined );
2026-10-15 18:34:12 -07:00
}
export function reviewCounts ( r ) {
2026-10-16 10:44:27 -07:00
return severityCounts ( findingsOf ( r ));
}
export function severityCounts ( findings ) {
2026-10-15 16:33:17 -07:00
const parts = severities . map (( s ) => [ s , findings . filter (( f ) => f [ "severity" ] === s ). length ]). filter (([, n ]) => n > 0 );
return parts . length > 0 ? parts . map (([ s , n ]) => ` ${ n } ${ s } ` ). join ( ", " ) : "no findings" ;
}
function stateText ( r ) {
switch ( r . state ) {
case "running" :
return `reviewing on ${ r . vendor } ( ${ r . model } )…` ;
case "done" :
return reviewCounts ( r );
case "failed" :
return "failed" ;
2026-10-15 20:07:36 -07:00
case "skipped" :
return "skipped: no changes" ;
2026-10-15 16:33:17 -07:00
}
}
export function reviewCard ( r , taskId ) {
return h ( "article" , { class : `review ${ r . state } ` }, h ( "header" , {}, h ( "span" , { class : "review-snapshot" }, `snapshot # ${ r . snapshot_n } ` ), h ( "span" , { class : `review-state ${ r . state } ` }, stateText ( r )), h ( "time" , { dateTime : r . created_at , title : r . created_at }, ago ( r . created_at ))), r . state === "failed" ? h ( "p" , { class : "error-text" }, r . error ) : null , r . state === "done" ? reviewResult ( r , taskId ) : null );
}
function reviewResult ( r , taskId ) {
const res = ( r . result ?? {});
2026-10-15 18:34:12 -07:00
const findings = findingsOf ( r );
2026-10-15 16:33:17 -07:00
const md = { taskId };
2026-10-16 08:38:42 -07:00
return h ( "div" , { class : "review-result" }, markdown ( String ( res [ "summary" ] ?? "" ), "md review-summary" , md ), reviewImages ( res [ "images" ]), findings . length > 0
2026-10-16 10:44:27 -07:00
? h ( "ol" , { class : "findings" }, ... findings . map (( f , i ) => {
2026-10-15 16:33:17 -07:00
const severity = String ( f [ "severity" ] ?? "" );
2026-10-15 19:02:33 -07:00
const path = typeof f [ "path" ] === "string" ? f [ "path" ] : "" ;
const line = typeof f [ "line" ] === "number" && f [ "line" ] > 0 ? f [ "line" ] : 0 ;
const end = typeof f [ "end_line" ] === "number" && f [ "end_line" ] > line ? f [ "end_line" ] : line ;
const where = path ? ` ${ path }${ line ? `: ${ line }${ end > line ? `– ${ end } ` : "" } ` : "" } ` : "" ;
2026-10-16 10:44:27 -07:00
return h ( "li" , { class : `finding ${ severity } ` }, h ( "div" , { class : "finding-head" }, h ( "span" , { class : `severity ${ severity } ` }, severity ), typeof f [ "category" ] === "string" && f [ "category" ] ? h ( "span" , { class : "finding-category" }, f [ "category" ]) : null , h ( "span" , { class : "finding-title" }, String ( f [ "title" ] ?? "" )), where ? h ( "code" , { class : "finding-where" }, where ) : null , taskId ? findingActions ( taskId , r , i , f , where ) : null ), taskId && path && line ? findingDiff ( taskId , r . snapshot_n , path , line , end ) : null , markdown ( String ( f [ "detail" ] ?? "" ), "md finding-detail" , md ), reviewImages ( f [ "images" ]));
2026-10-15 16:33:17 -07:00
}))
: null );
}
2026-10-15 19:02:33 -07:00
function reviewImages ( v ) {
if ( ! Array . isArray ( v ) || v . length === 0 ) {
return null ;
}
return h ( "div" , { class : "review-images" }, ... v . map (( img ) => {
const src = `/api/blobs/ ${ encodeURIComponent ( String ( img [ "blob" ] ?? "" )) } ` ;
const caption = typeof img [ "caption" ] === "string" ? img [ "caption" ] : "" ;
return h ( "figure" , {}, h ( "a" , { href : src , target : "_blank" , rel : "noopener" }, h ( "img" , { src , alt : caption || "screenshot" , loading : "lazy" })), caption ? h ( "figcaption" , {}, caption ) : null );
}));
}
const stepChanges = new Map ();
const stepPatches = new Map ();
function snapshotPatch ( taskId , n , path ) {
const key = ` ${ taskId } / ${ n } / ${ path } ` ;
const cached = stepPatches . get ( key );
if ( cached ) {
return cached ;
}
const fetched = ( async () => {
const ck = ` ${ taskId } / ${ n } ` ;
let pending = stepChanges . get ( ck );
if ( ! pending ) {
pending = get ( "/api/tasks/{taskId}/changes" , { path : { taskId }, query : { snapshot : n , step : true } });
stepChanges . set ( ck , pending );
}
const changes = await pending . catch (( err ) => {
stepChanges . delete ( ck );
throw err ;
});
const f = changes . files . find (( c ) => c . path === path );
if ( ! f || f . binary ) {
return undefined ;
}
const d = await get ( "/api/tasks/{taskId}/diff" , { path : { taskId }, query : { base : changes . base , tree : changes . tree , path : f . path , old_path : f . old_path } });
return d . patch ;
})();
stepPatches . set ( key , fetched );
fetched . catch (() => stepPatches . delete ( key ));
return fetched ;
}
function linesAround ( patch , line , end ) {
const lines = [];
let n = 0 ;
let inHunk = false ;
for ( const l of patch . split ( "\n" )) {
const m = /^@@ -\d+(?:,\d+)? \+(\d+)(?:,\d+)? @@/ . exec ( l );
if ( m ) {
n = Number ( m [ 1 ]);
inHunk = true ;
continue ;
}
if ( ! inHunk ) {
continue ;
}
if ( l . startsWith ( "+" )) {
lines . push ({ kind : "add" , text : l , at : n ++ });
}
else if ( l . startsWith ( "-" )) {
lines . push ({ kind : "del" , text : l , at : n });
}
else if ( l . startsWith ( " " )) {
lines . push ({ kind : "ctx" , text : l , at : n ++ });
}
}
return lines . filter (( d ) => d . at >= line - 3 && d . at <= end + 3 );
}
function findingDiff ( taskId , n , path , line , end ) {
const box = h ( "div" , { class : "finding-diff" });
snapshotPatch ( taskId , n , path ). then (( patch ) => {
const lines = patch === undefined ? [] : linesAround ( patch , line , end );
if ( lines . length === 0 ) {
box . remove ();
return ;
}
const rows = [];
lines . forEach (( d , i ) => {
const prev = lines [ i - 1 ];
if ( prev && d . kind !== "del" && prev . kind !== "del" && d . at > prev . at + 1 ) {
rows . push ( h ( "span" , { class : "line hunk" }, "⋯" ));
}
const mark = d . kind !== "del" && d . at >= line && d . at <= end ;
rows . push ( h ( "span" , { class : `line ${ d . kind }${ mark ? " mark" : "" } ` }, h ( "span" , { class : "lineno" }, d . kind === "del" ? "" : String ( d . at )), d . text || " " ));
});
replace ( box , h ( "pre" , { class : "diff" }, ... rows ));
}, ( err ) => replace ( box , h ( "div" , { class : "hint" }, `diff not available: ${ errorText ( err ) } ` )));
return box ;
}
2026-10-16 10:44:27 -07:00
function findingActions ( taskId , r , index , f , where ) {
const box = h ( "span" , { class : "finding-actions" });
const done = ( s ) => replace ( box , h ( "span" , { class : "finding-done" }, s === "fixed" ? "fix sent" : "ignored" ));
const current = r . finding_states [ String ( index )];
if ( current ) {
done ( current );
return box ;
}
const mark = ( s ) => put ( "/api/reviews/{reviewId}/findings/{index}" , { path : { reviewId : r . id , index }, body : { state : s } });
const fix = h ( "button" , { type : "button" , class : "finding-fix" , title : "Send this finding to the task's agent to fix" }, "fix" );
const ignore = h ( "button" , { type : "button" , class : "finding-ignore" , title : "Mark this finding done without fixing it; Merge won't warn about it" }, "ignore" );
fix . onclick = async () => {
2026-10-15 18:34:12 -07:00
const text = `Fix this finding from the review of snapshot # ${ r . snapshot_n } :\n\n**[ ${ String ( f [ "severity" ] ?? "" ) } ] ${ String ( f [ "title" ] ?? "" ) } ** ${ where ? ` (\` ${ where } \`)` : "" } \n\n ${ String ( f [ "detail" ] ?? "" ) } ` ;
2026-10-16 10:44:27 -07:00
fix . disabled = true ;
ignore . disabled = true ;
2026-10-15 18:34:12 -07:00
try {
const sessions = await get ( "/api/tasks/{taskId}/sessions" , { path : { taskId } });
const live = sessions . findLast (( s ) => s . state !== "completed" );
if ( live ) {
await post ( "/api/sessions/{sessionId}/messages" , { path : { sessionId : live . id }, body : { text } });
}
else {
await post ( "/api/tasks/{taskId}/sessions" , { path : { taskId }, body : { prompt : text } });
}
}
catch ( err ) {
2026-10-16 10:44:27 -07:00
fix . disabled = false ;
ignore . disabled = false ;
void showAlert ( errorText ( err ));
return ;
}
done ( "fixed" );
await mark ( "fixed" ). catch (( err ) => showAlert ( `fix sent, but not recorded: ${ errorText ( err ) } ` ));
};
ignore . onclick = async () => {
fix . disabled = true ;
ignore . disabled = true ;
try {
await mark ( "ignored" );
done ( "ignored" );
}
catch ( err ) {
fix . disabled = false ;
ignore . disabled = false ;
2026-10-15 18:34:12 -07:00
void showAlert ( errorText ( err ));
}
};
2026-10-16 10:44:27 -07:00
replace ( box , fix , ignore );
return box ;
2026-10-15 18:34:12 -07:00
}
2026-10-15 16:33:17 -07:00
export function mountReviews ( root ) {
2026-10-15 17:46:41 -07:00
const head = h ( "header" , { class : "reviews-header" });
const body = h ( "div" , { class : "review-list" });
2026-10-15 16:33:17 -07:00
let reviews ;
let error = "" ;
let target = 0 ;
let disposed = false ;
const load = async () => {
const taskId = state . selectedTaskId ;
if ( ! taskId ) {
render ();
return ;
}
const started = ++ target ;
try {
const fresh = await get ( "/api/tasks/{taskId}/reviews" , { path : { taskId } });
if ( started === target ) {
reviews = fresh ;
error = "" ;
}
}
catch ( err ) {
if ( started === target ) {
reviews = undefined ;
error = errorText ( err );
}
}
if ( ! disposed && started === target ) {
render ();
}
};
const render = () => {
const taskId = state . selectedTaskId ;
2026-10-15 17:46:41 -07:00
replace ( head );
head . hidden = true ;
2026-10-15 16:33:17 -07:00
if ( ! taskId ) {
2026-10-15 17:46:41 -07:00
replace ( body , h ( "div" , { class : "empty" }, "No task selected." ));
2026-10-15 16:33:17 -07:00
return ;
}
if ( error ) {
2026-10-15 17:46:41 -07:00
replace ( body , h ( "div" , { class : "empty error-text" }, error ));
2026-10-15 16:33:17 -07:00
return ;
}
if ( ! reviews ) {
2026-10-15 17:46:41 -07:00
replace ( body , h ( "div" , { class : "empty" }, "Loading…" ));
2026-10-15 16:33:17 -07:00
return ;
}
if ( reviews . length === 0 ) {
2026-10-15 17:46:41 -07:00
replace ( body , h ( "div" , { class : "empty" }, "No reviews yet. Each new snapshot of the task is reviewed by an agent from the other vendor." ));
2026-10-15 16:33:17 -07:00
return ;
}
2026-10-15 17:46:41 -07:00
const n = state . reviewed ?? reviews . at ( - 1 ). snapshot_n ;
const shown = reviews . findLast (( r ) => r . snapshot_n === n );
const ns = [... new Set ([ n , ... reviews . map (( r ) => r . snapshot_n )])]. sort (( a , b ) => b - a );
const picker = h ( "select" , { title : "Snapshot whose review to show" }, ... ns . map (( s ) => h ( "option" , { value : String ( s ) }, `snapshot # ${ s } ` )));
picker . value = String ( n );
picker . onchange = () => pickReview ( Number ( picker . value ));
const step = ( to , label ) => {
const b = h ( "button" , { type : "button" , class : "snap-step" , disabled : ! ns . includes ( to ) }, label );
b . onclick = () => pickReview ( to );
return b ;
};
const next = ns . filter (( s ) => s > n ). at ( - 1 );
const prev = ns . find (( s ) => s < n );
head . hidden = false ;
replace ( head , picker , step ( prev ?? n - 1 , `← # ${ prev ?? n - 1 } ` ), step ( next ?? n + 1 , `# ${ next ?? n + 1 } →` ));
replace ( body , shown ? reviewCard ( shown , taskId ) : h ( "div" , { class : "empty" }, `No review of snapshot # ${ n } yet.` ));
2026-10-15 16:33:17 -07:00
};
const offs = [
onEach ([ "selection" ], () => {
target ++ ;
reviews = undefined ;
error = "" ;
render ();
void load ();
}),
onEach ([ "transcript" ], ( fresh ) => {
if ( fresh . some ( isReviewEvent )) {
void load ();
}
}),
2026-10-15 17:46:41 -07:00
onEach ([ "review" ], render ),
2026-10-15 16:33:17 -07:00
];
2026-10-15 17:46:41 -07:00
replace ( root , h ( "div" , { class : "reviews-view" }, head , body ));
2026-10-15 16:33:17 -07:00
render ();
void load ();
return () => {
disposed = true ;
for ( const off of offs ) {
off ();
}
};
}
//# sourceMappingURL=reviews.js.map