66 * Assertions are grouped by grader name.
77 */
88
9- import { useState } from 'react' ;
9+ import { useMemo , useState } from 'react' ;
1010
1111import { useQuery } from '@tanstack/react-query' ;
1212import {
13+ artifactFileContentUrl ,
1314 isPassing ,
1415 projectEvalFileContentOptions ,
1516 projectEvalFilesOptions ,
@@ -31,14 +32,20 @@ import type { FileNode } from './FileTree';
3132import { FileTree } from './FileTree' ;
3233import { MonacoViewer } from './MonacoViewer' ;
3334import { ScoreBar } from './ScoreBar' ;
35+ import {
36+ TranscriptTimeline ,
37+ findAnswerPath ,
38+ findTranscriptPath ,
39+ parseTranscriptJsonl ,
40+ } from './TranscriptTimeline' ;
3441
3542interface EvalDetailProps {
3643 eval : EvalResult ;
3744 runId : string ;
3845 projectId ?: string ;
3946}
4047
41- type Tab = 'checks' | 'source' | 'files' | 'feedback' ;
48+ type Tab = 'checks' | 'transcript' | ' source' | 'files' | 'feedback' ;
4249
4350/** Recursively find the first file node in the tree. */
4451function findFirstFile ( nodes : FileNode [ ] ) : string | null {
@@ -54,16 +61,23 @@ function findFirstFile(nodes: FileNode[]): string | null {
5461
5562export function EvalDetail ( { eval : result , runId, projectId } : EvalDetailProps ) {
5663 const [ activeTab , setActiveTab ] = useState < Tab > ( 'checks' ) ;
64+ const [ selectedFilePath , setSelectedFilePath ] = useState < string | null > ( null ) ;
5765 const { data : config } = useStudioConfig ( projectId ) ;
5866 const isReadOnly = config ?. read_only === true ;
5967
6068 const tabs : { id : Tab ; label : string } [ ] = [
6169 { id : 'checks' , label : 'Checks' } ,
70+ { id : 'transcript' , label : 'Transcript' } ,
6271 { id : 'source' , label : 'Source' } ,
6372 { id : 'files' , label : 'Files' } ,
6473 ...( isReadOnly ? [ ] : [ { id : 'feedback' as const , label : 'Feedback' } ] ) ,
6574 ] ;
6675
76+ const openFile = ( filePath : string ) => {
77+ setSelectedFilePath ( filePath ) ;
78+ setActiveTab ( 'files' ) ;
79+ } ;
80+
6781 return (
6882 < div className = "flex min-h-full flex-col" >
6983 { /* Tab navigation — at the top so Files tab editor fills maximum height */ }
@@ -95,7 +109,23 @@ export function EvalDetail({ eval: result, runId, projectId }: EvalDetailProps)
95109 ) }
96110 { activeTab === 'files' && (
97111 < div className = "h-full p-4" >
98- < FilesTab result = { result } runId = { runId } projectId = { projectId } />
112+ < FilesTab
113+ result = { result }
114+ runId = { runId }
115+ projectId = { projectId }
116+ selectedPath = { selectedFilePath }
117+ onSelectedPathChange = { setSelectedFilePath }
118+ />
119+ </ div >
120+ ) }
121+ { activeTab === 'transcript' && (
122+ < div className = "overflow-auto p-4" >
123+ < TranscriptTab
124+ result = { result }
125+ runId = { runId }
126+ projectId = { projectId }
127+ onOpenFile = { openFile }
128+ />
99129 </ div >
100130 ) }
101131 { activeTab === 'source' && (
@@ -406,11 +436,162 @@ function ChecksTab({ result, projectId }: { result: EvalResult; projectId?: stri
406436 ) ;
407437}
408438
439+ function containsFilePath ( nodes : FileNode [ ] , filePath : string | null ) : boolean {
440+ if ( ! filePath ) return false ;
441+ for ( const node of nodes ) {
442+ if ( node . type === 'file' && node . path === filePath ) return true ;
443+ if ( node . children && containsFilePath ( node . children , filePath ) ) return true ;
444+ }
445+ return false ;
446+ }
447+
448+ function TranscriptTab ( {
449+ result,
450+ runId,
451+ projectId,
452+ onOpenFile,
453+ } : {
454+ result : EvalResult ;
455+ runId : string ;
456+ projectId ?: string ;
457+ onOpenFile : ( path : string ) => void ;
458+ } ) {
459+ const evalId = result . testId ;
460+ const { data : filesData , isLoading : isLoadingFiles } = projectId
461+ ? useQuery ( projectEvalFilesOptions ( projectId , runId , evalId ) )
462+ : useEvalFiles ( runId , evalId ) ;
463+ const files = filesData ?. files ?? [ ] ;
464+ const transcriptPath = findTranscriptPath ( files ) ;
465+ const answerPath = findAnswerPath ( files ) ;
466+
467+ const { data : transcriptContentData , isLoading : isLoadingTranscript } = projectId
468+ ? useQuery ( projectEvalFileContentOptions ( projectId , runId , evalId , transcriptPath ?? '' ) )
469+ : useEvalFileContent ( runId , evalId , transcriptPath ?? '' ) ;
470+ const { data : answerContentData } = projectId
471+ ? useQuery ( projectEvalFileContentOptions ( projectId , runId , evalId , answerPath ?? '' ) )
472+ : useEvalFileContent ( runId , evalId , answerPath ?? '' ) ;
473+
474+ const parsedTranscript = useMemo (
475+ ( ) => parseTranscriptJsonl ( transcriptContentData ?. content ?? '' ) ,
476+ [ transcriptContentData ?. content ] ,
477+ ) ;
478+
479+ if ( isLoadingFiles ) {
480+ return (
481+ < div className = "rounded-lg border border-gray-800 bg-gray-900 p-4 text-sm text-gray-500" >
482+ Loading transcript artifacts...
483+ </ div >
484+ ) ;
485+ }
486+
487+ if ( ! transcriptPath ) {
488+ return (
489+ < div className = "rounded-lg border border-gray-800 bg-gray-900 p-4" >
490+ < h3 className = "text-sm font-medium text-gray-300" > No structured transcript</ h3 >
491+ < p className = "mt-2 text-sm text-gray-500" >
492+ This run does not include canonical < code > outputs/transcript.jsonl</ code > . Dashboard does
493+ not parse < code > response.md</ code > or markdown transcripts for this view.
494+ </ p >
495+ </ div >
496+ ) ;
497+ }
498+
499+ if ( isLoadingTranscript ) {
500+ return (
501+ < div className = "rounded-lg border border-gray-800 bg-gray-900 p-4 text-sm text-gray-500" >
502+ Loading < code > { transcriptPath } </ code > ...
503+ </ div >
504+ ) ;
505+ }
506+
507+ if ( parsedTranscript . error ) {
508+ return (
509+ < div className = "rounded-lg border border-red-900/50 bg-red-950/20 p-4" >
510+ < h3 className = "text-sm font-medium text-red-300" > Transcript could not be parsed</ h3 >
511+ < p className = "mt-2 text-sm text-gray-300" > { parsedTranscript . error } </ p >
512+ < div className = "mt-3 flex flex-wrap gap-2" >
513+ < button
514+ type = "button"
515+ onClick = { ( ) => onOpenFile ( transcriptPath ) }
516+ className = "rounded-md border border-gray-700 px-3 py-1.5 text-sm text-gray-300 transition-colors hover:border-cyan-900/60 hover:text-cyan-300"
517+ >
518+ Open raw JSONL in Files
519+ </ button >
520+ < a
521+ href = { artifactFileContentUrl ( {
522+ projectId,
523+ runId,
524+ evalId,
525+ filePath : transcriptPath ,
526+ raw : true ,
527+ } ) }
528+ target = "_blank"
529+ rel = "noreferrer"
530+ className = "rounded-md px-3 py-1.5 text-sm text-cyan-400 transition-colors hover:text-cyan-300 hover:underline"
531+ >
532+ Open raw JSONL
533+ </ a >
534+ </ div >
535+ </ div >
536+ ) ;
537+ }
538+
539+ if ( parsedTranscript . entries . length === 0 ) {
540+ return (
541+ < div className = "rounded-lg border border-gray-800 bg-gray-900 p-4" >
542+ < h3 className = "text-sm font-medium text-gray-300" > Empty transcript</ h3 >
543+ < p className = "mt-2 text-sm text-gray-500" >
544+ < code > { transcriptPath } </ code > exists but contains no JSONL rows.
545+ </ p >
546+ </ div >
547+ ) ;
548+ }
549+
550+ const answerHref = answerPath
551+ ? artifactFileContentUrl ( { projectId, runId, evalId, filePath : answerPath , raw : true } )
552+ : undefined ;
553+ const transcriptHref = artifactFileContentUrl ( {
554+ projectId,
555+ runId,
556+ evalId,
557+ filePath : transcriptPath ,
558+ raw : true ,
559+ } ) ;
560+ const transcriptDownloadHref = artifactFileContentUrl ( {
561+ projectId,
562+ runId,
563+ evalId,
564+ filePath : transcriptPath ,
565+ download : true ,
566+ } ) ;
567+
568+ return (
569+ < TranscriptTimeline
570+ entries = { parsedTranscript . entries }
571+ finalAnswer = { answerPath ? ( answerContentData ?. content ?? result . output ) : undefined }
572+ answerPath = { answerPath }
573+ transcriptPath = { transcriptPath }
574+ answerHref = { answerHref }
575+ transcriptHref = { transcriptHref }
576+ transcriptDownloadHref = { transcriptDownloadHref }
577+ onOpenFile = { onOpenFile }
578+ />
579+ ) ;
580+ }
581+
409582function FilesTab ( {
410583 result,
411584 runId,
412585 projectId,
413- } : { result : EvalResult ; runId : string ; projectId ?: string } ) {
586+ selectedPath,
587+ onSelectedPathChange,
588+ } : {
589+ result : EvalResult ;
590+ runId : string ;
591+ projectId ?: string ;
592+ selectedPath : string | null ;
593+ onSelectedPathChange : ( path : string ) => void ;
594+ } ) {
414595 const evalId = result . testId ;
415596
416597 // Use project-scoped API hooks when projectId is present
@@ -419,10 +600,15 @@ function FilesTab({
419600 : useEvalFiles ( runId , evalId ) ;
420601 const files = filesData ?. files ?? [ ] ;
421602
422- const [ selectedPath , setSelectedPath ] = useState < string | null > ( null ) ;
603+ const [ localSelectedPath , setLocalSelectedPath ] = useState < string | null > ( null ) ;
423604 const [ mobileShowTree , setMobileShowTree ] = useState ( false ) ;
424605
425- const effectivePath = selectedPath ?? ( files . length > 0 ? findFirstFile ( files ) : null ) ;
606+ const requestedPath = selectedPath ?? localSelectedPath ;
607+ const effectivePath = containsFilePath ( files , requestedPath )
608+ ? requestedPath
609+ : files . length > 0
610+ ? findFirstFile ( files )
611+ : null ;
426612
427613 const { data : fileContentData , isLoading : isLoadingContent } = projectId
428614 ? useQuery ( projectEvalFileContentOptions ( projectId , runId , evalId , effectivePath ?? '' ) )
@@ -448,7 +634,8 @@ function FilesTab({
448634 files = { files }
449635 selectedPath = { effectivePath }
450636 onSelect = { ( path ) => {
451- setSelectedPath ( path ) ;
637+ setLocalSelectedPath ( path ) ;
638+ onSelectedPathChange ( path ) ;
452639 // On mobile, auto-switch to content viewer after selecting a file
453640 setMobileShowTree ( false ) ;
454641 } }
0 commit comments